Metadata-Version: 2.4
Name: pathward
Version: 0.2.0
Summary: Root-bounded filesystem operations and preflighted archive extraction for Python.
Author: m2hcz
License-Expression: MIT
Project-URL: Homepage, https://github.com/m2hcz/pathward
Project-URL: Repository, https://github.com/m2hcz/pathward
Project-URL: Issues, https://github.com/m2hcz/pathward/issues
Project-URL: Changelog, https://github.com/m2hcz/pathward/blob/main/CHANGELOG.md
Keywords: security,path-traversal,zip-slip,symlink,toctou,sandbox
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
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: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: mypy<2,>=1.11; extra == "dev"
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: ruff>=0.8; extra == "dev"
Requires-Dist: twine>=5.1; extra == "dev"
Dynamic: license-file

# pathward

[![CI](https://github.com/m2hcz/pathward/actions/workflows/ci.yml/badge.svg)](https://github.com/m2hcz/pathward/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/pathward)](https://pypi.org/project/pathward/)
[![Python](https://img.shields.io/pypi/pyversions/pathward)](https://pypi.org/project/pathward/)

Root-bounded filesystem operations and preflighted archive extraction for
Python.

`pathward` is for code that receives paths or archives across a trust boundary:
upload handlers, package importers, build systems, file servers, and automation
workers. It rejects lexical traversal, symlink escapes, unsafe archive members,
portable-name collisions, and common archive resource-exhaustion patterns.

- Zero runtime dependencies
- Python 3.9–3.13
- Linux, macOS, and Windows
- Typed package (`py.typed`)

> `pathward` is currently a 0.x library. Security behavior is documented and
> tested, but the API may still evolve before 1.0.

## Install

```bash
python -m pip install pathward
```

## Quick start

### Keep file operations below a root

```python
from pathward import safe_mkdir, safe_open

root = "/srv/app/uploads"

safe_mkdir(root, "accounts", account_id, parents=True, exist_ok=True)

with safe_open(root, "accounts", account_id, filename, mode="xb") as file:
    file.write(payload)
```

`safe_open` refuses symlinks by default. On POSIX, it opens each directory
component relative to a held directory descriptor with `O_NOFOLLOW`; swapping
an already-checked directory for a symlink does not redirect the final open.

Set `follow_symlinks=True` only when links are intentional. The resolved target
must still remain below the root.

### Inspect before extracting

```python
from pathward import inspect_archive, safe_extract

manifest = inspect_archive("upload.zip")

print(manifest.format)      # "zip"
print(manifest.files)       # regular files
print(manifest.total_size)  # declared uncompressed bytes

extracted = safe_extract("upload.zip", "/srv/app/imports/job-184")
```

Inspection and extraction apply the same validation. Extraction validates the
entire member table before creating the destination, writes regular files via
temporary files, and refuses to overwrite an existing file by default.

### Define an application policy

```python
from pathward import ExtractionPolicy, safe_extract

policy = ExtractionPolicy(
    max_members=2_000,
    max_total_size=200 * 1024 * 1024,
    max_file_size=50 * 1024 * 1024,
    max_compression_ratio=100.0,
    max_depth=24,
    overwrite=False,
)

safe_extract("upload.tar.gz", "/srv/app/imports/job-185", policy=policy)
```

## Secure extraction defaults

| Control | Default |
| --- | ---: |
| Members | 10,000 |
| Total uncompressed size | 1 GiB |
| Size of one file | 256 MiB |
| Zip compression ratio | 200:1 for files at least 1 MiB |
| Path depth | 64 components |
| Relative path length | 4,096 UTF-8 bytes |
| Component length | 255 UTF-8 bytes |
| Overwrite existing files | No |

Pass a narrower `ExtractionPolicy` for endpoints with smaller expected inputs.
`ExtractionPolicy.unlimited()` disables resource limits, but never disables
path, link, collision, or special-file validation.

Zip and tar extraction rejects:

- absolute, drive-qualified, UNC, parent-relative, and null-containing names;
- symlinks, hardlinks, devices, FIFOs, and unsupported member types;
- encrypted zip members;
- duplicate names and case/Unicode/separator-equivalent collisions;
- Windows alternate data streams, reserved device names, and trailing-dot or
  trailing-space aliases;
- a file that is also used as the parent of another member;
- destination paths containing a pre-existing symlink;
- files or archives that exceed the configured policy.

File ownership, permissions, and timestamps from the archive are deliberately
not restored.

## API

### Lexical and resolved paths

| Function | Behavior |
| --- | --- |
| `safe_join(base, *parts)` | Lexically joins untrusted parts below `base`; rejects traversal, absolute paths, drives, UNC prefixes, and null bytes. Both slash styles are treated as separators on every platform. |
| `is_within(base, target)` | Resolves both paths and reports whether `target` remains below `base`. |
| `resolve_within(base, *parts)` | Combines `safe_join` with resolved containment checking. |

`safe_join` performs no filesystem access. Use `safe_open` or `safe_mkdir` when
the check and operation need to be bound together.

### Files and directories

| Function | Behavior |
| --- | --- |
| `safe_open(base, *parts, mode="r", follow_symlinks=False, **kwargs)` | Opens a root-bounded file and refuses symlinks by default. |
| `safe_mkdir(base, *parts, mode=0o777, parents=False, exist_ok=False)` | Creates root-bounded directories without following symlink components. |
| `find_symlink(base, path)` | Returns the first symlink component below `base`, or `None`. |
| `contains_symlink(base, path)` | Boolean form of `find_symlink`. |
| `assert_no_symlinks(base, path)` | Raises when any component is a symlink. |

### Archives

| Function | Behavior |
| --- | --- |
| `inspect_archive(src, *, policy=...)` | Validates zip or tar metadata and returns an `ArchiveManifest` without writing. |
| `safe_extract(src, dest, *, policy=...)` | Auto-detects zip or tar, preflights it, and extracts regular files. |
| `safe_extract_zip(src, dest, *, policy=...)` | Zip-specific form; also accepts an open `ZipFile`. |
| `safe_extract_tar(src, dest, *, policy=...)` | Tar-specific form; also accepts an open `TarFile`. |

The legacy `max_members` and `max_total_size` keyword overrides remain
available on extraction functions.

### Exceptions

```text
PathwardError
├── PathTraversalError  (also ValueError)
├── SymlinkEscapeError
├── UnsafeArchiveError
└── FileChangedError
```

Catch `PathwardError` at a trust boundary to handle all library-defined
rejections. Ordinary operational errors such as permissions and a full disk
remain ordinary `OSError` subclasses.

## Security model

The caller must choose a trusted root directory whose ancestors cannot be
rewritten by an attacker. Archive names, archive metadata, requested relative
paths, and existing contents below that root may be untrusted.

On POSIX, `safe_open`, `safe_mkdir`, and archive writes use descriptor-relative
operations to prevent intermediate symlink substitution. Windows does not
offer equivalent Python primitives, so the Windows implementation uses
pre-checks, temporary files, and post-open identity verification as a
best-effort defense. Use OS isolation when an attacker can race filesystem
operations locally.

Archive preflight prevents metadata-known failures before writes begin. A late
I/O error or corrupt payload can still leave files that were committed earlier;
extract into a fresh temporary directory and rename it into place when the
application requires whole-archive atomicity.

See the full [threat model](docs/THREAT_MODEL.md) and
[security policy](SECURITY.md).

## Migrating from 0.1

Version 0.2 introduces secure resource defaults and refuses overwrites unless
`ExtractionPolicy(overwrite=True)` is explicit. It also rejects ambiguous or
non-portable archive names that 0.1 may have accepted.

See [Migrating to 0.2](docs/MIGRATING_TO_0.2.md) and the
[changelog](CHANGELOG.md) for the complete list.

## Development

```bash
python -m pip install -e ".[dev]"
pytest
ruff check .
ruff format --check .
mypy pathward
python -m build
```

Contributions should include a regression test for every security-relevant
behavior change. See [CONTRIBUTING.md](CONTRIBUTING.md).

## License

MIT
