Skip to content

test(fuzz): archive + paths targets, and four decoder hardening fixes - #1162

Merged
bryan-minimal merged 12 commits into
mainfrom
feat/fuzz-campaign
Aug 3, 2026
Merged

test(fuzz): archive + paths targets, and four decoder hardening fixes#1162
bryan-minimal merged 12 commits into
mainfrom
feat/fuzz-campaign

Conversation

@bryan-minimal

@bryan-minimal bryan-minimal commented Aug 3, 2026

Copy link
Copy Markdown
Member

A weekend fuzzing campaign on a Linux box (nightly + ASan — coverage this project has never had), landing two new fuzz targets and the four fixes they surfaced.

New targets

Target Crate Decodes Trust
archive_extract common extract_compressed_tar — build sources, OCI layers, remote-cache artifacts NET + SUPPLY
path_invariants paths the AbsPath/RelPath/EitherPath realm gates NET/OWN

archive_extract asserts containment after every extraction rather than trusting the return value — a successful extract that escaped the root trips neither a panic nor a sanitizer, and that is exactly the bug shape this target exists to catch. It is the surface that carried #651.

Fixes

  • common::archive — the tar link-target check only ran on the strip_prefix: Some(..) branch; an escaping symlink got through when no prefix was set.
  • common::archive — cap the xz arm decompressed output (it is the one compression path that buffers the whole tar up front, so a decompression bomb was unbounded).
  • pathsEitherPath could mint an unvalidated RelPath, bypassing the constructor gate.
  • minvmd — bound oneshot RPC response reads.

Notes for review

  • crates/paths/fuzz/ is a new fuzz workspace: added to fuzz-crates in both OS branches, with its own [workspace] and .gitignore, so just fuzz-check guards it.
  • Seeds are generated (scripts/gen-seeds.sh) and committed — an unseeded byte fuzzer burns ~10^7 execs before stumbling onto a valid ustar header.
  • Verified on this branch: cargo fmt --check clean, cargo clippy -p common -p paths -p minvmd --all-targets -D warnings clean, cargo test -p common -p paths 103 passing, just fuzz-check green.

A further fix from the same campaign (an SFTP/workspace-uploader chain) follows separately, along with a variant sweep for the same defect class.

Summary by CodeRabbit

  • Security

    • Archive extraction now limits decompressed XZ data and blocks links that escape the destination.
    • One-shot RPC responses are capped at 8 MiB.
    • Invalid relative paths are rejected more consistently.
  • Bug Fixes

    • Improved handling of relative sandbox and working-directory paths.
    • Preserved archive safety checks across compression formats and path-prefix configurations.
  • Tests

    • Added fuzzing coverage for archive extraction and path invariants.
  • Documentation

    • Expanded fuzzing setup and coverage guidance.

Note

Add fuzz targets for archive extraction and path invariants, and fix four decoder hardening bugs

  • Adds a archive_extract libFuzzer harness in crates/common/fuzz that exercises extract_compressed_tar across five compression modes and four strip-prefix options, with a post-extraction containment check for symlinks and directory traversal.
  • Adds a path_invariants libFuzzer harness in crates/paths/fuzz that differentially tests RelPath, EitherPath, and FromStr constructors for invariant consistency.
  • Fixes extract_tar_impl in archive.rs to skip escaping symlinks when no strip_prefix is set, resolve hardlinks relative to the destination directory instead of process CWD, and skip hardlinks whose targets are missing.
  • Caps xz decompression at 4 GiB via a new LimitedWriter wrapper; exceeding the cap returns ArchiveError::DecompressedTooLarge before any extraction.
  • Fixes EitherPath::new in paths/src/lib.rs to store a plain Utf8PathBuf in the Rel variant instead of a RelPath, so inputs containing .. no longer bypass RelPath validation. as_rel now returns Option<&Utf8Path>.
  • Fixes rpc_client::call_oneshot in rpc_client.rs to reject oneshot RPC responses larger than 8 MiB instead of buffering unboundedly.
  • Risk: EitherPath::as_rel return type changed from Option<&RelPath<R>> to Option<&Utf8Path>; callers relying on RelPath methods must re-validate.

Changes since #1162 opened

  • Hardened paths::EitherPath to reject relative paths containing parent directory segments by replacing infallible new constructor with fallible try_new method, changing the Rel variant to store RelPath<R> instead of Utf8PathBuf, and updating FromStr and serde Deserialize implementations to return validation errors [ddfd3e3]
  • Refactored paths::CwdRelative to store raw unresolved user input and allow climbing relative paths during resolution by replacing internal EitherPath<R> storage with Utf8PathBuf and PhantomData<R>, and updating resolve method to join climbing paths onto current working directory [ddfd3e3]
  • Modified minimald crate's resolve_output function to accept &Utf8Path instead of SandboxPath, allowing relative output paths with parent directory segments, explicitly rejecting empty strings, and normalizing absolute paths with SandboxAbsPath::try_new [ddfd3e3]
  • Updated sessions crate's FileSet::walk_root method to use fallible HostPath::try_new and return None when computed root contains disallowed segments, and modified FileSet::walk_fs to skip matching entries that cannot form valid HostPath instances [ddfd3e3]
  • Updated test code across mfile, minimal, sessions crates and integration tests to construct HostPath values using fallible HostPath::try_new(...).unwrap() instead of infallible HostPath::new(...) [ddfd3e3]
  • Updated documentation for env::resolve_output function [e374757]
  • Updated documentation for path types in paths module [e374757]

Macroscope summarized 2f4772b.

@bryan-minimal
bryan-minimal requested a review from a team as a code owner August 3, 2026 18:10
@CLAassistant

CLAassistant commented Aug 3, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change hardens archive extraction, adds archive and path fuzzing targets with seed corpora, updates path validation and resolution, caps oneshot RPC responses, and updates related tests and documentation.

Changes

Archive extraction safety

Layer / File(s) Summary
Bounded extraction and containment checks
crates/common/src/archive.rs
XZ decompression is capped at 4 GiB. Archive entries and link targets are checked for containment. Regression tests cover traversal and writer limits.
Archive fuzz target and corpus
crates/common/fuzz/...
The fuzz target checks compressed tar extraction and containment. Seed generation covers nested paths, links, modes, prefixes, and compression formats.

Path representation validation

Layer / File(s) Summary
Relative path representation and resolution
crates/paths/src/lib.rs, crates/minimald/src/env.rs
EitherPath uses fallible validation. CwdRelative stores unresolved raw paths and resolves them from an absolute cwd.
Path invariant fuzzing and integration
crates/paths/fuzz/*, crates/sessions/..., docs/fuzzing.md, justfile
The fuzz workspace checks path constructor invariants. Related consumers use fallible HostPath construction. Documentation and macOS checks include the new target.

RPC response limiting

Layer / File(s) Summary
Bounded oneshot RPC responses
crates/minvmd/src/rpc_client.rs
Oneshot RPC response bodies are limited to 8 MiB before JSON decoding.

Estimated code review effort: 5 (Critical) | ~90 minutes

Possibly related issues

  • gominimal/minimal#1163 — The changes address hardlink-versus-CWD escapes in common::archive and add fuzz coverage.

Suggested labels: needs-human

Suggested reviewers: norrietaylor, evanspearman

Poem

A rabbit checks each archive path,
And keeps each stream within its berth.
Fuzz seeds hop in line,
Links stay inside,
While large replies meet their limit.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the two fuzz targets and the decoder hardening fixes.
Description check ✅ Passed The description provides a detailed summary and testing evidence, but omits the template checklist.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/fuzz-campaign

Comment @coderabbitai help to get the list of available commands.

bryan-minimal and others added 6 commits August 3, 2026 11:13
`archive::extract_compressed_tar` decodes attacker-influenced bytes for
build sources, OCI image layers, and remote-cache artifacts (NET and
SUPPLY trust), and already carried one path-traversal fix (#651), but had
no fuzz coverage.

The harness asserts containment after every iteration rather than relying
on a panic oracle. The #651 bug was a *successful* extraction that wrote
outside the destination root: no panic, no sanitizer trip, so a
panic-only target runs past it. `assert_contained` walks the extracted
tree and fails on any entry — or symlink target — resolving outside the
root. Symlinks are judged without being followed, since a link merely
pointing outside is already the escape primitive.

Input layout is hand-rolled rather than `#[derive(Arbitrary)]` so that a
seed is two control bytes prepended to a real tarball. Seeding decides
whether this target works at all: unseeded it spends ~7M executions
before constructing its first valid ustar header. `scripts/gen-seeds.sh`
builds the corpus with the system `tar` across all five compressions and
every strip_prefix selector, including adversarial trees (escaping and
absolute-target symlinks, setuid bits).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`extract_tar_impl` had two extraction paths with different security
properties. With `strip_prefix: Some(..)` it ran a per-entry loop that
normalizes the entry path and skips any symlink/hardlink whose target
escapes the destination. With `None` it delegated to a whole-archive
`tar::Archive::unpack`, which writes link targets verbatim.

So the same tarball was safe with a prefix set and unsafe without one:
an entry `link -> ../../../etc` was skipped in one case and created on
disk in the other. Every existing symlink test passed
`Some(&"prefix")`, so nothing covered the gap.

Callers reach the `None` path with untrusted input — a package source
that declares no `strip_prefix` (op::sources) and remote-cache artifact
extraction (rcache::remote), both NET trust.

Route both cases through the same hardened loop, skipping only the strip
step when there is no prefix. Same-tarball write-through was already
contained by tar-rs's `validate_inside_dst`, so the exposure was
attacker-controlled symlinks left in the extracted tree rather than a
direct arbitrary write; the regression test for write-through asserts
that containment explicitly.

Found by the `archive_extract` fuzz target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Holding a `RelPath` is meant to be proof that a path is relative and free
of `..` — that is what lets the daemon composer trust a `SandboxRelPath`
arriving over the wire, since joining it against the sandbox home cannot
escape. `EitherPath::new` built its `Rel` variant with a struct literal,
bypassing `RelPath::try_new`, so `EitherPath::new("../../etc/passwd")`
forged one: `/srv/sandbox/home` joined with it resolves to
`/srv/etc/passwd`. Its doc comment claimed the opposite.

Reachable from more than the CLI: `EitherPath` has a `Deserialize` impl
and `HostPath`/`SandboxPath` alias it, so `WireSource::Project { path }`
mints one straight from wire JSON.

The fix is not to validate harder. `EitherPath` is built from inputs that
legitimately climb — `min materialize --output ../artifacts` typed in a
sandbox cwd, `--minimal-state-dir ../state` on the daemon command line —
so rejecting `..` would break real usage. Instead it stops claiming a
guarantee it cannot keep: `Rel` now holds a bare `Utf8PathBuf`. The realm
is already carried by `EitherPath<R>`, so the inner tag contributed
nothing but the false promise. `RelPath::try_new` is once again the only
door to a `RelPath`.

No live traversal resulted: `resolve_output` normalizes explicitly (its
comment names this exact shape) and `WireSource::Project.path` is used for
identity, not joins. Defense in depth held while the invariant did not.

Adds the `path_invariants` fuzz target that found it — a differential
asserting every route to a `RelPath` enforces the same rule, plus
join-containment, `FromStr`/`try_new` agreement, and absoluteness
partitioning. It ships with the fix because its assertions are written
against the corrected API. Minimal reproducer: ",/..".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gzip, zstd, and bz2 arms hand a streaming decoder straight to the tar
reader, so their disk use is bounded by what the archive actually
unpacks. `lzma_rs` exposes only a one-shot `xz_decompress(reader,
writer)`, so the xz arm has to materialize the entire decompressed tar to
a tempfile first — before a single entry has been validated. Uncapped,
that makes a few KB of hostile `.tar.xz` a disk-fill primitive on a
NET/SUPPLY-trust path.

Bound it with a counting writer and report the overflow as
`ArchiveError::DecompressedTooLarge`, following the named-const plus
explicit "too large" shape already used by `MAX_RECORD_LEN` and
`MAX_REQUEST_BYTES`. 4 GiB clears the largest source tarballs in common
use while still bounding the damage.

The cap is checked before the decompress result: `lzma_rs` wraps the
write error in its own type, and were it ever to swallow one, proceeding
would extract a silently truncated tar.

Tests cover `LimitedWriter` directly — driving 4 GiB through
`xz_decompress` in a unit test is not practical, so the bound is tested
where it lives and wired into the xz arm by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`call_oneshot` read the in-VM daemon's response with an unbounded
`read_to_end`. Every response on this path is a small JSON status object,
but the peer is across the vsock bridge: a wedged or hostile guest could
make the host buffer without limit.

`take` the cap plus one byte so hitting the cap stays distinguishable
from a response that merely fills it, then reject explicitly rather than
letting a truncated body surface as a confusing JSON decode error.
Mirrors `MAX_REQUEST_BYTES` in `minimald::diag`, which bounds the same
shape of read in the other direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds `path_invariants` to the target table and its finding to the track
record.

Rewrites the "More targets" list, which was misleading in two ways. It
named `SpecHash`, `Target::from_str`, and `mfile::File` as future work
when all three have had targets for some time — the same silent rot the
"Keeping the targets alive" section warns about, one section over, in the
prose that a guard cannot check. And it conflated being *possible* to
fuzz with being *worth* fuzzing: two of its suggestions turn out to be
hardened by construction.

Replaces it with the surfaces genuinely left, plus a table of five that
were audited and deliberately skipped, each with the reason. Recording
the negative results is the point — otherwise the next person re-derives
them, as this campaign did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
crates/common/fuzz/fuzz_targets/archive_extract.rs (1)

52-54: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider truncating the body instead of discarding it.

Line 52 returns early for any body over MAX_BODY. libFuzzer keeps producing longer inputs, and each one becomes a no-op execution with no coverage. Truncation keeps the cap and still exercises the decompressors. Alternatively, set -max_len in the fuzz recipe.

♻️ Proposed refactor
-    if body.len() > MAX_BODY {
-        return;
-    }
+    let body = &body[..body.len().min(MAX_BODY)];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/common/fuzz/fuzz_targets/archive_extract.rs` around lines 52 - 54,
Update the oversized-input handling in the archive extraction fuzz target so
bodies exceeding MAX_BODY are truncated to the cap rather than discarded via an
early return. Preserve the existing size limit while allowing the resulting
bounded input to continue through the decompressor execution path.
crates/minimald/src/env.rs (1)

1181-1181: 🧹 Nitpick | 🔵 Trivial

Confirm cross-platform test coverage for this change.

This file is minimald. Per coding guidelines, changes here are unverified on macOS until just test-cross has run. Confirm this recipe (or the platform-appropriate just scope) has been run for this change before merge.

As per coding guidelines: "On macOS, changes to minimald or the min CLI's tests are unverified until just test-cross has run; use the platform-appropriate just scopes."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/env.rs` at line 1181, Run the platform-appropriate
cross-platform test scope for the minimald change, using just test-cross on
macOS or the corresponding just recipe for the current platform, and confirm it
passes before merge.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/common/fuzz/fuzz_targets/archive_extract.rs`:
- Around line 82-114: Initialize the directory walk stack with
canonical_root.clone() instead of root.to_path_buf() in the stack setup, while
leaving the existing canonical-root containment checks and traversal logic
unchanged.

In `@crates/common/src/archive.rs`:
- Around line 727-741: Update the test around extract_compressed_tar and
escaping_symlink_tar to assert that the escaping symlink is not created, and
resolve the attempted write target from the actual dest/prefix/link path rather
than the current escaped path. Replace unwrap_or_default-based content checking
with the deterministic target assertion pattern used by
skipping_escaping_symlink_still_contains_write_through.

---

Nitpick comments:
In `@crates/common/fuzz/fuzz_targets/archive_extract.rs`:
- Around line 52-54: Update the oversized-input handling in the archive
extraction fuzz target so bodies exceeding MAX_BODY are truncated to the cap
rather than discarded via an early return. Preserve the existing size limit
while allowing the resulting bounded input to continue through the decompressor
execution path.

In `@crates/minimald/src/env.rs`:
- Line 1181: Run the platform-appropriate cross-platform test scope for the
minimald change, using just test-cross on macOS or the corresponding just recipe
for the current platform, and confirm it passes before merge.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a79b4ca2-7b5e-4e28-8673-0b7791db55c4

📥 Commits

Reviewing files that changed from the base of the PR and between 3e1fcbd and fde54a1.

📒 Files selected for processing (44)
  • crates/common/fuzz/Cargo.toml
  • crates/common/fuzz/fuzz_targets/archive_extract.rs
  • crates/common/fuzz/scripts/gen-seeds.sh
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s0
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s2
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s3
  • crates/common/fuzz/seeds/archive_extract/deep_c1_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c2_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c3_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c4_s1
  • crates/common/fuzz/seeds/archive_extract/links_c0_s0
  • crates/common/fuzz/seeds/archive_extract/links_c0_s1
  • crates/common/fuzz/seeds/archive_extract/links_c0_s2
  • crates/common/fuzz/seeds/archive_extract/links_c0_s3
  • crates/common/fuzz/seeds/archive_extract/links_c1_s1
  • crates/common/fuzz/seeds/archive_extract/links_c2_s1
  • crates/common/fuzz/seeds/archive_extract/links_c3_s1
  • crates/common/fuzz/seeds/archive_extract/links_c4_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s0
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s2
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s3
  • crates/common/fuzz/seeds/archive_extract/modes_c1_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c2_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c3_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c4_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s0
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s2
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s3
  • crates/common/fuzz/seeds/archive_extract/plain_c1_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c2_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c3_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c4_s1
  • crates/common/src/archive.rs
  • crates/minimald/src/env.rs
  • crates/minvmd/src/rpc_client.rs
  • crates/paths/fuzz/.gitignore
  • crates/paths/fuzz/Cargo.toml
  • crates/paths/fuzz/fuzz_targets/path_invariants.rs
  • crates/paths/src/lib.rs
  • docs/fuzzing.md
  • justfile

Comment thread crates/common/fuzz/fuzz_targets/archive_extract.rs
Comment thread crates/common/src/archive.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/common/fuzz/scripts/gen-seeds.sh`:
- Around line 69-74: Normalize archive generation in gen-seeds.sh by setting
umask 022 and LC_ALL=C, and update the tar invocation to use ustar format,
sorted names, the Unix epoch mtime, and numeric zero owner/group metadata. Then
regenerate the checked-in seed corpus using the normalized command.
- Around line 69-82: Update the seed-generation loop around emit and add a
second archive whose entries are rooted with the pkg operand, then use that
archive for selector 2 so its paths match strip_prefix("pkg"); keep the existing
base archive for selectors 0, 1, and 3.

In `@docs/fuzzing.md`:
- Around line 187-203: Add one blank line immediately after the audit table in
the documentation section, before the following ordered-list item, without
changing the table content or surrounding list structure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 34db8c9b-0500-4510-bdb8-69e0a048f029

📥 Commits

Reviewing files that changed from the base of the PR and between fde54a1 and e956c3a.

📒 Files selected for processing (44)
  • crates/common/fuzz/Cargo.toml
  • crates/common/fuzz/fuzz_targets/archive_extract.rs
  • crates/common/fuzz/scripts/gen-seeds.sh
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s0
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s2
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s3
  • crates/common/fuzz/seeds/archive_extract/deep_c1_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c2_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c3_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c4_s1
  • crates/common/fuzz/seeds/archive_extract/links_c0_s0
  • crates/common/fuzz/seeds/archive_extract/links_c0_s1
  • crates/common/fuzz/seeds/archive_extract/links_c0_s2
  • crates/common/fuzz/seeds/archive_extract/links_c0_s3
  • crates/common/fuzz/seeds/archive_extract/links_c1_s1
  • crates/common/fuzz/seeds/archive_extract/links_c2_s1
  • crates/common/fuzz/seeds/archive_extract/links_c3_s1
  • crates/common/fuzz/seeds/archive_extract/links_c4_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s0
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s2
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s3
  • crates/common/fuzz/seeds/archive_extract/modes_c1_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c2_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c3_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c4_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s0
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s2
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s3
  • crates/common/fuzz/seeds/archive_extract/plain_c1_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c2_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c3_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c4_s1
  • crates/common/src/archive.rs
  • crates/minimald/src/env.rs
  • crates/minvmd/src/rpc_client.rs
  • crates/paths/fuzz/.gitignore
  • crates/paths/fuzz/Cargo.toml
  • crates/paths/fuzz/fuzz_targets/path_invariants.rs
  • crates/paths/src/lib.rs
  • docs/fuzzing.md
  • justfile
🚧 Files skipped from review as they are similar to previous changes (41)
  • justfile
  • crates/common/fuzz/seeds/archive_extract/plain_c3_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c1_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s2
  • crates/minimald/src/env.rs
  • crates/common/fuzz/seeds/archive_extract/modes_c1_s1
  • crates/common/fuzz/seeds/archive_extract/links_c4_s1
  • crates/common/fuzz/seeds/archive_extract/links_c2_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c2_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c3_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c4_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s2
  • crates/common/fuzz/seeds/archive_extract/modes_c4_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c2_s1
  • crates/common/fuzz/seeds/archive_extract/links_c3_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s2
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c3_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s0
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s3
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s3
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s3
  • crates/common/fuzz/seeds/archive_extract/plain_c4_s1
  • crates/common/fuzz/fuzz_targets/archive_extract.rs
  • crates/common/fuzz/seeds/archive_extract/plain_c1_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s0
  • crates/common/fuzz/seeds/archive_extract/links_c1_s1
  • crates/paths/fuzz/.gitignore
  • crates/common/fuzz/seeds/archive_extract/links_c0_s3
  • crates/paths/fuzz/Cargo.toml
  • crates/common/fuzz/seeds/archive_extract/links_c0_s2
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s1
  • crates/common/fuzz/seeds/archive_extract/links_c0_s0
  • crates/common/fuzz/seeds/archive_extract/plain_c2_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s1
  • crates/common/fuzz/Cargo.toml
  • crates/paths/fuzz/fuzz_targets/path_invariants.rs
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s0
  • crates/minvmd/src/rpc_client.rs
  • crates/paths/src/lib.rs
  • crates/common/src/archive.rs

Comment thread crates/common/fuzz/scripts/gen-seeds.sh Outdated
Comment thread crates/common/fuzz/scripts/gen-seeds.sh Outdated
Comment on lines +69 to +82
tar -C "$work/$tree" -cf "$base" .

gzip -kfn "$base" # -> .tar.gz (-n: no timestamp)
zstd -qf "$base" -o "$base.zst"
xz -kfq "$base" # -> .tar.xz
bzip2 -kfq "$base" # -> .tar.bz2

# strip_prefix selectors: 0 None, 1 ".", 2 "pkg", 3 "..". Seed every
# prefix against the uncompressed tar (cheapest to mutate), and the
# "." prefix against each compressed form so all five decompressor
# branches get a structurally valid entry point.
for s in 0 1 2 3; do
emit "$tree" 0 "$s" "$base"
done

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

mkdir -p "$tmp/src/pkg"
printf 'x\n' > "$tmp/src/pkg/file"

tar -C "$tmp/src" -cf "$tmp/archive.tar" .
tar -tf "$tmp/archive.tar"

rg -n -C 3 'strip_prefix|control\[1\]|"pkg"' \
  crates/common/fuzz/fuzz_targets/archive_extract.rs \
  crates/common/src/archive.rs \
  crates/common/fuzz/scripts/gen-seeds.sh

Repository: gominimal/minimal

Length of output: 12035


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- seed generator ---'
cat -n crates/common/fuzz/scripts/gen-seeds.sh | sed -n '45,100p'

printf '%s\n' '--- existing archive_extract fixtures ---'
git ls-files crates/common/fuzz/seeds/archive_extract | sort | sed -n '1,120p'

printf '%s\n' '--- archive extraction path logic ---'
cat -n crates/common/src/archive.rs | sed -n '320,390p'
cat -n crates/common/fuzz/fuzz_targets/archive_extract.rs | sed -n '20,75p'

printf '%s\n' '--- fixture sizes and names ---'
find crates/common/fuzz/seeds/archive_extract -maxdepth 1 -type f -printf '%f %s bytes\n' | sort

Repository: gominimal/minimal

Length of output: 10922


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- stored paths in selector-2 fixture payloads ---'
for f in crates/common/fuzz/seeds/archive_extract/*_c0_s2; do
    printf '%s: ' "$f"
    tail -c +3 "$f" | tar -tf - | sed -n '1,8p' | paste -sd ' ' -
done

printf '%s\n' '--- stored paths for both tar operands ---'
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/src/pkg"
printf 'x\n' > "$tmp/src/pkg/file"

tar -C "$tmp/src" -cf "$tmp/dot.tar" .
tar -C "$tmp/src" -cf "$tmp/pkg.tar" pkg

printf '%s\n' 'dot operand:'
tar -tf "$tmp/dot.tar"
printf '%s\n' 'pkg operand:'
tar -tf "$tmp/pkg.tar"

printf '%s\n' '--- relevant standard-library behavior if rustc is available ---'
if command -v rustc >/dev/null 2>&1; then
    rustc --version
    cat > "$tmp/path_probe.rs" <<'RS'
use std::path::Path;
fn main() {
    for (path, prefix) in [("./pkg/file", "pkg"), ("./pkg/file", "."), ("pkg/file", "pkg")] {
        println!("{path:?}.strip_prefix({prefix:?}) = {:?}", Path::new(path).strip_prefix(prefix));
    }
}
RS
    rustc "$tmp/path_probe.rs" -o "$tmp/path_probe"
    "$tmp/path_probe"
else
    printf '%s\n' 'rustc unavailable'
fi

Repository: gominimal/minimal

Length of output: 303


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- stored paths in selector-2 fixture payloads ---'
for f in crates/common/fuzz/seeds/archive_extract/*_c0_s2; do
    printf '%s:\n' "$f"
    tail -c +3 "$f" | tar -tf - | sed -n '1,8p'
done

printf '%s\n' '--- stored paths for both tar operands ---'
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/src/pkg"
printf 'x\n' > "$tmp/src/pkg/file"

tar -C "$tmp/src" -cf "$tmp/dot.tar" .
tar -C "$tmp/src" -cf "$tmp/pkg.tar" pkg

printf '%s\n' 'dot operand:'
tar -tf "$tmp/dot.tar"
printf '%s\n' 'pkg operand:'
tar -tf "$tmp/pkg.tar"

printf '%s\n' '--- standard-library strip_prefix behavior ---'
if command -v rustc >/dev/null 2>&1; then
    rustc --version
    cat > "$tmp/path_probe.rs" <<'RS'
use std::path::Path;
fn main() {
    for (path, prefix) in [("./pkg/file", "pkg"), ("./pkg/file", "."), ("pkg/file", "pkg")] {
        println!("{path:?}.strip_prefix({prefix:?}) = {:?}", Path::new(path).strip_prefix(prefix));
    }
}
RS
    rustc "$tmp/path_probe.rs" -o "$tmp/path_probe"
    "$tmp/path_probe"
else
    printf '%s\n' 'rustc unavailable'
fi

Repository: gominimal/minimal

Length of output: 836


🌐 Web query:

Rust tar crate Entry::path leading ./ normalization path header documentation source

💡 Result:

In the Rust tar crate, the Entry::path method returns the path name for an archive entry [1]. Regarding path normalization, the implementation includes specific behaviors when processing paths: 1. Path Component Handling: During entry processing, path components are parsed [2]. Leading '/' characters are trimmed (e.g., '///test' is treated as 'test') [2]. Additionally, '.' components are ignored [2]. 2. Security Normalization: To prevent directory traversal security issues, if any part of the filename contains '..', the file is skipped during extraction [2][3]. 3. Builder Normalization: On the writing side, the Header::set_path method historically strips out '.' components [4]. There is known community discussion regarding this behavior, as some users have noted that this can lead to unexpected path modifications [4]. It is important to note that the crate's documentation and internal source code emphasize that the paths returned by Entry::path are not fully normalized in the sense of resolving all filesystem-specific behaviors (such as case folding or Unicode normalization) [5]. Users are advised to rely on the library's built-in extraction methods, such as Entry::unpack_in, which are designed to handle these security and path-related concerns safely [2][3]. Directly inspecting the header or manually processing paths is generally discouraged in favor of using these provided library methods to ensure archive formats and security constraints are handled correctly [2][1].

Citations:


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- tar dependency declarations ---'
rg -n -C 2 '(^|\s)tar\s*=|name = "tar"|source = ".*tar' \
  Cargo.toml Cargo.lock crates 2>/dev/null | sed -n '1,120p'

printf '%s\n' '--- available local tar sources ---'
find "$HOME/.cargo" -path '*/tar-*' -maxdepth 6 -type f 2>/dev/null | sed -n '1,40p'

printf '%s\n' '--- path handling references in repository ---'
rg -n -C 3 'Entry::path|header\.path|strip_prefix\(' crates/common | sed -n '1,180p'

Repository: gominimal/minimal

Length of output: 3832


🌐 Web query:

tar 0.4.46 src entry.rs Entry::path components dot ignored

💡 Result:

In the tar crate (including version 0.4.46), the behavior you identified in src/entry.rs regarding '.' (current directory) components is intentional and acts as part of the library's path normalization and security logic during extraction [1][2]. When the library processes entry paths—specifically during methods like unpack_in—it iterates over the path components [1][2]. Within this logic, the Component::CurDir (which represents a '.' component) is explicitly matched and ignored [1][2]. The relevant code comment explains: // Leading '/' characters, root paths, and '.' // components are just ignored and treated as "empty // components" Component::Prefix(..) | Component::RootDir | Component::CurDir => continue, This behavior serves to sanitize paths to prevent potential security vulnerabilities or invalid file system paths during extraction [1][2]. By skipping '.' components, the library avoids creating redundant directory structures or encountering issues with path resolution that could arise from ambiguous path specifications in an archive [1][2]. This mechanism is part of the broader logic in the crate to sanitize inputs and prevent directory traversal, similar to how it handles other path components like '..' (which causes the entry to be skipped to prevent security issues) and leading root directory indicators [1][2].

Citations:


🌐 Web query:

site:docs.rs/crate/tar/0.4.46/source/src/entry.rs "pub fn path" "CurDir"

💡 Result:

The matching code in tar 0.4.46 is:

pub fn path(&self) -> io::Result<Cow<'_, Path>> {
    self.fields.path()
}

CurDir appears in unpacking logic:

Component::Prefix(..) | Component::RootDir | Component::CurDir => continue,

Thus, . path components are ignored during extraction. (docs.rs)

Citations:


🌐 Web query:

Rust std Path strip_prefix "./pkg" "pkg" Components leading CurDir

💡 Result:

In Rust's standard library, Path::strip_prefix performs a lexical operation that determines if a given base path is a prefix of self [1][2]. Regarding your specific scenario with ./pkg and pkg: 1. Lexical Behavior: Path::strip_prefix does not normalize paths before comparing them [2]. It relies on the components of the path as they are structured [1]. 2. Components and CurDir: The Path::components() method treats ./ at the beginning of a path as a CurDir (current directory) component [3]. 3. Result: Because strip_prefix is a direct component-wise comparison, a path starting with ./pkg contains a CurDir component followed by a Normal("pkg") component [3]. A path just containing pkg consists only of a Normal("pkg") component. 4. Outcome: Calling strip_prefix("pkg") on a path of ./pkg will return an Err because the first component of ./pkg is CurDir, which does not match the first component of the base pkg (Normal("pkg")) [1][3]. To successfully strip such a prefix, you may need to normalize the path first (e.g., using Path::canonicalize, though that requires the file to exist on the filesystem) or manually handle the CurDir component by iterating through the path components and filtering them as needed [2][4]. Top results: [1][2][3]

Citations:


Use an archive with matching pkg paths for selector 2.

Entry::path() returns the raw ./pkg/... paths. Path::strip_prefix("pkg") does not normalize the leading ./, so *_c0_s2 exits before normalize_within_root. Create a second archive with operand pkg for selector 2.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/common/fuzz/scripts/gen-seeds.sh` around lines 69 - 82, Update the
seed-generation loop around emit and add a second archive whose entries are
rooted with the pkg operand, then use that archive for selector 2 so its paths
match strip_prefix("pkg"); keep the existing base archive for selectors 0, 1,
and 3.

Comment thread docs/fuzzing.md
bryan-minimal and others added 2 commits August 3, 2026 11:31
Unifying both `strip_prefix` branches onto the per-entry loop closed the
symlink hole and opened a hardlink one, because the two extraction APIs
harden against opposite halves of the threat:

  Archive::unpack   -> unpack_in, target_base = Some(dst): joins the link
                       name to dst and validates it. Hardlink-safe.
  Entry::unpack     -> fields.unpack(None, ..): uses the link name verbatim,
                       so the kernel resolves it against the process CWD.

tar's own source comments on exactly this asymmetry. The link-target check
was therefore necessary but not sufficient: a target of `etc/shadow`
normalizes cleanly, passes the check, and then hardlinks the real file into
the destination. Sharing an inode is an escape no path-based containment can
see -- the resulting path genuinely is inside the root -- so it defeats the
SFTP resolver and every other check in the daemon at once, and yields read
*and* write.

Reachable from a remote cache mirror (rcache::remote::materialize) and from
upstream tarballs (op::sources), both of which extract with
`strip_prefix: None` -- the branch that was hardlink-safe before.

Create hardlinks explicitly, anchored to `dest_dir`, mirroring what
`unpack_in`'s `Some(dst)` arm does. A link whose target is missing is
skipped with a warning rather than fatal, consistent with the policy for
escaping links.

The regression test asserts through the inode: it plants a secret in the
process CWD and requires the extracted entry not to read back as that file.
Verified to fail without this change. Note the `archive_extract` fuzz oracle
cannot catch this class -- it asserts path containment, and this escape has
a contained path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The containment walk descended from `root` while comparing against
`canonical_root`. On macOS the tempdir is `/var/folders/...` and its
canonical form is `/private/var/folders/...` (`/var` is a symlink), so
`dir.join(target)` carried a prefix `canonical_root` never matched and
`escapes()` fired on in-tree symlinks. Linux `/tmp` is not symlinked, which
is why the campaign that wrote this target never saw it.

Reported by CodeRabbit on #1162.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bryan-minimal

Copy link
Copy Markdown
Member Author

Two follow-ups pushed.

1. CodeRabbit review comment — fixed (8a89192b). Confirmed real: the containment walk descended from root while comparing against canonical_root, so on macOS (/var -> /private/var) escapes() fired on in-tree symlinks. Linux /tmp is not symlinked, which is why the campaign that wrote the target never hit it.

2. This PR introduced a hardlink escape; also fixed (4addf1e9).

Unifying both strip_prefix branches onto the per-entry loop closed the symlink hole and opened a hardlink one, because the two tar APIs harden against opposite halves of the threat:

symlink hardlink
Archive::unpack (the old None branch) unsafe safe — routes to unpack_in, target_base = Some(dst), validates
Entry::unpack (per-entry loop) safe (our check) unsafefields.unpack(None, ..) uses the link name verbatim, so the kernel resolves it against the process CWD

tar 0.4.46 comments on precisely this asymmetry in entry.rs. So the link-target check was necessary but not sufficient: a target of etc/shadow normalizes cleanly, passes, and then hardlinks the real file into the destination. Sharing an inode is an escape no path-based containment can detect — the resulting path really is inside the root — so it defeats the SFTP resolver and every other daemon check at once, and gives read and write.

Reachable from a remote cache mirror (rcache::remote::materialize) and upstream tarballs (op::sources) — both extract with strip_prefix: None, the branch that was hardlink-safe before this PR.

Fix creates hardlinks explicitly, anchored to dest_dir, mirroring unpack_in. The regression test asserts through the inode (plants a secret in the process CWD, requires the extracted entry not to read back as it) and is verified to fail without the change.

Worth flagging for the campaign: the archive_extract fuzz oracle is structurally blind to this class — it asserts path containment, and this escape has a contained path. A device+inode check would close that gap.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/common/src/archive.rs`:
- Around line 741-781: Update extract_hardlink_does_not_escape_via_cwd to avoid
unsynchronized process-wide current_dir mutation: either serialize the CWD swap
with the repository’s shared test mutex/serial mechanism or redesign the fixture
to avoid changing CWD. Strengthen the regression assertion so it
deterministically verifies the unsafe loot link is not created, without
conditional exists checks or unwrap_or_default masking failures. Validate using
the repository’s just test and, on macOS, just test-cross recipes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fabe8392-172b-4d17-827f-74ff2de13c6e

📥 Commits

Reviewing files that changed from the base of the PR and between e956c3a and 8a89192.

📒 Files selected for processing (2)
  • crates/common/fuzz/fuzz_targets/archive_extract.rs
  • crates/common/src/archive.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/common/fuzz/fuzz_targets/archive_extract.rs

Comment thread crates/common/src/archive.rs
The checked-in seed tarballs carried the generating host's tar metadata:
uid=1000, gid=100, and the literal account names uname='bryan',
gname='users'. 16 of 32 seeds contained the username, now in a public repo.
It also made regeneration non-reproducible, since mtimes and readdir order
vary per host.

Normalize in gen-seeds.sh (umask 022, LC_ALL=C, ustar format, zeroed
numeric owner, epoch mtimes) and regenerate. GNU tar and bsdtar spell these
flags differently, so the script now picks per flavour; byte-identical
output ACROSS the two is still not guaranteed (padding and entry ordering
differ) and is documented as such.

Also fixes the SC2059 shellcheck info in the same file, verified to leave
the generated seeds byte-identical.

Reported by CodeRabbit on #1162.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread crates/paths/src/lib.rs Outdated
/// Relative variant.
Rel(RelPath<R>),
///
/// Deliberately a bare [`Utf8PathBuf`] and **not** a [`RelPath<R>`].

@twitchyliquid64 twitchyliquid64 Aug 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks the model, paths in the paths crate carry the marker type Realm to make it difficult to accidentally interpret a path representing a sandbox as something on a host/daemon filesystem, so realm needs to be carried.

Also, it seems like this change was motivated by the whole .. thing - if thats the case then the fix is elsewhere depending on how RelPath is used/misused today:

  1. upstream uses through RelPath that may have traversal need to be resolved before they hit here, this might be a good fit for the CwdResolvable / CwdRelative types here that were intended for that purpose.
  2. Or if thats really not compatible with how its used today, then RelPath needs to be loosened to permit traversal

Comment thread crates/paths/src/lib.rs Outdated
/// cannot uphold `RelPath`'s no-`..` guarantee. Holding a `RelPath` here
/// would mint one that [`RelPath::try_new`] would have rejected, and every
/// downstream holder of a `RelPath` is entitled to assume that cannot
/// happen. The realm is already carried by `EitherPath<R>` itself, so

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, any nested use of the inner Utf8PathBuf for the rel variant looses the realm. The only reason its not a compile error for EitherPath to have the realm generic is because its used in the other variant, Abs. If Abs stopped being generic over realm, so would EitherPath.

So, its more correct to keep the realm as a generic on all variants.

Reverts the previous approach, which dropped `EitherPath::Rel` to a bare
`Utf8PathBuf` so it could hold a climbing path. That weakened the
abstraction to accommodate its misuse: the realm marker is what makes
misinterpreting a path a compile error, and it is lost the moment anything
destructures the variant, however the enum itself is parameterised. The
generic also became vestigial, justified only by the `Abs` arm.

`Rel` carries a validated `RelPath<R>` again. `EitherPath::new` — infallible,
and struct-literalling BOTH variants, so it forged an `AbsPath` or `RelPath`
that the real constructors would have rejected — becomes `try_new`, routing
through `AbsPath::try_new`/`RelPath::try_new`. `Deserialize` is fallible too,
so a climbing path is now refused at the wire boundary rather than becoming a
forged `RelPath` downstream.

A path that legitimately climbs is unresolved user input, which is what
`CwdRelative` is for. It stops wrapping `EitherPath` — that wrapping was what
forced the `Rel` variant to degrade — and owns the raw string instead, with
`resolve` as the only door out. `FromStr` stays infallible, so clap is
unaffected.

rustc then enumerated the callers that had been relying on the forge:

- `minimald::env::resolve_output` took a `SandboxPath` only to classify
  absolute-vs-relative, then normalized `..` itself two lines later. It never
  wanted the guarantee; it now takes the raw path, which says so.
- `sessions`' glob literal-prefix extraction: a climbing prefix is not a
  usable base, so it is dropped like an absent one.
- The rest were absolute literals in tests.

The test asserting the old behaviour now asserts the new: `EitherPath` must
refuse `../../etc/passwd`, and such a path is `CwdRelative`'s job.

Review feedback from @twitchyliquid64 on #1162.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bryan-minimal

Copy link
Copy Markdown
Member Author

@twitchyliquid64 you're right, and the "got lazy" read is fair — the change weakened the abstraction to accommodate its misuse rather than fixing the misuse. Redone in ddfd3e33.

What changed

  • EitherPath::Rel carries a validated RelPath<R> again, so the realm survives destructuring and the generic stops being justified by the Abs arm alone.
  • The actual bad use was EitherPath::new: infallible, and struct-literalling both variants, so it forged an AbsPath/RelPath that try_new would have rejected. It's now EitherPath::try_new, routing through AbsPath::try_new/RelPath::try_new. Deserialize is fallible too, so a climbing path is refused at the wire boundary (that was WireSource::Project's door) instead of becoming a forged RelPath downstream.
  • Per your second option: a path that legitimately climbs is unresolved user input, so it belongs to CwdRelative. It no longer wraps EitherPath — that wrapping is what forced Rel to degrade in the first place — and owns the raw string, with resolve() as the only exit. FromStr stays Infallible, so clap is unaffected.

Then rustc did the audit, which is the part that made your point concrete:

  • minimald::env::resolve_output took a SandboxPath purely to classify absolute-vs-relative, then normalized .. itself two lines later. It never wanted the guarantee — it now takes the raw path, which says so honestly.
  • sessions' glob literal-prefix extraction: a climbing prefix isn't a usable base, so it's dropped like an absent one.
  • The remaining ~14 were absolute literals in tests.

The test that asserted the old behaviour now asserts the new one: EitherPath::try_new("../../etc/passwd") must be Err(ContainsParentDir).

Green locally: paths 58, sessions 358, mfile 53, clippy + fmt clean. minimald/minimal don't build on macOS (procfs), so the resolve_output change is by-inspection — relying on CI for it.


One design question I'd rather ask than guess at, since it's the same shape as what you just pushed back on.

The plan for the remaining containment work (the SFTP escape and friends) is a primitive whose operations return fds, bytes, or metadata — never a path, so a caller can't obtain the thing they'd misuse. That mechanic seems aligned with what paths is doing: make misuse a compile error rather than a check someone forgets. Supporting evidence for the "retype the boundary" approach: sftp.rs was in nobody's audit list, but retyping SessionPaths.working would have surfaced it mechanically.

The open question is placement. The analysis argues for common, because common/lcache/op depend on neither paths nor camino, and common::archive can't pick a realm (rcache::remote calls it daemon-side). But that lands a second path-ish abstraction outside the realm system — which is arguably the same "stand outside the abstraction for convenience" move you just rejected, and risks being one-rule-two-implementations at the design level.

Options as I see them:

  1. common::contain with no realm, paths re-exporting a realm-typed newtype for daemon callers.
  2. Put it in paths and take the camino/realm dependency into common/lcache/op.
  3. Something you'd rather — this is your model and I'd sooner build the version you'd keep.

Happy to spec (1) or (2) properly before writing any of it.

`Utf8Path::new` takes a reference; `output` is an owned `String` at this
call site. Missed because minimald does not build on macOS (procfs), so the
change was made by inspection. Verified this time by running clippy in the
Linux sandbox before pushing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
crates/paths/src/lib.rs (1)

1290-1318: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a regression test for the climbing-path resolve contract, and simplify the redundant absolute-branch validation.

resolve() is the door where a CLI path that legitimately climbs (--minimal-state-dir ../state) turns into an AbsPath. The doc comment explains this intent, but no test resolves a ..-containing relative path and checks the literal (non-normalized) join result. Add one to lock down this contract, since it is exactly the kind of input the path_invariants fuzz target is meant to probe.

Separately, in the absolute branch, self.raw.is_absolute() is already checked before calling AbsPath::try_new(self.raw.clone()), whose only failure mode is !is_absolute(). The .map_err(|_| CwdResolveError::CwdNotAbsolute(...)) there is unreachable. The doubled { { ... } } block in the relative branch also serves no purpose.

🧪 Suggested test addition
+    #[test]
+    fn cwd_relative_resolve_preserves_climbing_components() {
+        let cli: CwdRelative<Host> = "../state".parse().unwrap();
+        let resolved = cli.resolve().expect("test process cwd is well-defined");
+        let cwd_std = std::env::current_dir().unwrap();
+        let cwd_utf8 = Utf8PathBuf::from_path_buf(cwd_std).unwrap();
+        assert_eq!(resolved.as_str(), format!("{cwd_utf8}/../state"));
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/paths/src/lib.rs` around lines 1290 - 1318, Add a regression test for
`resolve()` that resolves a relative path containing `..` and asserts the result
preserves the literal, non-normalized join against the current directory. In
`resolve()`, simplify the absolute-path branch by returning the validated
`AbsPath` directly without the unreachable `map_err`, and remove the redundant
nested block around the relative-path logic.
crates/sessions/src/core/primitives.rs (1)

852-904: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Document the second walk_root failure mode.

walk_root also returns None when a relative literal prefix contains .. and HostPath::try_new rejects it, such as direct FileSet::try_new("../shared/**/*.toml") usage. Patch sources reject .. earlier in expand_source, so they do not reach this path. Extend the walk_root documentation to describe this case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/core/primitives.rs` around lines 852 - 904, Update the
documentation for walk_root to mention that it can return None when a relative
literal prefix contains ".." and HostPath::try_new rejects it, including direct
FileSet::try_new inputs such as "../shared/**/*.toml"; note that expand_source
normally rejects this earlier for patch sources.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/common/fuzz/scripts/gen-seeds.sh`:
- Around line 93-94: Update the source timestamp normalization command in the
seed-generation script to run with TZ=UTC and use touch -h -t 197001010000,
preserving symlink timestamps without modifying targets. Remove the stderr
suppression and failure fallback so timestamp errors terminate generation, then
regenerate all affected seeds, including links_c4_s1.
- Line 94: Replace the timestamp normalization command in gen-seeds.sh so it
updates each filesystem entry under $work/$tree without following symlinks,
using a portable operation supported on macOS and Linux. Ensure symlink targets
outside $work cannot be modified, and remove the stderr suppression and
unconditional success fallback so failures remain visible.

---

Nitpick comments:
In `@crates/paths/src/lib.rs`:
- Around line 1290-1318: Add a regression test for `resolve()` that resolves a
relative path containing `..` and asserts the result preserves the literal,
non-normalized join against the current directory. In `resolve()`, simplify the
absolute-path branch by returning the validated `AbsPath` directly without the
unreachable `map_err`, and remove the redundant nested block around the
relative-path logic.

In `@crates/sessions/src/core/primitives.rs`:
- Around line 852-904: Update the documentation for walk_root to mention that it
can return None when a relative literal prefix contains ".." and
HostPath::try_new rejects it, including direct FileSet::try_new inputs such as
"../shared/**/*.toml"; note that expand_source normally rejects this earlier for
patch sources.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ed0179f4-ac0e-4ef5-b3d7-af164e4ef2b3

📥 Commits

Reviewing files that changed from the base of the PR and between 8a89192 and 1238802.

📒 Files selected for processing (44)
  • crates/common/fuzz/scripts/gen-seeds.sh
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s0
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s2
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s3
  • crates/common/fuzz/seeds/archive_extract/deep_c1_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c2_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c3_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c4_s1
  • crates/common/fuzz/seeds/archive_extract/links_c0_s0
  • crates/common/fuzz/seeds/archive_extract/links_c0_s1
  • crates/common/fuzz/seeds/archive_extract/links_c0_s2
  • crates/common/fuzz/seeds/archive_extract/links_c0_s3
  • crates/common/fuzz/seeds/archive_extract/links_c1_s1
  • crates/common/fuzz/seeds/archive_extract/links_c2_s1
  • crates/common/fuzz/seeds/archive_extract/links_c3_s1
  • crates/common/fuzz/seeds/archive_extract/links_c4_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s0
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s2
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s3
  • crates/common/fuzz/seeds/archive_extract/modes_c1_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c2_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c3_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c4_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s0
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s2
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s3
  • crates/common/fuzz/seeds/archive_extract/plain_c1_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c2_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c3_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c4_s1
  • crates/common/src/archive.rs
  • crates/mfile/src/project_composable.rs
  • crates/minimal/src/prompt.rs
  • crates/minimald/src/env.rs
  • crates/paths/src/lib.rs
  • crates/sessions/src/core/compose.rs
  • crates/sessions/src/core/primitives.rs
  • crates/sessions/src/daemon/composer.rs
  • crates/sessions/src/wire/primitives.rs
  • crates/sessions/tests/client_flow2.rs
  • docs/fuzzing.md
🚧 Files skipped from review as they are similar to previous changes (33)
  • crates/common/fuzz/seeds/archive_extract/deep_c4_s1
  • crates/common/fuzz/seeds/archive_extract/links_c1_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c1_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c1_s1
  • crates/common/fuzz/seeds/archive_extract/links_c0_s0
  • crates/common/fuzz/seeds/archive_extract/plain_c3_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s2
  • crates/common/fuzz/seeds/archive_extract/modes_c4_s1
  • crates/common/fuzz/seeds/archive_extract/links_c2_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c2_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c2_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c3_s1
  • crates/common/fuzz/seeds/archive_extract/links_c0_s2
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s0
  • crates/common/fuzz/seeds/archive_extract/links_c0_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c1_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s3
  • crates/common/fuzz/seeds/archive_extract/links_c3_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s3
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s2
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c0_s0
  • crates/common/fuzz/seeds/archive_extract/modes_c3_s1
  • crates/common/fuzz/seeds/archive_extract/deep_c2_s1
  • crates/common/fuzz/seeds/archive_extract/modes_c0_s1
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s3
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s0
  • crates/minimald/src/env.rs
  • crates/common/fuzz/seeds/archive_extract/plain_c0_s2
  • crates/common/fuzz/seeds/archive_extract/plain_c4_s1
  • docs/fuzzing.md
  • crates/common/src/archive.rs

Comment on lines +93 to +94
# Fixed mtime on the sources too: bsdtar has no --mtime for create.
find "$work/$tree" -exec touch -t 197001010000 {} + 2>/dev/null || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

TZ=UTC touch -t 197001010000 "$tmp/utc"
TZ=America/Los_Angeles touch -t 197001010000 "$tmp/pacific"

mtime() {
  stat -c %Y "$1" 2>/dev/null || stat -f %m "$1"
}

test "$(mtime "$tmp/utc")" != "$(mtime "$tmp/pacific")"

Repository: gominimal/minimal

Length of output: 233


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- target script ---'
sed -n '1,140p' crates/common/fuzz/scripts/gen-seeds.sh

printf '%s\n' '--- related seed and archive references ---'
rg -n -C 3 'links_c0_s3|links_c4_s1|197001010000|bsdtar|TZ=|LC_ALL' crates/common/fuzz .github 2>/dev/null || true

printf '%s\n' '--- repository files near fuzz seeds ---'
git ls-files crates/common/fuzz | sed -n '1,160p'

Repository: gominimal/minimal

Length of output: 9476


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/tree"
printf x > "$tmp/tree/target"
ln -s target "$tmp/tree/link"

TZ=UTC touch -t 197001010000 "$tmp/tree/target"
TZ=America/Los_Angeles touch -t 197001010000 "$tmp/tree/link" || true

python3 - "$tmp/tree/target" "$tmp/tree/link" <<'PY'
import os
import sys

for path in sys.argv[1:]:
    st = os.lstat(path)
    print(path, "lstat_mtime", st.st_mtime)
    if os.path.islink(path):
        st_target = os.stat(path)
        print(path, "target_mtime", st_target.st_mtime)
PY

printf '%s\n' '--- touch implementation ---'
command -v touch
touch --help 2>&1 | sed -n '1,100p'

printf '%s\n' '--- committed seed tar mtimes ---'
python3 - <<'PY'
from pathlib import Path

for path in sorted(Path("crates/common/fuzz/seeds/archive_extract").glob("*")):
    data = path.read_bytes()[2:]
    entries = []
    for offset in range(0, len(data), 512):
        block = data[offset:offset + 512]
        if not block or not any(block):
            break
        name = block[0:100].split(b"\0", 1)[0].decode("utf-8", "replace")
        mtime_raw = block[136:148].rstrip(b"\0 ")
        try:
            mtime = int(mtime_raw or b"0", 8)
        except ValueError:
            mtime = f"invalid:{mtime_raw!r}"
        typeflag = block[156:157] or b"0"
        entries.append((name, typeflag.decode("ascii", "replace"), mtime))
    if path.name.startswith("links_") and path.name.endswith("_c0_s3"):
        print(path, entries)
PY

Repository: gominimal/minimal

Length of output: 2300


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import bz2
import gzip
import lzma

root = Path("crates/common/fuzz/seeds/archive_extract")

def decode(path):
    payload = path.read_bytes()[2:]
    suffix = path.name.split("_c", 1)[1].split("_", 1)[0]
    if suffix == "1":
        return gzip.decompress(payload)
    if suffix == "3":
        return lzma.decompress(payload)
    if suffix == "4":
        return bz2.decompress(payload)
    return payload

def entries(tar):
    result = []
    offset = 0
    while offset + 512 <= len(tar):
        header = tar[offset:offset + 512]
        if not any(header):
            break
        name = header[0:100].split(b"\0", 1)[0].decode("utf-8", "replace")
        kind = (header[156:157] or b"0").decode("ascii", "replace")
        raw_size = header[124:136].rstrip(b"\0 ")
        raw_mtime = header[136:148].rstrip(b"\0 ")
        size = int(raw_size or b"0", 8)
        mtime = int(raw_mtime or b"0", 8)
        result.append((name, kind, mtime))
        offset += 512 + ((size + 511) // 512) * 512
    return result

for path in sorted(root.glob("links_*")):
    print(path.name, entries(decode(path)))
PY

Repository: gominimal/minimal

Length of output: 1591


Normalize bsdtar source timestamps in UTC. Export TZ=UTC and use touch -h -t 197001010000 so symlink timestamps are updated without touching their targets. Remove 2>/dev/null || true; timestamp failures must stop seed generation. Regenerate the affected seeds, including links_c4_s1.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/common/fuzz/scripts/gen-seeds.sh` around lines 93 - 94, Update the
source timestamp normalization command in the seed-generation script to run with
TZ=UTC and use touch -h -t 197001010000, preserving symlink timestamps without
modifying targets. Remove the stderr suppression and failure fallback so
timestamp errors terminate generation, then regenerate all affected seeds,
including links_c4_s1.

Source: MCP tools

for tree in "${trees[@]}"; do
base="$work/$tree.tar"
# Fixed mtime on the sources too: bsdtar has no --mtime for create.
find "$work/$tree" -exec touch -t 197001010000 {} + 2>/dev/null || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

mkdir -p "$tmp/tree"
printf x > "$tmp/outside"
ln -s "$tmp/outside" "$tmp/tree/link"

mtime() {
  stat -c %Y "$1" 2>/dev/null || stat -f %m "$1"
}

before="$(mtime "$tmp/outside")"
find "$tmp/tree" -exec touch -t 197001010000 {} +
after="$(mtime "$tmp/outside")"

test "$before" != "$after"

Repository: gominimal/minimal

Length of output: 198


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- target script ---'
sed -n '70,105p' crates/common/fuzz/scripts/gen-seeds.sh

printf '%s\n' '--- link payload references ---'
rg -n -C 2 'links_c0_s3|/etc/passwd|\\.\\./\\.\\./\\.\\./etc/passwd' crates/common/fuzz

printf '%s\n' '--- touch capabilities ---'
touch --help 2>&1 | sed -n '1,80p'

printf '%s\n' '--- isolated symlink behavior ---'
python3 - <<'PY'
import os
import subprocess
import tempfile

with tempfile.TemporaryDirectory() as d:
    tree = os.path.join(d, "tree")
    os.mkdir(tree)
    outside = os.path.join(d, "outside")
    link = os.path.join(tree, "link")
    with open(outside, "wb") as f:
        f.write(b"x")
    os.symlink(outside, link)

    before = os.stat(outside).st_mtime_ns
    subprocess.run(
        ["find", tree, "-exec", "touch", "-t", "197001010000", "{}", "+"],
        check=True,
    )
    followed = os.stat(outside).st_mtime_ns
    link_after_follow = os.lstat(link).st_mtime_ns

    subprocess.run(["touch", "-h", "-t", "197001010000", link], check=True)
    outside_after_h = os.stat(outside).st_mtime_ns
    link_after_h = os.lstat(link).st_mtime_ns

    print({
        "outside_changed_without_h": followed != before,
        "outside_unchanged_with_h": outside_after_h == followed,
        "link_changed_with_h": link_after_h != link_after_follow,
        "outside_mtime_after_h": outside_after_h,
    })
PY

Repository: gominimal/minimal

Length of output: 4299


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- script setup and tree construction ---'
sed -n '1,70p' crates/common/fuzz/scripts/gen-seeds.sh

printf '%s\n' '--- references to the generator ---'
rg -n -C 3 'gen-seeds\.sh|fuzz.*seed|seed.*fuzz' README.md docs crates .github 2>/dev/null || true

printf '%s\n' '--- touch portability references ---'
rg -n -C 2 -- '--no-dereference|touch -h|touch .*197001010000|TZ=UTC|bsdtar|macOS' . --glob '!target/**' --glob '!node_modules/**' 2>/dev/null || true

printf '%s\n' '--- available touch implementations ---'
command -v touch || true
touch --version 2>&1 | sed -n '1,3p' || true
uname -srm

Repository: gominimal/minimal

Length of output: 50376


🌐 Web query:

macOS BSD touch -h no-dereference symbolic link manual

💡 Result:

The standard touch utility included with macOS does not support a "no-dereference" flag (such as -h) to operate on a symbolic link itself rather than the target file it points to [1][2][3]. Although some manual pages or documentation may occasionally reference such options—often due to shared codebases with other BSD or GNU/Linux implementations—the native macOS version of touch consistently resolves symbolic links, meaning it updates the timestamp of the target file instead of the link [2][3]. If you require the functionality to update the timestamp of a symbolic link itself without dereferencing it, you can achieve this by installing the GNU coreutils package (e.g., via Homebrew), which provides a version of touch that includes the -h (--no-dereference) flag [3]. Alternatively, using custom scripts or utilities that interface with system functions like lutimes or setattrlist (with appropriate flags) can achieve the same result on macOS [3][4].

Citations:


🌐 Web query:

libarchive bsdtar symlink mtime archive creation --mtime macOS

💡 Result:

In bsdtar, the --mtime option (available in newer versions) allows you to set a fixed modification time for files added to an archive [1][2]. When creating an archive, this option sets the modification time for all added file entries—including symbolic links—to the date specified [3][2]. Key details regarding bsdtar and mtime: 1. Behavior with --mtime: When you use --mtime in create (c), append (r), or update (u) modes, bsdtar forces the modification time of the archived entries to that specific date [1][4]. This behavior was explicitly implemented to support reproducible builds, similar to the functionality found in GNU tar [3][5]. 2. macOS Considerations: - While macOS uses specific metadata (often handled by the --mac-metadata option), the --mtime flag is a general libarchive feature and operates independently of macOS-specific attributes [1][4]. - Note that older versions of bsdtar (typically those prior to recent updates incorporating pull request #2601) may not support the --mtime flag at all, leading users to believe it is missing [6][5]. Ensure your version of libarchive/bsdtar is up to date if you encounter issues [7]. 3. Symlink Handling: By default, bsdtar preserves symlinks in an archive as symbolic link entries rather than following them to archive the target file [8][4]. When --mtime is applied, the timestamp of the symbolic link entry itself is set to the provided date, consistent with how it handles regular files [3][2]. If you are on macOS and require specific metadata preservation (such as extended attributes or ACLs), ensure you continue to use the --mac-metadata flag alongside your other options, as --mtime only affects the modification timestamp [1][4].

Citations:


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- archive tool availability and options ---'
for tool in tar bsdtar gtar python3 perl; do
    if command -v "$tool" >/dev/null 2>&1; then
        printf '%s: %s\n' "$tool" "$(command -v "$tool")"
        "$tool" --help 2>&1 | grep -E -i -- '--mtime|symlink|dereference|no-dereference|follow' | head -20 || true
    fi
done

printf '%s\n' '--- repository requirements for generator tooling ---'
rg -n -C 2 'gen-seeds|bsdtar|libarchive|Python|python3|macOS.*tar|tar.*macOS' \
  docs/fuzzing.md AGENTS.md CONTRIBUTING.md crates/common/fuzz justfile* 2>/dev/null || true

Repository: gominimal/minimal

Length of output: 4475


Prevent timestamp normalization from following symlinks.

Use a portable no-follow timestamp operation. Native macOS touch does not support -h. The current command can update /etc/passwd or another path outside $work during a privileged run. Remove 2>/dev/null || true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/common/fuzz/scripts/gen-seeds.sh` at line 94, Replace the timestamp
normalization command in gen-seeds.sh so it updates each filesystem entry under
$work/$tree without following symlinks, using a portable operation supported on
macOS and Linux. Ensure symlink targets outside $work cannot be modified, and
remove the stderr suppression and unconditional success fallback so failures
remain visible.

Source: MCP tools

Comment thread crates/minimald/src/env.rs Outdated
));
}
cwd.join(rel.as_utf8_path())
/// Takes the raw user string, not a `SandboxPath`: `--output ../artifacts` is

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring over commentary: just say it resolves a user path that might be relative to their cwd to the absolute path within the sandbox mount namespace

Comment thread crates/paths/src/lib.rs Outdated
/// Relative variant — a validated [`RelPath<R>`], so it carries both the
/// realm and the no-`..` guarantee.
///
/// It must stay a `RelPath` and not a bare path: the realm marker is the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop the commentary with claude talking to itself about the current pr

@twitchyliquid64 twitchyliquid64 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, caveat nits about commentary in code comments

…d it

Several doc comments argued against a shape that is no longer in the tree —
the `Rel` variant explaining why it is not a bare path, `try_new` explaining
what the old infallible `new` did wrong, `CwdRelative` explaining what it
stopped wrapping. A reader of the merged code has no idea any of that
existed, so it reads as narration rather than documentation.

Replaced with what each thing is and when to reach for it. `resolve_output`
keeps its original docstring, which already said it plainly.

Review feedback from @twitchyliquid64 on #1162.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bryan-minimal

Copy link
Copy Markdown
Member Author

Both fixed in e374757c — and taken as the general note, not just the two lines.

The pattern was doc comments arguing against a shape that is no longer in the tree: the Rel variant explaining why it is not a bare path, EitherPath::try_new explaining what the old infallible new got wrong, CwdRelative explaining what it stopped wrapping. Nobody reading the merged code knows any of that existed, so it reads as me narrating the PR rather than documenting the type. Replaced with what each thing is and when to reach for it.

resolve_output already had a docstring that said it plainly; I had appended a second paragraph underneath it. Dropped that and kept the original, reworded to name the sandbox mount namespace as you suggested.

Net -35/+12. Tests and clippy green.

@bryan-minimal
bryan-minimal enabled auto-merge (squash) August 3, 2026 20:41
@bryan-minimal
bryan-minimal merged commit 90a76b6 into main Aug 3, 2026
30 checks passed
@bryan-minimal
bryan-minimal deleted the feat/fuzz-campaign branch August 3, 2026 20:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants