From 90b4ec36278bcd1e41cefa9ad7775e8da61a4709 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 04:53:11 +0200 Subject: [PATCH 01/14] docs: design cache-use pin hardening --- .../specs/2026-08-25-cache-use-pin-design.md | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-cache-use-pin-design.md diff --git a/docs/superpowers/specs/2026-08-25-cache-use-pin-design.md b/docs/superpowers/specs/2026-08-25-cache-use-pin-design.md new file mode 100644 index 0000000..bffbb8e --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-cache-use-pin-design.md @@ -0,0 +1,341 @@ +# Cache-use pin and spawn-boundary mount revalidation design + +Status: proposed for owner review +Date: 2026-08-25 +Baseline: `3fccc197e5055a2759ee7afe51b91133938ec904` +Scope: protect CCP-managed bind sources from in-process premature cleanup and +fail closed when a source changes before Docker container creation + +## Decision + +CCP will reuse its existing per-entry advisory lock as the cache-use pin. It +will not add a second TTL, heartbeat, daemon, or persistent lease format. + +Three linked changes close the demonstrated gaps: + +1. cloned prepared generations share one final-drop cleanup owner; +2. standard `run` revalidates structured mount sources immediately before each + `docker create` request; +3. `guard exec` gains an opt-in declaration for completed CCP cache entries + that must remain pinned while its child is active. + +The last contract is cooperative. `guard exec` remains a generic shell-free +argv supervisor and does not parse, rewrite, or attest arbitrary child argv. +An undeclared mount is therefore outside cache-pin coverage, not silently +treated as protected. + +## Problem and evidence + +Docker correctly rejects `--mount type=bind` when the host source is absent. +Replacing it with `-v` or source-creating behavior would hide the lifecycle +failure by materializing an empty directory and is forbidden. + +The standard `run` path already prepares each cache generation under an +exclusive per-entry advisory lock. `PreparedWorkspace` owns the prepared +entries through check execution and promotion. This is sufficient ownership +for CCP-aware cache operations, provided every mutating operation honors the +same lock. + +Two gaps remain in current source: + +- `PreparedCacheEntry` derives `Clone`, but every clone runs staging cleanup in + its own `Drop`. One clone can delete a staging directory while another clone + still holds the lock and data path. +- Docker bind sources are checked when the dry-run argv is rendered. The + rendered argv is later converted to `docker create` and executed without a + new structured-path validation at the spawn boundary. + +`guard exec` owns the host-wide heavy-work slot but its argv is opaque. That +slot prevents overlapping cooperative heavy workloads; it does not establish +ownership of a pre-resolved cache path embedded in a Docker command or nested +launcher. + +The exact actor that removed the previously observed bind source is unknown. +Current CCP cache cleanup is preview-only, so the evidence does not support +attributing the deletion to CCP cleanup. + +## Alternatives considered + +### A. Add a second durable cache lease with TTL and heartbeat + +Rejected for this tranche. The entry lock already expresses live ownership and +is released by process death. A second authority would introduce lock/lease +contradictions and recovery policy without improving Docker's path-based mount +semantics. + +### B. Parse every `guard exec` argv and infer bind sources + +Rejected. The child may be a script, Make target, nested runner, or non-Docker +program. Partial Docker parsing would create a false security claim and could +silently miss nested mounts. + +### C. Reuse entry locks and require explicit guard declarations + +Selected. It is compatible with the current cache model, works for direct and +nested launchers, adds no durable state, and keeps the boundary honest: +declared completed entries are pinned; other paths are not claimed. + +## Component design + +### Shared prepared-generation lifetime + +`PreparedCacheEntry` remains clonable for source compatibility, but its +entry-lock handle and staging-cleanup identity move into one shared `Arc`-owned +generation state. Only the final shared owner removes a still-valid staging +generation. + +The final-drop cleanup retains the current manifest checks: schema version, +key digest, plan digest, generation, and `staging` state must all match. +Promotion behavior remains unchanged. A promoted generation no longer has its +staging manifest, so final drop performs no destructive cleanup. + +The shared generation state owns the entry lock. Its final `Drop` performs the +validated staging cleanup while that lock is still held; the file handle is +released only after cleanup returns. + +### Structured mount-source expectations + +Each `MountBinding` records an internal expected source kind: + +- repository: plain directory; +- cache: plain directory; +- artifact: plain regular file or plain directory according to its declared + artifact contract. + +The artifact kind comes from `NormalizedCheck::artifact_contracts`; no second +configuration source is introduced. The source-kind field and every new +containment or generation expectation use `#[serde(skip)]` and never enter the +dry-run JSON or normalized plan digest. + +`WorkspacePlanV1` also retains internal canonical containment anchors for the +repository, managed cache root, and run root. These fields are skipped from +serialized dry-run output so the existing JSON schema and privacy surface do +not change. + +All standard and snapshot-backed workspace constructors populate these fields. +For a live cache mount, the expectation additionally records the exact cache +key digest, plan digest, generation, and required `staging` manifest state from +the corresponding `PreparedCacheEntry`. + +The live `PreparedWorkspace` has already created all writable sources. Its plan +therefore contains complete expectations. Pure non-executing dry-run planning +may still describe absent managed sources and is not relabeled as a live +revalidation. + +### Spawn-boundary revalidation + +A pure workspace validator rechecks every live binding immediately before +`DockerLifecyclePlan::build` and before any `SupervisorPort` call: + +1. the source exists; +2. the leaf is not a symbolic link; +3. its type matches the stored expected kind; +4. canonical resolution remains within the exact stored containment anchor; +5. the host path remains representable by the existing safe mount grammar. + +For cache mounts it also rereads the generation manifest and requires the exact +key digest, plan digest, generation, and staging state captured at preparation. +Every path component below an already canonical anchor must remain a plain +non-symlink object; parent-component symlinks are rejected even if they resolve +back inside the anchor. + +Repository bindings must resolve exactly to the prepared canonical repository. +Cache bindings must remain under the canonical managed cache root. Artifact +bindings must remain under the canonical run root. + +Failure returns a typed runtime/workspace error. It must produce zero Docker +supervisor requests, no container creation, no receipt, and no cache promotion. +The error is not converted into a check FAIL or PASS. + +The authoritative insertion point is the start of +`DockerCompatibleRuntime::execute_check`, before `DockerLifecyclePlan::build`. +The structured expectations travel in a nonserialized field of `DryRunCheck`, +which is already passed to that method. No Docker lifecycle path may invoke a +supervisor without first validating those expectations. + +This check narrows but does not eliminate the operating-system TOCTOU window: +Docker receives a pathname rather than an open host file descriptor. Entry +locks prevent cooperative CCP mutators that honor the same lock; external +manual deletion remains unsupported and cannot be made safe by revalidation +alone. + +### Managed cache pins for `guard exec` + +The CLI contract is: + +```console +commit-ci-preflight guard exec \ + --managed-cache-root /absolute/owned/cache-root \ + --managed-cache-source /absolute/owned/cache-root/entries//data \ + -- [args...] +``` + +`--managed-cache-source` is repeatable and requires exactly one +`--managed-cache-root`. Supplying a root without a source is rejected to avoid +an unused, misleading declaration. + +The parser accepts one root value globally. It canonicalizes and validates that +root before admission, canonicalizes each absolute source, rejects sources +outside that root, deduplicates canonical sources, and ignores caller ordering. + +Before admission, CCP performs only bounded argument and root validation. Once +the guard owns the host-wide slot, it opens the already-owned cache without +initializing or repairing it. Sources are deduplicated and sorted by entry +directory before exclusive locks are acquired, preventing caller-order +deadlocks. + +Every source must be exactly a completed entry's `data` directory beneath +`/entries//data`. Staging directories, workspaces, incomplete +entries, symlinks, wrong object types, foreign roots, temporary roots, and path +escapes fail closed. CCP does not create any missing path. + +One cache helper owns this interpretation. It opens the exact root through +`ManagedCache::open`, validates the ownership marker and fixed layout, requires +the exact `entries//data` shape with no alternate or extra +components, acquires the entry lock, then revalidates the key-shaped directory, +plain data directory, completion marker, and complete generation manifest. The +manifest key digest must match the directory key; its plan digest and generation +must be valid and self-consistent. Root validation uses the existing +`ResolvedCacheRoot` temporary-root and symlink-component predicates. + +Opening performs read-only ownership/layout validation. Acquiring the advisory +pin may update only the existing lock file's bounded owner text; it does not +initialize directories or repair ownership/completion metadata. + +Pins are acquired before the child starts, revalidated immediately before the +supervisor call, and held until child containment cleanup and guard-session +release are complete. Lock contention fails closed; it does not wait while +holding the heavy slot indefinitely and does not bypass admission. + +The RAII pin collection is created after guard admission and before child +launch. It remains in the outer `print_guard_exec` scope until every success, +child failure, timeout, cancellation, forced-containment-cleanup, watchdog, and +session-release path has completed. Pin acquisition and revalidation failures +finish the guard session without starting a child. + +The declaration does not prove that the child actually mounts or uses the +path. It prevents a declared CCP cache source from being changed by cooperative +CCP operations while the child may use it. It does not turn `guard exec` into a +receipt-producing or argv-attesting command. + +## Cleanup and quarantine contract + +Automatic deletion remains out of scope and `cache cleanup` stays preview-only. +The shared lock becomes the normative prerequisite for any future mutating +cleanup or quarantine implementation: + +1. select a candidate without mutating it; +2. acquire its entry lock; +3. revalidate ownership, status, type, and exact path after acquisition; +4. abort on busy, changed, missing, malformed, or ambiguous state; +5. only then perform an owner-reviewed recoverable transition. + +No implementation may delete from a stale inventory snapshot or treat absence +of a visible process as proof that a cache entry is unused. + +Future mutators must use non-blocking locks and a documented order compatible +with promotion/recovery. They must never wait for an entry while retaining a +root promotion lock; busy state is a fail-closed result, not a wait or bypass. + +## Failure, privacy, and compatibility + +- Existing valid configurations and serialized normalized plan digests, + receipts, policies, dry-run JSON, and verification behavior remain + byte-compatible. Internal prerelease Rust structs may gain nonserialized + fields required by this invariant. +- Serialized dry-run output gains no absolute containment-anchor fields. +- Pin errors may identify the local path only in human-readable local stderr. + Absolute paths never enter structured JSON, journals, receipts, resource + history, admission metadata, telemetry, or remote evidence. +- `guard exec` still emits no receipt and retains its existing child-exit, + timeout, cancellation, cleanup, and resource-pressure classifications. +- Missing, symlinked, wrong-type, busy, foreign, or ambiguous managed sources + fail before child execution. +- No cache root is initialized, repaired, pruned, quarantined, or deleted by + the pin path. +- No new dependency, background process, network operation, Docker invocation, + or persistent schema is introduced by the implementation tests. + +## Deterministic TDD strategy + +### Prepared-generation ownership + +- clone a prepared entry; +- drop one clone and prove staging data and the lock remain live; +- prove a second preparation is still blocked; +- drop the final clone and prove only the matching staging generation is + removed; +- preserve promotion and failed-generation regression coverage. + +### Runtime mount revalidation + +- valid prepared repository, cache, regular-file artifact, and directory + artifact sources pass; +- missing source, leaf symlink, parent-component escape, and wrong source type + fail; +- a recording `SupervisorPort` proves invalid input produces zero Docker + requests and valid input reaches exactly the expected `docker create` call; +- dry-run remains deterministic, shell-free, non-executing, and serialization + compatible. + +### Guard pinning + +- an exact completed entry pins successfully; +- duplicate declarations acquire only one lock; +- multiple declarations use deterministic order; +- active preparation and a second guard pin return busy; +- incomplete, staging, missing, symlinked, foreign, temporary, and escaped + sources fail without child execution; +- a recording child seam proves the pin remains held through success, child + failure, timeout/cancellation cleanup, and guard-session release; +- a deterministic hook removes or symlink-replaces the source after pin + acquisition but before the supervisor call, proving revalidation fails, + zero child requests occur, the guard session releases, and the pin unlocks; +- legacy `guard exec` without pin flags remains behaviorally unchanged. + +### Documentation contract + +Update `CACHE_AND_WORKSPACE.md`, `RUNTIME.md`, `LOCAL_RUN.md`, +`COORDINATION_RUNBOOK.md`, `TESTING_AND_FAULT_INJECTION.md`, and +`THREAT_MODEL.md`. Documentation must distinguish the standard `run` lock, +the opt-in guard pin, spawn-boundary revalidation, and unsupported manual +deletion. + +## Delivery sequence + +1. Shared prepared-generation final-drop ownership and focused cache tests. +2. Structured mount expectations plus spawn-boundary validation and recording + runtime tests. +3. Opt-in `guard exec` managed-cache pin CLI and deterministic lifecycle tests. +4. Documentation, full non-heavy Rust checks, code review, and issue #4 update. + +Each step is separately reviewable and committed only after its red-green TDD +cycle passes. Docker, OrbStack, a CCP heavy run, receipt publication, push, PR, +and merge remain separate later authorization boundaries. + +## Acceptance criteria + +1. No prepared-entry clone can remove a staging generation still owned by + another clone. +2. A missing, symlinked, escaped, wrong-type, or generation-mismatched live + mount source detected at the spawn boundary cannot cause a Docker supervisor + request or produce evidence. +3. A declared completed cache entry used by `guard exec` remains locked from + acquisition through child and cleanup completion. +4. Standard `run` and future cleanup use the same per-entry lock authority; + no second lease or recovery authority exists. +5. Legacy unpinned `guard exec`, serialized configuration digests, receipts, + policies, dry-run JSON, and valid historical fixtures remain compatible. +6. Tests are deterministic and require no Docker, network, model, CCP run, or + host cache mutation. +7. Documentation states that undeclared raw paths and manual deletion remain + outside the guarantee and must not be reinterpreted as safe. + +Same-path replacement by a non-cooperative actor is not claimed detectable on +every supported platform. The entry lock prevents cooperative CCP replacement; +manual mutation that ignores the lock remains unsupported. + +Issue #4 closure for this tranche proves cooperative CCP mutation is prevented +and changed sources detected by the final validator fail closed. It does not +identify the historical deleting actor or claim protection from arbitrary +external deletion or same-path replacement after validation. From 1ca157b371fb269e28243098c734d4cdb6042611 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 05:32:33 +0200 Subject: [PATCH 02/14] docs: plan cache-use pin hardening --- .../plans/2026-08-25-cache-use-pin.md | 792 ++++++++++++++++++ 1 file changed, 792 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-25-cache-use-pin.md diff --git a/docs/superpowers/plans/2026-08-25-cache-use-pin.md b/docs/superpowers/plans/2026-08-25-cache-use-pin.md new file mode 100644 index 0000000..6315c3e --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-cache-use-pin.md @@ -0,0 +1,792 @@ +# Cache-use Pin Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep declared CCP cache bind sources owned through execution and fail closed immediately before Docker creation when any structured mount source is unsafe or no longer matches its prepared generation. + +**Architecture:** Reuse the existing per-entry advisory lock as the sole cache-use authority. Make cloned prepared generations share one final-drop owner, carry nonserialized mount expectations into runtime execution, and add an opt-in `guard exec` pin for exact completed entries without parsing arbitrary child argv. + +**Tech Stack:** Rust 2024, standard library filesystem and synchronization primitives, `fs2` advisory locks already in the crate, Clap, Serde, existing fake supervisor and fixture infrastructure. + +**Spec:** `docs/superpowers/specs/2026-08-25-cache-use-pin-design.md` + +## Global Constraints + +- Baseline is `3fccc197e5055a2759ee7afe51b91133938ec904`; implementation branch already contains only spec commit `90b4ec36278bcd1e41cefa9ad7775e8da61a4709`. +- Every shell command begins with `rtk`. +- Use strict red-green TDD for every production change; record the expected failing assertion before implementation. +- The existing per-entry advisory lock is the only cache-use authority; add no TTL, heartbeat, daemon, persistent lease, or dependency. +- Existing valid configuration digests, receipts, policies, verification behavior, dry-run JSON, and fixtures remain serialized-compatible. +- New mount expectations are internal and `#[serde(skip)]`; absolute paths never enter structured JSON, journals, receipts, resource history, admission state, telemetry, or remote evidence. +- `guard exec` remains cooperative, shell-free, receipt-free, and does not parse or attest arbitrary child argv. +- Automatic cache deletion/quarantine remains unavailable. Never mutate the persistent host cache during tests. +- No Docker, OrbStack, network, model workload, CCP `run`, receipt publication, push, PR, or merge belongs to this plan. + +--- + +### Task 1: Share prepared-generation cleanup and lock lifetime + +**Files:** +- Modify: `src/cache.rs:357-414,738-764,1986-2013` + +**Interfaces:** +- Produces: `PreparedCacheEntry::generation_expectation(&self) -> CacheGenerationExpectation` for Task 2. +- Produces: one shared final-drop owner that retains the existing entry lock until validated staging cleanup finishes. +- Preserves: `PreparedCacheEntry: Clone`, public `path`, `data_path`, `was_complete`, promotion behavior, and current manifest format. + +- [ ] **Step 1: Write the clone-lifetime regression test** + +Add beside `active_entry_lock_blocks_a_second_preparation_until_release`: + +```rust +#[test] +fn prepared_entry_clones_share_cleanup_and_lock_until_final_drop() { + let (repo, resolved) = resolved_fixture("entry-clone-lifetime"); + let cache = ManagedCache::initialize(resolved.clone()).expect("initialize"); + let envelope = envelope(); + let key = CacheKey::for_plan_cache(&envelope, &envelope.plan.caches[0]).expect("key"); + let first = cache + .prepare_entry(&key, &envelope.plan_digest, 7) + .expect("prepare"); + let staging = first.staging_path.clone(); + let clone = first.clone(); + + drop(first); + assert!(staging.is_dir(), "one clone must not remove live staging"); + assert!(matches!( + cache.prepare_entry(&key, &envelope.plan_digest, 8), + Err(CacheError::LockBusy(_)) + )); + + drop(clone); + assert!(!staging.exists(), "final owner removes matching staging"); + let next = cache + .prepare_entry(&key, &envelope.plan_digest, 8) + .expect("lock released after final owner"); + drop(next); + clean(&resolved.path); + clean(&repo); +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```console +rtk cargo test --lib prepared_entry_clones_share_cleanup_and_lock_until_final_drop +``` + +Expected: FAIL at `staging.is_dir()` because dropping the first clone currently removes the shared staging directory. + +- [ ] **Step 3: Introduce shared final-drop state** + +Add a crate-visible immutable expectation and a private final-drop owner: + +```rust +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CacheGenerationExpectation { + pub key_digest: String, + pub plan_digest: String, + pub generation: u64, + pub state: &'static str, +} + +#[derive(Debug)] +struct PreparedCacheGenerationOwner { + staging_path: PathBuf, + key_digest: String, + plan_digest: String, + generation: u64, + entry_lock: Arc, +} +``` + +Move the existing manifest-checked cleanup from `Drop for PreparedCacheEntry` +to `Drop for PreparedCacheGenerationOwner`. Keep `entry_lock` in that struct so +Rust drops it only after the cleanup method returns. Add +`owner: Arc` to `PreparedCacheEntry`, retain the +existing immutable fields used by promotion, remove `_entry_lock` from the +outer struct, and construct exactly one owner in `prepare_entry`. + +Implement: + +```rust +pub(crate) fn generation_expectation(&self) -> CacheGenerationExpectation { + CacheGenerationExpectation { + key_digest: self.key_digest.clone(), + plan_digest: self.plan_digest.clone(), + generation: self.generation, + state: "staging", + } +} +``` + +- [ ] **Step 4: Verify GREEN and cache regressions** + +Run: + +```console +rtk cargo test --lib prepared_entry_clones_share_cleanup_and_lock_until_final_drop +rtk cargo test --lib cache::tests +``` + +Expected: the new test passes; all cache unit tests pass. + +- [ ] **Step 5: Commit Task 1** + +```console +rtk git add src/cache.rs +rtk git commit -m "fix: share prepared cache generation lifetime" +``` + +### Task 2: Revalidate structured mounts at the Docker spawn boundary + +**Files:** +- Modify: `src/cache.rs:738-780,912-980` +- Modify: `src/workspace.rs:35-149,152-248,745-770,787-850` +- Modify: `src/runtime.rs:185-277,690-775,847-871,1633-1726` + +**Interfaces:** +- Consumes: `PreparedCacheEntry::generation_expectation()` from Task 1. +- Produces: `cache::revalidate_generation_source(&Path, &CacheGenerationExpectation) -> Result<(), CacheError>`. +- Produces: side-effect-free, read-only + `workspace::revalidate_mount_sources(&[MountBinding]) -> Result<(), WorkspaceError>`. +- Produces: private `DryRunCheck.mounts: Vec` with `#[serde(skip)]`. +- Preserves: serialized `DryRunPlan`, `DryRunCheck`, normalized plan digest, Docker argv, and `RuntimePort::execute_check` signature. + +- [ ] **Step 1: Write read-only workspace validation tests** + +Add `prepared_fixture(name)` beside the existing `fixture(name)`. It initializes +`ManagedCache` and calls `PreparedWorkspace::prepare_with_generation(..., 7)`, +returning the owned base, cache, and prepared workspace. Assert the validator +accepts untouched mounts. Before calling the validator, explicitly assert that +the prepared cache mount has +`expectation.as_ref().and_then(|value| value.cache_generation.as_ref()).is_some()`. +Keep this test inside `workspace.rs`'s crate-local test module so it exercises +the intended crate-visible fields and validator rather than failing on fixture +privacy. The helper must return the exact `PreparedWorkspace.plan.mounts` that +`prepare_with_generation` mutates, including the snapshot-preparation path. +For separate tests, mutate only one source after preparation and assert the +named typed error: + +```rust +#[test] +fn live_mount_revalidation_rejects_missing_cache_generation() { + let (base, _cache, prepared) = prepared_fixture("revalidate-missing-cache"); + let cache_mount = prepared + .plan + .mounts + .iter() + .find(|mount| mount.purpose == MountPurpose::Cache) + .expect("cache mount") + .source + .clone(); + fs::remove_dir_all(cache_mount).expect("remove owned staged cache source"); + + assert!(matches!( + revalidate_mount_sources(&prepared.plan.mounts), + Err(WorkspaceError::MountSourceChanged { .. }) + )); + fs::remove_dir_all(base).expect("remove owned fixture"); +} +``` + +Cover these cases independently: + +- valid repository, cache, regular-file artifact, and directory artifact; +- missing source; +- wrong file/directory type; +- leaf symlink; +- symlink in a parent component beneath the anchor; +- cache manifest key, plan digest, generation, or state mismatch. + +Every fixture removes only its own test root. + +- [ ] **Step 2: Run the workspace tests and verify RED** + +Run: + +```console +rtk cargo test --lib live_mount_revalidation +``` + +Expected: compile failure because `revalidate_mount_sources`, the expectation +fields, and `WorkspaceError::MountSourceChanged` do not yet exist. + +- [ ] **Step 3: Add nonserialized mount expectations** + +Add internal types: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MountSourceKind { + Directory, + RegularFile, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MountSourceExpectation { + pub kind: MountSourceKind, + pub canonical_anchor: PathBuf, + pub exact_repository: Option, + pub cache_generation: Option, +} +``` + +Add to `MountBinding`: + +```rust +#[serde(skip)] +pub(crate) expectation: Option, +``` + +All `WorkspacePlanV1` constructors set repository and artifact expectations +from the canonical repository/run root and `artifact_kind_for`. Pure dry-run +cache bindings carry kind/anchor but no generation. After each live +`prepare_entry`, `PreparedWorkspace::prepare_with_generation` attaches the +exact generation expectation to its matching cache mount. Snapshot preparation +uses the same path. + +Add errors that do not serialize paths: + +```rust +MountExpectationMissing, +MountSourceChanged { purpose: MountPurpose }, +``` + +Their `Display` messages name only the bounded mount purpose, never the absolute +source. + +- [ ] **Step 4: Implement the side-effect-free read-only validator** + +`revalidate_mount_sources` must, for every mount: + +1. require an expectation; +2. walk each component from the canonical anchor with `symlink_metadata` and + reject any symlink; +3. require the expected leaf type; +4. canonicalize and require exact repository equality or cache/artifact + containment; +5. rerun `validate_host_path`; +6. for cache mounts, require a generation expectation and call + `cache::revalidate_generation_source`, which compares the private manifest's + schema, key digest, plan digest, generation, and state. + +Keep manifest constants and parsing in `cache.rs`; do not duplicate its on-disk +schema in `workspace.rs`. Do not create, repair, rename, or delete anything in +either validator. + +- [ ] **Step 5: Verify read-only validation GREEN** + +Run: + +```console +rtk cargo test --lib live_mount_revalidation +rtk cargo test --lib workspace::tests +``` + +Expected: all new validation cases and existing workspace tests pass. + +- [ ] **Step 6: Write the zero-supervisor-call runtime test** + +Extend the existing recording/fake supervisor. Render a check from a live +workspace, remove its owned cache source, then call `execute_check`: + +```rust +let result = runtime.execute_check(&check, &rendered, &context); +assert!(matches!( + result, + Err(RuntimeError::Workspace(WorkspaceError::MountSourceChanged { .. })) +)); +assert!(supervisor.calls.lock().expect("calls").is_empty()); +``` + +Add a serialization regression asserting the JSON value for the same dry-run +fixture is unchanged after private expectations are populated. + +- [ ] **Step 7: Run the runtime tests and verify RED** + +Run: + +```console +rtk cargo test --lib execute_check_revalidates_mounts_before_supervisor +rtk cargo test --lib dry_run_private_mount_expectations_are_not_serialized +``` + +Expected: the first test fails because `execute_check` does not call the +validator; the serialization test may not compile until the private mounts are +added to `DryRunCheck`. + +- [ ] **Step 8: Integrate the authoritative runtime gate** + +Add to `DryRunCheck`: + +```rust +#[serde(skip)] +mounts: Vec, +``` + +Populate it from `workspace.mounts.clone()` in every `DryRunCheck` constructor, +including test helpers; do not allow a constructor to silently default to an +empty mount vector. Before editing, inventory `DryRunCheck {` with `rtk rg`: +the baseline has one production literal in `docker_dry_run_check` and no +`Deserialize` or `Default` path. Record any newly discovered literal and make +the required field compile-time mandatory; all tests must obtain checks through +that constructor or set the real mount vector explicitly. At the +first line of `DockerCompatibleRuntime::execute_check`, before +`DockerLifecyclePlan::build`, call: + +```rust +crate::workspace::revalidate_mount_sources(&rendered.mounts) + .map_err(RuntimeError::Workspace)?; +``` + +No other Docker lifecycle entry point may call the supervisor with a rendered +check. + +- [ ] **Step 9: Verify Task 2 GREEN and compatibility** + +Run: + +```console +rtk cargo test --lib execute_check_revalidates_mounts_before_supervisor +rtk cargo test --lib dry_run_private_mount_expectations_are_not_serialized +rtk cargo test --lib workspace::tests +rtk cargo test --lib runtime::tests +rtk cargo test --test runtime_cli +rtk cargo test --test cache_cli +``` + +Expected: all commands pass and dry-run JSON assertions remain unchanged. + +- [ ] **Step 10: Commit Task 2** + +```console +rtk git add src/cache.rs src/workspace.rs src/runtime.rs +rtk git commit -m "fix: revalidate mounts before Docker creation" +``` + +### Task 3: Add opt-in managed-cache pins to `guard exec` + +**Files:** +- Modify: `src/cache.rs:322-356,663-764,1190-1235,1316-1350` +- Modify: `src/main.rs:250-330,574-660,2260-2310,2436-2800` +- Modify: `tests/guard_exec_cli.rs:1-100` only for CLI surface regressions that do not require native admission. + +**Interfaces:** +- Produces: `ManagedCache::pin_completed_sources(&[PathBuf]) -> Result, CacheError>`. +- Produces: `CacheUsePin::revalidate(&self) -> Result<(), CacheError>`. +- Produces CLI flags `--managed-cache-root ` and repeatable `--managed-cache-source `. +- Consumes: existing `ResolvedCacheRoot::resolve`, `ManagedCache::open`, entry lock, ownership marker, completion marker, and generation manifest. +- Preserves: legacy unpinned `guard exec` parsing and execution. + +- [ ] **Step 1: Write cache pin parser and lock tests** + +Add cache tests that construct only CCP-owned fixture roots: + +```rust +#[test] +fn completed_source_pin_holds_entry_lock_until_drop() { + let fixture = completed_entry_fixture("completed-source-pin"); + let pins = fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .expect("pin completed source"); + assert_eq!(pins.len(), 1); + assert!(matches!( + fixture.cache.pin_completed_sources(std::slice::from_ref(&fixture.data_path)), + Err(CacheError::LockBusy(_)) + )); + drop(pins); + assert_eq!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .expect("re-pin after release") + .len(), + 1 + ); +} +``` + +Separate tests cover duplicate source deduplication, deterministic ordering, +wrong root, extra components, invalid key directory, staging source, +incomplete entry, missing source, symlink component, wrong type, missing or +mismatched completion manifest, and an active prepared generation returning +`LockBusy`. + +- [ ] **Step 2: Run cache pin tests and verify RED** + +Run: + +```console +rtk cargo test --lib completed_source_pin +``` + +Expected: compile failure because `CacheUsePin` and +`pin_completed_sources` do not exist. + +- [ ] **Step 3: Implement one strict source parser and RAII pin** + +Add: + +```rust +#[derive(Debug)] +pub struct CacheUsePin { + entry_path: PathBuf, + data_path: PathBuf, + key_digest: String, + plan_digest: String, + generation: u64, + _entry_lock: Arc, +} +``` + +`pin_completed_sources` must: + +1. validate the existing owner marker and fixed root layout; +2. reject non-absolute/noncanonical paths and symlink components; +3. require exactly `entries/sha256-<64 lowercase hex>/data` beneath this + cache's exact canonical root; +4. canonicalize, deduplicate, and sort by entry path; +5. acquire each existing entry lock with current non-blocking semantics; +6. after acquisition, require plain entry/data directories, exact complete + marker, and a complete generation manifest whose key digest matches the + directory and whose plan digest is valid; +7. release every already-acquired pin automatically if any later source fails. + +`CacheUsePin::revalidate` repeats step 6 and exact path/root containment without +creating or repairing state. Human-readable `Display` may name a path, but the +CLI must not serialize it. + +- [ ] **Step 4: Verify cache pin GREEN** + +Run: + +```console +rtk cargo test --lib completed_source_pin +rtk cargo test --lib cache::tests +``` + +Expected: all pin, existing lock, promotion, inventory, and recovery tests pass. + +- [ ] **Step 5: Write guard CLI parsing tests** + +Extend `guard_exec_requires_double_dash_and_program` and add focused cases: + +```rust +let cli = Cli::try_parse_from([ + "commit-ci-preflight", "guard", "exec", + "--managed-cache-root", "/owned/cache", + "--managed-cache-source", "/owned/cache/entries/sha256-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/data", + "--", "fixture", +]).expect("managed cache pin parses"); +``` + +Assert `Cli::try_parse_from` stores exactly one raw root and one source. Then +invoke a pure `validate_guard_cache_args` helper and assert it rejects root-only, +source-only, and repeated root declarations with usage exit 2. Legacy args must +parse with empty root and source vectors and the helper must accept them as the +unpinned case. + +- [ ] **Step 6: Run parser tests and verify RED** + +Run: + +```console +rtk cargo test --bin commit-ci-preflight guard_exec_parses_managed_cache_pins +``` + +Expected: FAIL because `GuardExecArgs` has no managed-cache fields and +`validate_guard_cache_args` does not exist. + +- [ ] **Step 7: Add flags and typed guard errors** + +Add fields: + +```rust +#[arg(long)] +managed_cache_root: Vec, +#[arg(long)] +managed_cache_source: Vec, +``` + +Validate cardinality immediately after parsing: either both vectors are empty, +or `managed_cache_root.len() == 1` and the source vector is nonempty. Zero or +multiple roots with any source, root-only, and source-only all return +`GuardExecError::InvalidManagedCache` with usage exit 2 before admission or +cache access. This raw-vector representation makes repeated-root rejection +explicit instead of relying on ambiguous `Option` Clap behavior. Add a +separate fail-closed internal/cache error path for busy or changed owned +entries. Neither structured output nor persistent state may include the +absolute source. + +Implement that rule in a side-effect-free +`validate_guard_cache_args(&GuardExecArgs)` helper. Parser tests must first call +`Cli::try_parse_from`, extract `GuardExecArgs`, and then call this helper; they +must not claim Clap itself rejects combinations that are intentionally parsed +as raw vectors. `print_guard_exec` calls the same helper before admission, +filesystem canonicalization, or cache access. + +- [ ] **Step 8: Write the post-acquisition race and lifecycle tests** + +Extract two private helpers so the race boundary is directly testable: + +```rust +fn execute_with_guard_cache_pins( + pins: &[CacheUsePin], + child: impl FnOnce() -> Result, +) -> Result; + +fn with_guard_cache_pins( + cache: Option<&ManagedCache>, + sources: &[PathBuf], + child: impl FnOnce() -> Result, +) -> Result; +``` + +The first helper revalidates already-acquired pins immediately before calling +the closure. The second acquires pins, calls the first, captures the closure +result, and drops pins only after that result exists. Test with an atomic call +counter that: + +- missing/symlink-replaced data after acquisition yields zero child calls; +- success and child error both observe the lock as busy inside the closure; +- the lock is available after the helper returns; +- duplicate sources still create one pin. + +For the race test, acquire pins directly, remove or symlink-replace the owned +data source, then call `execute_with_guard_cache_pins`; this creates the exact +post-acquisition/pre-child sequence without a production sleep or global hook. +Treat this explicitly as a non-cooperative TOCTOU-limit test: removing or +replacing a path while its advisory lock is open is platform-sensitive, so the +portable assertion is only validator failure plus zero child calls. Test the +cooperative lock-busy lifetime separately on an untouched fixture. + +- [ ] **Step 9: Run lifecycle tests and verify RED** + +Run: + +```console +rtk cargo test --bin commit-ci-preflight guard_cache_pins +``` + +Expected: compile failure until the helper and guard integration exist. + +- [ ] **Step 10: Integrate pins into `print_guard_exec`** + +Before admission, resolve the explicit root with the existing +`CacheRootOptions`/`ResolvedCacheRoot::resolve` predicates and open it read-only; +do not initialize it. After `GuardExecSession::acquire` and before child launch, +call `with_guard_cache_pins`. Its closure performs +`supervisor.execute_with_output` and then `session.finish(...)`, so pins remain +in scope through both child containment cleanup and guard-session release. The +helper drops them only after that closure returns. + +If pin acquisition or revalidation fails after admission, call +`session.finish(Err(...), &cancellation)` and return without starting the child. +Legacy calls with no managed-cache flags bypass only the pin helper; admission, +resource watchdog, child containment, and exit classification remain unchanged. + +- [ ] **Step 11: Verify Task 3 GREEN** + +Run: + +```console +rtk cargo test --lib completed_source_pin +rtk cargo test --bin commit-ci-preflight guard_exec +rtk cargo test --test guard_exec_cli --no-run +rtk cargo test --lib cache::tests +``` + +Expected: all focused tests pass; the ignored native guard test is compiled but +not executed. + +- [ ] **Step 12: Commit Task 3** + +```console +rtk git add src/cache.rs src/main.rs tests/guard_exec_cli.rs +rtk git commit -m "feat: pin declared guard cache sources" +``` + +### Task 4: Document the contract and run the non-heavy verification gate + +**Files:** +- Create: `tests/cache_pin_contract.rs` +- Modify: `docs/CACHE_AND_WORKSPACE.md` +- Modify: `docs/RUNTIME.md` +- Modify: `docs/LOCAL_RUN.md` +- Modify: `docs/COORDINATION_RUNBOOK.md` +- Modify: `docs/TESTING_AND_FAULT_INJECTION.md` +- Modify: `docs/THREAT_MODEL.md` +- Modify: `/private/tmp/ccp-cache-use-pin-checkpoint.md` outside Git only as the durable operator checkpoint. + +**Interfaces:** +- Consumes: final cache pin flags and typed behavior from Tasks 1-3. +- Produces: operator guidance that distinguishes standard-run locks, opt-in guard pins, spawn-boundary revalidation, and unsupported manual deletion. +- Produces: a locally reviewed issue #4 comment draft with exact commit/test + evidence, but does not post it, push, open a PR, merge, publish a receipt, or + run CCP. + +- [ ] **Step 1: Add a documentation contract test before editing prose** + +Create `tests/cache_pin_contract.rs`. Load each named Markdown file from +`env!("CARGO_MANIFEST_DIR")`, join their text, and require these exact concepts: + +```rust +use std::fs; +use std::path::Path; + +#[test] +fn cache_pin_documentation_contract() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let docs = [ + "docs/CACHE_AND_WORKSPACE.md", + "docs/RUNTIME.md", + "docs/LOCAL_RUN.md", + "docs/COORDINATION_RUNBOOK.md", + "docs/TESTING_AND_FAULT_INJECTION.md", + "docs/THREAT_MODEL.md", + ] + .map(|path| (path, fs::read_to_string(root.join(path)).expect("read contract doc"))); + let combined_docs = docs + .iter() + .map(|(_, text)| text.as_str()) + .collect::>() + .join("\n"); + + for required in [ + "--managed-cache-root", + "--managed-cache-source", + "spawn-boundary revalidation", + "undeclared paths are not pinned", + "manual deletion remains unsupported", + ] { + assert!(combined_docs.contains(required), "missing {required}"); + } + + for forbidden in [ + "cache pin uses a TTL lease", + "automatic cache deletion is enabled", + "guard exec emits a receipt", + "external same-path replacement is prevented", + ] { + assert!(!combined_docs.contains(forbidden), "forbidden claim: {forbidden}"); + } + + for (path, required) in [ + ("docs/CACHE_AND_WORKSPACE.md", "--managed-cache-source"), + ("docs/RUNTIME.md", "spawn-boundary revalidation"), + ("docs/LOCAL_RUN.md", "--managed-cache-root"), + ("docs/COORDINATION_RUNBOOK.md", "undeclared paths are not pinned"), + ("docs/TESTING_AND_FAULT_INJECTION.md", "non-cooperative"), + ("docs/THREAT_MODEL.md", "manual deletion remains unsupported"), + ] { + let text = docs + .iter() + .find(|(candidate, _)| *candidate == path) + .map(|(_, text)| text) + .expect("named contract document"); + assert!(text.contains(required), "{path} missing {required}"); + } +} +``` + +- [ ] **Step 2: Run the documentation test and verify RED** + +Run: + +```console +rtk cargo test --test cache_pin_contract +``` + +Expected: FAIL because the new flag and lifecycle language is absent. + +- [ ] **Step 3: Update the six contract documents** + +Document these exact facts: + +- standard `run` retains the prepared entry lock and validates the exact staging + generation immediately before Docker creation; +- `guard exec` pins only exact completed entry data paths explicitly declared + under one already-owned persistent cache root; +- pins use the existing advisory entry lock, not TTL/heartbeat state; +- declarations are cooperative and do not prove actual child argv use; +- undeclared paths have no pin guarantee; +- pin validation never initializes, repairs, deletes, quarantines, or publishes + cache contents; +- any future mutator must acquire the same lock and revalidate after acquisition; +- manual deletion and non-cooperative external replacement remain unsupported; +- Docker/OrbStack/native qualification remains separate exact-head evidence. + +Do not copy absolute fixture paths, usernames, raw child argv, or cache contents +into documentation evidence. + +Read `docs/RECEIPT_SPEC.md` and `docs/CONFIGURATION.md` during this step to +confirm the new internal fields and guard-only flags do not contradict receipt +or configuration claims. Do not edit them unless a concrete contradiction is +found and first recorded in the checkpoint. + +- [ ] **Step 4: Verify documentation GREEN** + +Run: + +```console +rtk cargo test --test cache_pin_contract +rtk rg -n "managed-cache-(root|source)|spawn-boundary|manual deletion" docs +``` + +Expected: the contract test passes and every operational claim has one clear +home rather than contradictory duplicates. + +- [ ] **Step 5: Run formatting, lint, and the full non-heavy suite** + +Run: + +```console +rtk cargo fmt --all -- --check +rtk cargo clippy --all-targets --all-features -- -D warnings +rtk cargo test --all-targets --all-features +rtk cargo test --doc +rtk git diff --check +``` + +Expected: zero failures and zero warnings. The ignored native guard test remains +ignored; no Docker or CCP command is invoked. + +- [ ] **Step 6: Commit Task 4** + +```console +rtk git add docs/CACHE_AND_WORKSPACE.md docs/RUNTIME.md docs/LOCAL_RUN.md docs/COORDINATION_RUNBOOK.md docs/TESTING_AND_FAULT_INJECTION.md docs/THREAT_MODEL.md tests/cache_pin_contract.rs +rtk git commit -m "docs: define cache pin lifecycle contract" +``` + +- [ ] **Step 7: Prepare the issue #4 comment draft locally** + +First save the proposed comment in this plan's SDD workspace. It must contain: + +- exact branch HEAD and baseline; +- the historical Docker exit 125 classified as correct fail-closed behavior, + without claiming the deleting actor; +- implementation summary for clone lifetime, spawn revalidation, and guard pin; +- exact test commands and terminal counts; +- explicit limitations: cooperative lock only, no automatic cleanup, no receipt, + no heavy/native qualification, no arbitrary external replacement guarantee; +- a statement that no retry authorization was consumed. + +Review the body locally and stop with its path and hash recorded. Posting the +comment is a separate GitHub mutation requiring a later explicit authorization; +this plan records `not posted`. Do not open a duplicate issue or modify any PR. + +- [ ] **Step 8: Update the durable checkpoint** + +Append task commits, RED/GREEN evidence with exact terminal test counts, final +verification output, issue-comment draft path/hash and `not posted`, remaining +qualification boundaries, baseline/final HEADs, and exact clean/dirty status to +`/private/tmp/ccp-cache-use-pin-checkpoint.md` using `apply_patch`. + +- [ ] **Step 9: Stop at the branch-review boundary** + +Confirm the worktree is clean and report the exact HEAD. Do not push, open a PR, +run CCP, publish evidence, or merge without new explicit authorization. From 2812da8302c6cbf73e392dbd69cf43a4a04def01 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 05:44:11 +0200 Subject: [PATCH 03/14] fix: share prepared cache generation lifetime --- .../2026-08-25-cache-use-pin/task-1-report.md | 52 ++++++++++++++ src/cache.rs | 70 ++++++++++++++++++- 2 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 .superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md diff --git a/.superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md b/.superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md new file mode 100644 index 0000000..b156cfb --- /dev/null +++ b/.superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md @@ -0,0 +1,52 @@ +# Task 1 Report + +## RED + +Command: + +```console +rtk cargo test --lib prepared_entry_clones_share_cleanup_and_lock_until_final_drop +``` + +Result: failed as expected before the production edit. The test panicked at +`src/cache.rs:2022:9` with `one clone must not remove live staging` (0 passed, +1 failed, 219 filtered out). This demonstrated that dropping the first clone +removed shared staging while the second clone still existed. + +## GREEN + +Commands and results: + +```console +rtk cargo test --lib prepared_entry_clones_share_cleanup_and_lock_until_final_drop +# 1 passed, 219 filtered out + +rtk cargo test --lib cache::tests +# 18 passed, 202 filtered out + +rtk cargo fmt -- --check +# passed + +rtk git diff --check +# passed +``` + +## Scope and implementation + +`src/cache.rs` now gives each prepared generation one `Arc`-owned final-drop +state containing the staging identity and entry lock. Cleanup therefore runs +only on the final clone, while the lock remains held through validated cleanup. +The crate-visible `CacheGenerationExpectation` and +`PreparedCacheEntry::generation_expectation` API were added for Task 2. The +regression test verifies staging retention, lock contention, final cleanup, and +subsequent lock release. + +## Commit + +Commit SHA: `bd759e2`. + +## Concerns + +No known concerns within Task 1 scope. The report is intentionally limited to +`src/cache.rs` and this report file; no CCP, Docker, network, host-cache, or +remote operations were performed. diff --git a/src/cache.rs b/src/cache.rs index 300cb56..7a7c5f2 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -401,6 +401,13 @@ impl ManagedCache { state: "staging".to_owned(), }; write_generation_manifest(&staging_path, &manifest)?; + let owner = Arc::new(PreparedCacheGenerationOwner { + staging_path: staging_path.clone(), + key_digest: key.digest.clone(), + plan_digest: plan_digest.to_owned(), + generation, + entry_lock, + }); Ok(PreparedCacheEntry { path, data_path, @@ -408,7 +415,7 @@ impl ManagedCache { key_digest: key.digest.clone(), plan_digest: plan_digest.to_owned(), generation, - _entry_lock: entry_lock, + owner, was_complete: complete, }) } @@ -742,11 +749,20 @@ pub struct PreparedCacheEntry { key_digest: String, plan_digest: String, generation: u64, - _entry_lock: Arc, + owner: Arc, pub was_complete: bool, } -impl Drop for PreparedCacheEntry { +#[derive(Debug)] +struct PreparedCacheGenerationOwner { + staging_path: PathBuf, + key_digest: String, + plan_digest: String, + generation: u64, + entry_lock: Arc, +} + +impl Drop for PreparedCacheGenerationOwner { fn drop(&mut self) { let Ok(manifest) = read_generation_manifest(&self.staging_path.join(GENERATION_MANIFEST_FILE)) @@ -764,6 +780,25 @@ impl Drop for PreparedCacheEntry { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CacheGenerationExpectation { + pub key_digest: String, + pub plan_digest: String, + pub generation: u64, + pub state: &'static str, +} + +impl PreparedCacheEntry { + pub(crate) fn generation_expectation(&self) -> CacheGenerationExpectation { + CacheGenerationExpectation { + key_digest: self.key_digest.clone(), + plan_digest: self.plan_digest.clone(), + generation: self.generation, + state: "staging", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CacheGenerationManifestV1 { @@ -2006,6 +2041,35 @@ timeout_seconds = 60 clean(&repo); } + #[test] + fn prepared_entry_clones_share_cleanup_and_lock_until_final_drop() { + let (repo, resolved) = resolved_fixture("entry-clone-lifetime"); + let cache = ManagedCache::initialize(resolved.clone()).expect("initialize"); + let envelope = envelope(); + let key = CacheKey::for_plan_cache(&envelope, &envelope.plan.caches[0]).expect("key"); + let first = cache + .prepare_entry(&key, &envelope.plan_digest, 7) + .expect("prepare"); + let staging = first.staging_path.clone(); + let clone = first.clone(); + + drop(first); + assert!(staging.is_dir(), "one clone must not remove live staging"); + assert!(matches!( + cache.prepare_entry(&key, &envelope.plan_digest, 8), + Err(CacheError::LockBusy(_)) + )); + + drop(clone); + assert!(!staging.exists(), "final owner removes matching staging"); + let next = cache + .prepare_entry(&key, &envelope.plan_digest, 8) + .expect("lock released after final owner"); + drop(next); + clean(&resolved.path); + clean(&repo); + } + #[test] fn multi_entry_promotion_is_journaled_and_cleans_only_after_success() { let (repo, resolved) = resolved_fixture("multi-entry-promotion"); From ce4c21c8bafc34fd57493cc04784ba1e2ae197ef Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 05:44:39 +0200 Subject: [PATCH 04/14] docs: bind cache lifetime report to commit --- .superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md b/.superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md index b156cfb..d42a125 100644 --- a/.superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md +++ b/.superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md @@ -43,7 +43,7 @@ subsequent lock release. ## Commit -Commit SHA: `bd759e2`. +Implementation commit SHA: `2812da8302c6cbf73e392dbd69cf43a4a04def01`. ## Concerns From ef8c162ade38c867bae9a04199b327fd8a09a108 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 05:57:25 +0200 Subject: [PATCH 05/14] fix: clean task 1 cache handoff warnings --- .../2026-08-25-cache-use-pin/task-1-report.md | 52 ------------------- src/cache.rs | 12 +++-- 2 files changed, 8 insertions(+), 56 deletions(-) delete mode 100644 .superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md diff --git a/.superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md b/.superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md deleted file mode 100644 index d42a125..0000000 --- a/.superpowers/sdd/2026-08-25-cache-use-pin/task-1-report.md +++ /dev/null @@ -1,52 +0,0 @@ -# Task 1 Report - -## RED - -Command: - -```console -rtk cargo test --lib prepared_entry_clones_share_cleanup_and_lock_until_final_drop -``` - -Result: failed as expected before the production edit. The test panicked at -`src/cache.rs:2022:9` with `one clone must not remove live staging` (0 passed, -1 failed, 219 filtered out). This demonstrated that dropping the first clone -removed shared staging while the second clone still existed. - -## GREEN - -Commands and results: - -```console -rtk cargo test --lib prepared_entry_clones_share_cleanup_and_lock_until_final_drop -# 1 passed, 219 filtered out - -rtk cargo test --lib cache::tests -# 18 passed, 202 filtered out - -rtk cargo fmt -- --check -# passed - -rtk git diff --check -# passed -``` - -## Scope and implementation - -`src/cache.rs` now gives each prepared generation one `Arc`-owned final-drop -state containing the staging identity and entry lock. Cleanup therefore runs -only on the final clone, while the lock remains held through validated cleanup. -The crate-visible `CacheGenerationExpectation` and -`PreparedCacheEntry::generation_expectation` API were added for Task 2. The -regression test verifies staging retention, lock contention, final cleanup, and -subsequent lock release. - -## Commit - -Implementation commit SHA: `2812da8302c6cbf73e392dbd69cf43a4a04def01`. - -## Concerns - -No known concerns within Task 1 scope. The report is intentionally limited to -`src/cache.rs` and this report file; no CCP, Docker, network, host-cache, or -remote operations were performed. diff --git a/src/cache.rs b/src/cache.rs index 7a7c5f2..1f68675 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -406,7 +406,7 @@ impl ManagedCache { key_digest: key.digest.clone(), plan_digest: plan_digest.to_owned(), generation, - entry_lock, + _entry_lock: entry_lock, }); Ok(PreparedCacheEntry { path, @@ -415,7 +415,7 @@ impl ManagedCache { key_digest: key.digest.clone(), plan_digest: plan_digest.to_owned(), generation, - owner, + _generation_owner: owner, was_complete: complete, }) } @@ -749,7 +749,7 @@ pub struct PreparedCacheEntry { key_digest: String, plan_digest: String, generation: u64, - owner: Arc, + _generation_owner: Arc, pub was_complete: bool, } @@ -759,7 +759,7 @@ struct PreparedCacheGenerationOwner { key_digest: String, plan_digest: String, generation: u64, - entry_lock: Arc, + _entry_lock: Arc, } impl Drop for PreparedCacheGenerationOwner { @@ -780,6 +780,8 @@ impl Drop for PreparedCacheGenerationOwner { } } +/// Temporary Task 2 handoff API; remove this allowance when Task 2 consumes it. +#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CacheGenerationExpectation { pub key_digest: String, @@ -789,6 +791,8 @@ pub(crate) struct CacheGenerationExpectation { } impl PreparedCacheEntry { + /// Temporary Task 2 handoff API; remove this allowance when Task 2 consumes it. + #[allow(dead_code)] pub(crate) fn generation_expectation(&self) -> CacheGenerationExpectation { CacheGenerationExpectation { key_digest: self.key_digest.clone(), From 6a51e9e713ac0d28ef57fe2f51da491ae0f118ce Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 06:07:23 +0200 Subject: [PATCH 06/14] fix: revalidate mounts before Docker creation --- src/cache.rs | 29 +++++++++-- src/runtime.rs | 6 +++ src/workspace.rs | 132 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 162 insertions(+), 5 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 1f68675..ea12c70 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -780,8 +780,6 @@ impl Drop for PreparedCacheGenerationOwner { } } -/// Temporary Task 2 handoff API; remove this allowance when Task 2 consumes it. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CacheGenerationExpectation { pub key_digest: String, @@ -791,8 +789,6 @@ pub(crate) struct CacheGenerationExpectation { } impl PreparedCacheEntry { - /// Temporary Task 2 handoff API; remove this allowance when Task 2 consumes it. - #[allow(dead_code)] pub(crate) fn generation_expectation(&self) -> CacheGenerationExpectation { CacheGenerationExpectation { key_digest: self.key_digest.clone(), @@ -803,6 +799,31 @@ impl PreparedCacheEntry { } } +pub(crate) fn revalidate_generation_source( + source: &Path, + expected: &CacheGenerationExpectation, +) -> Result<(), CacheError> { + let metadata = fs::symlink_metadata(source).map_err(CacheError::Io)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(CacheError::GenerationMismatch); + } + let manifest = read_generation_manifest( + &source + .parent() + .ok_or(CacheError::GenerationMismatch)? + .join(GENERATION_MANIFEST_FILE), + )?; + if manifest.schema_version != GENERATION_SCHEMA_VERSION + || manifest.key_digest != expected.key_digest + || manifest.plan_digest != expected.plan_digest + || manifest.generation != expected.generation + || manifest.state != expected.state + { + return Err(CacheError::GenerationMismatch); + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CacheGenerationManifestV1 { diff --git a/src/runtime.rs b/src/runtime.rs index 13ca062..8917ade 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -222,6 +222,8 @@ impl RuntimePort for DockerCompatibleRuntime { rendered: &DryRunCheck, context: &RuntimeExecutionContext<'_>, ) -> Result { + crate::workspace::revalidate_mount_sources(&rendered.mounts) + .map_err(RuntimeError::Workspace)?; let lifecycle = DockerLifecyclePlan::build(context.run_id, check, rendered)?; let cleanup_cancellation = CancellationToken::default(); let create = self.execute_cli(&lifecycle.create_argv, context, context.cancellation)?; @@ -753,6 +755,7 @@ fn docker_dry_run_check( program: "docker", argv, depends_on: check.depends_on.clone(), + mounts: workspace.mounts.clone(), }) } @@ -867,6 +870,8 @@ pub struct DryRunCheck { pub program: &'static str, pub argv: Vec, pub depends_on: Vec, + #[serde(skip)] + pub(crate) mounts: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1696,6 +1701,7 @@ timeout_seconds = 60 access: MountAccess::ReadOnly, purpose: crate::workspace::MountPurpose::Repository, logical_id: None, + expectation: None, }; for source in [ "/safe,comma", diff --git a/src/workspace.rs b/src/workspace.rs index 9b93066..3d51d43 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -22,7 +22,10 @@ use std::sync::atomic::{AtomicU64, Ordering}; use serde::Serialize; use sha2::{Digest, Sha256}; -use crate::cache::{CacheError, CacheKey, CachePromotionOutcome, ManagedCache, ResolvedCacheRoot}; +use crate::cache::{ + CacheError, CacheGenerationExpectation, CacheKey, CachePromotionOutcome, ManagedCache, + ResolvedCacheRoot, +}; use crate::config::{ArtifactKind, ExecutionPlanEnvelopeV1, NormalizedCheck}; use crate::receipt::{ArtifactEvidence, canonical_digest}; @@ -45,6 +48,20 @@ pub enum MountPurpose { Artifact, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MountSourceKind { + Directory, + RegularFile, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MountSourceExpectation { + pub kind: MountSourceKind, + pub canonical_anchor: PathBuf, + pub exact_repository: Option, + pub cache_generation: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MountBinding { pub source: PathBuf, @@ -53,6 +70,8 @@ pub struct MountBinding { pub purpose: MountPurpose, #[serde(skip_serializing_if = "Option::is_none")] pub logical_id: Option, + #[serde(skip)] + pub(crate) expectation: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -97,6 +116,12 @@ impl WorkspacePlanV1 { access: MountAccess::ReadOnly, purpose: MountPurpose::Repository, logical_id: None, + expectation: Some(MountSourceExpectation { + kind: MountSourceKind::Directory, + canonical_anchor: repository.clone(), + exact_repository: Some(repository.clone()), + cache_generation: None, + }), }]; let mut targets = BTreeSet::from([CONTAINER_WORKSPACE.to_owned()]); @@ -118,6 +143,12 @@ impl WorkspacePlanV1 { access: MountAccess::ReadWrite, purpose: MountPurpose::Cache, logical_id: Some(declared.id.clone()), + expectation: Some(MountSourceExpectation { + kind: MountSourceKind::Directory, + canonical_anchor: cache.path.clone(), + exact_repository: None, + cache_generation: None, + }), }); } @@ -135,6 +166,16 @@ impl WorkspacePlanV1 { access: MountAccess::ReadWrite, purpose: MountPurpose::Artifact, logical_id: Some(format!("{}:{artifact}", check.id)), + expectation: Some(MountSourceExpectation { + kind: if artifact_kind_for(check, artifact) == ArtifactKind::Directory { + MountSourceKind::Directory + } else { + MountSourceKind::RegularFile + }, + canonical_anchor: run_root.clone(), + exact_repository: None, + cache_generation: None, + }), }); } } @@ -203,6 +244,19 @@ impl PreparedWorkspace { &cache_sources, None, )?; + let mut plan = plan; + for mount in &mut plan.mounts { + if mount.purpose == MountPurpose::Cache { + if let Some(entry) = cache_entries + .iter() + .find(|entry| entry.data_path == mount.source) + { + if let Some(expectation) = mount.expectation.as_mut() { + expectation.cache_generation = Some(entry.generation_expectation()); + } + } + } + } Ok(Self { plan, cache_entries, @@ -758,6 +812,74 @@ pub fn validate_host_path(path: &Path) -> Result<(), WorkspaceError> { Ok(()) } +pub(crate) fn revalidate_mount_sources(mounts: &[MountBinding]) -> Result<(), WorkspaceError> { + for mount in mounts { + let expectation = mount + .expectation + .as_ref() + .ok_or(WorkspaceError::MountExpectationMissing)?; + let relative = mount + .source + .strip_prefix(&expectation.canonical_anchor) + .map_err(|_| WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + })?; + let mut current = expectation.canonical_anchor.clone(); + for component in relative.components() { + current.push(component.as_os_str()); + let metadata = + fs::symlink_metadata(¤t).map_err(|_| WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + })?; + if metadata.file_type().is_symlink() { + return Err(WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + }); + } + } + let metadata = fs::symlink_metadata(&mount.source).map_err(|_| { + WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + } + })?; + let kind_ok = match expectation.kind { + MountSourceKind::Directory => metadata.is_dir(), + MountSourceKind::RegularFile => metadata.is_file(), + }; + if !kind_ok + || expectation + .exact_repository + .as_ref() + .is_some_and(|path| fs::canonicalize(&mount.source).ok().as_ref() != Some(path)) + { + return Err(WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + }); + } + let canonical = + fs::canonicalize(&mount.source).map_err(|_| WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + })?; + if !canonical.starts_with(&expectation.canonical_anchor) { + return Err(WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + }); + } + validate_host_path(&mount.source)?; + if mount.purpose == MountPurpose::Cache { + crate::cache::revalidate_generation_source( + &mount.source, + expectation + .cache_generation + .as_ref() + .ok_or(WorkspaceError::MountExpectationMissing)?, + ) + .map_err(WorkspaceError::Cache)?; + } + } + Ok(()) +} + pub fn validate_container_mount_target(target: &str) -> Result<(), WorkspaceError> { if target.contains(',') || target.contains('=') @@ -794,6 +916,8 @@ pub enum WorkspaceError { PathEscape, Busy(PathBuf), UnsafeManagedObject, + MountExpectationMissing, + MountSourceChanged { purpose: MountPurpose }, Cache(CacheError), Io(std::io::Error), } @@ -826,6 +950,12 @@ impl fmt::Display for WorkspaceError { Self::UnsafeManagedObject => { formatter.write_str("unsafe object found inside managed workspace") } + Self::MountExpectationMissing => { + formatter.write_str("mount source expectation is missing") + } + Self::MountSourceChanged { purpose } => { + write!(formatter, "mount source changed for {purpose:?}") + } Self::Cache(error) => write!(formatter, "{error}"), Self::Io(_) => formatter.write_str("workspace filesystem operation failed"), } From a5ce9509f143d46a2ec786bedee89ea383dd9684 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 06:20:11 +0200 Subject: [PATCH 07/14] Revert "fix: revalidate mounts before Docker creation" This reverts commit 6a51e9e713ac0d28ef57fe2f51da491ae0f118ce. --- src/cache.rs | 29 ++--------- src/runtime.rs | 6 --- src/workspace.rs | 132 +---------------------------------------------- 3 files changed, 5 insertions(+), 162 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index ea12c70..1f68675 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -780,6 +780,8 @@ impl Drop for PreparedCacheGenerationOwner { } } +/// Temporary Task 2 handoff API; remove this allowance when Task 2 consumes it. +#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CacheGenerationExpectation { pub key_digest: String, @@ -789,6 +791,8 @@ pub(crate) struct CacheGenerationExpectation { } impl PreparedCacheEntry { + /// Temporary Task 2 handoff API; remove this allowance when Task 2 consumes it. + #[allow(dead_code)] pub(crate) fn generation_expectation(&self) -> CacheGenerationExpectation { CacheGenerationExpectation { key_digest: self.key_digest.clone(), @@ -799,31 +803,6 @@ impl PreparedCacheEntry { } } -pub(crate) fn revalidate_generation_source( - source: &Path, - expected: &CacheGenerationExpectation, -) -> Result<(), CacheError> { - let metadata = fs::symlink_metadata(source).map_err(CacheError::Io)?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(CacheError::GenerationMismatch); - } - let manifest = read_generation_manifest( - &source - .parent() - .ok_or(CacheError::GenerationMismatch)? - .join(GENERATION_MANIFEST_FILE), - )?; - if manifest.schema_version != GENERATION_SCHEMA_VERSION - || manifest.key_digest != expected.key_digest - || manifest.plan_digest != expected.plan_digest - || manifest.generation != expected.generation - || manifest.state != expected.state - { - return Err(CacheError::GenerationMismatch); - } - Ok(()) -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CacheGenerationManifestV1 { diff --git a/src/runtime.rs b/src/runtime.rs index 8917ade..13ca062 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -222,8 +222,6 @@ impl RuntimePort for DockerCompatibleRuntime { rendered: &DryRunCheck, context: &RuntimeExecutionContext<'_>, ) -> Result { - crate::workspace::revalidate_mount_sources(&rendered.mounts) - .map_err(RuntimeError::Workspace)?; let lifecycle = DockerLifecyclePlan::build(context.run_id, check, rendered)?; let cleanup_cancellation = CancellationToken::default(); let create = self.execute_cli(&lifecycle.create_argv, context, context.cancellation)?; @@ -755,7 +753,6 @@ fn docker_dry_run_check( program: "docker", argv, depends_on: check.depends_on.clone(), - mounts: workspace.mounts.clone(), }) } @@ -870,8 +867,6 @@ pub struct DryRunCheck { pub program: &'static str, pub argv: Vec, pub depends_on: Vec, - #[serde(skip)] - pub(crate) mounts: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1701,7 +1696,6 @@ timeout_seconds = 60 access: MountAccess::ReadOnly, purpose: crate::workspace::MountPurpose::Repository, logical_id: None, - expectation: None, }; for source in [ "/safe,comma", diff --git a/src/workspace.rs b/src/workspace.rs index 3d51d43..9b93066 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -22,10 +22,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use serde::Serialize; use sha2::{Digest, Sha256}; -use crate::cache::{ - CacheError, CacheGenerationExpectation, CacheKey, CachePromotionOutcome, ManagedCache, - ResolvedCacheRoot, -}; +use crate::cache::{CacheError, CacheKey, CachePromotionOutcome, ManagedCache, ResolvedCacheRoot}; use crate::config::{ArtifactKind, ExecutionPlanEnvelopeV1, NormalizedCheck}; use crate::receipt::{ArtifactEvidence, canonical_digest}; @@ -48,20 +45,6 @@ pub enum MountPurpose { Artifact, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum MountSourceKind { - Directory, - RegularFile, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct MountSourceExpectation { - pub kind: MountSourceKind, - pub canonical_anchor: PathBuf, - pub exact_repository: Option, - pub cache_generation: Option, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MountBinding { pub source: PathBuf, @@ -70,8 +53,6 @@ pub struct MountBinding { pub purpose: MountPurpose, #[serde(skip_serializing_if = "Option::is_none")] pub logical_id: Option, - #[serde(skip)] - pub(crate) expectation: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -116,12 +97,6 @@ impl WorkspacePlanV1 { access: MountAccess::ReadOnly, purpose: MountPurpose::Repository, logical_id: None, - expectation: Some(MountSourceExpectation { - kind: MountSourceKind::Directory, - canonical_anchor: repository.clone(), - exact_repository: Some(repository.clone()), - cache_generation: None, - }), }]; let mut targets = BTreeSet::from([CONTAINER_WORKSPACE.to_owned()]); @@ -143,12 +118,6 @@ impl WorkspacePlanV1 { access: MountAccess::ReadWrite, purpose: MountPurpose::Cache, logical_id: Some(declared.id.clone()), - expectation: Some(MountSourceExpectation { - kind: MountSourceKind::Directory, - canonical_anchor: cache.path.clone(), - exact_repository: None, - cache_generation: None, - }), }); } @@ -166,16 +135,6 @@ impl WorkspacePlanV1 { access: MountAccess::ReadWrite, purpose: MountPurpose::Artifact, logical_id: Some(format!("{}:{artifact}", check.id)), - expectation: Some(MountSourceExpectation { - kind: if artifact_kind_for(check, artifact) == ArtifactKind::Directory { - MountSourceKind::Directory - } else { - MountSourceKind::RegularFile - }, - canonical_anchor: run_root.clone(), - exact_repository: None, - cache_generation: None, - }), }); } } @@ -244,19 +203,6 @@ impl PreparedWorkspace { &cache_sources, None, )?; - let mut plan = plan; - for mount in &mut plan.mounts { - if mount.purpose == MountPurpose::Cache { - if let Some(entry) = cache_entries - .iter() - .find(|entry| entry.data_path == mount.source) - { - if let Some(expectation) = mount.expectation.as_mut() { - expectation.cache_generation = Some(entry.generation_expectation()); - } - } - } - } Ok(Self { plan, cache_entries, @@ -812,74 +758,6 @@ pub fn validate_host_path(path: &Path) -> Result<(), WorkspaceError> { Ok(()) } -pub(crate) fn revalidate_mount_sources(mounts: &[MountBinding]) -> Result<(), WorkspaceError> { - for mount in mounts { - let expectation = mount - .expectation - .as_ref() - .ok_or(WorkspaceError::MountExpectationMissing)?; - let relative = mount - .source - .strip_prefix(&expectation.canonical_anchor) - .map_err(|_| WorkspaceError::MountSourceChanged { - purpose: mount.purpose, - })?; - let mut current = expectation.canonical_anchor.clone(); - for component in relative.components() { - current.push(component.as_os_str()); - let metadata = - fs::symlink_metadata(¤t).map_err(|_| WorkspaceError::MountSourceChanged { - purpose: mount.purpose, - })?; - if metadata.file_type().is_symlink() { - return Err(WorkspaceError::MountSourceChanged { - purpose: mount.purpose, - }); - } - } - let metadata = fs::symlink_metadata(&mount.source).map_err(|_| { - WorkspaceError::MountSourceChanged { - purpose: mount.purpose, - } - })?; - let kind_ok = match expectation.kind { - MountSourceKind::Directory => metadata.is_dir(), - MountSourceKind::RegularFile => metadata.is_file(), - }; - if !kind_ok - || expectation - .exact_repository - .as_ref() - .is_some_and(|path| fs::canonicalize(&mount.source).ok().as_ref() != Some(path)) - { - return Err(WorkspaceError::MountSourceChanged { - purpose: mount.purpose, - }); - } - let canonical = - fs::canonicalize(&mount.source).map_err(|_| WorkspaceError::MountSourceChanged { - purpose: mount.purpose, - })?; - if !canonical.starts_with(&expectation.canonical_anchor) { - return Err(WorkspaceError::MountSourceChanged { - purpose: mount.purpose, - }); - } - validate_host_path(&mount.source)?; - if mount.purpose == MountPurpose::Cache { - crate::cache::revalidate_generation_source( - &mount.source, - expectation - .cache_generation - .as_ref() - .ok_or(WorkspaceError::MountExpectationMissing)?, - ) - .map_err(WorkspaceError::Cache)?; - } - } - Ok(()) -} - pub fn validate_container_mount_target(target: &str) -> Result<(), WorkspaceError> { if target.contains(',') || target.contains('=') @@ -916,8 +794,6 @@ pub enum WorkspaceError { PathEscape, Busy(PathBuf), UnsafeManagedObject, - MountExpectationMissing, - MountSourceChanged { purpose: MountPurpose }, Cache(CacheError), Io(std::io::Error), } @@ -950,12 +826,6 @@ impl fmt::Display for WorkspaceError { Self::UnsafeManagedObject => { formatter.write_str("unsafe object found inside managed workspace") } - Self::MountExpectationMissing => { - formatter.write_str("mount source expectation is missing") - } - Self::MountSourceChanged { purpose } => { - write!(formatter, "mount source changed for {purpose:?}") - } Self::Cache(error) => write!(formatter, "{error}"), Self::Io(_) => formatter.write_str("workspace filesystem operation failed"), } From 4d4636210534b07b66c1eabc2dab84a211124fae Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 06:56:21 +0200 Subject: [PATCH 08/14] fix: validate prepared workspace mounts --- src/cache.rs | 21 ++- src/runtime.rs | 1 + src/workspace.rs | 394 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 412 insertions(+), 4 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 1f68675..671ac7c 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -780,8 +780,6 @@ impl Drop for PreparedCacheGenerationOwner { } } -/// Temporary Task 2 handoff API; remove this allowance when Task 2 consumes it. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CacheGenerationExpectation { pub key_digest: String, @@ -791,8 +789,6 @@ pub(crate) struct CacheGenerationExpectation { } impl PreparedCacheEntry { - /// Temporary Task 2 handoff API; remove this allowance when Task 2 consumes it. - #[allow(dead_code)] pub(crate) fn generation_expectation(&self) -> CacheGenerationExpectation { CacheGenerationExpectation { key_digest: self.key_digest.clone(), @@ -803,6 +799,23 @@ impl PreparedCacheEntry { } } +pub(crate) fn revalidate_generation_source( + source: &Path, + expected: &CacheGenerationExpectation, +) -> Result<(), CacheError> { + let staging = source.parent().ok_or(CacheError::PromotionUncertain)?; + let manifest = read_generation_manifest(&staging.join(GENERATION_MANIFEST_FILE))?; + if manifest.schema_version != GENERATION_SCHEMA_VERSION + || manifest.key_digest != expected.key_digest + || manifest.plan_digest != expected.plan_digest + || manifest.generation != expected.generation + || manifest.state != expected.state + { + return Err(CacheError::PromotionUncertain); + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CacheGenerationManifestV1 { diff --git a/src/runtime.rs b/src/runtime.rs index 13ca062..0ac9b36 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1696,6 +1696,7 @@ timeout_seconds = 60 access: MountAccess::ReadOnly, purpose: crate::workspace::MountPurpose::Repository, logical_id: None, + expectation: None, }; for source in [ "/safe,comma", diff --git a/src/workspace.rs b/src/workspace.rs index 9b93066..49cb00a 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -45,6 +45,19 @@ pub enum MountPurpose { Artifact, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MountSourceKind { + Directory, + RegularFile, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MountSourceExpectation { + pub kind: MountSourceKind, + pub canonical_anchor: PathBuf, + pub exact_repository: Option, + pub cache_generation: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MountBinding { pub source: PathBuf, @@ -53,6 +66,8 @@ pub struct MountBinding { pub purpose: MountPurpose, #[serde(skip_serializing_if = "Option::is_none")] pub logical_id: Option, + #[serde(skip)] + pub(crate) expectation: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -97,6 +112,12 @@ impl WorkspacePlanV1 { access: MountAccess::ReadOnly, purpose: MountPurpose::Repository, logical_id: None, + expectation: Some(MountSourceExpectation { + kind: MountSourceKind::Directory, + canonical_anchor: repository.clone(), + exact_repository: Some(repository.clone()), + cache_generation: None, + }), }]; let mut targets = BTreeSet::from([CONTAINER_WORKSPACE.to_owned()]); @@ -118,6 +139,12 @@ impl WorkspacePlanV1 { access: MountAccess::ReadWrite, purpose: MountPurpose::Cache, logical_id: Some(declared.id.clone()), + expectation: Some(MountSourceExpectation { + kind: MountSourceKind::Directory, + canonical_anchor: cache.path.clone(), + exact_repository: None, + cache_generation: None, + }), }); } @@ -135,6 +162,16 @@ impl WorkspacePlanV1 { access: MountAccess::ReadWrite, purpose: MountPurpose::Artifact, logical_id: Some(format!("{}:{artifact}", check.id)), + expectation: Some(MountSourceExpectation { + kind: if artifact_kind_for(check, artifact) == ArtifactKind::Directory { + MountSourceKind::Directory + } else { + MountSourceKind::RegularFile + }, + canonical_anchor: run_root.clone(), + exact_repository: None, + cache_generation: None, + }), }); } } @@ -203,6 +240,18 @@ impl PreparedWorkspace { &cache_sources, None, )?; + let mut plan = plan; + for entry in &cache_entries { + if let Some(mount) = plan + .mounts + .iter_mut() + .find(|m| m.purpose == MountPurpose::Cache && m.source == entry.data_path) + { + if let Some(expectation) = mount.expectation.as_mut() { + expectation.cache_generation = Some(entry.generation_expectation()); + } + } + } Ok(Self { plan, cache_entries, @@ -758,6 +807,81 @@ pub fn validate_host_path(path: &Path) -> Result<(), WorkspaceError> { Ok(()) } +#[allow(dead_code)] +pub(crate) fn revalidate_mount_sources(mounts: &[MountBinding]) -> Result<(), WorkspaceError> { + for mount in mounts { + let expectation = mount + .expectation + .as_ref() + .ok_or(WorkspaceError::MountExpectationMissing)?; + let mut current = expectation.canonical_anchor.clone(); + let relative = mount + .source + .strip_prefix(&expectation.canonical_anchor) + .map_err(|_| WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + })?; + for component in relative.components() { + current.push(component); + let metadata = + fs::symlink_metadata(¤t).map_err(|_| WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + })?; + if metadata.file_type().is_symlink() { + return Err(WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + }); + } + } + let metadata = fs::symlink_metadata(&mount.source).map_err(|_| { + WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + } + })?; + let right_kind = match expectation.kind { + MountSourceKind::Directory => metadata.is_dir(), + MountSourceKind::RegularFile => metadata.is_file(), + }; + if !right_kind { + return Err(WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + }); + } + validate_host_path(&mount.source).map_err(|_| WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + })?; + let canonical = + fs::canonicalize(&mount.source).map_err(|_| WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + })?; + if let Some(repository) = &expectation.exact_repository { + if &canonical != repository { + return Err(WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + }); + } + } + if !canonical.starts_with(&expectation.canonical_anchor) { + return Err(WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + }); + } + if mount.purpose == MountPurpose::Cache { + let generation = expectation.cache_generation.as_ref().ok_or( + WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + }, + )?; + crate::cache::revalidate_generation_source(&mount.source, generation).map_err( + |_| WorkspaceError::MountSourceChanged { + purpose: mount.purpose, + }, + )?; + } + } + Ok(()) +} + pub fn validate_container_mount_target(target: &str) -> Result<(), WorkspaceError> { if target.contains(',') || target.contains('=') @@ -785,6 +909,8 @@ pub fn validate_container_mount_target(target: &str) -> Result<(), WorkspaceErro #[derive(Debug)] pub enum WorkspaceError { + MountExpectationMissing, + MountSourceChanged { purpose: MountPurpose }, RepositoryNotDirectory, InvalidLogicalPath, UnsupportedHostPath, @@ -801,6 +927,10 @@ pub enum WorkspaceError { impl fmt::Display for WorkspaceError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::MountExpectationMissing => formatter.write_str("mount expectation is missing"), + Self::MountSourceChanged { purpose } => { + write!(formatter, "mount source changed: {purpose:?}") + } Self::RepositoryNotDirectory => formatter.write_str("repository is not a directory"), Self::InvalidLogicalPath => formatter.write_str("workspace logical path is invalid"), Self::UnsupportedHostPath => { @@ -1329,4 +1459,268 @@ max_entries = 1 assert_ne!(plan.mounts[0].source, repository); fs::remove_dir_all(test_root(name)).expect("clean fixture"); } + + #[test] + fn live_mount_revalidation_accepts_prepared_mounts() { + let name = "revalidate-red"; + let (repository, resolved, envelope) = fixture(name); + let cache = ManagedCache::initialize(resolved).expect("cache"); + let prepared = + PreparedWorkspace::prepare_with_generation(&envelope, &repository, &cache, 7) + .expect("prepare"); + let cache_mount = prepared + .plan + .mounts + .iter() + .find(|mount| mount.purpose == MountPurpose::Cache) + .expect("cache mount"); + assert!( + cache_mount + .expectation + .as_ref() + .and_then(|value| value.cache_generation.as_ref()) + .is_some() + ); + assert!(revalidate_mount_sources(&prepared.plan.mounts).is_ok()); + drop(prepared); + drop(cache); + fs::remove_dir_all(test_root(name)).expect("clean fixture"); + } + + fn prepared_fixture(name: &str) -> (PathBuf, ManagedCache, PreparedWorkspace) { + let (repository, resolved, envelope) = fixture(name); + let base = repository + .parent() + .expect("repository parent") + .to_path_buf(); + let cache = ManagedCache::initialize(resolved).expect("cache"); + let prepared = + PreparedWorkspace::prepare_with_generation(&envelope, &repository, &cache, 7) + .expect("prepared workspace"); + (base, cache, prepared) + } + + #[test] + fn live_mount_revalidation_accepts_regular_and_directory_artifacts() { + let (repository, resolved, _) = fixture("revalidate-directory-artifact"); + fs::create_dir_all(repository.join("target/reports")).expect("directory artifact target"); + let envelope = ConfigV1::parse( + r#"schema_version = "1.0" +project = "owner/repository" +[runtime] +kind = "docker_compatible" +image = "example.invalid/ci@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +cpu_count = 1 +memory_mib = 128 +pids_limit = 16 +[[caches]] +id = "cargo" +mount_path = ".cache/cargo" +[[checks]] +id = "reports" +required = true +argv = ["fixture", "reports"] +working_directory = "." +timeout_seconds = 60 +artifacts = ["target/reports"] +[[checks.artifact_contracts]] +path = "target/reports" +kind = "directory" +max_bytes = 1024 +max_entries = 10 +"#, + ) + .expect("config") + .into_plan() + .expect("plan"); + let base = repository + .parent() + .expect("repository parent") + .to_path_buf(); + let cache = ManagedCache::initialize(resolved).expect("cache"); + let prepared = + PreparedWorkspace::prepare_with_generation(&envelope, &repository, &cache, 7) + .expect("prepared workspace"); + assert!(revalidate_mount_sources(&prepared.plan.mounts).is_ok()); + drop(prepared); + drop(cache); + fs::remove_dir_all(base).expect("cleanup"); + } + + #[test] + fn live_mount_revalidation_rejects_missing_source() { + let (base, cache, prepared) = prepared_fixture("revalidate-missing"); + let source = prepared + .plan + .mounts + .iter() + .find(|m| m.purpose == MountPurpose::Cache) + .expect("cache") + .source + .clone(); + fs::remove_dir_all(source).expect("remove source"); + assert!(matches!( + revalidate_mount_sources(&prepared.plan.mounts), + Err(WorkspaceError::MountSourceChanged { .. }) + )); + drop(prepared); + drop(cache); + fs::remove_dir_all(base).expect("cleanup"); + } + + #[test] + fn live_mount_revalidation_rejects_wrong_kind() { + let (base, cache, prepared) = prepared_fixture("revalidate-kind"); + let old = prepared + .plan + .mounts + .iter() + .find(|m| m.purpose == MountPurpose::Cache) + .expect("cache") + .source + .clone(); + fs::remove_dir_all(&old).expect("remove source"); + fs::write(&old, b"wrong kind").expect("replace source"); + assert!(matches!( + revalidate_mount_sources(&prepared.plan.mounts), + Err(WorkspaceError::MountSourceChanged { .. }) + )); + fs::remove_file(old).expect("remove replacement"); + drop(prepared); + drop(cache); + fs::remove_dir_all(base).expect("cleanup"); + } + + #[cfg(unix)] + #[test] + fn live_mount_revalidation_rejects_leaf_symlink() { + use std::os::unix::fs::symlink; + let (base, cache, prepared) = prepared_fixture("revalidate-symlink"); + let old = prepared + .plan + .mounts + .iter() + .find(|m| m.purpose == MountPurpose::Cache) + .expect("cache") + .source + .clone(); + let target = old.with_extension("real"); + fs::rename(&old, &target).expect("move source"); + symlink(&target, &old).expect("leaf symlink"); + assert!(matches!( + revalidate_mount_sources(&prepared.plan.mounts), + Err(WorkspaceError::MountSourceChanged { .. }) + )); + fs::remove_file(&old).expect("remove leaf"); + fs::rename(&target, &old).expect("restore source"); + drop(prepared); + drop(cache); + fs::remove_dir_all(base).expect("cleanup"); + } + + fn mutate_manifest(name: &str, field: &str, value: serde_json::Value) { + let (base, cache, prepared) = prepared_fixture(name); + let source = prepared + .plan + .mounts + .iter() + .find(|m| m.purpose == MountPurpose::Cache) + .expect("cache") + .source + .clone(); + let manifest = source + .parent() + .expect("staging parent") + .join(".generation-v1.json"); + let mut json: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest).expect("manifest")).expect("json"); + json[field] = value; + fs::write(&manifest, serde_json::to_vec(&json).expect("encode")).expect("write manifest"); + assert!(matches!( + revalidate_mount_sources(&prepared.plan.mounts), + Err(WorkspaceError::MountSourceChanged { .. }) + )); + drop(prepared); + drop(cache); + fs::remove_dir_all(base).expect("cleanup"); + } + + #[cfg(unix)] + #[test] + fn live_mount_revalidation_rejects_symlinked_parent_below_anchor() { + use std::os::unix::fs::symlink; + let (base, cache, prepared) = prepared_fixture("revalidate-parent-symlink"); + let mount = prepared + .plan + .mounts + .iter() + .find(|m| m.purpose == MountPurpose::Cache) + .expect("cache"); + let anchor = mount.source.parent().expect("anchor").to_path_buf(); + let external = base.join("external"); + fs::create_dir_all(&external).expect("external"); + let moved = external.join("staging"); + fs::rename(&anchor, &moved).expect("move staging parent"); + symlink(&moved, &anchor).expect("parent symlink"); + assert!(matches!( + revalidate_mount_sources(&prepared.plan.mounts), + Err(WorkspaceError::MountSourceChanged { .. }) + )); + fs::remove_file(&anchor).expect("remove symlink"); + fs::rename(&moved, &anchor).expect("restore staging parent"); + drop(prepared); + drop(cache); + fs::remove_dir_all(base).expect("cleanup"); + } + + #[test] + fn live_mount_revalidation_rejects_key_digest_change() { + mutate_manifest("revalidate-key", "key_digest", serde_json::json!("bad")); + } + #[test] + fn live_mount_revalidation_rejects_plan_digest_change() { + mutate_manifest("revalidate-plan", "plan_digest", serde_json::json!("bad")); + } + #[test] + fn live_mount_revalidation_rejects_generation_change() { + mutate_manifest("revalidate-generation", "generation", serde_json::json!(99)); + } + #[test] + fn live_mount_revalidation_rejects_state_change() { + mutate_manifest("revalidate-state", "state", serde_json::json!("complete")); + } + + #[test] + fn snapshot_preparation_populates_cache_expectation() { + let (repository, resolved, envelope) = fixture("revalidate-snapshot"); + let base = repository + .parent() + .expect("repository parent") + .to_path_buf(); + let cache = ManagedCache::initialize(resolved).expect("cache"); + let prepared = PreparedWorkspace::prepare_snapshot_with_generation( + &envelope, + &repository, + "snapshot-digest", + &cache, + 7, + ) + .expect("snapshot"); + let cache_mount = prepared + .plan + .mounts + .iter() + .find(|m| m.purpose == MountPurpose::Cache) + .expect("cache"); + assert!( + cache_mount + .expectation + .as_ref() + .and_then(|e| e.cache_generation.as_ref()) + .is_some() + ); + drop(prepared); + drop(cache); + fs::remove_dir_all(base).expect("cleanup"); + } } From 538e6f641495c7b9493683552a799b460072c94e Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 07:09:53 +0200 Subject: [PATCH 09/14] fix: gate Docker creation on mount revalidation --- src/runtime.rs | 125 ++++++++++++++++++++++++++++++++++++++++++++++- src/workspace.rs | 1 - 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/runtime.rs b/src/runtime.rs index 0ac9b36..dd1e6e7 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -33,7 +33,7 @@ use crate::process::{ }; use crate::receipt::canonical_digest; use crate::workspace::{ - MountAccess, WorkspaceError, WorkspacePlanV1, validate_container_mount_target, + MountAccess, MountBinding, WorkspaceError, WorkspacePlanV1, validate_container_mount_target, validate_host_path, }; @@ -222,6 +222,8 @@ impl RuntimePort for DockerCompatibleRuntime { rendered: &DryRunCheck, context: &RuntimeExecutionContext<'_>, ) -> Result { + crate::workspace::revalidate_mount_sources(&rendered.mounts) + .map_err(RuntimeError::Workspace)?; let lifecycle = DockerLifecyclePlan::build(context.run_id, check, rendered)?; let cleanup_cancellation = CancellationToken::default(); let create = self.execute_cli(&lifecycle.create_argv, context, context.cancellation)?; @@ -753,6 +755,7 @@ fn docker_dry_run_check( program: "docker", argv, depends_on: check.depends_on.clone(), + mounts: workspace.mounts.clone(), }) } @@ -867,6 +870,8 @@ pub struct DryRunCheck { pub program: &'static str, pub argv: Vec, pub depends_on: Vec, + #[serde(skip)] + mounts: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1141,6 +1146,8 @@ mod tests { use crate::config::ConfigV1; use crate::process::{CapturedStream, ExitOutcome}; use std::collections::BTreeMap; + use std::fs; + use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; @@ -1280,6 +1287,122 @@ timeout_seconds = 60 WorkspacePlanV1::build(envelope, &repository, &cache).expect("workspace") } + #[test] + fn execute_check_revalidates_mounts_before_supervisor() { + let config = CONFIG.replace( + "[[checks]]", + "[[caches]]\nid = \"cargo\"\nmount_path = \".cache/cargo\"\n\n[[checks]]", + ); + let envelope = ConfigV1::parse(&config) + .expect("config") + .into_plan() + .expect("plan"); + let temp_root = fs::canonicalize(std::env::temp_dir()).expect("canonical temp root"); + let root = temp_root.join(format!( + "ccp-runtime-task-2b-{}-{}", + std::process::id(), + envelope.plan_digest + )); + fs::create_dir_all(&root).expect("fixture root"); + let repository = root.join("repository"); + fs::create_dir_all(repository.join(".cache/cargo")).expect("fixture repository"); + let cache = crate::cache::ManagedCache::initialize(ResolvedCacheRoot { + path: root.join("cache"), + source: CacheRootSource::Explicit, + }) + .expect("cache"); + let prepared = crate::workspace::PreparedWorkspace::prepare_with_generation( + &envelope, + &repository, + &cache, + 7, + ) + .expect("prepared workspace"); + let dry_run = DockerCompatibleRuntime + .dry_run(&envelope, &prepared.plan) + .expect("dry run"); + let cache_source = prepared + .plan + .mounts + .iter() + .find(|mount| mount.purpose == crate::workspace::MountPurpose::Cache) + .expect("cache mount") + .source + .clone(); + fs::remove_dir_all(cache_source).expect("remove owned cache source"); + + let identity = RunIdentity { + project: envelope.plan.project.clone(), + commit: Some("a".repeat(40)), + config_digest: envelope.plan_digest.clone(), + generation: "1".to_owned(), + }; + let generation = GenerationGuard::new(identity.clone()); + let supervisor = LifecycleSupervisor::new(); + let execution_root = PathBuf::from("/workspace"); + let environment = BTreeMap::new(); + let cancellation = CancellationToken::default(); + let context = RuntimeExecutionContext { + execution_root: execution_root.as_path(), + environment: &environment, + identity: &identity, + run_id: "sha256:run", + supervisor: &supervisor, + cancellation: &cancellation, + generation: &generation, + timeout_seconds: 60, + }; + let result = DockerCompatibleRuntime.execute_check( + &envelope.plan.checks[0], + &dry_run.checks[0], + &context, + ); + assert!(matches!( + result, + Err(RuntimeError::Workspace( + WorkspaceError::MountSourceChanged { .. } + )) + )); + assert!(supervisor.calls.lock().expect("calls").is_empty()); + drop(prepared); + drop(cache); + fs::remove_dir_all(root).expect("cleanup fixture"); + } + + #[test] + fn dry_run_private_mount_expectations_are_not_serialized() { + let envelope = envelope(); + let workspace = workspace(&envelope); + let dry_run = DockerCompatibleRuntime + .dry_run(&envelope, &workspace) + .expect("dry run"); + let check = &dry_run.checks[0]; + let _private_mounts = &check.mounts; + let json = serde_json::to_value(&dry_run).expect("serialize dry run"); + assert!(json["checks"][0].get("mounts").is_none()); + assert!(json.to_string().contains("workspace_mount_policy")); + assert!(json["workspace"]["mounts"][0].get("source").is_some()); + assert!( + json["workspace"]["mounts"][0] + .get("canonical_anchor") + .is_none() + ); + assert!( + json["workspace"]["mounts"][0] + .get("exact_repository") + .is_none() + ); + assert!( + json["workspace"]["mounts"][0] + .get("cache_generation") + .is_none() + ); + let serialized = json.to_string(); + assert!(!serialized.contains("canonical_anchor")); + assert!(!serialized.contains("exact_repository")); + assert!(!serialized.contains("cache_generation")); + } + #[derive(Clone, Copy)] enum ProbeMode { OrbStack, diff --git a/src/workspace.rs b/src/workspace.rs index 49cb00a..5d42139 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -807,7 +807,6 @@ pub fn validate_host_path(path: &Path) -> Result<(), WorkspaceError> { Ok(()) } -#[allow(dead_code)] pub(crate) fn revalidate_mount_sources(mounts: &[MountBinding]) -> Result<(), WorkspaceError> { for mount in mounts { let expectation = mount From 401d988ac51a80f6ac06a6c04d4b02d83a547742 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 07:28:17 +0200 Subject: [PATCH 10/14] feat: add managed cache use pins --- src/cache.rs | 528 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 521 insertions(+), 7 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 671ac7c..e8e584f 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -354,6 +354,48 @@ impl ManagedCache { self.root.workspace_path(plan_digest) } + /// Pins completed cache entries for the lifetime of the returned guards. + /// + /// Sources are deliberately accepted only in their canonical, exact + /// `entries/sha256-/data` form. This keeps the lock ownership + /// bound to the entry which is actually going to be mounted. + pub fn pin_completed_sources( + &self, + sources: &[PathBuf], + ) -> Result, CacheError> { + validate_owner_marker(&self.root.path.join(OWNER_FILE))?; + validate_plain_directory(&self.root.path.join(ENTRIES_DIR))?; + validate_plain_directory(&self.root.path.join(WORKSPACES_DIR))?; + + let mut ordered = BTreeSet::new(); + for source in sources { + ordered.insert(validate_pin_source_path(&self.root.path, source)?); + } + + let mut pins = Vec::with_capacity(ordered.len()); + for source in ordered { + let entry_path = source + .parent() + .ok_or(CacheError::UnsafePath("cache source has no entry parent"))? + .to_path_buf(); + let lock_path = entry_path.join(ENTRY_LOCK_FILE); + match fs::symlink_metadata(&lock_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(CacheError::SymlinkInManagedRoot(lock_path)); + } + Ok(metadata) if !metadata.is_file() => { + return Err(CacheError::UnexpectedEntry(lock_path)); + } + Ok(_) => {} + Err(error) => return Err(CacheError::Io(error)), + } + let entry_lock = acquire_existing_entry_lock(&entry_path)?; + let pin = CacheUsePin::from_locked_source(self, source, entry_lock)?; + pins.push(pin); + } + Ok(pins) + } + pub fn prepare_entry( &self, key: &CacheKey, @@ -741,6 +783,144 @@ impl ManagedCache { } } +#[derive(Debug)] +pub struct CacheUsePin { + entry_path: PathBuf, + data_path: PathBuf, + key_digest: String, + plan_digest: String, + generation: u64, + _entry_lock: Arc, +} + +impl CacheUsePin { + fn from_locked_source( + cache: &ManagedCache, + source: PathBuf, + entry_lock: Arc, + ) -> Result { + let entry_path = source + .parent() + .ok_or(CacheError::UnsafePath("cache source has no entry parent"))? + .to_path_buf(); + let manifest = validate_completed_entry(&cache.root.path, &source, &entry_path)?; + Ok(Self { + key_digest: manifest.key_digest, + plan_digest: manifest.plan_digest, + generation: manifest.generation, + entry_path, + data_path: source, + _entry_lock: entry_lock, + }) + } + + pub fn revalidate(&self) -> Result<(), CacheError> { + let root = self + .entry_path + .parent() + .and_then(Path::parent) + .ok_or(CacheError::UnsafePath("cache pin has no managed root"))?; + validate_owner_marker(&root.join(OWNER_FILE))?; + validate_plain_directory(&root.join(ENTRIES_DIR))?; + validate_plain_directory(&root.join(WORKSPACES_DIR))?; + let manifest = validate_completed_entry(root, &self.data_path, &self.entry_path)?; + if manifest.key_digest != self.key_digest + || manifest.plan_digest != self.plan_digest + || manifest.generation != self.generation + { + return Err(CacheError::GenerationMismatch); + } + Ok(()) + } +} + +fn validate_pin_source_path(root: &Path, source: &Path) -> Result { + if !source.is_absolute() { + return Err(CacheError::UnsafePath("cache source must be absolute")); + } + reject_lexical_escape(source)?; + reject_symlink_components(source)?; + let canonical = fs::canonicalize(source).map_err(CacheError::Io)?; + if canonical != source { + return Err(CacheError::UnsafePath("cache source must be canonical")); + } + let relative = source + .strip_prefix(root) + .map_err(|_| CacheError::UnsafePath("cache source is outside managed root"))?; + let components: Vec<_> = relative.components().collect(); + if components.len() != 3 + || components[0].as_os_str() != ENTRIES_DIR + || components[2].as_os_str() != "data" + { + return Err(CacheError::UnsafePath( + "cache source must be an entry data path", + )); + } + let key_name = components[1] + .as_os_str() + .to_str() + .ok_or(CacheError::InvalidDigest)?; + let key_hex = key_name + .strip_prefix("sha256-") + .ok_or(CacheError::InvalidDigest)?; + if key_hex.len() != 64 + || !key_hex + .as_bytes() + .iter() + .all(|byte| (b'0'..=b'9').contains(byte) || (b'a'..=b'f').contains(byte)) + { + return Err(CacheError::InvalidDigest); + } + Ok(source.to_path_buf()) +} + +fn validate_completed_entry( + root: &Path, + data_path: &Path, + entry_path: &Path, +) -> Result { + validate_pin_source_path(root, data_path)?; + validate_plain_directory(entry_path)?; + validate_plain_directory(data_path)?; + let marker = entry_path.join(COMPLETE_FILE); + match fs::symlink_metadata(&marker) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(CacheError::SymlinkInManagedRoot(marker)); + } + Ok(metadata) if !metadata.is_file() => return Err(CacheError::UnexpectedEntry(marker)), + Err(error) => return Err(CacheError::Io(error)), + Ok(_) => {} + } + if fs::read(&marker).map_err(CacheError::Io)? != COMPLETE_BYTES { + return Err(CacheError::GenerationMismatch); + } + let manifest_path = entry_path.join(GENERATION_MANIFEST_FILE); + match fs::symlink_metadata(&manifest_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(CacheError::SymlinkInManagedRoot(manifest_path)); + } + Ok(metadata) if !metadata.is_file() => { + return Err(CacheError::UnexpectedEntry(manifest_path)); + } + Err(error) => return Err(CacheError::Io(error)), + Ok(_) => {} + } + let manifest = read_generation_manifest(&manifest_path)?; + let key_name = entry_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or(CacheError::InvalidDigest)?; + let key_digest = format!("sha256:{}", &key_name[7..]); + validated_digest_hex(&manifest.plan_digest)?; + if manifest.schema_version != GENERATION_SCHEMA_VERSION + || manifest.key_digest != key_digest + || manifest.state != "complete" + { + return Err(CacheError::GenerationMismatch); + } + Ok(manifest) +} + #[derive(Debug, Clone)] pub struct PreparedCacheEntry { pub path: PathBuf, @@ -1243,11 +1423,30 @@ fn acquire_entry_lock(entry: &Path) -> Result, CacheError> { acquire_advisory_lock(&entry.join(ENTRY_LOCK_FILE), "cache entry") } +fn acquire_existing_entry_lock(entry: &Path) -> Result, CacheError> { + acquire_advisory_lock_existing(&entry.join(ENTRY_LOCK_FILE), "cache entry") +} + fn acquire_promotion_lock(root: &Path) -> Result, CacheError> { acquire_advisory_lock(&root.join(PROMOTION_LOCK_FILE), "cache promotion") } fn acquire_advisory_lock(path: &Path, label: &'static str) -> Result, CacheError> { + acquire_advisory_lock_with_mode(path, label, true) +} + +fn acquire_advisory_lock_existing( + path: &Path, + label: &'static str, +) -> Result, CacheError> { + acquire_advisory_lock_with_mode(path, label, false) +} + +fn acquire_advisory_lock_with_mode( + path: &Path, + label: &'static str, + create: bool, +) -> Result, CacheError> { match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() => { return Err(CacheError::SymlinkInManagedRoot(path.to_path_buf())); @@ -1259,13 +1458,12 @@ fn acquire_advisory_lock(path: &Path, label: &'static str) -> Result, Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(CacheError::Io(error)), } - let mut file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(path) - .map_err(CacheError::Io)?; + let mut options = OpenOptions::new(); + options.read(true).write(true).truncate(false); + if create { + options.create(true); + } + let mut file = options.open(path).map_err(CacheError::Io)?; if let Err(error) = file.try_lock_exclusive() { if error.kind() == io::ErrorKind::WouldBlock { return Err(CacheError::LockBusy(path.to_path_buf())); @@ -2173,4 +2371,320 @@ timeout_seconds = 60 clean(&resolved.path); clean(&repo); } + + struct CompletedSourceFixture { + repo: PathBuf, + resolved: ResolvedCacheRoot, + cache: ManagedCache, + data_path: PathBuf, + entry_path: PathBuf, + } + + fn completed_entry_fixture(name: &str) -> CompletedSourceFixture { + let (repo, resolved) = resolved_fixture(name); + let cache = ManagedCache::initialize(resolved.clone()).expect("initialize"); + let plan = envelope(); + let key = CacheKey::for_plan_cache(&plan, &plan.plan.caches[0]).expect("key"); + let prepared = cache + .prepare_entry(&key, &plan.plan_digest, 1) + .expect("prepare"); + fs::write(prepared.data_path.join("payload"), b"owned fixture").expect("payload"); + cache.promote_entry(&prepared).expect("promote"); + let entry_path = cache.entry_path(&key); + CompletedSourceFixture { + repo, + resolved, + cache, + data_path: entry_path.join("data"), + entry_path, + } + } + + fn finish_fixture(fixture: CompletedSourceFixture) { + clean(&fixture.resolved.path); + clean(&fixture.repo); + } + + #[test] + fn completed_source_pin_holds_entry_lock_until_drop() { + let fixture = completed_entry_fixture("completed-source-pin"); + let pins = fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .expect("pin completed source"); + assert_eq!(pins.len(), 1); + assert!(matches!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)), + Err(CacheError::LockBusy(_)) + )); + drop(pins); + assert_eq!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .expect("re-pin") + .len(), + 1 + ); + finish_fixture(fixture); + } + + #[test] + fn completed_source_pin_rejects_missing_entry_lock_without_recreating_it() { + let fixture = completed_entry_fixture("completed-source-pin-missing-lock"); + let lock = fixture.entry_path.join(ENTRY_LOCK_FILE); + fs::remove_file(&lock).expect("remove entry lock"); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .is_err() + ); + assert!(!lock.exists(), "pin must not recreate missing lock"); + finish_fixture(fixture); + } + + #[test] + fn completed_source_pin_rejects_missing_workspaces_directory() { + let fixture = completed_entry_fixture("completed-source-pin-missing-workspaces"); + let workspaces = fixture.resolved.path.join(WORKSPACES_DIR); + fs::remove_dir(&workspaces).expect("remove empty workspaces directory"); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .is_err() + ); + finish_fixture(fixture); + } + + #[test] + fn completed_source_pin_deduplicates_and_orders_sources() { + let first = completed_entry_fixture("completed-source-pin-order"); + let second_key = CacheKey { + digest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_owned(), + }; + let plan = envelope(); + let prepared = first + .cache + .prepare_entry(&second_key, &plan.plan_digest, 1) + .expect("second prepare"); + fs::write(prepared.data_path.join("payload"), b"second fixture").expect("payload"); + first + .cache + .promote_entry(&prepared) + .expect("second promote"); + drop(prepared); + let second_data_path = first.cache.entry_data_path(&second_key); + let sources = [ + second_data_path.clone(), + first.data_path.clone(), + second_data_path, + ]; + let pins = first.cache.pin_completed_sources(&sources).expect("pins"); + assert_eq!(pins.len(), 2); + assert!(pins[0].entry_path < pins[1].entry_path); + drop(pins); + finish_fixture(first); + } + + #[test] + fn completed_source_pin_rejects_invalid_source_shapes() { + let fixture = completed_entry_fixture("completed-source-pin-invalid"); + let root = fixture.resolved.path.clone(); + let cases = [ + root.join("other/entries/sha256-").join("data"), + fixture.entry_path.join("data/extra"), + fixture.entry_path.join("not-data"), + root.join("entries/invalid-key/data"), + root.join("entries/sha256-").join("data"), + root.join("entries/sha256-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/staging/data"), + ]; + for source in cases { + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&source)) + .is_err(), + "accepted {source:?}" + ); + } + finish_fixture(fixture); + } + + #[test] + fn completed_source_pin_rejects_non_absolute_and_other_root_sources() { + let fixture = completed_entry_fixture("completed-source-pin-roots"); + let relative = PathBuf::from("relative/data"); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&relative)) + .is_err() + ); + let other = completed_entry_fixture("completed-source-pin-other-root"); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&other.data_path)) + .is_err() + ); + finish_fixture(other); + finish_fixture(fixture); + } + + #[test] + fn completed_source_pin_rejects_valid_missing_key_source() { + let fixture = completed_entry_fixture("completed-source-pin-missing-key"); + let missing = fixture.resolved.path.join( + "entries/sha256-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/data", + ); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&missing)) + .is_err() + ); + finish_fixture(fixture); + } + + #[test] + fn completed_source_pin_rejects_missing_or_mismatched_generation_manifest() { + let fixture = completed_entry_fixture("completed-source-pin-manifest-missing"); + fs::remove_file(fixture.entry_path.join(GENERATION_MANIFEST_FILE)).expect("manifest"); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .is_err() + ); + finish_fixture(fixture); + + let fixture = completed_entry_fixture("completed-source-pin-manifest-mismatch"); + let manifest = fixture.entry_path.join(GENERATION_MANIFEST_FILE); + let mut parsed = read_generation_manifest(&manifest).expect("manifest"); + parsed.key_digest = + "sha256-cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_owned(); + fs::write(&manifest, serde_json::to_vec(&parsed).expect("encode")).expect("write"); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .is_err() + ); + finish_fixture(fixture); + } + + #[test] + fn completed_source_pin_rejects_invalid_plan_digest_and_non_complete_state() { + let fixture = completed_entry_fixture("completed-source-pin-plan-invalid"); + let manifest = fixture.entry_path.join(GENERATION_MANIFEST_FILE); + let mut parsed = read_generation_manifest(&manifest).expect("manifest"); + parsed.plan_digest = "not-a-digest".to_owned(); + fs::write(&manifest, serde_json::to_vec(&parsed).expect("encode")).expect("write"); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .is_err() + ); + finish_fixture(fixture); + + let fixture = completed_entry_fixture("completed-source-pin-state"); + let manifest = fixture.entry_path.join(GENERATION_MANIFEST_FILE); + let mut parsed = read_generation_manifest(&manifest).expect("manifest"); + parsed.state = "staging".to_owned(); + fs::write(&manifest, serde_json::to_vec(&parsed).expect("encode")).expect("write"); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .is_err() + ); + finish_fixture(fixture); + } + + #[test] + fn completed_source_pin_rejects_missing_source_and_incomplete_entry() { + let fixture = completed_entry_fixture("completed-source-pin-incomplete"); + let missing = fixture.resolved.path.join("entries/sha256-").join("data"); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&missing)) + .is_err() + ); + fs::remove_file(fixture.entry_path.join(COMPLETE_FILE)).expect("marker"); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .is_err() + ); + finish_fixture(fixture); + } + + #[cfg(unix)] + #[test] + fn completed_source_pin_rejects_symlink_component_and_wrong_type() { + use std::os::unix::fs::symlink; + let fixture = completed_entry_fixture("completed-source-pin-symlink"); + let link = fixture.repo.join("link"); + symlink(&fixture.resolved.path, &link).expect("symlink"); + let escaped = link + .join("entries") + .join(fixture.entry_path.file_name().unwrap()) + .join("data"); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&escaped)) + .is_err() + ); + fs::remove_dir_all(&fixture.data_path).expect("data dir"); + fs::write(&fixture.data_path, b"wrong type").expect("wrong type"); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .is_err() + ); + finish_fixture(fixture); + } + + #[test] + fn active_prepared_generation_makes_completed_source_pin_busy_and_failure_releases_prior_pins() + { + let fixture = completed_entry_fixture("completed-source-pin-busy"); + let plan = envelope(); + let key = CacheKey::for_plan_cache(&plan, &plan.plan.caches[0]).expect("key"); + let prepared = fixture + .cache + .prepare_entry(&key, &plan.plan_digest, 2) + .expect("prepare"); + assert!(matches!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)), + Err(CacheError::LockBusy(_)) + )); + drop(prepared); + let bad = fixture.resolved.path.join("missing"); + assert!( + fixture + .cache + .pin_completed_sources(&[fixture.data_path.clone(), bad]) + .is_err() + ); + assert!( + fixture + .cache + .pin_completed_sources(std::slice::from_ref(&fixture.data_path)) + .is_ok() + ); + finish_fixture(fixture); + } } From 9fd0c73d1c8e6b81686ebb65e68d39c7dbcf10eb Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 07:46:03 +0200 Subject: [PATCH 11/14] feat: validate guard cache pin arguments --- src/main.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/src/main.rs b/src/main.rs index 773c17c..c100ee2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -287,6 +287,12 @@ struct GuardExecArgs { /// Disable local observation history without changing admission or watchdog behavior. #[arg(long)] no_resource_history: bool, + /// Managed cache root paired with one or more completed cache sources. + #[arg(long)] + managed_cache_root: Vec, + /// Completed managed-cache source to pin for the guarded child; repeatable. + #[arg(long)] + managed_cache_source: Vec, /// Program and arguments. The `--` separator is required. #[arg(last = true, required = true, allow_hyphen_values = true)] argv: Vec, @@ -572,6 +578,7 @@ fn run_guard_command(action: GuardCommand) -> Result<(), CliError> { } fn print_guard_exec(args: GuardExecArgs) -> Result<(), CliError> { + validate_guard_cache_args(&args).map_err(CliError::Guard)?; let admission_timeout = Duration::from_secs(args.admission_timeout_seconds); if admission_timeout.is_zero() || admission_timeout > GUARD_EXEC_MAX_TIMEOUT { return Err(CliError::Guard(GuardExecError::InvalidAdmissionTimeout)); @@ -659,6 +666,18 @@ fn print_guard_exec(args: GuardExecArgs) -> Result<(), CliError> { classify_guard_result(result, &cancellation).map_err(CliError::Guard) } +fn validate_guard_cache_args(args: &GuardExecArgs) -> Result<(), GuardExecError> { + let root_empty = args.managed_cache_root.is_empty(); + let sources_empty = args.managed_cache_source.is_empty(); + if root_empty && sources_empty { + return Ok(()); + } + if args.managed_cache_root.len() != 1 || sources_empty { + return Err(GuardExecError::InvalidManagedCache); + } + Ok(()) +} + fn detect_resource_executor(argv: &[OsString]) -> ResourceExecutorV2 { let Some(program) = argv.first().and_then(|value| Path::new(value).file_name()) else { return ResourceExecutorV2::Unknown; @@ -2223,6 +2242,7 @@ enum GuardExecError { MissingProgram, InvalidResourceProfile, InvalidResourceContext, + InvalidManagedCache, InvalidCurrentDirectory, Admission(AdmissionError), Resource(ResourceGuardError), @@ -2250,6 +2270,7 @@ impl GuardExecError { | Self::MissingProgram | Self::InvalidResourceProfile | Self::InvalidResourceContext + | Self::InvalidManagedCache | Self::InvalidCurrentDirectory => 2, Self::Admission(error) => error.exit_code(), Self::Resource(_) | Self::ResourcePressure => 6, @@ -2278,6 +2299,9 @@ impl fmt::Display for GuardExecError { Self::InvalidResourceContext => formatter.write_str( "guard exec resource context labels must be bounded ASCII tokens and numeric limits must be positive", ), + Self::InvalidManagedCache => formatter.write_str( + "guard exec managed cache requires exactly one root and at least one source", + ), Self::InvalidCurrentDirectory => { formatter.write_str("guard exec current directory could not be canonicalized") } @@ -2448,6 +2472,7 @@ mod tests { }; use commit_ci_preflight::resource_history::{ResourceExecutorV2, ResourceTerminalDetailV2}; use std::ffi::OsString; + use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; #[test] @@ -2543,6 +2568,94 @@ mod tests { assert!(Cli::try_parse_from(["commit-ci-preflight", "guard", "exec", "echo"]).is_err()); } + #[test] + fn guard_exec_parses_managed_cache_pins() { + let root = PathBuf::from("/owned/cache"); + let source = PathBuf::from( + "/owned/cache/entries/sha256-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/data", + ); + let second_source = PathBuf::from( + "/owned/cache/entries/sha256-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/data", + ); + let cli = Cli::try_parse_from([ + "commit-ci-preflight", + "guard", + "exec", + "--managed-cache-root", + "/owned/cache", + "--managed-cache-source", + source.to_str().expect("source is utf-8"), + "--managed-cache-source", + second_source.to_str().expect("second source is utf-8"), + "--", + "fixture", + ]) + .expect("managed cache pin parses"); + + let super::Command::Guard { + action: GuardCommand::Exec(args), + } = cli.command.expect("command is present") + else { + panic!("unexpected command"); + }; + assert_eq!(args.managed_cache_root, vec![root.clone()]); + assert_eq!( + args.managed_cache_source, + vec![source.clone(), second_source.clone()] + ); + super::validate_guard_cache_args(&args).expect("managed cache arguments are valid"); + + let legacy = Cli::try_parse_from(["commit-ci-preflight", "guard", "exec", "--", "fixture"]) + .expect("legacy guard exec parses"); + let super::Command::Guard { + action: GuardCommand::Exec(legacy_args), + } = legacy.command.expect("legacy command is present") + else { + panic!("unexpected legacy command"); + }; + assert!(legacy_args.managed_cache_root.is_empty()); + assert!(legacy_args.managed_cache_source.is_empty()); + super::validate_guard_cache_args(&legacy_args).expect("legacy arguments are valid"); + + for argv in [ + vec!["--managed-cache-root", "/owned/cache", "--", "fixture"], + vec![ + "--managed-cache-source", + "/owned/cache/source", + "--", + "fixture", + ], + vec![ + "--managed-cache-root", + "/owned/cache-a", + "--managed-cache-root", + "/owned/cache-b", + "--managed-cache-source", + "/owned/cache-a/source", + "--", + "fixture", + ], + ] { + let mut command = vec!["commit-ci-preflight", "guard", "exec"]; + command.extend(argv); + let parsed = Cli::try_parse_from(command).expect("raw combinations parse"); + let super::Command::Guard { + action: GuardCommand::Exec(invalid_args), + } = parsed.command.expect("invalid command is present") + else { + panic!("unexpected invalid command"); + }; + let error = super::validate_guard_cache_args(&invalid_args) + .expect_err("invalid managed cache arguments are rejected"); + assert_eq!(error.exit_code(), 2); + assert_eq!( + error.to_string(), + "guard exec managed cache requires exactly one root and at least one source" + ); + assert!(!error.to_string().contains("/owned")); + } + } + #[test] fn guard_exec_parses_explicit_resource_context() { let cli = Cli::try_parse_from([ @@ -2670,6 +2783,8 @@ mod tests { resource_cpu_limit_millis: None, resource_memory_limit_bytes: None, no_resource_history: false, + managed_cache_root: Vec::new(), + managed_cache_source: Vec::new(), argv: vec![OsString::from("fixture")], } } From 2fa74e644de1a203f0819c13a5dd5ecd8dc2aedd Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 08:18:31 +0200 Subject: [PATCH 12/14] feat: hold guard cache pins through execution --- src/main.rs | 293 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 279 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index c100ee2..524fdd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,9 @@ use commit_ci_preflight::admission::{ use commit_ci_preflight::benchmark::{ BenchmarkError, run_benchmark, verify_benchmark_document, write_new_receipt, }; -use commit_ci_preflight::cache::{CacheError, CacheRootOptions, ManagedCache, ResolvedCacheRoot}; +use commit_ci_preflight::cache::{ + CacheError, CacheRootOptions, CacheUsePin, ManagedCache, ResolvedCacheRoot, +}; use commit_ci_preflight::config::{ConfigError, ConfigV1, ExecutionPlanEnvelopeV1}; use commit_ci_preflight::github_actions::{ GithubActionsError, MigrationReadiness, analyze_workflow_file, @@ -614,11 +616,35 @@ fn print_guard_exec(args: GuardExecArgs) -> Result<(), CliError> { return Err(CliError::Guard(GuardExecError::InvalidCurrentDirectory)); } + let managed_cache_root = if args.managed_cache_root.is_empty() { + None + } else { + Some( + ResolvedCacheRoot::resolve( + ¤t_dir, + &CacheRootOptions::from_process(Some(args.managed_cache_root[0].clone())), + ) + .map_err(GuardExecError::from_cache) + .map_err(CliError::Guard)?, + ) + }; + let cancellation = CancellationToken::default(); install_cancellation_handler(&cancellation)?; let supervisor = Arc::new(ProcessSupervisor::standard()); let mut session = GuardExecSession::acquire(&cancellation, admission_timeout)?; + let managed_cache = match managed_cache_root { + None => None, + Some(root) => match ManagedCache::open(root).map_err(GuardExecError::from_cache) { + Ok(cache) => Some(cache), + Err(error) => { + let result = session.finish(Err(error), &cancellation); + return result.map(|_| ()).map_err(CliError::Guard); + } + }, + }; + let baseline = match resource_pre_start(supervisor.clone(), &cancellation) { Ok(baseline) => baseline, Err(error) => { @@ -652,20 +678,82 @@ fn print_guard_exec(args: GuardExecArgs) -> Result<(), CliError> { timeout, max_capture_bytes: GUARD_EXEC_CAPTURE_BYTES, }; - let process = supervisor - .execute_with_output( - &request, - &cancellation, - &GenerationGuard::new(request.identity.clone()), - OutputMode::Tee, - ) - .map_err(GuardExecError::Process); - let result = session - .finish(process, &cancellation) - .map_err(CliError::Guard)?; + let pins = match managed_cache.as_ref() { + None => None, + Some(_) if args.managed_cache_source.is_empty() => { + let error = GuardExecError::InvalidManagedCache; + let result = session.finish(Err(error), &cancellation); + return result.map(|_| ()).map_err(CliError::Guard); + } + Some(cache) => match cache + .pin_completed_sources(&args.managed_cache_source) + .map_err(GuardExecError::from_cache) + { + Ok(pins) => Some(pins), + Err(error) => { + let result = session.finish(Err(error), &cancellation); + return result.map(|_| ()).map_err(CliError::Guard); + } + }, + }; + let mut session = Some(session); + let process_and_finish = || { + let session = session.take().ok_or(GuardExecError::InternalFailure)?; + let process = supervisor + .execute_with_output( + &request, + &cancellation, + &GenerationGuard::new(request.identity.clone()), + OutputMode::Tee, + ) + .map_err(GuardExecError::Process); + session.finish(process, &cancellation) + }; + let result = match pins.as_ref() { + Some(pins) => execute_with_guard_cache_pins(pins, process_and_finish), + None => with_guard_cache_pins(None, &[], process_and_finish), + }; + let result = match result { + Ok(result) => result, + Err(error) => { + if let Some(session) = session { + let finished = session.finish(Err(error), &cancellation); + return finished.map(|_| ()).map_err(CliError::Guard); + } + return Err(CliError::Guard(error)); + } + }; classify_guard_result(result, &cancellation).map_err(CliError::Guard) } +fn execute_with_guard_cache_pins( + pins: &[CacheUsePin], + child: impl FnOnce() -> Result, +) -> Result { + for pin in pins { + pin.revalidate().map_err(GuardExecError::from_cache)?; + } + child() +} + +fn with_guard_cache_pins( + cache: Option<&ManagedCache>, + sources: &[PathBuf], + child: impl FnOnce() -> Result, +) -> Result { + match (cache, sources.is_empty()) { + (None, true) => child(), + (None, false) => Err(GuardExecError::InvalidManagedCache), + (Some(cache), false) => { + let pins = cache + .pin_completed_sources(sources) + .map_err(GuardExecError::from_cache)?; + execute_with_guard_cache_pins(&pins, child) + } + (Some(_), true) => Err(GuardExecError::InvalidManagedCache), + } +} + fn validate_guard_cache_args(args: &GuardExecArgs) -> Result<(), GuardExecError> { let root_empty = args.managed_cache_root.is_empty(); let sources_empty = args.managed_cache_source.is_empty(); @@ -2244,6 +2332,7 @@ enum GuardExecError { InvalidResourceContext, InvalidManagedCache, InvalidCurrentDirectory, + CacheInternal, Admission(AdmissionError), Resource(ResourceGuardError), ResourcePressure, @@ -2255,6 +2344,10 @@ enum GuardExecError { } impl GuardExecError { + fn from_cache(_: CacheError) -> Self { + Self::CacheInternal + } + fn from_cli(error: CliError) -> Self { match error { CliError::Admission(error) => Self::Admission(error), @@ -2274,7 +2367,7 @@ impl GuardExecError { | Self::InvalidCurrentDirectory => 2, Self::Admission(error) => error.exit_code(), Self::Resource(_) | Self::ResourcePressure => 6, - Self::Process(_) | Self::InternalFailure => 70, + Self::Process(_) | Self::CacheInternal | Self::InternalFailure => 70, Self::ChildExit(code) if (1..=255).contains(code) => *code, Self::ChildExit(_) => 70, Self::TimedOut => 124, @@ -2305,6 +2398,7 @@ impl fmt::Display for GuardExecError { Self::InvalidCurrentDirectory => { formatter.write_str("guard exec current directory could not be canonicalized") } + Self::CacheInternal => formatter.write_str("guard exec managed cache validation failed"), Self::Admission(error) => write!(formatter, "{error}"), Self::Resource(error) => write!(formatter, "{error}"), Self::ResourcePressure => { @@ -2463,6 +2557,10 @@ mod tests { reconcile_watchdog_outcome, resource_run_outcome, resource_terminal_detail, }; use clap::{CommandFactory, Parser}; + use commit_ci_preflight::cache::{ + CacheError, CacheKey, CacheRootSource, ManagedCache, ResolvedCacheRoot, + }; + use commit_ci_preflight::config::ConfigV1; use commit_ci_preflight::process::CancellationToken; use commit_ci_preflight::process::{ CleanupStatus, ExitOutcome, ProcessResult, ProcessTermination, RunIdentity, @@ -2472,8 +2570,175 @@ mod tests { }; use commit_ci_preflight::resource_history::{ResourceExecutorV2, ResourceTerminalDetailV2}; use std::ffi::OsString; - use std::path::PathBuf; + use std::fs; + use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn guard_cache_fixture(name: &str) -> (ManagedCache, PathBuf, PathBuf) { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let temp_root = fs::canonicalize(std::env::temp_dir()).expect("canonical temp root"); + let base = temp_root.join(format!("ccp-guard-cache-pins-{name}-{stamp}")); + let repository = base.join("repository"); + let root = base.join("cache"); + fs::create_dir_all(&repository).expect("repository fixture"); + let resolved = ResolvedCacheRoot { + path: root, + source: CacheRootSource::Explicit, + }; + let cache = ManagedCache::initialize(resolved).expect("initialize cache"); + let envelope = ConfigV1::parse( + r#" +schema_version = "1.0" +project = "guard/cache-pins" + +[runtime] +kind = "docker_compatible" +image = "example.invalid/ci@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +cpu_count = 1 +memory_mib = 128 +pids_limit = 16 + +[[caches]] +id = "cargo" +mount_path = ".cache/cargo" + +[[checks]] +id = "test" +required = true +argv = ["true"] +working_directory = "." +timeout_seconds = 60 +"#, + ) + .expect("fixture config") + .into_plan() + .expect("fixture plan"); + let key = CacheKey::for_plan_cache(&envelope, &envelope.plan.caches[0]).expect("key"); + let prepared = cache + .prepare_entry(&key, &envelope.plan_digest, 1) + .expect("prepare"); + fs::write(prepared.data_path.join("payload"), b"fixture").expect("payload"); + cache.promote_entry(&prepared).expect("promote"); + let source = cache.entry_data_path(&key); + (cache, source, base) + } + + fn cleanup_guard_cache_fixture(base: &Path) { + let _ = fs::remove_dir_all(base); + } + + #[test] + fn guard_cache_pins_revalidate_before_child_after_noncooperative_change() { + let (cache, source, base) = guard_cache_fixture("revalidate"); + let pins = cache + .pin_completed_sources(std::slice::from_ref(&source)) + .expect("pin"); + fs::remove_dir_all(&source).expect("remove source"); + let calls = AtomicUsize::new(0); + let result = super::execute_with_guard_cache_pins(&pins, || { + calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, GuardExecError>(()) + }); + assert!(result.is_err()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + drop(pins); + cleanup_guard_cache_fixture(&base); + } + + #[test] + fn guard_cache_pins_hold_lock_through_success_and_release_after_return() { + let (cache, source, base) = guard_cache_fixture("success"); + let result = + super::with_guard_cache_pins(Some(&cache), std::slice::from_ref(&source), || { + assert!(matches!( + cache.pin_completed_sources(std::slice::from_ref(&source)), + Err(CacheError::LockBusy(_)) + )); + Ok::<_, GuardExecError>(()) + }); + assert!(result.is_ok()); + assert!( + cache + .pin_completed_sources(std::slice::from_ref(&source)) + .is_ok() + ); + cleanup_guard_cache_fixture(&base); + } + + #[test] + fn guard_cache_pins_hold_lock_through_child_error_and_release_after_return() { + let (cache, source, base) = guard_cache_fixture("error"); + let result = + super::with_guard_cache_pins(Some(&cache), std::slice::from_ref(&source), || { + assert!(matches!( + cache.pin_completed_sources(std::slice::from_ref(&source)), + Err(CacheError::LockBusy(_)) + )); + Err::<(), _>(GuardExecError::InternalFailure) + }); + assert!(matches!(result, Err(GuardExecError::InternalFailure))); + assert!( + cache + .pin_completed_sources(std::slice::from_ref(&source)) + .is_ok() + ); + cleanup_guard_cache_fixture(&base); + } + + #[test] + fn guard_cache_pins_deduplicate_sources_and_call_child_once() { + let (cache, source, base) = guard_cache_fixture("duplicate"); + let calls = AtomicUsize::new(0); + let result = + super::with_guard_cache_pins(Some(&cache), &[source.clone(), source.clone()], || { + calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, GuardExecError>(()) + }); + assert!(result.is_ok()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + cleanup_guard_cache_fixture(&base); + } + + #[test] + fn guard_cache_pins_legacy_none_empty_calls_child_once() { + let calls = AtomicUsize::new(0); + let result = super::with_guard_cache_pins(None, &[], || { + calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, GuardExecError>(()) + }); + assert!(result.is_ok()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn guard_cache_pins_none_with_source_fails_closed_without_child() { + let (_cache, source, base) = guard_cache_fixture("none-source"); + let calls = AtomicUsize::new(0); + let result = super::with_guard_cache_pins(None, std::slice::from_ref(&source), || { + calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, GuardExecError>(()) + }); + assert!(matches!(result, Err(GuardExecError::InvalidManagedCache))); + assert_eq!(calls.load(Ordering::SeqCst), 0); + cleanup_guard_cache_fixture(&base); + } + + #[test] + fn guard_cache_pins_some_with_empty_sources_fails_closed_without_child() { + let (cache, _source, base) = guard_cache_fixture("some-empty"); + let calls = AtomicUsize::new(0); + let result = super::with_guard_cache_pins(Some(&cache), &[], || { + calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, GuardExecError>(()) + }); + assert!(matches!(result, Err(GuardExecError::InvalidManagedCache))); + assert_eq!(calls.load(Ordering::SeqCst), 0); + cleanup_guard_cache_fixture(&base); + } #[test] fn cli_definition_is_valid() { From 693f93cc50272f1381419ebdb1150c9a08a4a50f Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 08:27:18 +0200 Subject: [PATCH 13/14] docs: define cache pin lifecycle contract --- docs/CACHE_AND_WORKSPACE.md | 25 +++++++++++ docs/COORDINATION_RUNBOOK.md | 22 +++++++++ docs/LOCAL_RUN.md | 20 +++++++++ docs/RUNTIME.md | 12 +++++ docs/TESTING_AND_FAULT_INJECTION.md | 18 ++++++++ docs/THREAT_MODEL.md | 11 ++++- tests/cache_pin_contract.rs | 70 +++++++++++++++++++++++++++++ 7 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 tests/cache_pin_contract.rs diff --git a/docs/CACHE_AND_WORKSPACE.md b/docs/CACHE_AND_WORKSPACE.md index a8ebd10..bf62051 100644 --- a/docs/CACHE_AND_WORKSPACE.md +++ b/docs/CACHE_AND_WORKSPACE.md @@ -179,3 +179,28 @@ Current evidence proves deterministic source behavior and macOS execution of the test suite. Windows and Linux native cache/path behavior remains PENDING until it is executed on those platforms; no macOS result is relabeled as native evidence for another platform. + +Standard `run` retains its prepared-entry lock for the full execution +lifecycle and revalidates the exact staging generation immediately before +Docker creation. A changed, missing, or ambiguous generation fails closed. + +`guard exec` may opt into a cooperative pin for an already completed source: + +```console +commit-ci-preflight guard exec \ + --managed-cache-root /absolute/owned/cache-root \ + --managed-cache-source /absolute/owned/cache-root/entries/sha256-<64>/data \ + -- [args...] +``` + +`--managed-cache-source` is repeatable and requires one owned root. Each source +must be the exact completed `entries/sha256-<64>/data` directory beneath it; +staging paths, workspaces, incomplete entries, symlinks, wrong object types, +and escapes fail closed. Sources must already be canonical; aliases are +rejected. Accepted sources are deduplicated and locked in stable order. The +existing advisory entry lock is held through child +cleanup and guard-session release; no additional TTL or heartbeat format is +introduced. The declaration is cooperative and non-attesting: undeclared +paths are not pinned, and manual or non-cooperative replacement is unsupported. +Pinning does not initialize, repair, delete, quarantine, publish, or attest +cache contents. diff --git a/docs/COORDINATION_RUNBOOK.md b/docs/COORDINATION_RUNBOOK.md index f24eb10..583e305 100644 --- a/docs/COORDINATION_RUNBOOK.md +++ b/docs/COORDINATION_RUNBOOK.md @@ -37,6 +37,28 @@ CCP's own integration tests and any command that invokes `run`, `benchmark`, or `guard exec` internally must not be wrapped in another `guard exec`. Admission is intentionally non-reentrant. +### Standard run lock and opt-in cache pins + +The host-wide admission slot remains the primary coordination mechanism for +`run`, `benchmark`, and `guard exec`. An opt-in `guard exec` managed-cache pin +is an additional filesystem-use boundary for already-complete cache entries; +it is not a second scheduler, a replacement for admission, or a receipt +qualification signal. Pins use the entry's existing advisory lock and remain +held for the guarded child lifecycle; they have no TTL and are released when +the guarded operation returns. + +Only explicitly declared, completed managed-cache sources are pinned; in +particular, `undeclared paths are not pinned`. A path used by a raw launcher +but omitted from the pin arguments receives no CCP ownership or race +protection. Pin acquisition and spawn-boundary revalidation +must succeed before the child starts. A cooperative mutator must use the same +entry lock and revalidate after acquiring it; manual deletion, quarantine, +replacement, or other non-cooperative mutation is outside this guarantee. + +Pins do not initialize, repair, delete, quarantine, or publish receipts. A +qualification result remains separate and requires its own exact source, +configuration, runtime, and receipt evidence. + ## Required preflight before heavy work Every activity must perform these checks immediately before reserving a heavy diff --git a/docs/LOCAL_RUN.md b/docs/LOCAL_RUN.md index 6ed04a9..87a04c1 100644 --- a/docs/LOCAL_RUN.md +++ b/docs/LOCAL_RUN.md @@ -191,3 +191,23 @@ These are macOS-hosted OrbStack results. They are not Windows-native, Linux-host-native, hosted-runner, identity-attestation, or GitHub policy evidence. PR 06 adds an independent verifier; later plan tranches add the small remote gate and cross-platform qualification. + +For a cooperative child using a completed CCP cache entry, declare one +`--managed-cache-root` and repeatable `--managed-cache-source` options: + +```console +commit-ci-preflight guard exec \ + --managed-cache-root /absolute/owned/cache-root \ + --managed-cache-source /absolute/owned/cache-root/entries/sha256-<64>/data \ + -- [args...] +``` + +The source must be the exact completed `entries/sha256-<64>/data` directory +under that root. Sources must already be canonical; aliases are rejected. +Accepted declarations are deduplicated and locked in a stable order, then +revalidated immediately before child spawn. The +existing advisory entry lock stays held through child cleanup and guard-session +release. This declaration is cooperative and non-attesting: undeclared paths +are not pinned, and manual or non-cooperative replacement is unsupported. The +flags do not initialize, repair, delete, quarantine, publish, or attest cache +contents. Legacy `guard exec` without these flags remains unchanged. diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index 9a9d03f..389cfb4 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -163,3 +163,15 @@ runner does not mount the Docker socket into a job, enable privileged mode, insert a shell, or expose undeclared host paths. Network remains disabled unless the configuration explicitly enables it. See `docs/LOCAL_RUN.md` for evidence and remaining platform limitations. + +The standard `run` path retains its prepared-entry lock through execution and +performs spawn-boundary revalidation of the exact staging generation +immediately before Docker creation. A changed, missing, or ambiguous source +fails closed at that boundary. + +`guard exec` supports an opt-in cooperative pin for exact completed managed +cache sources. The existing advisory entry lock remains held through child +cleanup and session release. Only sources declared beneath one managed root +are covered; undeclared paths are not pinned, and arbitrary child argv is not +parsed or attested. No additional TTL or heartbeat lease is introduced. +Manual or non-cooperative replacement remains unsupported. diff --git a/docs/TESTING_AND_FAULT_INJECTION.md b/docs/TESTING_AND_FAULT_INJECTION.md index 50ccb78..b750854 100644 --- a/docs/TESTING_AND_FAULT_INJECTION.md +++ b/docs/TESTING_AND_FAULT_INJECTION.md @@ -86,6 +86,24 @@ journal avoids the limitation by publishing immutable create-new events. Native crash/power-loss and Windows-host qualification remain separate gates. Deterministic source tests do not claim either result. +## Managed-cache pin contract + +The managed-cache pin tests are deterministic contract tests over an owned +fixture root. They cover canonical completed-entry selection, duplicate +deduplication and stable ordering, advisory-lock lifetime, invalid roots and +components, missing or incomplete entries, symlink and wrong-type rejection, +and release of previously acquired pins when a later acquisition fails. The +pin API does not initialize, repair, delete, quarantine, or publish receipts. + +The lifecycle tests separately verify spawn-boundary revalidation and that a +failed validation makes zero child calls. The non-cooperative race test is +bounded to a change after pin acquisition but before the child spawn: the +validator must fail and the child-call count must remain zero. Replacement +after spawn-boundary revalidation is not prevented or guaranteed by this pin; +it is an unsupported external race and must not be converted into a success +claim. Qualification of a real runtime or receipt is a separate +native/evidence-gated activity. + ## Compatibility fixtures The v1 compatibility baseline remains pinned in: diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 3fc5c88..71fcf39 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -72,6 +72,8 @@ Boundary rules: - project checks are trusted build/test code, not arbitrary hostile workloads; - the source checkout is read-only inside the container; - only declared cache and artifact mounts are writable; +- managed-cache pins are opt-in, apply only to explicitly declared completed + entries, and are revalidated at the spawn boundary; - the evidence branch is untrusted data; - verifier code and policy come from the reviewed base branch; - GitHub event identity and permissions remain remote facts. @@ -94,13 +96,13 @@ Boundary rules: | T12 | Workflow privilege escalation | Minimal permissions, pinned official actions, no secrets/cache/deployment credential | Compromise of GitHub or a pinned action commit remains upstream risk | | T13 | Marketplace action execution during migration | Migration assistant parses bounded YAML as data and emits inert classifications | Human reviewers can still make a bad manual translation | | T14 | GitHub expression or secret misinterpretation | Unsupported expressions, permissions, secrets, reusable workflows, and arbitrary actions fail closed or require review | Compatibility is deliberately incomplete | -| T15 | Cache poisoning | Versioned ownership marker, content-addressed keys, completion marker, active-run lock | Caches accelerate execution but are not attestation evidence | +| T15 | Cache poisoning or cache-use race | Versioned ownership marker, content-addressed keys, completion marker, active-run lock, opt-in managed-cache pin with spawn-boundary revalidation | Caches accelerate execution but are not attestation evidence; undeclared paths are not pinned | | T16 | Destructive cleanup | 0.1.0 exposes preview-only cleanup; resolved-root and containment checks | Operators retain responsibility for manual filesystem deletion | | T17 | Image drift | OCI digest is mandatory and included in plan, receipt, and policy | A multi-platform index can resolve to different platform manifests by design | | T18 | Dependency compromise | Committed lockfile, exact critical pins, SPDX SBOM, bundled notices, advisory review | Registry and compiler compromise cannot be eliminated locally | | T19 | Platform overclaim | Native receipts name OS/architecture; emulation and runtime probes are separate; PASS/PENDING/NOT_RUN are explicit | Benchmark qualification is narrower than full runtime qualification | | T20 | Identity overclaim | Structural, integrity, policy, and identity levels are separate; identity is not implemented | No cryptographic proof of operator or machine exists in 0.1.0 | -| T21 | Symlink or filesystem race | Canonical path checks, managed roots, create-new/atomic writes, runtime revalidation | Host filesystem and privileged local actors remain trusted | +| T21 | Symlink or filesystem race | Canonical path checks, managed roots, create-new/atomic writes, runtime revalidation, existing advisory-lock pin held through the guarded child lifecycle | Cooperative mutators must use the same lock and revalidate after acquisition; manual deletion remains unsupported; privileged local actors remain trusted | | T22 | Evidence parser denial of service | One MiB remote input cap, strict unknown-field rejection, bounded summaries | Base verifier compilation still consumes bounded remote time | | T23 | Release substitution | Local SHA-256 manifest, SBOM, notices, checksum verification instructions | Checksums are not signatures and must come through an independent channel | | T24 | Unsafe upgrade or rollback | Isolated install, version smoke test, preserved previous binary, versioned schemas and cache markers | Operator mistakes remain possible; no automatic updater exists | @@ -151,6 +153,11 @@ before publication. deduplicated license/notice texts found in packaged crates. - The host admission coordinator does not infer liveness from PIDs or wall clocks; it reclaims only tickets whose advisory locks are demonstrably free. +- The standard run lock coordinates the host-wide heavy slot. An opt-in + managed-cache pin is a separate advisory-lock pin for an explicitly + declared completed entry; it has no TTL and does not provide receipt or + qualification evidence. Spawn-boundary revalidation is required before the + child starts, and undeclared paths are not pinned. - The macOS resource guard uses only bounded, strict output from absolute system tools and fails closed on unavailable or contradictory samples. Its status surface is bounded and excludes identity, path, command, and process data. diff --git a/tests/cache_pin_contract.rs b/tests/cache_pin_contract.rs new file mode 100644 index 0000000..4970ab8 --- /dev/null +++ b/tests/cache_pin_contract.rs @@ -0,0 +1,70 @@ +use std::fs; +use std::path::Path; + +#[test] +fn cache_pin_documentation_contract() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let docs = [ + "docs/CACHE_AND_WORKSPACE.md", + "docs/RUNTIME.md", + "docs/LOCAL_RUN.md", + "docs/COORDINATION_RUNBOOK.md", + "docs/TESTING_AND_FAULT_INJECTION.md", + "docs/THREAT_MODEL.md", + ] + .map(|path| { + ( + path, + fs::read_to_string(root.join(path)).expect("read contract doc"), + ) + }); + let combined_docs = docs + .iter() + .map(|(_, text)| text.as_str()) + .collect::>() + .join("\n"); + + for required in [ + "--managed-cache-root", + "--managed-cache-source", + "spawn-boundary revalidation", + "undeclared paths are not pinned", + "manual deletion remains unsupported", + ] { + assert!(combined_docs.contains(required), "missing {required}"); + } + + for forbidden in [ + "cache pin uses a TTL lease", + "automatic cache deletion is enabled", + "guard exec emits a receipt", + "external same-path replacement is prevented", + ] { + assert!( + !combined_docs.contains(forbidden), + "forbidden claim: {forbidden}" + ); + } + + for (path, required) in [ + ("docs/CACHE_AND_WORKSPACE.md", "--managed-cache-source"), + ("docs/RUNTIME.md", "spawn-boundary revalidation"), + ("docs/LOCAL_RUN.md", "--managed-cache-root"), + ( + "docs/COORDINATION_RUNBOOK.md", + "undeclared paths are not pinned", + ), + ("docs/TESTING_AND_FAULT_INJECTION.md", "non-cooperative"), + ( + "docs/THREAT_MODEL.md", + "manual deletion remains unsupported", + ), + ] { + let text = docs + .iter() + .find(|(candidate, _)| *candidate == path) + .map(|(_, text)| text) + .expect("named contract document"); + assert!(text.contains(required), "{path} missing {required}"); + } +} From c6f29e8ba972461c5d37bb648b27d4453b5d9929 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Tue, 25 Aug 2026 17:13:28 +0200 Subject: [PATCH 14/14] fix: use ascii helper for cache key validation --- src/cache.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cache.rs b/src/cache.rs index e8e584f..c566278 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -867,7 +867,7 @@ fn validate_pin_source_path(root: &Path, source: &Path) -> Result