Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ jobs:
toolchain: ${{ matrix.rust }}
- run: cargo build --verbose
- run: cargo test --verbose
# Exercise every feature combo so guard/secure-open compile-only paths get linted.
- run: cargo build --all-features --verbose
- run: cargo test --all-features --verbose

# Verify we compile on the Minimum Supported Rust Version
msrv:
Expand All @@ -38,8 +41,8 @@ jobs:
- uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master
with:
toolchain: "1.85"
- run: cargo build --verbose
- run: cargo test --verbose
- run: cargo build --all-features --verbose
- run: cargo test --all-features --verbose

# Cross-compile and test on aarch64-linux (ARM servers, Raspberry Pi, etc.)
cross:
Expand Down Expand Up @@ -67,7 +70,7 @@ jobs:
- uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
with:
components: clippy
- run: cargo clippy -- -D warnings
- run: cargo clippy --all-features --all-targets -- -D warnings

fmt:
runs-on: ubuntu-latest
Expand All @@ -83,5 +86,33 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
- run: cargo doc --no-deps
- run: cargo doc --all-features --no-deps

# Supply-chain check: advisories, licenses, dep bans, sources.
# Config lives in deny.toml.
deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
- name: Install cargo-deny
run: cargo install --locked cargo-deny
- name: Run cargo-deny
run: cargo deny --all-features check

# Semver discipline: catch breaking changes between commits and the
# last-published version on crates.io. Runs on PRs only; main is allowed to
# bump major/minor explicitly.
semver-checks:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
- name: Install cargo-semver-checks
run: cargo install --locked cargo-semver-checks
- name: Run cargo-semver-checks
run: cargo semver-checks check-release --all-features || true
# `|| true` for now: 0.4.0 is the first release with the guard surface
# so a baseline doesn't exist. Remove the suffix once a baseline ships.

25 changes: 24 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ on:
permissions:
contents: read

env:
CARGO_TERM_COLOR: always

jobs:
publish:
name: Publish to crates.io
Expand All @@ -19,11 +22,31 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable

# Sanity gate: the git tag and Cargo.toml version MUST match.
# Catches the "tagged v0.5.0 but forgot to bump Cargo.toml" mistake before
# we ship a confusing release.
- name: Verify tag matches Cargo.toml version
run: |
set -euo pipefail
TAG_VERSION="${GITHUB_REF_NAME#v}"
CARGO_VERSION=$(awk -F'"' '/^version *=/ { print $2; exit }' Cargo.toml)
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
echo "::error::Tag version '$TAG_VERSION' does not match Cargo.toml version '$CARGO_VERSION'"
exit 1
fi
echo "tag and Cargo.toml agree on version $CARGO_VERSION"

- name: Run tests
run: cargo test --all-features

# Pre-flight: validate the publish package without actually pushing it.
# Catches packaging mistakes (missing files, unbuildable crate as
# shipped, license errors) before a real publish — `cargo publish`
# has no rollback story.
- name: Dry-run publish
run: cargo publish --dry-run

- name: Publish
run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

13 changes: 10 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `FdJail::open()` / `FdJail::create()` perform a single TOCTOU-safe syscall on Linux
- `FdJail::check()` validates a path without opening (logging/display only — must not be used as the basis for a subsequent open)
- `Attestation` records `jail_root`, `opened_path`, `root_inode`, `file_inode`, `device`, `nlink`, `toctou_safe`, `opened_at`
- `Attestation::content_bytes()` for deterministic comparison; `signing_bytes()` for future Ed25519 signing
- `OpenOptions` with `read`/`write`/`append`/`truncate`/`create`/`create_new`/`no_symlinks`
- `Attestation::content_bytes()` for deterministic comparison; `Attestation::signing_bytes()` now public for external verifiers
- `OpenOptions` with `read`/`write`/`append`/`truncate`/`create`/`create_new`/`no_symlinks`/`no_xdev`
- `JailFile::has_hard_links()` exposes hard-link policy; library does not enforce, caller decides
- macOS/BSD fallback via `O_NOFOLLOW`; `Attestation::toctou_safe` is `false` on the fallback path
- **Pluggable attestation signing** (`guard::Signer`, `guard::Verifier`, `guard::VerifyError`)
- `JailFile::sign_attestation(&signer)` returns a signed `Attestation`
- `Attestation::verify(&verifier)` checks the signature on the enforcement side
- Zero vendored crypto — bring your own (`ed25519-dalek`, `ring`, HSM, KMS, etc.)
- **`OpenOptions::no_xdev`** — opt in to `RESOLVE_NO_XDEV` for mount-point containment (defends against bind-mount escapes)
- **aarch64 Linux support** for the `guard` feature (alongside x86_64); riscv64 is still gated by `compile_error!`
- New error variants (guarded by `guard` feature): `Escape`, `SymlinkRejected`, `MagicLink`, `UnsupportedKernel`, `InvalidJailRoot`

### Changed

- **Breaking**: MSRV bumped from 1.80 to 1.85 to accommodate transitive dev-dependencies that require Cargo edition 2024
- Attestation fields are now read via `File::metadata()` instead of an inline-asm `fstat` syscall (portable across architectures, eliminates the arch-specific struct-stat layout problem)
- Crate package now `exclude`s `docs/`, `.claude/`, `.github/`, `tests/`

### Notes

- The `guard` feature uses only `std` and raw syscalls — zero new runtime dependencies
- `guard` is currently x86_64 Linux only for the raw-asm `openat2` path; aarch64/riscv64 support is planned
- `guard` supports x86_64 and aarch64 Linux for the raw-asm `openat2` path; riscv64 support is planned

## [0.3.0] - 2026-01-05

Expand Down
184 changes: 184 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Security Policy

This document is the threat model and security contract for `path_jail`. It
exists because every security library needs one: callers can only use the
library correctly if they know what it defends against and what it doesn't.

If you find a vulnerability, see [Reporting a vulnerability](#reporting-a-vulnerability) below.

---

## Attacker model

`path_jail` is designed to defend against an attacker who supplies path
strings to your application — for example:

- A web client uploading a file with a chosen filename
- A user-controlled config value naming a path inside a sandbox directory
- A workflow step naming a file inside a CI working directory

The attacker can supply:

- Arbitrary bytes in the path (including `..`, leading `/`, null bytes, magic
link prefixes like `/proc/self/fd/N`)
- A pre-existing symlink inside the jail that points outside the jail
- A pre-existing hard link inside the jail that points to sensitive content
- Concurrent filesystem activity attempting to swap paths between validation
and open (TOCTOU)

The attacker is assumed to **not** have:

- Privileges to mount filesystems, run as root inside the jail's filesystem,
call `ptrace`, or otherwise escape the OS sandbox the application runs in
- The ability to modify the running process's memory
- A working kernel exploit

If your attacker can do any of the above, no userspace library can help —
you need a process-level sandbox (`seccomp`, `landlock`, containers, VMs).

---

## What each API defends against

`path_jail` ships three API layers with different security/ergonomics
tradeoffs. Pick the strongest one your environment supports.

| Threat | `Jail` (default) | `secure-open` | `guard` (Linux 5.6+) |
|-----------------------------------------------|:---:|:---:|:---:|
| Path traversal via `..` | ✅ | ✅ | ✅ |
| Absolute path injection (`/etc/passwd`) | ✅ | ✅ | ✅ |
| Null-byte injection | ✅ | ✅ | ✅ |
| Symlink target outside the jail | ✅ | ✅ | ✅ |
| Broken symlinks (cannot verify target) | ✅ | ✅ | ✅ |
| Symlink swap on final component (TOCTOU) | ❌ | ✅ | ✅ |
| Symlink swap on intermediate directories | ❌ | ❌ | ✅ |
| Concurrent rename of jail root mid-operation | ❌ | ❌ | ✅¹ |
| Magic links (`/proc/self/fd`, `/proc/self/root`) | ❌ | ❌ | ✅ |
| Hard link to sensitive content (detect) | ❌² | ❌² | ✅³ |
| Bind-mount escape (opt-in) | ❌ | ❌ | ✅⁴ |
| Atomic open with kernel-enforced containment | ❌ | ❌ | ✅ |
| Signed attestation of the open event | ❌ | ❌ | ✅⁵ |

Footnotes:

1. `guard::FdJail` pins an `O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC` fd to the
jail root at construction time. Subsequent renames or replacements of the
root *path* do not affect the jail — all operations remain scoped to the
original directory inode.
2. Hard links are not detectable in user space before open. The path-based
APIs do not stat the opened file and so cannot surface `nlink`.
3. `guard::JailFile::has_hard_links()` exposes `nlink > 1` from the post-open
`fstat`. **Policy is the caller's responsibility** — a content-addressed
store may legitimately use hard links. If your policy rejects hard links,
check `has_hard_links()` **before** reading or writing.
4. Opt in with `OpenOptions::no_xdev(true)` (maps to `RESOLVE_NO_XDEV`).
Off by default to preserve directory-tree containment semantics, which
are what most callers want.
5. Opt in by implementing the `Signer` trait. `path_jail` ships no crypto;
bring your own (`ed25519-dalek`, `ring`, HSM client, KMS, etc.).

---

## Out of scope

These threats are documented as **not defended** by any API:

- **Privileged local attackers.** A process with root or `CAP_SYS_ADMIN` on
the host can mount, bind-mount, or `ptrace` around any user-space check.
- **Kernel and filesystem exploits.** A kernel-level bug, a FUSE filesystem
misbehaving, or an `openat2` semantic regression in a specific kernel
version are outside our control. We pin to documented kernel ABI.
- **Side-channel attacks.** Timing, cache, filesystem-metadata leaks.
- **Directory iteration (`read_dir`) and recursive walks.** Iterating jail
contents has its own TOCTOU surface (rename-during-walk) that we do not
currently address. If you walk a directory tree, treat it as untrusted
input on every iteration.
- **Windows.** No Windows-specific protections are implemented. `Jail` and
`secure-open` compile on Windows but provide no defenses beyond the
cross-platform path-string checks; `guard` is Linux-only.
- **Unicode normalization.** Paths are accepted byte-for-byte. We do not
normalize NFC/NFD on macOS or fold case on Windows/macOS. If your storage
layer is case-insensitive, treat `Report.PDF` and `report.pdf` as
potentially the same file.
- **Resource exhaustion.** Very long paths, deep symlink chains, etc. are
rejected by the kernel (`ENAMETOOLONG`, `ELOOP`) but `path_jail` does not
impose its own limits.

---

## Choosing the right API

```text
┌──────────────────────────────────┐
You only need a validated │ Use `Jail::join` / `join_typed`. │
path (e.g., for logging) → │ Cheap, portable. │
└──────────────────────────────────┘

┌──────────────────────────────────┐
You open the file in │ Use `secure-open`. │
process, on Unix, and need │ Protects final-component swaps. │
final-component TOCTOU → └──────────────────────────────────┘

┌──────────────────────────────────┐
Security-critical opens on │ Use `guard` (Linux 5.6+). │
Linux, attestation needed, │ Kernel-enforced; signable. │
or hostile multi-tenant → └──────────────────────────────────┘
```

`guard` is the strongest. Use it on Linux where you can.

---

## Versioning & supported releases

- We follow semver. While we are pre-1.0 (`0.y.z`), minor-version bumps
(`0.4 → 0.5`) may include breaking changes; patch bumps (`0.4.0 → 0.4.1`)
will not.
- Security fixes are issued on the latest minor line. We do not currently
backport to older 0.x lines.
- The MSRV (currently 1.85) may be bumped in any minor release. We treat MSRV
bumps as breaking.

---

## Reporting a vulnerability

**Do not** open a public GitHub issue for security bugs.

Use **GitHub Private Vulnerability Reporting** on the
[`tenuo-ai/path_jail` repository](https://github.com/tenuo-ai/path_jail)
(Security tab → "Report a vulnerability"), or email **security@tenuo.ai**
with:

- A minimal reproducer (Rust code that demonstrates the issue)
- The affected `path_jail` version and feature flags
- The platform (OS, kernel version, architecture)
- Your assessment of the impact (information disclosure / write outside jail
/ etc.)

We aim to acknowledge within 5 business days and to ship a fix within 30
days for high-severity findings (escape from a documented containment
guarantee). Lower-severity findings (e.g., a missing defense for a
documented out-of-scope threat) will be triaged on the open repository.

---

## A note on the `Jail` default API

The README quick-start uses `Jail::new` and `Jail::join` — the path-based
API. That API is **not TOCTOU-safe**. It validates a path string and returns
a `PathBuf`; whatever the caller does with that `PathBuf` is a separate
operation with its own race window.

This is documented but easy to miss. If you operate in any of these
environments, **strongly prefer `guard`** over the path-based API:

- Multi-tenant systems where another local process can manipulate the
filesystem
- File-upload paths where the same directory is also writable by other
workers
- Anywhere "the file you validated" and "the file you opened" need to be
the same file with certainty

We may make `guard` the default in a future major release. For now, choose
explicitly.
73 changes: 73 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# cargo-deny configuration for path_jail
#
# Run locally:
# cargo install cargo-deny
# cargo deny check
#
# Enforced in CI by .github/workflows/ci.yml (job: deny).

[graph]
all-features = true
no-default-features = false

[output]
feature-depth = 1

# ── Security advisories ──────────────────────────────────────────────────────
# RustSec advisory database. New advisories MUST be triaged within the SLA
# documented in SECURITY.md.

[advisories]
db-path = "~/.cargo/advisory-db"
db-urls = ["https://github.com/rustsec/advisory-db"]
yanked = "deny"
ignore = []

# ── Licenses ────────────────────────────────────────────────────────────────
# Permissive licenses only. If a transitive dep brings in something else,
# the build fails until it's reviewed.

[licenses]
confidence-threshold = 0.93
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-3.0",
"Unicode-DFS-2016",
"Unlicense",
"Zlib",
"CC0-1.0",
]

# ── Dependency bans ─────────────────────────────────────────────────────────
# path_jail is a security library. We deny both wildcards and duplicate
# versions: duplicates are where advisories hide (one copy patched, another
# isn't) and they bloat binaries. If a transitive dep forces a new duplicate
# in a future bump, the deny build fails and we resolve it deliberately
# (cargo update, [patch], or a documented `skip = [...]` here) rather than
# letting it drift.

[bans]
multiple-versions = "deny"
wildcards = "deny"
highlight = "all"
# Empty by default; populate when a specific crate must be banned.
deny = []
# Add entries here ONLY with a justification when an upstream split forces
# a temporary duplicate we can't fix immediately.
skip = []

# ── Source restrictions ─────────────────────────────────────────────────────
# Crates must come from crates.io. No git deps, no private registries.
# (When this changes — e.g., to consume a private Tenuo crate — explicitly
# allowlist the registry/git URL here so the decision is reviewable.)

[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []
Loading
Loading