Metadata-Version: 2.3
Name: linkward
Version: 0.1.1
Summary: Read local files without following symlinks, with owner, mode, and size checks.
Project-URL: Homepage, https://github.com/alohaaaaaaaaaaaaaa/linkward
Project-URL: Issues, https://github.com/alohaaaaaaaaaaaaaa/linkward/issues
Project-URL: Source, https://github.com/alohaaaaaaaaaaaaaa/linkward
Author: Elton Pang
License: MIT License
        
        Copyright (c) 2026 Elton Pang
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
Keywords: file,o_nofollow,race-condition,security,symlink,toctou
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Security
Classifier: Topic :: System :: Filesystems
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: test
Requires-Dist: pytest>=7; extra == 'test'
Description-Content-Type: text/markdown

# linkward

**Read a local file while rejecting symbolic links in every path component, with optional owner, permission, and size checks.**

Pure Python, zero runtime dependencies. Linux and macOS, Python 3.9+.

Use it when a Python service or CLI reads a credential or configuration file and
must reject symlink redirection. `linkward` opens each directory relative to the
previous directory's descriptor with `O_NOFOLLOW`, then opens and reads the final
file through the same descriptor used for policy checks.

| Read method | Rejects a symlink at the final file | Rejects symlinks in parent directories |
| --- | --- | --- |
| `open(path, "rb")` | No | No |
| `os.open(path, os.O_RDONLY \| os.O_NOFOLLOW)` | Yes | No |
| `safe_open(path)` | Yes | Yes |

This prevents symlink traversal during this open operation. It does **not** make
an attacker-writable regular file trustworthy or provide a snapshot of its contents.

## Install

Install from [PyPI](https://pypi.org/project/linkward/):

```bash
python -m pip install linkward
```

For a reproducible deployment, pin a reviewed release in your requirements or
lock file. To install from source instead:

```bash
python -m pip install 'git+https://github.com/alohaaaaaaaaaaaaaa/linkward.git'
```

Pin a reviewed source commit after the `.git` URL with `@<commit>` when using
this option in a deployment.

## Read a credential

```python
import os
from pathlib import Path

from linkward import SafeOpenError, safe_open

# A configured absolute path with no symlinks in any component.
credential_path = Path("/srv/myapp/token")

try:
    token = safe_open(
        credential_path,
        require_owner=os.geteuid(),
        require_mode=0o600,
        max_bytes=64 * 1024,
    )
except SafeOpenError:
    # Abort startup rather than using a credential that failed these checks.
    raise
```

Owner and permission checks are **off by default**; enable them for your trust
policy. The example requires the effective user's ownership and exactly `0600`
ordinary permission bits. A matching owner and mode do not establish content
authenticity, and ACLs or other writers may still permit changes.

Paths must be absolute `str` or `os.PathLike[str]` values. Relative paths, `.` or
`..` components, NUL characters, the filesystem root, and trailing slashes are
rejected. Validation applies to the text returned by `os.fspath()`; a `Path`
object may already have removed `.` components or trailing slashes. Bytes paths
are unsupported.

Parent directories are opened read-only, so the caller needs directory read and
search permissions as well as permission to read the file.

On macOS, `/tmp`, `/var`, and `/etc` commonly contain or are symlinks. Configure a
trusted canonical path such as `/private/etc/myapp/token` if appropriate for your
system. Do not call `resolve()` or `realpath()` on an untrusted path just to make it
pass: those operations follow links, and a later open can encounter a changed path.

## Run the attack demonstration

After installation, from a repository checkout:

```bash
python examples/symlink_attack.py
```

The demonstration compares ordinary `open()`, final-component-only `O_NOFOLLOW`,
and `safe_open()`. It also performs a controlled swap between a separate path
check and open. The timing is deliberate: this illustrates the failure mode and
the protection, rather than measuring a probabilistic race's success rate.

## API

The package and import name are `linkward`; `safe_open()` and `SafeOpenError`
remain the public function and base exception names.

```python
safe_open(
    path,
    *,
    max_bytes=1024 * 1024,
    require_mode=None,
    require_owner=None,
    require_regular_file=True,
    require_single_hardlink=False,
) -> bytes

open_nofollow(path) -> int
```

| `safe_open` option | Behavior |
| --- | --- |
| `max_bytes` | Nonnegative integer; reject an oversized file. Returned bytes never exceed this limit. |
| `require_mode` | Optional integer from `0` to `0o777`; require an exact match with `st_mode & 0o777`. Special mode bits and ACLs are not checked by this option. |
| `require_owner` | Optional nonnegative user ID to match against `st_uid`, commonly `os.geteuid()`. |
| `require_regular_file` | Default `True`; reject directories, FIFOs, devices, sockets, and other nonregular files. |
| `require_single_hardlink` | Default `False`; if enabled, require `st_nlink == 1` at the initial inspection. This is a link-count check, not a guarantee of exclusive access. |

Integer options reject booleans; the two boolean options require actual `bool`
values. Policy checks use the initial descriptor metadata, followed by the
before/after change check. Setting `require_regular_file=False` does not turn this
into a general stream reader: size and read-length consistency checks still apply,
so pseudo-files that report a size of zero can be rejected despite having content.

Size detection may read one byte beyond `max_bytes` before rejecting a growing
file. Chunk storage and combining the result also require memory beyond the
returned bytes; `max_bytes` is not a strict process-memory limit.

`open_nofollow()` only provides the path traversal guarantee. It does not enforce
file type, owner, permissions, size, link count, or content stability. Its descriptor
is opened read-only and nonblocking and is not inherited across `exec`; the caller
must close it:

```python
import os
from linkward import open_nofollow

fd = open_nofollow("/srv/myapp/token")
try:
    metadata = os.fstat(fd)
    # Apply your own policy and read from this descriptor.
finally:
    os.close(fd)
```

All library failures derive from `SafeOpenError`:

| Exception | Meaning |
| --- | --- |
| `SymlinkRejected` | A symbolic link was identified in a rejected path component. |
| `FilePolicyError` | The opened file did not meet the requested type, owner, mode, size, or link-count policy. |
| `FileChangedDuringRead` | Descriptor metadata or the amount read indicated a change during the read. |
| `SafeOpenError` | Invalid input, unsupported platform, or another filesystem failure. |

Filesystem `OSError`s are wrapped with the original exception in `__cause__` for
diagnostics. A concurrent path change can affect the exception classification;
the operation still rejects a component it cannot open safely.

## Security boundaries

- **Concurrent content writes:** the before/after `fstat` comparison is a
  best-effort change detector. Writes through `mmap`, filesystem timestamp
  behavior, or other concurrent writes can leave indistinguishable metadata.
  A successful read can contain a mixture of bytes from different moments.
  There is no snapshot or authenticity guarantee. Use an appropriate publication
  or integrity scheme when those guarantees are required.
- **Renames and directory replacement:** once opened, a descriptor stays attached
  to the selected object. Renaming a path does not make the reader follow the new
  name and does not necessarily cause an error. Already-opened directories can
  also be moved; traversal is not confinement within a pathname boundary.
- **Other redirection and writers:** malicious regular files, hard links, bind
  mounts, mount changes, privileged attackers, and other processes with write
  access are outside the symlink guarantee. Protect files and their directories
  according to your threat model. This library is not a sandbox or `chroot`.
- **Platform and I/O:** Windows is unsupported. Filesystem behavior still matters;
  nonblocking open avoids the usual FIFO-open wait but does not guarantee a time
  limit on file operations or safe reads from arbitrary devices. Keep the default
  regular-file requirement unless you explicitly handle other file types.

See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.

## Related work

Safe path handling predates this project. Other approaches include
[`pathrs`](https://pypi.org/project/pathrs/) and
[`google/safeopen`](https://github.com/google/safeopen); their APIs, dependencies,
and confinement semantics differ. This project's focus is a small Python API for
rejecting every symlink component and checking the descriptor being read.

The `filelock` advisories
[CVE-2025-68146](https://github.com/tox-dev/filelock/security/advisories/GHSA-w853-jp5j-5j7f)
and [CVE-2026-22701](https://github.com/tox-dev/filelock/security/advisories/GHSA-qmgc-5h2g-mvrw)
illustrate related symlink risks in lock-file creation and locking. `linkward` is
read-only; it does not implement those locking operations or replace their fixes.

## Development

```bash
git clone https://github.com/alohaaaaaaaaaaaaaa/linkward.git
cd linkward
python -m pip install --upgrade pip
python -m pip install -e '.[test]'
python -m pytest
```

The tests cover accepted reads as well as rejected paths and policies, controlled
concurrent changes, and descriptor cleanup.

## License

MIT. See [LICENSE](LICENSE).
