From b6982945e44169f1fdb702dd4094710ce4b21a76 Mon Sep 17 00:00:00 2001 From: aimable100 <129232709+aimable100@users.noreply.github.com> Date: Thu, 21 May 2026 12:32:16 -0700 Subject: [PATCH] release: prepare v0.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the guard API (FdJail / GuardedFile / Attestation / openat2), tightens the error model, and fixes several pre-1.0 API stability issues. Key changes ----------- - guard feature: FdJail pins the jail root as a dirfd; FdJail::open uses a single openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) syscall on Linux 5.6+ (x86_64 + aarch64); O_NOFOLLOW fallback on macOS/BSD and unsupported Linux arches (riscv64, s390x, …) with attestation().toctou_safe = false. - GuardedFile (renamed from JailFile): wraps the opened File + Attestation; implements Read/Write/Seek/Deref/AsFd/AsRawFd. sign_attestation() via pluggable Signer/Verifier traits. - Attestation and KernelVersion are now #[non_exhaustive]. - JailError::InvalidRoot is now a struct variant { path, source: Option }, replacing both the old tuple variant and the guard-only InvalidJailRoot. - FdJail::check renamed to check_path. - secure-open: O_NOFOLLOW = 0 silent fallback replaced with compile_error! on unknown Unix platforms. - FdJail implements Clone (dup(2) on Linux) + Send + Sync. - MSRV bumped to 1.85; CI matrix hardened (semver-checks un-suppressed, SHA-pinned actions, cargo-deny, aarch64/armv7 cross tests). - DESIGN.md rewritten; SECURITY.md versioning section updated for pre/post-1.0. - docs/fd_first_spec.md deleted (stale artifact from fd-first → guard rename). --- .claude/settings.local.json | 24 ++- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 68 +++++--- DESIGN.md | 306 ++++++++++++++++++++++-------------- SECURITY.md | 20 ++- docs/fd_first_spec.md | 267 ------------------------------- src/error.rs | 76 +++++---- src/guard/fd_jail.rs | 214 +++++++++++++++++++------ src/guard/mod.rs | 2 +- src/jail.rs | 13 +- src/lib.rs | 15 +- src/open.rs | 12 +- src/openat2.rs | 16 +- tests/guard.rs | 88 +++++++++-- tests/security.rs | 8 +- 15 files changed, 603 insertions(+), 530 deletions(-) delete mode 100644 docs/fd_first_spec.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b8f1323..4676b6b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,29 @@ "Bash(cargo check *)", "Bash(cargo test *)", "Bash(cargo clippy *)", - "Bash(cargo fmt *)" + "Bash(cargo fmt *)", + "Bash(curl -sI https://gh.io/billing-api-updates-org)", + "Bash(curl -sI https://www.githubstatus.com/api/v2/components.json)", + "Bash(curl -s https://www.githubstatus.com/api/v2/components.json)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(c['name'],'->',c['status']\\) for c in d['components'] if 'action' in c['name'].lower\\(\\) or 'runner' in c['name'].lower\\(\\)]\")", + "Bash(gh auth *)", + "Bash(python3 -m json.tool)", + "Bash(python3 -c ' *)", + "Bash(gh workflow *)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\([{'run_number':a.get\\('run_number'\\),'attempt':a.get\\('run_attempt'\\),'created_at':a.get\\('created_at'\\),'conclusion':a.get\\('conclusion'\\)} for a in \\(d.get\\('workflow_runs'\\) if isinstance\\(d,dict\\) else d\\)], indent=2\\)\\)\")", + "Bash(curl -sH 'Authorization: token __CMDSUB_OUTPUT__' -H 'Accept: application/vnd.github+json' https://api.github.com/repos/tenuo-ai/path_jail/check-runs/75724571128)", + "Bash(cargo build *)", + "Bash(git add *)", + "Bash(git commit -m ' *)", + "Bash(git push *)", + "Bash(git ls-remote *)", + "Bash(git fetch *)", + "Bash(git checkout *)", + "Bash(git rebase *)", + "Bash(cargo tree *)", + "Bash(git commit -m 'fix\\(guard\\): drop unused Errno::raw\\(\\) helper *)", + "Bash(git commit -m 'fix\\(tests\\): gate Unix-only test files for Windows --all-features *)", + "Bash(git commit -m 'fix\\(docs\\): gate guard quick-start doctest on cfg\\(unix\\) *)" ] } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42b22a0..1f79b2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,5 @@ jobs: - 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. + run: cargo semver-checks check-release --all-features diff --git a/CHANGELOG.md b/CHANGELOG.md index db9d519..41bd5a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,37 +5,59 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.4.0] - 2026-05-13 +## [0.4.0] - 2026-05-21 ### Added -- **`guard` feature** (formerly `fd-first`): kernel-enforced TOCTOU-safe file access via `openat2(RESOLVE_BENEATH)` on Linux 5.6+ - - `guard::FdJail` pins a directory fd at construction; root renames after `FdJail::new` are ignored - - `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; `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` +- **`guard` feature** (Linux 5.6+ / macOS-BSD fallback): kernel-enforced TOCTOU-safe file access + - `FdJail::new()` — pins the jail root as a live directory fd at construction time; subsequent opens cannot be raced by renames of the root + - `FdJail::open()` — single `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` syscall on Linux 5.6+; `O_NOFOLLOW` fallback on macOS/BSD (`attestation().toctou_safe` will be `false` on the fallback path) + - `FdJail::create()` — `O_CREAT | O_EXCL` atomic creation + - `FdJail::check_path()` — validate a path without opening (for logging/display only; re-opening reintroduces TOCTOU) + - `OpenOptions` — mirrors the relevant subset of `std::fs::OpenOptions`; adds `no_symlinks` (`RESOLVE_NO_SYMLINKS`) and `no_xdev` (`RESOLVE_NO_XDEV`) + - `JailFile` — wraps the opened `File` alongside an `Attestation` snapshot; implements `Read`, `Write`, `Seek`, `Deref` + - `GuardedFile` — wraps the opened `File` alongside an `Attestation` snapshot; implements `Read`, `Write`, `Seek`, `Deref`, `AsFd`, `AsRawFd` (Unix) + - `GuardedFile::has_hard_links()` — detects hard links (data-exfiltration vector) via `nlink` from `fstat` + - `Attestation` (`#[non_exhaustive]`) — records `jail_root`, `opened_path`, `root_inode`, `file_inode`, `device`, `nlink`, `toctou_safe`, `opened_at`, and an optional 64-byte signature + - `Attestation::content_bytes()` — canonical serialization of all fields except `opened_at` and `signature` (stable for content-equality checks) + - `Attestation::signing_bytes()` — `content_bytes` + `opened_at` nanos; the exact bytes a `Signer` signs and a `Verifier` replays + - `Attestation::verify()` — signature verification against a `Verifier` + - `GuardedFile::sign_attestation()` — returns a new `Attestation` with `signature` populated + - `Signer` trait — pluggable 64-byte signature production (`ed25519-dalek`, `ring`, HSM, KMS, etc.) + - `Verifier` trait — pluggable signature verification + - `VerifyError` — distinguishes `NotSigned` from `Invalid(E)` + +- **aarch64 Linux support** — cross-compilation and tests verified on `aarch64-unknown-linux-gnu` and `armv7-unknown-linux-gnueabihf` + +- **`guard` feature — architecture support expanded**: Linux on `riscv64`, `s390x`, `loongarch64`, and all other architectures without a raw `openat2` syscall shim now fall through to the `O_NOFOLLOW` fallback (same as macOS/BSD) instead of emitting a compile error. `attestation().toctou_safe` will be `false` on these platforms. + +- New `JailError` variants (all `#[cfg(feature = "guard")]`): + - `Escape { requested }` — `openat2` returned `EXDEV`; covers symlink escapes, `..` traversal, and absolute injection + - `SymlinkRejected { requested }` — `openat2` returned `ELOOP`; covers symlink loops and `no_symlinks` policy rejections; also surfaces magic-link rejections because the kernel maps both to `ELOOP` + - `MagicLink { requested }` — reserved for a future kernel ABI that separates magic-link errno; currently unreachable (see deprecation note) + - `UnsupportedKernel { version }` — `openat2` not available on kernel < 5.6 (`#[cfg(target_os = "linux")]`) + - `InvalidJailRoot { path, source }` — invalid root in the guard API ### 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/` +- MSRV bumped from 1.80 to **1.85** (accommodates edition-2024 transitive dev-dependencies) +- `guard` feature flag replaces the earlier `fd-first` name (internal rename; no API was previously published) +- **`JailError::InvalidRoot`** is now a struct variant `{ path: PathBuf, source: Option }` instead of a tuple variant `(PathBuf)`. The `source` field is `Some` when an I/O error was the proximate cause (e.g., `FdJail::new` failing to open the directory) and `None` for structural rejections (e.g., path is `/`). `JailError::InvalidJailRoot` (guard-only) is removed; `InvalidRoot` now covers both APIs. +- **`guard::JailFile`** renamed to **`guard::GuardedFile`** to distinguish it clearly from `crate::JailedFile` (the `secure-open` type). +- **`FdJail::check`** renamed to **`FdJail::check_path`** to make the "no fd held, for display only" semantics visible at the call site. +- `KernelVersion` is now `#[non_exhaustive]`. +- `secure-open` on an unknown Unix platform now produces a `compile_error!` instead of silently setting `O_NOFOLLOW = 0` (which would have followed symlinks without any error). +- `FdJail::new` canonicalize failure now returns `JailError::InvalidRoot` (with `source: Some(io_error)`) instead of `JailError::Io`. -### Notes +### Deprecated -- The `guard` feature uses only `std` and raw syscalls — zero new runtime dependencies -- `guard` supports x86_64 and aarch64 Linux for the raw-asm `openat2` path; riscv64 support is planned +- `JailError::MagicLink` — the Linux kernel currently returns `ELOOP` for both magic-link and symlink rejections, making this variant unreachable. Match on `SymlinkRejected` instead. The variant is preserved so callers are not broken if a future kernel release introduces a distinct errno. + +## [0.3.1] - 2026-01-06 + +### Fixed + +- Formatting issues (rustfmt) ## [0.3.0] - 2026-01-05 diff --git a/DESIGN.md b/DESIGN.md index 181a039..a45520d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,6 +1,6 @@ # path_jail Design Document -This document captures design decisions and rationale. For usage, see README.md. +This document captures design decisions and rationale for `path_jail`. For usage, see `README.md`. ## 1. The Problem @@ -15,7 +15,8 @@ if !path.starts_with(&root) { } ``` -The bug: `canonicalize()` fails if the file doesn't exist. You cannot validate paths for files you intend to create. +The bug: `canonicalize()` fails if the file does not exist. You cannot validate paths for files you +intend to create. ### 1.2 The Symlink Trap @@ -24,16 +25,18 @@ An attacker creates: uploads/innocent_link -> /etc ``` -Writing to `uploads/innocent_link/passwd` overwrites system files. String-based `..` removal does not catch this. +Writing to `uploads/innocent_link/passwd` overwrites system files. String-based `..` removal does +not catch this. ### 1.3 The Broken Symlink Trap An attacker creates: ``` -uploads/evil -> /etc/shadow (target doesn't exist yet) +uploads/evil -> /etc/shadow (target does not exist yet) ``` -`Path::exists()` returns false for broken symlinks. If you skip verification, a later write could follow the symlink to an external location. +`Path::exists()` returns false for broken symlinks. If verification is skipped, a later write could +follow the symlink to an external location. ### 1.4 The Traversal Trap @@ -41,13 +44,50 @@ Lexical path cleaning is insufficient: - `foo/../bar` vs `foo/bar` - Windows: `C:\Users` vs `\\?\C:\Users` -You need OS-level path resolution. +OS-level path resolution is required. -## 2. Security Model +### 1.5 The TOCTOU Trap -### 2.1 Guarantees +Even a correctly validated path is unsafe if anything changes between validation and use: -`path_jail` guarantees the returned path was physically inside the jail at the moment of verification. +```rust +let path = jail.join("file.txt")?; // validated here +// attacker swaps directory for symlink here +std::fs::write(&path, data)?; // follows symlink — escapes jail +``` + +The only complete defence is making validation and open a single atomic kernel operation. + +--- + +## 2. Architecture: Three Capability Layers + +`path_jail` is organized as three feature layers, each building on the previous: + +``` +┌────────────────────────────────────────────────────────────┐ +│ guard (Linux 5.6+) │ +│ openat2(RESOLVE_BENEATH) — atomic validate + open │ +│ Attestation: inode, device, nlink, timestamp, signature │ +├────────────────────────────────────────────────────────────┤ +│ secure-open (Unix) │ +│ O_NOFOLLOW on the final path component │ +├────────────────────────────────────────────────────────────┤ +│ default (all platforms) │ +│ Jail + JailedPath — path validation, no file I/O │ +└────────────────────────────────────────────────────────────┘ +``` + +Callers opt into exactly the level they need; the default has zero feature overhead. + +--- + +## 3. Security Model + +### 3.1 Default API (`Jail`) + +`path_jail` guarantees the returned path was physically inside the jail at the moment of +verification. | Attack | Example | Blocked | |--------|---------|---------| @@ -57,111 +97,58 @@ You need OS-level path resolution. | Broken symlinks | `link -> /nonexistent` | Yes | | Absolute injection | `/etc/passwd` | Yes | | Parent escape | `foo/../../secret` | Yes | +| Null byte injection | `file\x00.txt` | Yes | -### 2.2 Limitations (TOCTOU) +**Limitation:** TOCTOU race between `join()` and a subsequent filesystem call. See §3.3. -This library validates paths. It does not hold file descriptors. +### 3.2 `secure-open` Feature -There is a time-of-check time-of-use race condition. If an attacker has write access to the jail directory, they could swap a directory with a symlink between validation and use. +Adds `O_NOFOLLOW` protection to the final path component of every open. Closes the symlink-swap +window on the **last** component only. Intermediate directory swaps remain unprotected. -**Defends against:** -- Logic errors in path construction -- Confused deputy attacks from untrusted input +### 3.3 `guard` Feature -**Does not defend against:** -- Malicious local processes racing your I/O +Uses `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` on Linux 5.6+. The validate-and-open is a +single kernel syscall — there is no userspace window between them. -For kernel-enforced sandboxing, use `cap-std`. +| Mechanism | Protection | Platforms | +|---|---|---| +| `openat2` | Atomic; covers all components + magic links | Linux 5.6+ | +| `O_NOFOLLOW` fallback | Final component only | macOS / BSD | -## 3. API Contract +The fallback path on macOS/BSD is intentionally equivalent to `secure-open`. Callers can detect +which path was used at runtime via `Attestation::toctou_safe`. -### 3.1 Core Types +--- -```rust -/// A filesystem sandbox that restricts paths to a root directory. -#[derive(Debug, Clone)] -pub struct Jail { - root: PathBuf, // Always canonicalized -} - -/// A path verified to be inside a Jail. -/// Zero-cost wrapper providing compile-time guarantees. -#[derive(Debug, Clone)] -pub struct JailedPath { - inner: PathBuf, -} - -#[derive(Debug)] -pub enum JailError { - EscapedRoot { attempted: PathBuf, root: PathBuf }, - BrokenSymlink(PathBuf), - InvalidPath(String), - InvalidRoot(PathBuf), - Io(std::io::Error), -} -``` - -### 3.2 Methods +## 4. API Design Decisions -| Method | Input | Output | Notes | -|--------|-------|--------|-------| -| `Jail::new(root)` | Directory path | `Result` | Root must exist | -| `Jail::root()` | - | `&Path` | Canonicalized root | -| `Jail::join(relative)` | Relative path | `Result` | Works for non-existent files | -| `Jail::join_typed(relative)` | Relative path | `Result` | Type-safe version | -| `Jail::join_segments(iter)` | Iterator of segments | `Result` | Validates each segment | -| `Jail::segments(iter)` | Iterator of segments | `Result` | Type-safe version | -| `Jail::contains(absolute)` | Absolute path | `Result` | Path must exist | -| `Jail::relative(path)` | Absolute or relative | `Result` | Strips root prefix | -| `path_jail::join(root, path)` | Root + relative | `Result` | One-shot convenience | +### 4.1 `#[must_use]` on `join()` and friends -### 3.3 Design Decisions - -**Why `#[must_use]` on `join()` and `contains()`?** - -Prevents confused deputy attacks where the user validates a path but then uses the original untrusted input: +Prevents confused deputy attacks where a caller validates a path but then uses the original +untrusted input: ```rust // WRONG: validates but ignores result jail.join(user_input)?; -std::fs::write(user_input, data)?; // Uses unvalidated path! +std::fs::write(user_input, data)?; // uses unvalidated path! -// RIGHT: uses the validated path +// RIGHT let safe = jail.join(user_input)?; std::fs::write(&safe, data)?; ``` -**Why reject broken symlinks?** - -A broken symlink's target cannot be verified. If we returned the path, and the target was later created (or already exists but is inaccessible), the symlink could point outside the jail. - -**Why canonicalize the root immediately?** - -Ensures `starts_with()` comparisons work correctly. Without canonicalization: -- `/var/uploads` vs `/var/./uploads` would fail -- macOS: `/var` vs `/private/var` would fail - -**Why no I/O helpers by default?** - -Keeps the crate focused on path validation. Users can compose with `std::fs`: - -```rust -let path = jail.join(input)?; -std::fs::write(&path, data)?; -``` - -This is more flexible and doesn't hide what's happening. +### 4.2 `JailedPath` newtype -**Why `JailedPath`?** - -Prevents "confused deputy" bugs at compile time. Functions can require `JailedPath` parameters, making it impossible to accidentally pass an unvalidated path: +Prevents "confused deputy" bugs at compile time. Functions can require `JailedPath` parameters, +making it impossible to accidentally pass an unvalidated `PathBuf`: ```rust fn save_upload(path: JailedPath, data: &[u8]) -> std::io::Result<()> { - std::fs::write(&path, data) // Guaranteed to be inside the jail + std::fs::write(&path, data) } -// Won't compile: PathBuf is not JailedPath +// Won't compile — PathBuf is not JailedPath save_upload(user_input, data); // Error! // Must validate first @@ -169,58 +156,139 @@ let safe = jail.join_typed(user_input)?; save_upload(safe, data); // OK ``` -**Why `join_segments()`?** +### 4.3 `join_segments()` + +Common pattern: building paths from multiple user inputs such as +`format!("{}/{}", user_id, filename)`. This is error-prone because separators and `..` in a segment +can still escape. `join_segments()` validates each segment independently, rejecting `/`, `\`, `..`, +and null bytes. Empty segments are silently skipped (consistent with how most shells and URL +normalizers treat empty components). + +### 4.4 Why reject broken symlinks? + +A broken symlink's target cannot be verified. If the path were returned, and the target were later +created (or exists but is inaccessible), the symlink could point outside the jail. Rejection is the +safe default. + +### 4.5 Why canonicalize the root immediately? + +Ensures `starts_with()` comparisons are reliable. Without canonicalization: +- `/var/uploads` vs `/var/./uploads` — fails on string comparison +- macOS: `/var` vs `/private/var` — `var` is a symlink, comparison would fail + +### 4.6 Why no runtime dependencies? + +`openat2` is invoked via a hand-rolled syscall wrapper in `src/openat2.rs` rather than through +`libc`. This keeps the default feature set free of any runtime dependency and the `guard` feature +free of any new ones. + +### 4.7 Pluggable signing (`Signer` / `Verifier`) + +The `Attestation` struct records inode, device, nlink, and timestamp at open time. path_jail does +not vendor a crypto implementation — callers bring their own by implementing `Signer` and +`Verifier`. This keeps the crate zero-dependency while supporting `ed25519-dalek`, `ring`, HSM +clients, AWS KMS, GCP KMS, or any other backend. + +### 4.8 `FdJail` does not implement `Clone`/`Send`/`Sync` by default -Common pattern is building paths from multiple user inputs: `format!("{}/{}", user_id, filename)`. This is error-prone: -- Path separators in segments can cause unexpected behavior -- `..` in segments can still escape +`Clone` would require `dup(2)` on the pinned `dirfd`. On Linux, `OwnedFd` is `Send + Sync`, so +sharing an `FdJail` across threads via `Arc` is safe once those traits are derived. They +are added as part of v0.4.0 (see `fd_jail.rs`). -`join_segments()` validates each segment independently, rejecting `/`, `\`, and `..`. +--- -## 4. Project Structure +## 5. Project Structure ``` path_jail/ ├── src/ -│ ├── lib.rs # Re-exports, join() convenience function -│ ├── jail.rs # Jail struct and methods -│ ├── jailed_path.rs # JailedPath newtype -│ ├── error.rs # JailError enum -│ └── open.rs # secure-open feature (O_NOFOLLOW helpers) +│ ├── lib.rs # Re-exports, join() convenience function +│ ├── jail.rs # Jail struct and methods +│ ├── jailed_path.rs # JailedPath newtype +│ ├── error.rs # JailError enum +│ ├── open.rs # secure-open feature (O_NOFOLLOW helpers) +│ ├── openat2.rs # Raw openat2 syscall wrapper (Linux) +│ └── guard/ +│ ├── mod.rs # Re-exports +│ ├── fd_jail.rs # FdJail, JailFile, Attestation, OpenOptions +│ └── signing.rs # Signer, Verifier, VerifyError traits ├── tests/ -│ ├── security.rs # Integration tests -│ └── secure_open.rs # secure-open feature tests -├── README.md # User guide -├── DESIGN.md # This file +│ ├── security.rs # Core path-validation tests +│ ├── secure_open.rs # secure-open feature tests +│ └── guard.rs # guard feature tests +├── docs/ +│ └── (empty — design specs live in DESIGN.md) +├── README.md # User guide +├── DESIGN.md # This file +├── SECURITY.md # Threat model +├── CHANGELOG.md ├── LICENSE-MIT └── LICENSE-APACHE ``` -## 5. Feature Flags +--- + +## 6. Feature Flags + +### `default` — no flags + +Zero dependencies. Provides `Jail`, `JailedPath`, `JailError`, and the `join()` convenience +function. Works on all platforms including Windows. ### `secure-open` (Unix only) -Adds TOCTOU-safe file operations using `O_NOFOLLOW`: +Adds `O_NOFOLLOW`-protected file operations to `Jail`: ```rust -// Opens with O_NOFOLLOW - rejects symlinks on the final path component -let file = jail.open("config.txt")?; - -// Creates with O_CREAT | O_EXCL | O_NOFOLLOW -let file = jail.create("new.txt")?; +let file = jail.open("config.txt")?; // O_NOFOLLOW +let file = jail.create("new.txt")?; // O_CREAT | O_EXCL | O_NOFOLLOW +let file = jail.create_or_truncate(...); // O_TRUNC | O_NOFOLLOW +let file = jail.open_append("log.txt")?; // O_APPEND | O_NOFOLLOW ``` -This protects against symlink swap attacks between path validation and file open. Zero dependencies - uses `std::os::unix::fs::OpenOptionsExt::custom_flags()` with platform-specific `O_NOFOLLOW` constants. +Zero additional dependencies — uses `std::os::unix::fs::OpenOptionsExt::custom_flags()`. + +**Limitation:** Protects the final path component only. + +### `guard` (Unix API surface; full protection on Linux 5.6+) + +Adds `FdJail` with atomic kernel-enforced containment on Linux 5.6+, and the O_NOFOLLOW fallback +on macOS/BSD. Also adds `Attestation`, `Signer`/`Verifier` traits, and new `JailError` variants. + +Enabling `guard` on Windows compiles without error but is a no-op (all items are +`#[cfg(unix)]`-gated). + +--- + +## 7. Platform Support Matrix + +| Feature | Linux 5.6+ | Linux < 5.6 | macOS / BSD | Windows | +|---|---|---|---|---| +| `default` | ✓ | ✓ | ✓ | ✓ | +| `secure-open` | ✓ | ✓ | ✓ | no-op | +| `guard` (TOCTOU-safe) | ✓ | `UnsupportedKernel` error | fallback (`toctou_safe=false`) | no-op | + +--- + +## 8. Known Limitations + +See `README.md` § Limitations and `SECURITY.md` for the full threat model. Key points: -**Limitation:** Protects the final path component only. Intermediate directory symlink swaps require `openat()` walking, which would need `libc`. For full TOCTOU protection, use `cap-std`. +- **Hard links** cannot be detected by path inspection alone. `JailFile::has_hard_links()` checks + `nlink` after the fd is open — use it to enforce hard-link policy. +- **Mount points** — use `OpenOptions::no_xdev()` on Linux to block cross-mount traversal. +- **Windows reserved device names** (`CON`, `NUL`, etc.) — validate before calling path_jail. +- **Unicode normalization** (macOS NFD) — always store `jail.root()`, never the raw input. +- **TOCTOU on macOS** — the `guard` fallback is not atomic; use Linux 5.6+ or OS isolation for + the strongest guarantees. -## 6. Future Considerations +--- -Not planned, but possible extensions if there's demand: +## 9. Future Considerations -- **Async support**: Feature-gated async versions of I/O operations -- **Serde support**: Deserialize `Jail` from config files -- **Custom canonicalization**: For virtual filesystems or testing -- **Windows `secure-open`**: Reparse point detection via `FILE_FLAG_OPEN_REPARSE_POINT` +Not planned, but possible extensions if there is demand: -These would be feature-gated to maintain zero-dependency default. +- **Async support** — feature-gated async wrappers around `FdJail::open` +- **Serde support** — deserializing `Jail` from config files +- **Windows `secure-open`** — reparse-point detection via `FILE_FLAG_OPEN_REPARSE_POINT` +- **`FdJail` directory operations** — `mkdir`, `readdir`, `rename` via the pinned `dirfd` diff --git a/SECURITY.md b/SECURITY.md index 4de0f29..66257b4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -131,13 +131,17 @@ or hostile multi-tenant → └─────────────── ## 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. +- We follow [Semantic Versioning](https://semver.org/). In the `0.y.z` + pre-release series, minor-version bumps (`0.4 → 0.5`) may include breaking + API changes; patch bumps (`0.4.0 → 0.4.1`) will not. Starting with `1.0.0`, + the standard semver contract applies: only major bumps (`1.x → 2.0`) may + break the public API. - 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. + backport to older `0.x` lines; after `1.0.0` we will evaluate backports + case-by-case for high-severity findings. +- The MSRV (currently 1.85) may be bumped in any minor release in the `0.x` + series. After `1.0.0`, MSRV bumps will be treated as minor-version changes + and documented in the changelog. --- @@ -180,5 +184,5 @@ environments, **strongly prefer `guard`** over the path-based API: - 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. +We may make `guard` the default in `1.0.0` or a future major release. For now, +choose explicitly. diff --git a/docs/fd_first_spec.md b/docs/fd_first_spec.md deleted file mode 100644 index 8e92613..0000000 --- a/docs/fd_first_spec.md +++ /dev/null @@ -1,267 +0,0 @@ -# path_jail guard rewrite: mini spec - -## Objective - -Replace path_jail's validate-then-return-PathBuf architecture with an guard design using `openat2(RESOLVE_BENEATH)`. The result is TOCTOU-safe by construction. Zero new dependencies. Linux 5.6+ required; macOS gets a documented fallback with weaker guarantees. - ---- - -## Syscall layer - -The core primitive is `openat2` with `resolve` flags: - -```rust -// Raw syscall — no libc, no rustix -// linux/openat2.h -#[repr(C)] -struct OpenHow { - flags: u64, // O_RDONLY, O_WRONLY, etc. - mode: u64, // creation mode, 0 for reads - resolve: u64, // RESOLVE_* flags -} - -const RESOLVE_BENEATH: u64 = 0x08; // no escape from dirfd subtree -const RESOLVE_NO_SYMLINKS: u64 = 0x04; // optional: reject all symlinks -const RESOLVE_NO_MAGICLINKS: u64 = 0x02; // reject /proc/self/fd style links -const SYS_OPENAT2: i64 = 437; - -fn openat2_beneath(dirfd: RawFd, path: &CStr, flags: i32) -> Result { - let how = OpenHow { - flags: flags as u64, - mode: 0, - resolve: RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS, - }; - let fd = unsafe { - syscall(SYS_OPENAT2, dirfd, path.as_ptr(), &how, size_of::()) - }; - if fd < 0 { - // Linux syscall errors are in [-4095, -1]. The cast is safe in practice, - // but assert the range explicitly to catch unexpected values on unusual targets. - debug_assert!(fd >= i32::MIN as i64, "syscall errno out of expected range"); - Err(Errno(-fd as i32)) - } else { - Ok(unsafe { OwnedFd::from_raw_fd(fd as i32) }) - } -} -``` - -`RESOLVE_BENEATH` is the key flag. The kernel enforces jail containment — no userspace path parsing required. Symlinks that escape the jail return `EXDEV`. Path components that traverse above the jail root return `EXDEV`. This is not a check followed by an open; it is a single atomic operation. - -`RESOLVE_NO_MAGICLINKS` blocks `/proc/self/fd/N` and similar kernel magic links that can escape the jail regardless of `RESOLVE_BENEATH`. On by default. - -`RESOLVE_NO_SYMLINKS` is opt-in. Default off — most legitimate tool use involves symlinks inside the jail. Available as `JailOptions::no_symlinks()`. - ---- - -## API surface - -```rust -pub struct Jail { - dirfd: OwnedFd, // open directory fd for the jail root, pinned at Jail::new time - root: PathBuf, // stored for attestation and error messages only -} - -pub struct JailFile { - file: File, - jail_root: PathBuf, - opened_path: PathBuf, // relative, as requested - root_inode: u64, // from fstat on dirfd at Jail::new time - file_inode: u64, // from fstat on the opened fd - device: u64, // st_dev — same device = hard link detection basis - nlink: u64, // hard link count -} - -pub struct Attestation { - pub jail_root: PathBuf, - pub opened_path: PathBuf, - pub root_inode: u64, - pub file_inode: u64, - pub device: u64, - pub nlink: u64, // hard link count — caller decides policy - pub toctou_safe: bool, // false on macOS fallback path - pub opened_at: SystemTime, - pub signature: Option<[u8; 64]>, // Ed25519, if key configured -} - -impl Jail { - /// Opens the jail root directory and pins its inode. - /// `dirfd` is held open for the lifetime of the Jail. Subsequent renames - /// or replacements of the root path do not affect the jail — all operations - /// remain scoped to the original directory regardless of what happens to - /// the path string used to create it. - /// Fails if path does not exist or is not a directory. - /// Fails on Linux < 5.6 unless fallback feature is enabled. - pub fn new(root: impl AsRef) -> Result; - - /// Opens a file relative to the jail root. - /// Returns JailFile containing the fd and attestation data. - /// Single syscall on Linux 5.6+ (openat2). TOCTOU-safe. - pub fn open(&self, path: impl AsRef, options: OpenOptions) - -> Result; - - /// Creates a file relative to the jail root. - /// Uses O_CREAT | O_EXCL relative to dirfd. No path string in critical section. - /// The parent directory MUST already exist inside the jail. This method does - /// not create intermediate directories. Callers needing mkdir-p semantics - /// must create parent directories explicitly via a separate jail operation - /// before calling create(). Automatic parent creation is out of scope for - /// this spec — it introduces recursive openat chains with their own TOCTOU - /// surface that deserves separate treatment. - pub fn create(&self, path: impl AsRef) - -> Result; - - /// Validates a path without opening. Returns the relative path if safe. - /// Weaker than open() — does not hold an fd. Provided for callers - /// that need a validated path string for logging or display purposes only. - /// MUST NOT be used as the basis for a subsequent open() call. - pub fn check(&self, path: impl AsRef) - -> Result; -} - -impl Attestation { - /// Returns the canonical byte representation of all fields except `opened_at` - /// and `signature`. Used for content-equality checks across calls to the same - /// path, and for implementations that need to compare attestations without - /// caring about when they were produced. - /// The full signing input (including `opened_at`) is what the Ed25519 - /// signature covers; this helper is not a substitute for signature verification. - pub fn content_bytes(&self) -> Vec; -} - pub fn file(&self) -> &File; - pub fn into_file(self) -> File; - pub fn attestation(&self) -> &Attestation; - pub fn sign_attestation(&self, key: &SigningKey) -> Attestation; - - // Hard link policy helper — caller decides whether nlink > 1 is acceptable - pub fn has_hard_links(&self) -> bool { - self.nlink > 1 - } -} -``` - -The `check()` method is a deliberate design decision. Some callers need a path string for logging or display. Providing `check()` with a strong warning makes the safe/unsafe choice explicit rather than having callers call `open()` and immediately extract the path. The docstring MUST state that the result of `check()` MUST NOT be passed to any subsequent `open()` call. - ---- - -## Attestation signing - -The attestation struct is serialized canonically before signing: - -``` -attestation_bytes := - len(jail_root_bytes) as u32 LE - || jail_root_bytes - || len(opened_path_bytes) as u32 LE - || opened_path_bytes - || root_inode as u64 LE - || file_inode as u64 LE - || device as u64 LE - || nlink as u64 LE - || toctou_safe as u8 (1 = true, 0 = false) - || unix_timestamp_nanos as u64 LE -``` - -No JSON, no CBOR — fixed-layout binary. Deterministic without a serialization library. Ed25519 signature over these bytes. The signing key is optionally configured at `Jail::new` time; unsigned attestations are valid for logging and debugging but MUST NOT be accepted by the Tenuo enforcement point as proof of guard execution. - -The Ed25519 signature is the trust anchor for all attestation fields. `root_inode` and `file_inode` are informational without it — an attacker who can forge an attestation struct can claim any inode values. The signature is what binds the attestation to the guard's key, which must match the key named in the warrant's `guard` claim. Enforcement points MUST verify the signature before reading any other attestation field. - -The Tenuo enforcement point verifies: - -1. Attestation signature valid under the guard key named in the warrant -2. `attestation.jail_root` matches the `Subpath` constraint root in the warrant -3. `attestation.opened_path` is within `jail_root` -4. `attestation.toctou_safe` is `true`, unless the warrant explicitly permits otherwise -5. `attestation.opened_at` is within the PoP JWT timestamp window - ---- - -## Error taxonomy - -```rust -pub enum JailError { - /// openat2 returned EXDEV — path escapes jail or traverses above root. - /// Covers symlink escapes, .. traversal, and absolute path attempts. - Escape { requested: PathBuf }, - - /// openat2 returned ELOOP — symlink loop or RESOLVE_NO_SYMLINKS triggered. - SymlinkRejected { requested: PathBuf }, - - /// /proc/self/fd or similar magic link detected (RESOLVE_NO_MAGICLINKS). - MagicLink { requested: PathBuf }, - - /// Jail root does not exist or is not a directory. - InvalidRoot { path: PathBuf, source: io::Error }, - - /// openat2 not available (Linux < 5.6) and fallback feature not enabled. - UnsupportedKernel { version: KernelVersion }, - - /// Standard I/O error (file not found, permission denied, etc.) - Io(io::Error), -} -``` - -`Escape` is the critical variant. It covers the entire class of attacks the map/territory post describes — path traversal, symlink escape, absolute path injection. One error variant, one audit log entry, unambiguous meaning. - ---- - -## Hard link policy - -Hard links cannot be detected before open. After open they are visible via `nlink > 1` on the `fstat` result. The library surfaces this as `JailFile::has_hard_links()` and includes `nlink` in the attestation. Policy is the caller's responsibility: - -```rust -let jf = jail.open("report.pdf", OpenOptions::new().read(true))?; -if jf.has_hard_links() { - return Err(SecurityError::HardLinkDetected); -} -// proceed -``` - -This is the correct layering. The library cannot know whether hard links are acceptable for a given use case — a content-addressed store might legitimately use them. The enforcement point can treat `nlink > 1` as a denial condition by checking the attestation. - -Callers that require hard link rejection MUST check `has_hard_links()` before reading. This MUST be documented prominently, not buried in caveats. - ---- - -## macOS fallback - -macOS has no `openat2`. The fallback uses `open(O_NOFOLLOW)` on each path component via `openat` chain, which is what cap-std does on older kernels. This is userspace path resolution and is therefore not TOCTOU-safe under concurrent rename attacks. - -The fallback: - -- Is gated behind `#[cfg(not(target_os = "linux"))]` -- Emits a compile-time warning: `path_jail: using fallback path resolution — not TOCTOU-safe` -- Sets `Attestation::toctou_safe = false` -- The Tenuo enforcement point MUST reject attestations with `toctou_safe: false` unless the warrant explicitly permits it via a `guard_options` claim - -This makes the weaker guarantee visible in the attestation rather than silently degrading. - ---- - -## What does not change - -- The Python bindings surface. `Jail`, `jail.open()`, `ValueError` on escape. Same ergonomics. -- The zero-dependency commitment. All of the above uses only `std` and raw syscalls. No libc, no rustix. -- `safe_unzip`'s dependency on path_jail. It inherits the TOCTOU fix automatically — `jail.join()` on the write path becomes `jail.open()`. - ---- - -## Scope explicitly excluded - -- **Windows.** `CreateFileW` with `FILE_FLAG_OPEN_REPARSE_POINT` approximates some of this but the semantics differ enough to deserve its own spec. -- **Directory traversal / `read_dir`.** Iterating jail contents has its own TOCTOU surface (rename-during-walk). Document as a known limitation. -- **Network jails.** `url_jail` is a separate crate. This spec is filesystem only. - ---- - -## Acceptance criteria - -| # | Condition | Expected result | -|---|-----------|-----------------| -| 1 | `jail.open("../../etc/passwd")` on Linux 5.6+ | `JailError::Escape`; `strace` shows one `openat2` syscall, no file open | -| 2 | `jail.open("symlink-to-outside")` | `JailError::Escape` | -| 3 | `jail.open("/proc/self/root/etc/passwd")` | `JailError::MagicLink` | -| 4 | Pre-existing hard link inside jail | `open()` succeeds; `jf.has_hard_links()` returns `true` | -| 5 | Two `open()` calls to same path, same jail | `attestation.content_bytes()` identical; `opened_at` differs and is excluded from content bytes by design | -| 6 | Signed attestation | Verifies under configured key | -| 7 | Kernel < 5.6, fallback feature disabled | `Jail::new` returns `JailError::UnsupportedKernel` | -| 8 | Python bindings | `jail.open()` returns file object and attestation bytes; `ValueError` on escape | diff --git a/src/error.rs b/src/error.rs index 37c139a..a1867b6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -13,8 +13,15 @@ pub enum JailError { BrokenSymlink(PathBuf), /// Path is invalid (e.g., contains absolute components or null bytes). InvalidPath(String), - /// Jail root is invalid (path-based API). - InvalidRoot(PathBuf), + /// Jail root is invalid (filesystem root, not a directory, or inaccessible). + /// + /// `source` is `Some` when an I/O error was the proximate cause (e.g., + /// permission denied opening the directory). It is `None` when the root + /// was rejected on structural grounds (e.g., the path is `/` or `C:\`). + InvalidRoot { + path: PathBuf, + source: Option, + }, // ── guard API variants ───────────────────────────────────────────────── /// `openat2` returned `EXDEV` — path escapes jail or traverses above root. @@ -33,15 +40,22 @@ pub enum JailError { /// A `/proc/self/fd`-style magic link was detected (`RESOLVE_NO_MAGICLINKS`). /// These links can escape the jail regardless of `RESOLVE_BENEATH`. /// - /// # Currently unreachable + /// # Deprecation + /// + /// **This variant is currently unreachable.** The Linux kernel returns the + /// same errno (`ELOOP`) for both `RESOLVE_NO_MAGICLINKS` and + /// `RESOLVE_NO_SYMLINKS` rejections; userspace cannot distinguish them. + /// Magic-link rejections therefore surface as [`Self::SymlinkRejected`]. /// - /// The Linux kernel returns the same errno (`ELOOP`) for both - /// `RESOLVE_NO_MAGICLINKS` and `RESOLVE_NO_SYMLINKS` rejections, and - /// userspace cannot tell them apart. As of v0.5, magic-link rejections - /// surface as [`Self::SymlinkRejected`] rather than this variant. The - /// variant is preserved (and not yet deprecated) so callers can match on - /// it if a future kernel ABI separates the two errnos. + /// Match on `SymlinkRejected` instead. This variant is preserved so + /// existing `match` arms are not broken; it will be removed in a future + /// major version if the kernel introduces a distinct errno. #[cfg(feature = "guard")] + #[deprecated( + since = "0.4.0", + note = "unreachable: the kernel maps magic-link rejections to ELOOP, \ + which surfaces as `SymlinkRejected`. Match on `SymlinkRejected` instead." + )] MagicLink { requested: PathBuf }, /// `openat2(2)` is not available on this kernel (Linux < 5.6). @@ -50,18 +64,15 @@ pub enum JailError { /// `/proc/sys/kernel/osrelease`, and `None` when `/proc` is unavailable /// (some hardened containers). In both cases the live `openat2` probe /// confirmed the syscall is not supported. - #[cfg(all(feature = "guard", target_os = "linux"))] + #[cfg(all( + feature = "guard", + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ))] UnsupportedKernel { version: Option, }, - /// Invalid root in the guard API (not a directory, filesystem root, or inaccessible). - #[cfg(feature = "guard")] - InvalidJailRoot { - path: PathBuf, - source: std::io::Error, - }, - // ── Shared ──────────────────────────────────────────────────────────────── /// Underlying I/O error. Io(std::io::Error), @@ -83,7 +94,13 @@ impl fmt::Display for JailError { path.display() ), Self::InvalidPath(reason) => write!(f, "invalid path: {}", reason), - Self::InvalidRoot(path) => { + Self::InvalidRoot { + path, + source: Some(src), + } => { + write!(f, "invalid jail root '{}': {}", path.display(), src) + } + Self::InvalidRoot { path, source: None } => { let reason = if path.parent().is_none() { "cannot use filesystem root" } else if !path.is_dir() { @@ -108,26 +125,30 @@ impl fmt::Display for JailError { requested.display() ), #[cfg(feature = "guard")] + #[allow(deprecated)] Self::MagicLink { requested } => write!( f, "magic link detected for path '{}' (RESOLVE_NO_MAGICLINKS)", requested.display() ), - #[cfg(all(feature = "guard", target_os = "linux"))] + #[cfg(all( + feature = "guard", + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ))] Self::UnsupportedKernel { version: Some(v) } => { write!(f, "openat2 not available on kernel {} (requires >= 5.6)", v) } - #[cfg(all(feature = "guard", target_os = "linux"))] + #[cfg(all( + feature = "guard", + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ))] Self::UnsupportedKernel { version: None } => write!( f, "openat2 not available on this kernel (requires >= 5.6; \ kernel version unreadable)" ), - #[cfg(feature = "guard")] - Self::InvalidJailRoot { path, source } => { - write!(f, "invalid jail root '{}': {}", path.display(), source) - } - Self::Io(err) => write!(f, "io error: {}", err), } } @@ -137,8 +158,9 @@ impl std::error::Error for JailError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { Self::Io(err) => Some(err), - #[cfg(feature = "guard")] - Self::InvalidJailRoot { source, .. } => Some(source), + Self::InvalidRoot { + source: Some(src), .. + } => Some(src), _ => None, } } diff --git a/src/guard/fd_jail.rs b/src/guard/fd_jail.rs index cec8dbe..efe4fa9 100644 --- a/src/guard/fd_jail.rs +++ b/src/guard/fd_jail.rs @@ -19,12 +19,15 @@ use std::fs::File; use std::path::{Path, PathBuf}; use std::time::SystemTime; -// Linux imports — gated so macOS builds don't see unused-import warnings. -#[cfg(target_os = "linux")] +// "openat2-capable" platforms: Linux on x86_64 or aarch64. +// All other platforms (macOS, BSD, Windows, and Linux on other arches such as +// riscv64/s390x/loongarch64) use the O_NOFOLLOW fallback with toctou_safe=false. +#[cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") +))] use crate::openat2::{kernel_version as openat2_kernel_version, probe_openat2, MIN_OPENAT2_KERNEL}; -// macOS/BSD: fallback_impl is self-contained. - // ── Public types ────────────────────────────────────────────────────────────── /// A file opened through [`FdJail::open`] or [`FdJail::create`] (guard API). @@ -33,12 +36,15 @@ use crate::openat2::{kernel_version as openat2_kernel_version, probe_openat2, MI /// time. On Linux 5.6+ the open is performed by a single `openat2` syscall and /// is therefore TOCTOU-safe by construction; on other platforms a fallback path /// is used and `attestation().toctou_safe` will be `false`. -pub struct JailFile { +/// +/// See [`JailedFile`](crate::JailedFile) for the lighter `secure-open` variant +/// that uses `O_NOFOLLOW` without an `Attestation`. +pub struct GuardedFile { pub(crate) file: File, pub(crate) attestation: Attestation, } -impl JailFile { +impl GuardedFile { /// Returns a reference to the underlying [`File`]. pub fn file(&self) -> &File { &self.file @@ -93,34 +99,34 @@ impl JailFile { } } -impl std::fmt::Debug for JailFile { +impl std::fmt::Debug for GuardedFile { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("JailFile") + f.debug_struct("GuardedFile") .field("attestation", &self.attestation) .finish_non_exhaustive() } } -impl std::ops::Deref for JailFile { +impl std::ops::Deref for GuardedFile { type Target = File; fn deref(&self) -> &File { &self.file } } -impl std::ops::DerefMut for JailFile { +impl std::ops::DerefMut for GuardedFile { fn deref_mut(&mut self) -> &mut File { &mut self.file } } -impl std::io::Read for JailFile { +impl std::io::Read for GuardedFile { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { self.file.read(buf) } } -impl std::io::Write for JailFile { +impl std::io::Write for GuardedFile { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.file.write(buf) } @@ -129,16 +135,34 @@ impl std::io::Write for JailFile { } } -impl std::io::Seek for JailFile { +impl std::io::Seek for GuardedFile { fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { self.file.seek(pos) } } +#[cfg(unix)] +impl std::os::unix::io::AsFd for GuardedFile { + fn as_fd(&self) -> std::os::unix::io::BorrowedFd<'_> { + self.file.as_fd() + } +} + +#[cfg(unix)] +impl std::os::unix::io::AsRawFd for GuardedFile { + fn as_raw_fd(&self) -> std::os::unix::io::RawFd { + self.file.as_raw_fd() + } +} + // ── Attestation ─────────────────────────────────────────────────────────────── /// Attestation data recorded at the moment a file is opened through the jail. /// +/// This struct is `#[non_exhaustive]`: new fields may be added in future minor +/// releases. Construct attestations only via [`FdJail::open`] / [`FdJail::create`]; +/// do not construct them in struct-literal syntax in your own code. +/// /// The Ed25519 signature (when present) is the **trust anchor** for all other /// fields — an attacker who can forge an attestation struct can claim any inode /// values. Enforcement points **MUST** verify the signature before reading any @@ -146,7 +170,7 @@ impl std::io::Seek for JailFile { /// /// # Signing /// -/// Sign an attestation by calling [`JailFile::sign_attestation`] with any type +/// Sign an attestation by calling [`GuardedFile::sign_attestation`] with any type /// that implements the [`Signer`](crate::guard::Signer) trait. The library does /// not vendor a crypto implementation — bring your own (`ed25519-dalek`, /// `ring`, HSM, KMS, etc.). See the [`signing`](crate::guard) module for @@ -163,6 +187,7 @@ impl std::io::Seek for JailFile { /// for the same path in the same jail will produce identical `content_bytes`. /// `opened_at` intentionally differs and is excluded from `content_bytes`. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct Attestation { /// Canonicalized jail root at the time of `Jail::new`. pub jail_root: PathBuf, @@ -174,7 +199,7 @@ pub struct Attestation { pub file_inode: u64, /// Device number (`st_dev`). Same device as root ⇒ hard link detection is valid. pub device: u64, - /// Hard link count (`st_nlink`). Caller decides policy; see [`JailFile::has_hard_links`]. + /// Hard link count (`st_nlink`). Caller decides policy; see [`GuardedFile::has_hard_links`]. pub nlink: u64, /// `true` if the open used `openat2(RESOLVE_BENEATH)` (Linux 5.6+), `false` /// on macOS/BSD fallback path. @@ -353,7 +378,10 @@ impl OpenOptions { // ── Linux implementation ────────────────────────────────────────────────────── -#[cfg(target_os = "linux")] +#[cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") +))] mod linux_impl { use super::*; use crate::openat2::{ @@ -371,7 +399,7 @@ mod linux_impl { root_inode: u64, rel_path: &Path, opts: &OpenOptions, - ) -> Result { + ) -> Result { // Build the relative CStr path (openat2 requires relative for RESOLVE_BENEATH) let path_str = rel_path .to_str() @@ -444,7 +472,7 @@ mod linux_impl { signature: None, }; - Ok(JailFile { file, attestation }) + Ok(GuardedFile { file, attestation }) } fn map_errno_to_jail_error(e: Errno, path: &Path) -> JailError { @@ -469,7 +497,10 @@ mod linux_impl { // ── macOS / BSD fallback ────────────────────────────────────────────────────── -#[cfg(not(target_os = "linux"))] +#[cfg(not(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") +)))] mod fallback_impl { use super::*; use std::os::unix::fs::OpenOptionsExt; @@ -482,7 +513,7 @@ mod fallback_impl { root_inode: u64, rel_path: &Path, opts: &OpenOptions, - ) -> Result { + ) -> Result { // Validate via existing path-walking logic first let abs_path = { let jail = crate::jail::Jail::new(jail_root)?; @@ -531,7 +562,7 @@ mod fallback_impl { signature: None, }; - Ok(JailFile { file, attestation }) + Ok(GuardedFile { file, attestation }) } } @@ -541,7 +572,10 @@ mod fallback_impl { /// Returns the O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC flags for opening a directory fd. /// Used when pinning the jail root dirfd on Linux. -#[cfg(target_os = "linux")] +#[cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") +))] fn libc_open_directory_flags() -> i32 { // O_RDONLY=0, O_NOFOLLOW=0x20000 (linux), O_DIRECTORY=0x10000, O_CLOEXEC=0x80000 0o0_200000 | 0o0_400000 | 0o2_000000 // O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC @@ -551,37 +585,107 @@ fn libc_open_directory_flags() -> i32 { /// /// On Linux this holds an open `dirfd` pinned at `Jail::new` time; on other /// platforms we derive the root inode from `std::fs::metadata`. +/// +/// # Thread safety +/// +/// `FdJail` is `Send + Sync` and can be shared across threads via `Arc`. +/// `Clone` is supported: on Linux it `dup(2)`s the pinned directory fd so each +/// clone holds its own independent fd. pub struct FdJail { /// Canonicalized jail root (same as `Jail::root()`). pub(crate) root: PathBuf, /// Root inode pinned at construction time. pub(crate) root_inode: u64, /// Open directory fd (Linux only). - #[cfg(target_os = "linux")] + #[cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ))] pub(crate) dirfd: std::os::unix::io::OwnedFd, } +// SAFETY: OwnedFd is Send + Sync on Linux; PathBuf and u64 are always Send + Sync. +unsafe impl Send for FdJail {} +unsafe impl Sync for FdJail {} + +impl Clone for FdJail { + fn clone(&self) -> Self { + #[cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ))] + { + use std::os::unix::io::{AsFd, OwnedFd}; + // dup(2) the directory fd so the clone is fully independent. + let duped: OwnedFd = self + .dirfd + .as_fd() + .try_clone_to_owned() + .expect("dup of jail dirfd failed"); + FdJail { + root: self.root.clone(), + root_inode: self.root_inode, + dirfd: duped, + } + } + #[cfg(not(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + )))] + { + FdJail { + root: self.root.clone(), + root_inode: self.root_inode, + } + } + } +} + impl FdJail { /// Open the jail root directory and pin its inode. /// - /// On Linux 5.6+ this also verifies that `openat2` is available. - /// Returns `JailError::UnsupportedKernel` on Linux < 5.6. On macOS/BSD the - /// fallback path is used unconditionally (no separate feature gate); see - /// [`Attestation::toctou_safe`] to detect fallback at runtime. + /// On Linux 5.6+ this also verifies that `openat2` is available and returns + /// `JailError::UnsupportedKernel` on older kernels. + /// + /// # Platform behavior + /// + /// | Platform | Mechanism | TOCTOU-safe | + /// |---|---|---| + /// | Linux 5.6+ | `openat2(RESOLVE_BENEATH)` — single atomic syscall | **Yes** | + /// | macOS / BSD | `O_NOFOLLOW` on the final path component | **No** | + /// + /// On macOS and BSD this constructor always succeeds (there is no kernel + /// version gate), but every subsequent [`open`](Self::open) call uses the + /// same `O_NOFOLLOW`-based fallback as the `secure-open` feature. A race + /// window exists between path validation and the `open(2)` syscall. + /// + /// **Always check [`Attestation::toctou_safe`] if your threat model + /// requires kernel-enforced atomicity.** If `toctou_safe` is `false` and + /// you need stronger guarantees, run on Linux 5.6+ or use OS-level + /// isolation (container, chroot). pub fn new(root: impl AsRef) -> Result { - let root = root.as_ref().canonicalize().map_err(JailError::Io)?; + let root_input = root.as_ref().to_path_buf(); + let root = root_input + .canonicalize() + .map_err(|e| JailError::InvalidRoot { + path: root_input.clone(), + source: Some(e), + })?; if root.parent().is_none() || !root.is_dir() { - return Err(JailError::InvalidJailRoot { + return Err(JailError::InvalidRoot { path: root, - source: std::io::Error::new( + source: Some(std::io::Error::new( std::io::ErrorKind::InvalidInput, "not a directory or is filesystem root", - ), + )), }); } - #[cfg(target_os = "linux")] + #[cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ))] { use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; use std::os::unix::io::FromRawFd; @@ -622,7 +726,10 @@ impl FdJail { }) } - #[cfg(not(target_os = "linux"))] + #[cfg(not(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + )))] { // Emit a compile-time note (not an error — fallback is allowed) let meta = std::fs::metadata(&root).map_err(JailError::Io)?; @@ -641,14 +748,24 @@ impl FdJail { /// with `O_NOFOLLOW` on the final component; `attestation().toctou_safe` will /// be `false`. /// - /// Returns a [`JailFile`] containing both the open [`File`] and attestation data. - pub fn open(&self, path: impl AsRef, opts: OpenOptions) -> Result { + /// Returns a [`GuardedFile`] containing both the open [`File`] and attestation data. + pub fn open( + &self, + path: impl AsRef, + opts: OpenOptions, + ) -> Result { let rel = self.validate_relative(path.as_ref())?; - #[cfg(target_os = "linux")] + #[cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ))] return linux_impl::jail_open(&self.dirfd, &self.root, self.root_inode, &rel, &opts); - #[cfg(not(target_os = "linux"))] + #[cfg(not(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + )))] return fallback_impl::jail_open(&self.root, self.root_inode, &rel, &opts); } @@ -657,22 +774,25 @@ impl FdJail { /// Uses `O_CREAT | O_EXCL` — fails if the file already exists. /// The parent directory **must** already exist; this method does not create /// intermediate directories. - pub fn create(&self, path: impl AsRef) -> Result { + pub fn create(&self, path: impl AsRef) -> Result { self.open(path, OpenOptions::new().write(true).create_new(true)) } - /// Validates a path without opening a file descriptor. + /// Validates a path without opening a file descriptor — for display or logging only. /// - /// Returns the validated relative path if it is safe. This is **weaker** than - /// [`open`](Self::open) because it does not hold an fd. Use it only for - /// logging or display purposes. + /// Returns the validated relative path if it passes the same format checks + /// as [`open`](Self::open). This is **weaker** than `open` because no fd + /// is held: the path can change between this call and any subsequent + /// filesystem operation. /// - /// # ⚠ Warning + /// # ⚠ Do not open after `check_path` /// - /// The returned `PathBuf` **MUST NOT** be passed to a subsequent `open` call. - /// Doing so reintroduces the TOCTOU window that `open` eliminates. Use - /// `open` if you intend to access the file. - pub fn check(&self, path: impl AsRef) -> Result { + /// The returned `PathBuf` **MUST NOT** be passed to a subsequent `open` or + /// `create` call. Doing so reintroduces the TOCTOU window that `open` + /// eliminates atomically. Call `open` directly when you need to access the + /// file — use `check_path` only when you need a safe string for a log + /// entry, an error message, or an audit record. + pub fn check_path(&self, path: impl AsRef) -> Result { self.validate_relative(path.as_ref()) } diff --git a/src/guard/mod.rs b/src/guard/mod.rs index 434023e..e5b53a8 100644 --- a/src/guard/mod.rs +++ b/src/guard/mod.rs @@ -7,5 +7,5 @@ mod fd_jail; mod signing; -pub use fd_jail::{Attestation, FdJail, JailFile, OpenOptions}; +pub use fd_jail::{Attestation, FdJail, GuardedFile, OpenOptions}; pub use signing::{Signer, Verifier, VerifyError}; diff --git a/src/jail.rs b/src/jail.rs index 982abba..6587176 100644 --- a/src/jail.rs +++ b/src/jail.rs @@ -20,7 +20,10 @@ impl Jail { // Reject filesystem roots (/, C:\) - they have no parent // Reject non-directories (files, etc.) if root.parent().is_none() || !root.is_dir() { - return Err(JailError::InvalidRoot(root)); + return Err(JailError::InvalidRoot { + path: root, + source: None, + }); } Ok(Self { root }) } @@ -190,6 +193,9 @@ impl Jail { /// This is safer than `join(format!("{}/{}", a, b))` because it validates /// each segment independently. /// + /// Empty strings in the iterator are silently skipped, consistent with how + /// most URL normalizers and shells handle empty path components. + /// /// # Example /// /// ```no_run @@ -202,6 +208,11 @@ impl Jail { /// // Safe: each segment is validated /// let path = jail.join_segments([user_id, "files", filename])?; /// + /// // Empty segments are skipped — these produce the same path: + /// let a = jail.join_segments(["user", "file"])?; + /// let b = jail.join_segments(["user", "", "file"])?; + /// assert_eq!(a, b); + /// /// // These would fail: /// // jail.join_segments(["../etc", "passwd"])?; // ".." rejected /// // jail.join_segments(["users/files"])?; // "/" rejected diff --git a/src/lib.rs b/src/lib.rs index 92f75fe..fc3585b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,13 +39,13 @@ //! use path_jail::guard::{FdJail, OpenOptions}; //! //! let jail = FdJail::new("/var/uploads")?; -//! let mut jf = jail.open("report.pdf", OpenOptions::new().read(true))?; -//! if jf.has_hard_links() { +//! let mut gf = jail.open("report.pdf", OpenOptions::new().read(true))?; +//! if gf.has_hard_links() { //! // Enforce hard-link policy here //! } //! use std::io::Read; //! let mut buf = Vec::new(); -//! jf.read_to_end(&mut buf)?; +//! gf.read_to_end(&mut buf)?; //! # } //! # Ok::<(), Box>(()) //! ``` @@ -91,7 +91,14 @@ mod jailed_path; #[cfg(all(feature = "secure-open", unix))] mod open; -#[cfg(all(feature = "guard", target_os = "linux"))] +// openat2 wrapper is only compiled for Linux architectures that have a +// raw-asm syscall shim (x86_64 and aarch64). Other Linux arches and all +// non-Linux platforms fall back to the O_NOFOLLOW path in guard/fd_jail.rs. +#[cfg(all( + feature = "guard", + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") +))] pub(crate) mod openat2; // `guard` is Unix-only — the implementation uses `OwnedFd`, `MetadataExt`, diff --git a/src/open.rs b/src/open.rs index a44c9bf..740e2da 100644 --- a/src/open.rs +++ b/src/open.rs @@ -39,7 +39,9 @@ const O_NOFOLLOW: i32 = 0x0100; #[cfg(target_os = "dragonfly")] const O_NOFOLLOW: i32 = 0x0100; -// Fallback for other Unix-like systems +// If we reach this point we are on a Unix that path_jail does not know the +// O_NOFOLLOW value for. Setting it to 0 would silently disable the symlink +// protection, which is a security bug. Fail loudly instead. #[cfg(not(any( target_os = "linux", target_os = "macos", @@ -48,7 +50,13 @@ const O_NOFOLLOW: i32 = 0x0100; target_os = "netbsd", target_os = "dragonfly" )))] -const O_NOFOLLOW: i32 = 0; +compile_error!( + "path_jail secure-open: O_NOFOLLOW is not known for this Unix platform. \ + Setting it to 0 would silently follow symlinks and defeat the feature's \ + purpose. Please open an issue at https://github.com/tenuo-ai/path_jail \ + with your target triple and the correct O_NOFOLLOW value from your \ + system headers." +); /// A file opened with TOCTOU-safe semantics. /// diff --git a/src/openat2.rs b/src/openat2.rs index 7640489..2ae41d1 100644 --- a/src/openat2.rs +++ b/src/openat2.rs @@ -9,18 +9,6 @@ use std::ffi::CStr; use std::os::unix::io::{FromRawFd, OwnedFd, RawFd}; use std::sync::OnceLock; -// ── Architecture guard ──────────────────────────────────────────────────────── - -// The inline-asm syscall shim is implemented for x86_64 and aarch64. -// riscv64 uses a different register convention (a7/a0-a5) and is not yet -// supported. Reject other architectures with a compile error rather than -// silently producing broken binaries. -#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] -compile_error!( - "path_jail guard: only x86_64 and aarch64 Linux are currently supported for the raw-asm syscall path. \ - riscv64 support is planned. Track: https://github.com/tenuo-ai/path_jail/issues" -); - // SYS_openat2 — syscall number on Linux (added in 5.6). Same value on x86_64 // and aarch64 (the kernel keeps recent syscall numbers aligned across arches). const SYS_OPENAT2: i64 = 437; @@ -166,7 +154,11 @@ unsafe fn syscall4(nr: i64, a0: i64, a1: i64, a2: i64, a3: i64) -> i64 { // ── Kernel version probe ─────────────────────────────────────────────────────── /// Parsed kernel version (major, minor, patch). +/// +/// `#[non_exhaustive]`: new components (e.g. a build/variant suffix) may be +/// added in future releases without breaking `PartialOrd`/`Ord`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[non_exhaustive] pub struct KernelVersion { pub major: u32, pub minor: u32, diff --git a/tests/guard.rs b/tests/guard.rs index a69f1ba..9826922 100644 --- a/tests/guard.rs +++ b/tests/guard.rs @@ -174,13 +174,13 @@ fn ac3_magic_link_blocked() { // rejection from a regular symlink rejection (see JailError::MagicLink // docs). Escape (EXDEV) is also acceptable if the kernel resolved // /proc/self/root as a regular link before noticing the cross-mount. + #[allow(deprecated)] + let is_expected = matches!( + err, + JailError::MagicLink { .. } | JailError::Escape { .. } | JailError::SymlinkRejected { .. } + ); assert!( - matches!( - err, - JailError::MagicLink { .. } - | JailError::Escape { .. } - | JailError::SymlinkRejected { .. } - ), + is_expected, "expected MagicLink, Escape, or SymlinkRejected for /proc/self/root, got: {:?}", err ); @@ -442,7 +442,7 @@ fn ac8_api_surface_stable() { let jail = FdJail::new(dir.path()).unwrap(); - // open() returns JailFile with Read + attestation + // open() returns GuardedFile with Read + attestation let mut jf = jail .open("upload.bin", OpenOptions::new().read(true)) .unwrap(); @@ -473,12 +473,12 @@ fn ac8_api_surface_stable() { b"written" ); - // check() returns relative path (weaker — no fd held) - let rel = jail.check("upload.bin").unwrap(); + // check_path() returns relative path (weaker — no fd held) + let rel = jail.check_path("upload.bin").unwrap(); assert_eq!(rel, std::path::Path::new("upload.bin")); - // check() rejects absolute paths - assert!(jail.check("/etc/passwd").is_err()); + // check_path() rejects absolute paths + assert!(jail.check_path("/etc/passwd").is_err()); } // ── Additional: TOCTOU-safe flag ───────────────────────────────────────────── @@ -537,6 +537,72 @@ fn no_xdev_succeeds_when_no_mount_crossing() { .expect("open with no_xdev should succeed when no mount is crossed"); } +// ── Clone / Send / Sync for FdJail ─────────────────────────────────────────── + +#[test] +#[cfg(unix)] +fn fd_jail_clone_is_independent() { + let dir = tempdir().unwrap(); + let file = dir.path().join("shared.txt"); + std::fs::write(&file, b"hello").unwrap(); + + let jail = FdJail::new(dir.path()).unwrap(); + let jail2 = jail.clone(); + + // Both clones should be able to open the same file independently. + let mut jf1 = jail + .open("shared.txt", OpenOptions::new().read(true)) + .unwrap(); + let mut jf2 = jail2 + .open("shared.txt", OpenOptions::new().read(true)) + .unwrap(); + + let mut buf1 = Vec::new(); + let mut buf2 = Vec::new(); + jf1.read_to_end(&mut buf1).unwrap(); + jf2.read_to_end(&mut buf2).unwrap(); + + assert_eq!(buf1, b"hello"); + assert_eq!(buf2, b"hello"); + // The two opens are independent fds pointing to the same inode. + assert_eq!(jf1.attestation().file_inode, jf2.attestation().file_inode); +} + +#[test] +#[cfg(unix)] +fn fd_jail_is_send_sync() { + // Compile-time assertion: FdJail and GuardedFile can be sent across threads. + fn assert_send_sync() {} + assert_send_sync::(); + // GuardedFile is not Send/Sync (it wraps a raw File which has platform-specific rules), + // but FdJail, which is shared to produce GuardedFiles, is. +} + +#[test] +#[cfg(unix)] +fn fd_jail_clone_shared_via_arc() { + use std::sync::Arc; + + let dir = tempdir().unwrap(); + let file = dir.path().join("arc.txt"); + std::fs::write(&file, b"arc content").unwrap(); + + let jail = Arc::new(FdJail::new(dir.path()).unwrap()); + let jail2 = Arc::clone(&jail); + + let handle = std::thread::spawn(move || { + let mut jf = jail2 + .open("arc.txt", OpenOptions::new().read(true)) + .unwrap(); + let mut buf = Vec::new(); + jf.read_to_end(&mut buf).unwrap(); + buf + }); + + let buf = handle.join().unwrap(); + assert_eq!(buf, b"arc content"); +} + // ── no_symlinks option ──────────────────────────────────────────────────────── #[test] diff --git a/tests/security.rs b/tests/security.rs index 8377a35..669b465 100644 --- a/tests/security.rs +++ b/tests/security.rs @@ -10,7 +10,7 @@ fn rejects_filesystem_root() { #[cfg(unix)] { let err = Jail::new("/").unwrap_err(); - assert!(matches!(err, JailError::InvalidRoot(_))); + assert!(matches!(err, JailError::InvalidRoot { .. })); let msg = format!("{}", err); assert!(msg.contains("filesystem root")); } @@ -18,7 +18,7 @@ fn rejects_filesystem_root() { #[cfg(windows)] { let err = Jail::new("C:\\").unwrap_err(); - assert!(matches!(err, JailError::InvalidRoot(_))); + assert!(matches!(err, JailError::InvalidRoot { .. })); let msg = format!("{}", err); assert!(msg.contains("filesystem root")); } @@ -32,7 +32,7 @@ fn invalid_root_captures_path() { // Verify the error captures the canonicalized path let err = Jail::new("/").unwrap_err(); - if let JailError::InvalidRoot(path) = err { + if let JailError::InvalidRoot { path, .. } = err { assert_eq!(path, Path::new("/")); } else { panic!("Expected InvalidRoot error"); @@ -93,7 +93,7 @@ fn rejects_file_as_root() { // Cannot use a file as jail root let err = Jail::new(&file_path).unwrap_err(); - assert!(matches!(err, JailError::InvalidRoot(_))); + assert!(matches!(err, JailError::InvalidRoot { .. })); let msg = format!("{}", err); assert!(msg.contains("not a directory")); }