Skip to content

Commit 6efb4b5

Browse files
authored
docs(specs): update specs for post-v0.6.0 security and type-safety hardening (#563)
Documents the GHSA-wcmx-7f9h-5mv5 component-aware containment fix in the security pipeline spec, and adds a retrospective spec for the SanitizedMode newtype plus the six enums newly marked non_exhaustive. Also documents the CLI Verbosity enum replacing the verbose/quiet bool pair, including the split-level clap flag bug it fixes. Claude-Session: https://claude.ai/code/session_01P7y9J9CVJ7NosUvwt86Src
1 parent c52d1aa commit 6efb4b5

4 files changed

Lines changed: 230 additions & 4 deletions

File tree

‎specs/001-security-pipeline/spec.md‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ tags:
88
- security
99
- rust
1010
created: 2026-05-20
11-
updated: 2026-08-04
11+
updated: 2026-09-02
1212
status: draft
1313
related:
1414
- "[[constitution]]"
@@ -17,6 +17,7 @@ related:
1717
- "[[003-config-api/spec]]"
1818
- "[[013-quota-permit-capability-token/spec]]"
1919
- "[[014-config-typestate-validation/spec]]"
20+
- "[[016-sanitized-mode-and-non-exhaustive-enums/spec]]"
2021
---
2122

2223
# Feature: Security Pipeline
@@ -157,13 +158,14 @@ THEN the written file has those bits cleared; ValidatedEntry.mode reflects the s
157158
| FR-016 | WHEN opening a destination path for a file, symlink-target read, or 7z temp-file write, THE SYSTEM SHALL use `O_EXCL`/`O_NOFOLLOW` (Unix) or an equivalent atomic create-exclusive open rather than a prior `Path::exists()` check, since `exists()` follows symlinks and reports `false` for a dangling one — letting a pre-planted symlink at the destination bypass duplicate detection and redirect the write outside the extraction root | must |
158159
| FR-017 | WHEN a file entry is validated, THE SYSTEM SHALL produce a `QuotaPermit` capability token from `QuotaTracker::reserve` that the eventual write path must consume by value, so a validated `File` entry with no quota charge is unrepresentable and a reservation cannot be spent twice (see [[013-quota-permit-capability-token/spec]]) | must |
159160
| FR-018 | WHEN `SecurityConfig`/`CreationConfig` builder methods are used, THE SYSTEM SHALL make them available only on the `Unvalidated` typestate, and `validate()` SHALL consume `self` and return `Result<T<Validated>>`; every downstream security function SHALL require `&SecurityConfig<Validated>` (see [[014-config-typestate-validation/spec]]) | must |
161+
| FR-019 | WHEN comparing a validated entry's canonicalized parent path against the destination root on macOS/Windows, THE SYSTEM SHALL compare `Path::components()` pairwise (case-folding each segment) rather than the raw lowercased path strings, so a sibling directory sharing the destination root's name as a string prefix (e.g. `/tmp/destevil` vs. `/tmp/dest`) is not treated as contained within it, closing GHSA-wcmx-7f9h-5mv5 | must |
160162

161163
## 4. Non-Functional Requirements
162164

163165
| ID | Category | Requirement |
164166
|----|----------|-------------|
165167
| NFR-001 | Security | All security checks run before any bytes are written to disk for each entry |
166-
| NFR-002 | Security | Path component matching is case-insensitive to prevent bypass on case-insensitive filesystems |
168+
| NFR-002 | Security | Path component matching is case-insensitive and component-wise (not a raw string-prefix comparison) to prevent bypass on case-insensitive filesystems (GHSA-wcmx-7f9h-5mv5) |
167169
| NFR-003 | Security | Default limits: 50 MB per file, 500 MB total, 100× compression ratio, 10,000 files, depth 32 |
168170
| NFR-004 | Security | Default banned components: `.git`, `.ssh`, `.gnupg`, `.aws`, `.kube`, `.docker`, `.env` |
169171
| NFR-005 | Safety | `deny(unsafe_code)` workspace-wide — no exceptions in this module |
@@ -267,6 +269,26 @@ THEN the written file has those bits cleared; ValidatedEntry.mode reflects the s
267269
> legitimate entry's real content — see `formats::tar_metadata_limit::BudgetedReader` for the
268270
> synthetic-vs-real byte accounting that makes this direction-independent.
269271
272+
> [!note] (unreleased, post-v0.6.0): sibling-directory containment bypass closed on macOS/Windows (GHSA-wcmx-7f9h-5mv5, #543)
273+
> `paths_start_with` — used by `SafePath`'s macOS/Windows containment check — compared `path`/`base`
274+
> as raw lowercased strings, so `/tmp/destevil` was wrongly treated as contained within `/tmp/dest`
275+
> (`"...destevil".starts_with("...dest")` is true, since there is no component boundary between
276+
> `dest` and `evil`). The non-macOS Unix arm was unaffected — it already used `Path::starts_with`,
277+
> which is component-aware. Fixed by comparing `Path::components()` pairwise, case-folding each
278+
> segment, matching the same semantics as the Unix arm. Covered by unit tests (sibling rejection,
279+
> genuine-subdirectory acceptance, case-insensitivity, base-longer-than-path short-circuit) plus an
280+
> end-to-end regression test driving the attack through a real on-disk symlink.
281+
282+
> [!note] (unreleased, post-v0.6.0): `SanitizedMode` newtype and `#[non_exhaustive]` enum hardening (#554)
283+
> `sanitize_permissions` now returns `SanitizedMode` (`security::SanitizedMode`) instead of a plain
284+
> `u32`; `ValidatedEntry::mode()`, `EntryValidator::validate_entry()`'s sanitized output, and
285+
> `formats::common::create_file_with_mode`/`extract_file_with_permit` take `Option<SanitizedMode>`
286+
> instead of `Option<u32>`, so an unsanitized mode read from an archive header can no longer reach
287+
> permission-setting code by mistake — the invariant is enforced at compile time, matching the
288+
> `SafePath`/`QuotaPermit` sealed-type pattern. Full detail, including the six enums newly marked
289+
> `#[non_exhaustive]` (`ArchiveError`, `QuotaResource`, and others outside this pipeline), is tracked
290+
> in [[016-sanitized-mode-and-non-exhaustive-enums/spec]].
291+
270292
## 6. Edge Cases and Error Handling
271293

272294
| Scenario | Expected Behavior |
@@ -285,6 +307,7 @@ THEN the written file has those bits cleared; ValidatedEntry.mode reflects the s
285307
| Symlink pointing outside `output_dir` | `ArchiveError::SymlinkEscape` |
286308
| Symlink or hardlink target is empty or contains a null byte | `ArchiveError::SecurityViolation` (raw target bytes never embedded in the error message) |
287309
| Dangling symlink pre-planted at an entry's destination path | Rejected via `O_EXCL`/`O_NOFOLLOW` open, not silently followed; requires an attacker-writable destination directory |
310+
| On macOS/Windows, a validated entry's canonicalized path resolves (e.g. via a symlink) into a sibling directory sharing the destination root's name as a string prefix (e.g. `/tmp/destevil` vs. `/tmp/dest`) | Rejected — `paths_start_with` compares `Path::components()` pairwise, not raw lowercased strings (GHSA-wcmx-7f9h-5mv5, unreleased) |
288311
| Hardlink with `allowed.hardlinks = false` | Entry skipped |
289312
| Hardlink to a path not previously seen | `ArchiveError::HardlinkEscape` |
290313
| setuid/setgid bits on Unix | Stripped silently via `fchmod` on the open descriptor; `ValidatedEntry.mode()` reflects sanitized value |
@@ -348,4 +371,7 @@ THEN the written file has those bits cleared; ValidatedEntry.mode reflects the s
348371
- [[013-quota-permit-capability-token/spec]] — `QuotaPermit` capability-token pattern in detail
349372
- [[014-config-typestate-validation/spec]] — `SecurityConfig`/`CreationConfig` `Unvalidated`/`Validated` typestate in detail
350373
- [[015-atomic-force-destination-swap-hardening/spec]] — CLI-side `--atomic --force` symlink/TOCTOU hardening (GHSA-x8wr-7ww2-c94x)
374+
- [[016-sanitized-mode-and-non-exhaustive-enums/spec]] — `SanitizedMode` capability-token newtype and `#[non_exhaustive]` enum hardening
351375
- [[001-exarch-system/spec]] — original monolithic spec (archived)
376+
- `crates/exarch-core/src/types/safe_path.rs` — `paths_start_with`
377+
- Advisory GHSA-wcmx-7f9h-5mv5 — the macOS/Windows sibling-directory containment bypass fixed here

‎specs/005-cli/spec.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ tags:
88
- cli
99
- rust
1010
created: 2026-05-20
11-
updated: 2026-08-04
11+
updated: 2026-09-02
1212
status: draft
1313
related:
1414
- "[[constitution]]"
@@ -166,6 +166,7 @@ THEN stdout contains valid JSON with all report fields; stderr contains no progr
166166
| FR-084 | `extract --atomic --force` SHALL reject a destination that is itself a symlink (or, on Windows, a junction/reparse point) on all platforms, and SHALL perform every rename/remove in its destination swap `*at`-relative to a file descriptor pinned on the destination's parent directory (Unix only), never by re-resolving a logical path mid-extraction. This is scoped to `--atomic --force` only — see [[015-atomic-force-destination-swap-hardening/spec]] for the full GHSA-x8wr-7ww2-c94x fix | must |
167167
| FR-085 | WHEN `extract --atomic --force`'s best-effort cleanup on failure cannot locate its temp/backup directory at the expected logical path (e.g. because an intermediate path component was redirected mid-extraction), THE CLI SHALL disclose the directory's actual current path (resolved via an open file descriptor, not the possibly-stale logical path) in its error output rather than leaving surviving content undisclosed (v0.6.0, #530) | should |
168168
| FR-086 | `verify`/`list` SHALL flag a TAR entry whose path is not valid UTF-8 as a `Medium`-severity `SuspiciousPath` `VerificationIssue` (portability risk, not a security issue — `security_status` is unaffected), flipping `status` to `Warning` instead of reporting `Pass` for an entry that may fail to extract on filesystems requiring UTF-8 names (v0.6.0, #528) | should |
169+
| FR-087 | `--verbose` and `--quiet` SHALL be resolved once into a single `output::Verbosity` (`Quiet` \| `Normal` \| `Verbose`) via `impl From<&cli::Cli> for Verbosity`, and threaded through `output::create_formatter`, `HumanFormatter::new`/`with_writers`, `commands::extract::execute`, and `commands::create::execute`, rather than each site independently interpreting the two raw booleans; `Verbosity::from_flags` SHALL resolve `verbose: true, quiet: true` to `Quiet` in every case, including when the two flags are parsed at different `clap` `ArgMatches` levels (e.g. a global `--verbose` combined with a subcommand-level `--quiet`), which `clap`'s `conflicts_with` on `--quiet` does not reject (unreleased, post-v0.6.0, #550, behavior change — see Edge Cases) | must |
169170

170171
## 4. Non-Functional Requirements
171172

@@ -191,6 +192,7 @@ THEN stdout contains valid JSON with all report fields; stderr contains no progr
191192
| `HumanFormatter<O: Write = Term, E: Write = Term>` | Human-readable formatter; writes non-error output to `O`, errors to `E` (v0.6.0) | `HumanFormatter::new()` (stdout/stderr default), `HumanFormatter::with_writers()` (injects custom writers + `use_colors` for deterministic tests) |
192193
| `JsonFormatter<W: Write = Stdout>` | JSON formatter (v0.6.0) | `JsonFormatter::stdout()` (replaces the old unit-struct constructor), `JsonFormatter::with_writer()` |
193194
| `PinnedDir` (`commands::atomic_swap`) | Unix-only file-descriptor handle on the destination's parent directory, used by `--atomic --force` to perform `*at`-relative renames (v0.6.0, #526) | See [[015-atomic-force-destination-swap-hardening/spec]] |
195+
| `Verbosity` (`output`) | Enum replacing independent `verbose: bool, quiet: bool` parameters across formatter/progress construction (unreleased, post-v0.6.0, #550) | `Quiet` \| `Normal` \| `Verbose`; `from_flags(verbose, quiet)` resolves `quiet` as the deterministic tie-break when both are `true`; `impl From<&cli::Cli> for Verbosity` is the single resolution point |
194196

195197
### CLI Command Syntax
196198

@@ -242,6 +244,7 @@ exarch completion <SHELL> # bash | zsh | fish | powershell | elvish (output
242244
| `extract` pre-flight destination conflict with many pre-existing files | At most 10 conflicting paths listed (sorted), remainder collapsed into `... and N more` (v0.6.0, #500) |
243245
| Human-readable size >= 1 TB (e.g. `u64::MAX` bytes) | Renders as `"... TB"`, not an inflated GB figure (v0.6.0, #451) |
244246
| `verify`/`list` on a TAR archive with a non-UTF8 entry name | `status: Warning` with a `Medium`-severity `SuspiciousPath` issue, not `Pass` (v0.6.0, #528/#529) — portability risk only, `security_status` unaffected |
247+
| `exarch --verbose extract archive.tar.gz out --quiet` (global `--verbose`, subcommand-level `--quiet`) | Resolves deterministically to `Quiet` (no progress output, no summary) via `Verbosity::from_flags` (unreleased, post-v0.6.0, #550, behavior change) — previously the progress reporter selected verbose output (verbose-wins) while the formatter suppressed the summary (quiet-wins), an inconsistency `clap`'s `conflicts_with` did not catch since the two flags parsed at different `ArgMatches` levels |
245248

246249
## 7. Success Criteria
247250

0 commit comments

Comments
 (0)