From 8f7c91134aba03259aae298e467b1f9b27e4c25b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 14 Aug 2026 07:03:45 -0700 Subject: [PATCH 1/2] fix(vendor): fail closed in the cargo vendored flow Fixes five fail-closed gaps found by the cargo vendored-flow audit (findings B1-B5): B1: a failed artifact rebuild (hot path or a fresh run whose config entry already points at the copy) deleted the live-wired vendored copy, leaving [patch.crates-io] dangling and every cargo build broken. Rebuilds are now staged in a `.socket-stage` sibling and swapped in only on success; on failure the previous (drifted-but-buildable) copy, marker, config, and lock are left byte-identical. B2: vendoring a crate whose Cargo.lock resolves the SAME name+version from multiple sources (registry + git fork - a legal, cargo-generated shape) detached the registry entry while consumers' dependencies arrays reference it by full package-id string, silently corrupting the lock (`cargo build --locked` then fails on every fresh checkout). The preflight now counts same-name+version entries and refuses the shape with `locked_multi_source_conflict`. B3: a service download that failed integrity verification (ServiceArtifact::IntegrityMismatch) fell back to a local build under the default `--vendor-source=auto`, downgrading an active tamper signal to a warning. Both consumers (cargo_service_copy and the Tier-A service_archive_copy used by maven/nuget) now hard-refuse with `vendor_prebuilt_integrity_mismatch` in every mode, per the documented ServiceArtifact contract. B4: path_is_socket_owned classified any path merely traversing a `.socket/vendor/cargo/` directory as socket-owned, so a user-authored [patch.crates-io] entry pointing into a FOREIGN checkout's vendor tree (`../shared-fork/.socket/...`, absolute paths) was silently overwritten by vendor and deleted by revert. Ownership now requires a root-anchored relative path (no `..`, not absolute, no nested sub-checkout) under THIS project's socket dirs. B5: the service-mode layout-mismatch hard failure left an empty `.socket/vendor/cargo//` husk (plus freshly created parents) behind. All failure paths now prune empty vendor levels; the pruning uses remove_dir so non-empty dirs (live copies, markers, other crates) always survive. Regression tests adapted from the audit's reproduction probes (security_scratch_audit.rs REPRO 3 and the cargo_config.rs audit test mod), plus new coverage for each finding. Co-authored-by: Claude Fable 5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-core/src/vendor/cargo.rs | 440 ++++++++++++++++-- .../src/vendor/cargo_config.rs | 102 +++- .../src/vendor/cargo_lock.rs | 61 +++ .../src/vendor/service_fetch.rs | 38 +- 5 files changed, 590 insertions(+), 53 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b77faae2..3a0f4f44 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -380,7 +380,7 @@ per service outcome: | Service outcome | `auto` | `service` | |---|---|---| | granted/reused, integrity ok | **use service** | **use service** | -| integrity mismatch | local build + `vendor_prebuilt_integrity_mismatch` | refuse (`vendor_prebuilt_required`) | +| integrity mismatch | cargo/maven/nuget: **refuse** (`vendor_prebuilt_integrity_mismatch`) — tampered bytes never fall back; other ecosystems (to be aligned): local build + `vendor_prebuilt_integrity_mismatch` | refuse (cargo/maven/nuget: `vendor_prebuilt_integrity_mismatch`; others: `vendor_prebuilt_required`) | | still building (`pending_build` / serve 408) | local build + `vendor_prebuilt_pending` | refuse | | not built / withdrawn / not found / no usable artifact | local build (quiet) | refuse | | 401 / 403 grant / 5xx / network error | local build + `vendor_prebuilt_unavailable` | refuse | diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 86c630e5..0dd5e16a 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -80,6 +80,65 @@ async fn wiring_in_sync(project_root: &Path, name: &str, version: &str, copy_rel ) } +/// The staging sibling for a copy dir: `/-.socket-stage`. +/// Rebuilds are materialised here and swapped into place only on success, so +/// a failure can never destroy a pre-existing (possibly live-wired) copy. +/// Same directory as the copy → the swap is a real rename, never a cross- +/// device copy. The `.socket-stage` suffix can never collide with a copy dir: +/// `` is a validated single segment and cargo versions never end in +/// `.socket-stage`. +fn stage_dir_for(copy_dir: &Path) -> std::path::PathBuf { + let name = copy_dir + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "copy".to_string()); + match copy_dir.parent() { + Some(parent) => parent.join(format!("{name}.socket-stage")), + None => copy_dir.join(".socket-stage"), + } +} + +/// Swap a fully-built stage into place: remove the old copy (if any), then +/// rename the stage over it. The rename is same-directory and only happens +/// after the stage passed every check, so the destroy-then-replace window is +/// as small as the filesystem allows. +async fn swap_stage_into_place(stage: &Path, copy_dir: &Path) -> std::io::Result<()> { + remove_tree(copy_dir).await?; + tokio::fs::rename(stage, copy_dir).await +} + +/// Best-effort removal of an EMPTY `/` dir plus the empty +/// `.socket/vendor/cargo/` and `.socket/vendor/` levels a failed run may have +/// created, so a hard failure leaves no husk for the user to commit. +/// `remove_dir` refuses non-empty dirs, so live copies, markers, and other +/// crates' vendor dirs always survive. +async fn prune_empty_vendor_dirs(uuid_dir: &Path) { + if tokio::fs::remove_dir(uuid_dir).await.is_err() { + return; + } + let Some(eco_dir) = uuid_dir.parent() else { + return; + }; + if tokio::fs::remove_dir(eco_dir).await.is_err() { + return; + } + if let Some(vendor_dir) = eco_dir.parent() { + let _ = tokio::fs::remove_dir(vendor_dir).await; + } +} + +/// Failure cleanup for a staged (re)build: always remove the stage, then +/// either unwind the whole `/` dir (`unwind_uuid_dir` — a fresh vendor +/// with no pre-existing state worth keeping) or leave existing state +/// untouched; either way prune any empty-husk dirs left behind. +async fn cleanup_failed_stage(stage: &Path, uuid_dir: &Path, unwind_uuid_dir: bool) { + let _ = remove_tree(stage).await; + if unwind_uuid_dir { + let _ = remove_tree(uuid_dir).await; + } + prune_empty_vendor_dirs(uuid_dir).await; +} + /// Outcome of attempting to materialise the cargo copy from the patch service. enum CargoServiceCopy { /// The prebuilt crate was extracted into `copy_dir`. @@ -125,23 +184,27 @@ async fn cargo_service_copy( }; match fetch_verified_archive(cfg, &record.uuid).await { ServiceArtifact::Ready(archive) => { - // Clean copy dir, then extract the `.crate` (tar.gz; strip its - // single `{name}-{version}/` top-level dir) into it. - let _ = remove_tree(copy_dir).await; - if let Err(e) = tokio::fs::create_dir_all(copy_dir).await { + // Extract the `.crate` (tar.gz; strip its single + // `{name}-{version}/` top-level dir) into a STAGE sibling and + // swap it into the copy dir only once fully verified — a failure + // then leaves any pre-existing copy untouched and no husk behind. + let stage = stage_dir_for(copy_dir); + let _ = remove_tree(&stage).await; + if let Err(e) = tokio::fs::create_dir_all(&stage).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; return hard( "vendor_prebuilt_write_failed", - format!("cannot create {}: {e}", copy_dir.display()), + format!("cannot create {}: {e}", stage.display()), ); } - if let Err(e) = extract_tgz(&archive.bytes, copy_dir) { - let _ = remove_tree(uuid_dir).await; + if let Err(e) = extract_tgz(&archive.bytes, &stage) { + cleanup_failed_stage(&stage, uuid_dir, false).await; return hard( "vendor_prebuilt_extract_failed", format!("cannot extract the prebuilt crate: {e}"), ); } - let _ = tokio::fs::remove_file(copy_dir.join(".cargo-checksum.json")).await; + let _ = tokio::fs::remove_file(stage.join(".cargo-checksum.json")).await; // Verify the EXTRACTED TREE, not just the archive bytes: the SRI // proves the download is intact, but an unexpected internal // layout (the single `{name}-{version}/` strip leaving an extra @@ -149,8 +212,8 @@ async fn cargo_service_copy( // paths and the caller would synthesize success from // `record.files` while the copy is wrong. Fail closed → `auto` // falls back to the local build. (Mirrors composer_lock.rs.) - if !copy_matches_after_hashes(copy_dir, &record.files).await { - let _ = remove_tree(copy_dir).await; + if !copy_matches_after_hashes(&stage, &record.files).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; return miss( warnings, "vendor_prebuilt_layout_mismatch", @@ -160,6 +223,13 @@ async fn cargo_service_copy( ), ); } + if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; + return hard( + "vendor_prebuilt_write_failed", + format!("cannot move the extracted crate into place: {e}"), + ); + } warnings.push(VendorWarning::new( "vendor_prebuilt_downloaded", format!( @@ -169,10 +239,16 @@ async fn cargo_service_copy( )); CargoServiceCopy::Used } - ServiceArtifact::IntegrityMismatch(reason) => miss( - warnings, + // Bytes that fail integrity verification are an active tamper signal: + // ALWAYS a hard error, in `auto` exactly as in `service` — never a + // quiet local-build fallback (`ServiceArtifact`'s documented + // contract; nothing was extracted, so there is nothing to clean up). + ServiceArtifact::IntegrityMismatch(reason) => hard( "vendor_prebuilt_integrity_mismatch", - format!("prebuilt crate failed integrity ({reason})"), + format!( + "prebuilt crate for {name} failed integrity verification ({reason}); \ + refusing to fall back to a local build on tampered bytes" + ), ), ServiceArtifact::Pending => miss( warnings, @@ -197,15 +273,18 @@ async fn cargo_service_copy( } } -/// Copy the pristine source into `copy_dir` and run the hardened apply -/// pipeline against it (vendor auto-force policy — see -/// [`super::force_apply_staged`]). On failure the whole uuid dir is removed — -/// a partial copy (or an empty `/` husk) under `.socket/vendor/` would -/// be misjudged by verify/sweep — and the failed [`ApplyResult`] is the `Err` -/// for the caller to bubble. On success the copy carries no -/// `.cargo-checksum.json` (a path-dep copy must never have one; the fresh -/// copy excludes it, and it is re-removed defensively in case the patch -/// recreated it). +/// Copy the pristine source into a STAGE sibling of `copy_dir`, run the +/// hardened apply pipeline against it (vendor auto-force policy — see +/// [`super::force_apply_staged`]), and swap the stage into `copy_dir` only on +/// success. A failed (re)build therefore never destroys a pre-existing copy: +/// with `unwind_uuid_dir` (a fresh vendor — nothing pre-existing to keep) the +/// whole uuid dir is removed, without it (a live-wired rebuild) the previous +/// copy, marker, and wiring are left exactly as they were; either way no +/// partial copy or empty `/` husk — which verify/sweep would misjudge — +/// survives, and the failed [`ApplyResult`] is the `Err` for the caller to +/// bubble. On success the copy carries no `.cargo-checksum.json` (a path-dep +/// copy must never have one; the fresh copy excludes it, and it is re-removed +/// defensively in case the patch recreated it). #[allow(clippy::too_many_arguments)] async fn copy_and_patch( purl: &str, @@ -215,12 +294,15 @@ async fn copy_and_patch( record: &PatchRecord, sources: &PatchSources<'_>, force: bool, + unwind_uuid_dir: bool, name: &str, version: &str, warnings: &mut Vec, ) -> Result { - if let Err(e) = fresh_copy(pristine_src, copy_dir, Some(".cargo-checksum.json")).await { - let _ = remove_tree(uuid_dir).await; + let stage = stage_dir_for(copy_dir); + // `fresh_copy` removes + recreates the stage itself. + if let Err(e) = fresh_copy(pristine_src, &stage, Some(".cargo-checksum.json")).await { + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; return Err(synthesized_result( purl, copy_dir, @@ -230,15 +312,21 @@ async fn copy_and_patch( )); } let mut result = super::force_apply_staged( - purl, copy_dir, record, sources, false, force, name, version, warnings, + purl, &stage, record, sources, false, force, name, version, warnings, ) .await; result.package_path = copy_dir.display().to_string(); if !result.success { - let _ = remove_tree(uuid_dir).await; + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; + return Err(result); + } + let _ = tokio::fs::remove_file(stage.join(".cargo-checksum.json")).await; + if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { + cleanup_failed_stage(&stage, uuid_dir, unwind_uuid_dir).await; + result.success = false; + result.error = Some(format!("failed to move the rebuilt copy into place: {e}")); return Err(result); } - let _ = tokio::fs::remove_file(copy_dir.join(".cargo-checksum.json")).await; debug_assert!( result.sidecar.is_none(), "vendor copy must not produce a cargo sidecar" @@ -335,6 +423,24 @@ pub async fn vendor_cargo_crate( } } } + // (b2) The lock must resolve name+version from a SINGLE entry. A second + // same-name+version entry (registry + a git fork — a legal, + // cargo-generated shape) means consumers' `dependencies` arrays + // disambiguate with full package-id strings, which the detach surgery + // would dangle: vendor would "succeed" while the committed lock breaks + // every `cargo build --locked` (real-cargo verified). Refuse up front. + if cargo_lock::count_lock_entries(project_root, name, version).await > 1 { + return refused( + "locked_multi_source_conflict", + format!( + "Cargo.lock resolves `{name}@{version}` from multiple sources \ + (e.g. the registry plus a git fork); detaching the registry \ + entry would corrupt the full package-id references in the \ + lock's dependencies arrays, so this crate cannot be vendored \ + in this project" + ), + ); + } // (c) A user-authored same-name `[patch.crates-io]` entry is never // overwritten. (`ensure_patch_entry` would also refuse, but pre-flighting // it keeps the refusal ahead of any write.) @@ -404,7 +510,10 @@ pub async fn vendor_cargo_crate( // Wired but the committed copy is missing/stale: rebuild the // ARTIFACT only — config + lock are already correct, and the full // path's surgery would re-record live vendored state over the - // first run's unrecoverable lock originals. + // first run's unrecoverable lock originals. The rebuild is staged: a + // failure must leave the previous (drifted-but-buildable) copy and + // the live wiring exactly as they were, never a deleted copy under a + // still-pointing `[patch]` entry. let mut warnings: Vec = Vec::new(); let result = match copy_and_patch( purl, @@ -414,6 +523,7 @@ pub async fn vendor_cargo_crate( record, sources, force, + false, // live-wired: never unwind the uuid dir on failure name, version, &mut warnings, @@ -461,6 +571,13 @@ pub async fn vendor_cargo_crate( if let Some(refusal) = service_offline_conflict(service) { return refusal; } + // When the pre-existing config entry already points at THIS copy (wiring + // out of sync only because of the lock — e.g. it was re-resolved or went + // corrupt post-vendor), a failure must not delete the copy that entry + // points at: the unwind restores the entry, and removing the uuid dir + // would dangle it and break every build. + let prior_points_here = + prior_entry.as_ref().and_then(|i| i.path.as_deref()) == Some(copy_rel.as_str()); let mut result = match cargo_service_copy( service, record, @@ -486,6 +603,7 @@ pub async fn vendor_cargo_crate( record, sources, force, + !prior_points_here, name, version, &mut warnings, @@ -501,8 +619,13 @@ pub async fn vendor_cargo_crate( // ── wire the config entry ───────────────────────────────────────────── if let Err(e) = cargo_config::ensure_patch_entry(project_root, name, ©_rel, false).await { // The config was left untouched on refusal; unwind the copy so no - // unwired artifact lingers under .socket/vendor/. - let _ = remove_tree(&uuid_dir).await; + // unwired artifact lingers under .socket/vendor/ — unless the + // existing config entry points at this very copy, which deleting + // would dangle. + if !prior_points_here { + let _ = remove_tree(&uuid_dir).await; + } + prune_empty_vendor_dirs(&uuid_dir).await; result.success = false; result.error = Some(format!("failed to update .cargo/config.toml: {e}")); return done(result, None, warnings); @@ -544,8 +667,10 @@ pub async fn vendor_cargo_crate( // config edit so the project is back where it started: // restore the prior socket-owned entry when this was a // re-vendor (dropping it would destroy the first run's live - // wiring), else drop the entry we just added. Either way - // remove this run's copy. + // wiring), else drop the entry we just added. Remove this + // run's copy — unless the restored entry points at it, in + // which case deleting it would dangle that entry and break + // every build. match prior_path.as_deref() { Some(p) => { let _ = @@ -555,11 +680,14 @@ pub async fn vendor_cargo_crate( let _ = cargo_config::drop_patch_entry(project_root, name, false).await; } } - let _ = remove_tree(&uuid_dir).await; + if !prior_points_here { + let _ = remove_tree(&uuid_dir).await; + } + prune_empty_vendor_dirs(&uuid_dir).await; result.success = false; result.error = Some(format!( "failed to detach the Cargo.lock entry for {name}@{version}: {e} \ - (config entry and copy were unwound; nothing was vendored)" + (the config edit was unwound and nothing new was vendored)" )); return done(result, None, warnings); } @@ -1135,6 +1263,170 @@ mod tests { ); } + /// AUDIT B1: a failed hot-path artifact rebuild must never destroy the + /// live-wired vendored copy. Drift the committed copy (bad merge / + /// formatter), then re-run with the patch content unavailable (empty + /// blobs dir — the offline shape: a drifted file harvests no blob): the + /// rebuild fails, but the previous — drifted yet buildable — copy, the + /// marker, the config entry, and the detached lock must all be left + /// exactly as they were. (Adapted from the audit probe + /// `audit_failed_rebuild_deletes_wired_artifact`.) + #[tokio::test] + async fn test_failed_rebuild_preserves_live_wired_copy() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + + let lib = root.join(copy_rel()).join("src/lib.rs"); + tokio::fs::write(&lib, b"drifted but buildable\n") + .await + .unwrap(); + let cfg1 = tokio::fs::read(root.join(".cargo/config.toml")) + .await + .unwrap(); + let lock1 = tokio::fs::read(root.join("Cargo.lock")).await.unwrap(); + + let empty = root.join(".socket/empty-blobs"); + tokio::fs::create_dir_all(&empty).await.unwrap(); + let (result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &empty, &pristine, &record, false).await); + assert!(!result.success, "rebuild must fail without patch content"); + assert!(entry.is_none()); + + // The live-wired state is untouched: copy, marker, config, lock. + assert_eq!( + tokio::fs::read(&lib).await.unwrap(), + b"drifted but buildable\n", + "the previous committed copy must survive a failed rebuild" + ); + assert!( + root.join(format!(".socket/vendor/cargo/{UUID}/{VENDOR_MARKER_FILE}")) + .exists(), + "marker must survive" + ); + assert_eq!( + tokio::fs::read(root.join(".cargo/config.toml")).await.unwrap(), + cfg1, + "config untouched" + ); + assert_eq!( + tokio::fs::read(root.join("Cargo.lock")).await.unwrap(), + lock1, + "lock untouched" + ); + // And the failed rebuild's stage never leaks into the uuid dir. + let uuid_dir = root.join(format!(".socket/vendor/cargo/{UUID}")); + let mut rd = tokio::fs::read_dir(&uuid_dir).await.unwrap(); + while let Some(e) = rd.next_entry().await.unwrap() { + let n = e.file_name().to_string_lossy().into_owned(); + assert!(!n.contains("socket-stage"), "stage litter: {n}"); + } + } + + /// AUDIT B1 (same destroy class, fresh path): when the pre-existing + /// config entry already points at THIS copy (wiring out of sync only + /// because the lock went corrupt post-vendor), a detach failure's unwind + /// restores that entry — so the uuid dir it points at must survive, or + /// the restored entry dangles and every build breaks. + #[tokio::test] + async fn test_detach_failure_keeps_copy_the_config_points_at() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + // The lock went corrupt post-vendor (the preflight cross-check + // deliberately skips an unparseable lock). + tokio::fs::write(root.join("Cargo.lock"), "not = = toml [[[") + .await + .unwrap(); + + let (result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(!result.success); + assert!(entry.is_none()); + // The restored prior entry still points at a live copy. + assert_eq!( + cargo_config::read_patch_entries(root).await["cfg-if"] + .path + .as_deref(), + Some(copy_rel().as_str()) + ); + assert!( + root.join(copy_rel()).join("src/lib.rs").exists(), + "the copy the restored config entry points at must survive the unwind" + ); + } + + /// AUDIT B2: a lock resolving the SAME name+version from multiple sources + /// (registry + same-version git fork — a legal, cargo-generated shape) + /// must be refused: consumers' `dependencies` arrays disambiguate those + /// entries with full package-id strings, which detaching + /// `source`/`checksum` dangles (real-cargo verified: the next + /// `cargo build --locked` fails with "cannot update the lock file"). + #[tokio::test] + async fn test_refuses_same_version_multi_source_lock() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let lock = format!( + "version = 4\n\n\ + [[package]]\nname = \"a\"\nversion = \"0.1.0\"\ndependencies = [\n \"cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)\",\n]\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{SOURCE}\"\nchecksum = \"{CHECKSUM}\"\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"git+https://example.com/fork/cfg-if#abcdef\"\n" + ); + tokio::fs::write(root.join("Cargo.lock"), &lock).await.unwrap(); + + let detail = expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "locked_multi_source_conflict", + ); + assert!(detail.contains("cfg-if"), "{detail}"); + // Refused before any write. + assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); + assert!(!root.join(".cargo").exists()); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock, + "the multi-source lock must be byte-identical after the refusal" + ); + } + + /// AUDIT B4 (security_scratch_audit.rs REPRO 3): a user-authored entry + /// whose path merely TRAVERSES a foreign checkout's + /// `.socket/vendor/cargo/` is user-authored — vendor must refuse up + /// front, never silently rewrite (or later delete) it. + #[tokio::test] + async fn test_refuses_user_entry_through_foreign_socket_dir() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + tokio::fs::create_dir_all(root.join(".cargo")).await.unwrap(); + let user_cfg = format!( + "[patch.crates-io]\ncfg-if = {{ path = \"../shared-fork/.socket/vendor/cargo/{UUID2}/cfg-if-1.0.4\" }}\n" + ); + tokio::fs::write(root.join(".cargo/config.toml"), &user_cfg) + .await + .unwrap(); + + expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "user_authored_patch_entry", + ); + assert_eq!( + tokio::fs::read_to_string(root.join(".cargo/config.toml")) + .await + .unwrap(), + user_cfg, + "the user's entry must be byte-identical after the refusal" + ); + assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock_body() + ); + } + #[tokio::test] async fn test_in_sync_rerun_is_byte_stable() { let (dir, blobs, pristine, record) = fixture().await; @@ -1767,10 +2059,88 @@ mod tests { )), ) .await; - expect_refused(outcome, "vendor_prebuilt_required"); + expect_refused(outcome, "vendor_prebuilt_integrity_mismatch"); assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); } + /// AUDIT B3: bytes that fail integrity verification are an active tamper + /// signal — the DEFAULT `auto` mode must hard-fail exactly like `service` + /// mode, never quietly warn and build locally (the module contract: + /// "IntegrityMismatch → ALWAYS a hard error regardless of mode"). + #[tokio::test] + async fn service_integrity_mismatch_auto_mode_hard_fails() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let crate_tgz = make_crate_tgz("cfg-if-1.0.4", &[("src/lib.rs", PATCHED)]); + let wrong = sri_sha512(b"different bytes"); + let server = wiremock::MockServer::start().await; + mount_cargo_granted(&server, &wrong, &crate_tgz).await; + let sources = PatchSources::blobs_only(&blobs); + + let outcome = vendor_cargo_crate( + PURL, + &pristine, + root, + &record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + Some(&cargo_service_cfg(&server.uri(), VendorSource::Auto, false)), + ) + .await; + expect_refused(outcome, "vendor_prebuilt_integrity_mismatch"); + assert!( + !copy_lib(root).exists(), + "must not fall back to a local build on tampered bytes" + ); + assert!(!root.join(".socket/vendor").exists(), "no vendor debris"); + assert!(!root.join(".cargo").exists(), "nothing wired"); + } + + /// AUDIT B5: the service-mode layout-mismatch hard failure must not leave + /// an empty `.socket/vendor/cargo//` husk (nor the vendor parents + /// this run created) behind for the user to commit. + #[tokio::test] + async fn service_layout_mismatch_service_mode_leaves_no_husk() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + // The crate extracts fine but carries the patched file at the wrong + // path → the extracted-tree afterHash check fails. + let crate_tgz = make_crate_tgz("cfg-if-1.0.4", &[("src/other.rs", PATCHED)]); + let sri = sri_sha512(&crate_tgz); + let server = wiremock::MockServer::start().await; + mount_cargo_granted(&server, &sri, &crate_tgz).await; + let sources = PatchSources::blobs_only(&blobs); + + let outcome = vendor_cargo_crate( + PURL, + &pristine, + root, + &record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + Some(&cargo_service_cfg( + &server.uri(), + VendorSource::Service, + false, + )), + ) + .await; + expect_refused(outcome, "vendor_prebuilt_required"); + assert!( + !root.join(format!(".socket/vendor/cargo/{UUID}")).exists(), + "no empty uuid husk after the hard failure" + ); + assert!( + !root.join(".socket/vendor").exists(), + "the vendor levels created by this failed run are pruned" + ); + assert!(!root.join(".cargo").exists()); + } + /// `auto` + a not-built service status falls back to the local build (which /// copies the pristine source + patches it). #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/cargo_config.rs b/crates/socket-patch-core/src/vendor/cargo_config.rs index d2557ba1..e39f5467 100644 --- a/crates/socket-patch-core/src/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/vendor/cargo_config.rs @@ -8,12 +8,14 @@ //! user's existing formatting + comments via `toml_edit`. //! //! ## Ownership model (no sidecar manifest) -//! A `[patch.crates-io]` entry is *socket-owned* iff its `path` value lies -//! under `.socket/vendor/cargo/` (this backend's committed copies) **or** the +//! A `[patch.crates-io]` entry is *socket-owned* iff its `path` value is a +//! root-anchored relative path (not absolute, no `..`) under THIS project's +//! `.socket/vendor/cargo/` (this backend's committed copies) **or** the //! legacy `.socket/cargo-patches/` (the retired `[patch]`-redirect backend) — //! recognising the legacy prefix lets vendor take over / clean up entries left //! by old releases instead of refusing them as user-authored. Anything else — -//! a `git`/`registry` source, or a `path` pointing elsewhere — is +//! a `git`/`registry` source, or a `path` pointing elsewhere (including one +//! that merely traverses a *foreign* checkout's `.socket/vendor/cargo/`) — is //! user-authored and is never modified or removed. The path prefix is the //! entire ownership signal; there is no `managed.json`. //! @@ -175,17 +177,34 @@ async fn edit_config( // ── pure transforms ────────────────────────────────────────────────────────── -/// True if a `[patch]` `path` value lies under a socket-owned prefix -/// ([`CARGO_VENDOR_DIR`] or the legacy [`LEGACY_CARGO_PATCHES_DIR`]). +/// True if a `[patch]` `path` value denotes one of THIS project's +/// socket-owned copies: a relative path that escapes nothing (not absolute, +/// no `..` segment) and sits under [`CARGO_VENDOR_DIR`] or the legacy +/// [`LEGACY_CARGO_PATCHES_DIR`]. Cargo resolves relative `[patch]` paths +/// against the project root, so only a root-anchored relative prefix can be a +/// copy this backend wrote — a path that merely *traverses* some other +/// checkout's `.socket/vendor/cargo/` (`../shared/.socket/vendor/cargo/…`, +/// `/abs/.socket/vendor/cargo/…`, `sub/.socket/vendor/cargo/…`) is +/// user-authored and must never be rewritten or removed. fn path_is_socket_owned(path: &str) -> bool { let norm = path.replace('\\', "/"); - for dir in [CARGO_VENDOR_DIR, LEGACY_CARGO_PATCHES_DIR] { - let prefix = format!("{dir}/"); - if norm.starts_with(&prefix) || norm.contains(&format!("/{prefix}")) { - return true; - } - } - false + if norm.starts_with('/') { + return false; // absolute (also covers //unc-style prefixes) + } + if norm.as_bytes().get(1) == Some(&b':') { + return false; // Windows drive-letter absolute (C:/…) + } + let segments: Vec<&str> = norm + .split('/') + .filter(|s| !s.is_empty() && *s != ".") + .collect(); + if segments.contains(&"..") { + return false; + } + [CARGO_VENDOR_DIR, LEGACY_CARGO_PATCHES_DIR].iter().any(|dir| { + let prefix: Vec<&str> = dir.split('/').collect(); + segments.len() > prefix.len() && segments[..prefix.len()] == prefix[..] + }) } /// The `path` string of a `[patch]` entry (inline table or sub-table), if any. @@ -297,8 +316,7 @@ mod tests { #[test] fn test_is_socket_owned() { assert!(path_is_socket_owned(&vendor_path("cfg-if", "1.0.4"))); - assert!(path_is_socket_owned("./.socket/vendor/cargo/u/x-1.0.0")); // contains "/.socket/…" - assert!(path_is_socket_owned("sub/.socket/vendor/cargo/u/x-1.0.0")); + assert!(path_is_socket_owned("./.socket/vendor/cargo/u/x-1.0.0")); // "." segment normalised assert!(path_is_socket_owned(r".socket\vendor\cargo\u\x-1.0.0")); // backslash normalised // Legacy redirect copies are recognised as ours (takeover / cleanup). assert!(path_is_socket_owned(".socket/cargo-patches/cfg-if-1.0.0")); @@ -311,6 +329,62 @@ mod tests { assert!(!path_is_socket_owned(".socket/vendor/npm/u/x.tgz")); } + /// AUDIT B4: only a root-anchored relative path can be a copy this + /// backend wrote (cargo resolves relative `[patch]` paths against the + /// project root). A path that merely TRAVERSES some other checkout's + /// `.socket/vendor/cargo/` — via `..`, an absolute prefix, or a nested + /// sub-checkout — is user-authored and must never be classified ours. + #[test] + fn test_foreign_socket_paths_are_user_authored() { + // Sibling checkout, reached with `..`. + assert!(!path_is_socket_owned( + "../other-checkout/.socket/vendor/cargo/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/x-1.0.0" + )); + // Absolute paths (unix, and Windows drive-letter form). + assert!(!path_is_socket_owned( + "/home/u/shared/.socket/vendor/cargo/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/x-1.0.0" + )); + assert!(!path_is_socket_owned( + r"C:\shared\.socket\vendor\cargo\u\x-1.0.0" + )); + // A `..` INSIDE the owned prefix escapes it. + assert!(!path_is_socket_owned(".socket/vendor/cargo/../../../etc")); + assert!(!path_is_socket_owned( + ".socket/cargo-patches/../../secrets" + )); + // A nested sub-checkout's socket dir is not THIS project's. + assert!(!path_is_socket_owned("sub/.socket/vendor/cargo/u/x-1.0.0")); + // The bare owned dir itself (no copy segment) is not an entry we write. + assert!(!path_is_socket_owned(".socket/vendor/cargo")); + } + + /// AUDIT B4: a user-authored entry pointing through a foreign checkout's + /// socket dir must be a remove no-op — never deleted on revert. + #[test] + fn test_remove_foreign_socket_path_entry_is_noop() { + let toml = "[patch.crates-io]\ncfg-if = { path = \"../other-checkout/.socket/vendor/cargo/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/cfg-if-1.0.4\" }\n"; + let out = remove_patch_entry(toml, "cfg-if").unwrap(); + assert!( + out.is_none(), + "user entry must be a no-op, but it was removed: {out:?}" + ); + } + + /// AUDIT B4: ...and an upsert over it must refuse, never overwrite. + #[test] + fn test_upsert_refuses_foreign_socket_path_entry() { + let toml = "[patch.crates-io]\ncfg-if = { path = \"../shared-fork/.socket/vendor/cargo/99999999-9999-9999-9999-999999999999/cfg-if-1.0.4\" }\n"; + assert!( + upsert_patch_entry(toml, "cfg-if", &vendor_path("cfg-if", "1.0.4")).is_err(), + "foreign-checkout entry must refuse the overwrite" + ); + let toml = "[patch.crates-io]\ncfg-if = { path = \"/abs/.socket/vendor/cargo/99999999-9999-9999-9999-999999999999/cfg-if-1.0.4\" }\n"; + assert!( + upsert_patch_entry(toml, "cfg-if", &vendor_path("cfg-if", "1.0.4")).is_err(), + "absolute-path entry must refuse the overwrite" + ); + } + // ── upsert ─────────────────────────────────────────────────────── #[test] fn test_upsert_into_empty_creates_entry() { diff --git a/crates/socket-patch-core/src/vendor/cargo_lock.rs b/crates/socket-patch-core/src/vendor/cargo_lock.rs index 8cb5f4e1..b3eaf5ee 100644 --- a/crates/socket-patch-core/src/vendor/cargo_lock.rs +++ b/crates/socket-patch-core/src/vendor/cargo_lock.rs @@ -10,6 +10,14 @@ //! unlocked, claims 2/4) and the `dependencies` arrays reference the crate by //! plain name, so nothing else needs rewriting (claim 8). //! +//! Claim 8 holds only while `name`+`version` is unique in the lock. When the +//! same name+version resolves from MULTIPLE sources (a registry entry plus a +//! same-version git fork — a legal, cargo-generated shape), consumers' +//! `dependencies` arrays disambiguate with FULL package-id strings +//! (`"cfg-if 1.0.0 (registry+…)"`); detaching `source`/`checksum` from one +//! entry dangles those references and breaks `--locked` builds. Vendor +//! refuses that shape upstream via [`count_lock_entries`]. +//! //! The lock is generated-but-committed, so edits are text-preserving //! (`toml_edit`): untouched entries, the `@generated` header comment, and the //! `version = 4` line keep their exact bytes — zero formatting churn in the @@ -201,6 +209,28 @@ pub async fn read_locked_versions(project_root: &Path) -> Option usize { + let Ok((_path, doc)) = read_lock(project_root).await else { + return 0; + }; + let Some(pkgs) = doc.get("package").and_then(Item::as_array_of_tables) else { + return 0; + }; + pkgs.iter() + .filter(|t| { + t.get("name").and_then(Item::as_str) == Some(name) + && t.get("version").and_then(Item::as_str) == Some(version) + }) + .count() +} + #[cfg(test)] mod tests { use super::*; @@ -583,6 +613,37 @@ mod tests { ); } + /// AUDIT B2 helper: the same name+version under multiple sources must be + /// counted, so vendor can refuse the lock shape whose `dependencies` + /// arrays reference entries by full package-id string. + #[tokio::test] + async fn count_lock_entries_sees_multi_source_duplicates() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write( + dir.path().join("Cargo.lock"), + format!( + "version = 4\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{SOURCE}\"\nchecksum = \"{CHECKSUM}\"\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"git+https://example.com/fork/cfg-if#abcdef\"\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\nsource = \"{SOURCE}\"\n" + ), + ) + .await + .unwrap(); + assert_eq!(count_lock_entries(dir.path(), "cfg-if", "1.0.4").await, 2); + assert_eq!(count_lock_entries(dir.path(), "cfg-if", "0.1.10").await, 1); + assert_eq!(count_lock_entries(dir.path(), "cfg-if", "9.9.9").await, 0); + + // Missing / unparseable locks count zero (the caller's cross-check is + // skipped, matching read_locked_versions). + let empty = tempfile::tempdir().unwrap(); + assert_eq!(count_lock_entries(empty.path(), "cfg-if", "1.0.4").await, 0); + tokio::fs::write(empty.path().join("Cargo.lock"), "[[[ nope") + .await + .unwrap(); + assert_eq!(count_lock_entries(empty.path(), "cfg-if", "1.0.4").await, 0); + } + #[tokio::test] async fn edits_leave_no_stage_litter() { let dir = fixture().await; diff --git a/crates/socket-patch-core/src/vendor/service_fetch.rs b/crates/socket-patch-core/src/vendor/service_fetch.rs index e2e0bbd5..33d24a2c 100644 --- a/crates/socket-patch-core/src/vendor/service_fetch.rs +++ b/crates/socket-patch-core/src/vendor/service_fetch.rs @@ -171,10 +171,16 @@ pub(crate) async fn service_archive_copy( )); ServiceCopy::Used(archive.bytes) } - ServiceArtifact::IntegrityMismatch(reason) => miss( - warnings, + // Bytes that fail integrity verification are an active tamper signal: + // ALWAYS a hard error, in `auto` exactly as in `service` — never a + // quiet local-build fallback ([`ServiceArtifact`]'s documented + // contract). + ServiceArtifact::IntegrityMismatch(reason) => hard( "vendor_prebuilt_integrity_mismatch", - format!("prebuilt {noun} failed integrity ({reason})"), + format!( + "prebuilt {noun} failed integrity verification ({reason}); \ + refusing to fall back to a local build on tampered bytes" + ), ), ServiceArtifact::Pending => miss( warnings, @@ -330,6 +336,32 @@ mod tests { )); } + /// AUDIT B3: IntegrityMismatch is a hard error in EVERY mode — under the + /// default `auto` the Tier-A copy must refuse, never fall back to a local + /// rebuild on tampered bytes (the enum's own contract: "never fall back"). + #[tokio::test] + async fn service_copy_integrity_mismatch_auto_hard_fails() { + let server = MockServer::start().await; + let body = b"the real bytes"; + let wrong = PackedTarball::from_bytes(b"completely different bytes").integrity; + mount_granted(&server, &wrong, body).await; + let mut cfg = cfg_for(&server); + cfg.source = VendorSource::Auto; + let mut warnings = Vec::new(); + match service_archive_copy(Some(&cfg), UUID, "x", ".jar", &mut warnings).await { + ServiceCopy::HardFail(outcome) => match *outcome { + VendorOutcome::Refused { code, .. } => { + assert_eq!(code, "vendor_prebuilt_integrity_mismatch"); + } + other => panic!("expected Refused, got {other:?}"), + }, + ServiceCopy::Used(_) => panic!("tampered bytes must never be used"), + ServiceCopy::FallBack => { + panic!("auto fell back to a local build on tampered bytes") + } + } + } + /// `--vendor-source=service --offline` is a fail-closed refusal (the same /// `vendor_service_offline_conflict` the other backends give via /// `service_offline_conflict`), never a silent local-build fallback — From a6f2b6d59a04e3dc3b86d50cad80dbfda9f00caf Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 14 Aug 2026 09:33:18 -0700 Subject: [PATCH 2/2] fix(vendor): make the stage swap non-destructive Review follow-up on the B1 fix: swap_stage_into_place removed the old copy and then renamed the stage over it, so a swap failure (a partial remove_dir_all is realistic under Windows file locks) could strand a half-deleted live-wired copy - and the failure handler then deleted the fully-verified stage too, leaving less recoverable state than either tree while [patch.crates-io] and the detached lock still pointed at the wreckage. The swap now parks the old copy at a .socket-old sibling with an atomic same-dir rename, renames the stage into place, and only then deletes the backup; if the stage rename fails the backup is renamed straight back, so the previous copy survives every failure mode. A stale parked backup from an interrupted swap is cleared before the park rename so re-runs cannot trip over it. Regression tests cover the failed-swap restore, the success path (including a stale parked backup), the first-time vacant-path swap, and backup-litter absence after a failed rebuild. Co-authored-by: Claude Fable 5 --- crates/socket-patch-core/src/vendor/cargo.rs | 144 ++++++++++++++++--- 1 file changed, 128 insertions(+), 16 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 0dd5e16a..469a4f37 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -80,31 +80,68 @@ async fn wiring_in_sync(project_root: &Path, name: &str, version: &str, copy_rel ) } -/// The staging sibling for a copy dir: `/-.socket-stage`. -/// Rebuilds are materialised here and swapped into place only on success, so -/// a failure can never destroy a pre-existing (possibly live-wired) copy. -/// Same directory as the copy → the swap is a real rename, never a cross- -/// device copy. The `.socket-stage` suffix can never collide with a copy dir: +/// A swap sibling for a copy dir: `/-`. Same +/// directory as the copy → every swap step is a real rename, never a +/// cross-device copy. The suffixes can never collide with a copy dir: /// `` is a validated single segment and cargo versions never end in -/// `.socket-stage`. -fn stage_dir_for(copy_dir: &Path) -> std::path::PathBuf { +/// `.socket-stage` / `.socket-old`. +fn swap_sibling_for(copy_dir: &Path, suffix: &str) -> std::path::PathBuf { let name = copy_dir .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "copy".to_string()); match copy_dir.parent() { - Some(parent) => parent.join(format!("{name}.socket-stage")), - None => copy_dir.join(".socket-stage"), + Some(parent) => parent.join(format!("{name}{suffix}")), + None => copy_dir.join(suffix), } } -/// Swap a fully-built stage into place: remove the old copy (if any), then -/// rename the stage over it. The rename is same-directory and only happens -/// after the stage passed every check, so the destroy-then-replace window is -/// as small as the filesystem allows. +/// The staging sibling for a copy dir: `/-.socket-stage`. +/// Rebuilds are materialised here and swapped into place only on success, so +/// a failure can never destroy a pre-existing (possibly live-wired) copy. +fn stage_dir_for(copy_dir: &Path) -> std::path::PathBuf { + swap_sibling_for(copy_dir, ".socket-stage") +} + +/// The backup sibling the old copy is parked at mid-swap: +/// `/-.socket-old`. +fn backup_dir_for(copy_dir: &Path) -> std::path::PathBuf { + swap_sibling_for(copy_dir, ".socket-old") +} + +/// Swap a fully-built stage into place without a destructive window: park the +/// old copy (if any) at `.socket-old` with a same-dir rename, rename the +/// stage over the now-vacant copy path, and only then delete the backup. Every +/// step is a single atomic rename — unlike a remove-then-rename swap (where a +/// partial `remove_dir_all`, realistic under Windows file locks, strands a +/// half-deleted copy) no step can leave less recoverable state than it started +/// with. If the stage rename fails the backup is renamed straight back; should +/// even that restore fail (an external process racing the uuid dir), the old +/// copy still exists intact at `.socket-old` instead of being destroyed. async fn swap_stage_into_place(stage: &Path, copy_dir: &Path) -> std::io::Result<()> { - remove_tree(copy_dir).await?; - tokio::fs::rename(stage, copy_dir).await + let backup = backup_dir_for(copy_dir); + // A stale backup (crash mid-swap on an earlier run) would make the + // park rename fail; `remove_tree` is a no-op when it is absent. + remove_tree(&backup).await?; + let had_old = match tokio::fs::rename(copy_dir, &backup).await { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => return Err(e), + }; + match tokio::fs::rename(stage, copy_dir).await { + Ok(()) => { + if had_old { + let _ = remove_tree(&backup).await; + } + Ok(()) + } + Err(e) => { + if had_old { + let _ = tokio::fs::rename(&backup, copy_dir).await; + } + Err(e) + } + } } /// Best-effort removal of an EMPTY `/` dir plus the empty @@ -1314,15 +1351,90 @@ mod tests { lock1, "lock untouched" ); - // And the failed rebuild's stage never leaks into the uuid dir. + // And the failed rebuild's swap siblings never leak into the uuid dir. let uuid_dir = root.join(format!(".socket/vendor/cargo/{UUID}")); let mut rd = tokio::fs::read_dir(&uuid_dir).await.unwrap(); while let Some(e) = rd.next_entry().await.unwrap() { let n = e.file_name().to_string_lossy().into_owned(); assert!(!n.contains("socket-stage"), "stage litter: {n}"); + assert!(!n.contains("socket-old"), "backup litter: {n}"); } } + /// REVIEW must-fix (B1 follow-up): the swap itself must never leave less + /// recoverable state than it started with. Force the stage rename to fail + /// (stage absent — the same io::Error surface as a Windows file lock) + /// with a live copy in place: the old copy must be restored + /// byte-identical, with no backup parked beside it. + #[tokio::test] + async fn test_swap_failure_restores_previous_copy() { + let dir = tempfile::tempdir().unwrap(); + let copy = dir.path().join("cfg-if-1.0.4"); + tokio::fs::create_dir_all(copy.join("src")).await.unwrap(); + tokio::fs::write(copy.join("src/lib.rs"), b"live\n") + .await + .unwrap(); + + let stage = stage_dir_for(©); + assert!( + swap_stage_into_place(&stage, ©).await.is_err(), + "swapping a missing stage must fail" + ); + assert_eq!( + tokio::fs::read(copy.join("src/lib.rs")).await.unwrap(), + b"live\n", + "the previous copy must be restored after a failed swap" + ); + assert!(!backup_dir_for(©).exists(), "no parked backup litter"); + } + + /// A successful swap replaces the old copy with the stage and leaves + /// neither a stage nor a parked backup behind — including when a stale + /// backup from an earlier interrupted swap is already parked there. + #[tokio::test] + async fn test_swap_success_replaces_copy_without_litter() { + let dir = tempfile::tempdir().unwrap(); + let copy = dir.path().join("cfg-if-1.0.4"); + tokio::fs::create_dir_all(copy.join("src")).await.unwrap(); + tokio::fs::write(copy.join("src/lib.rs"), b"old\n") + .await + .unwrap(); + let stage = stage_dir_for(©); + tokio::fs::create_dir_all(stage.join("src")).await.unwrap(); + tokio::fs::write(stage.join("src/lib.rs"), b"new\n") + .await + .unwrap(); + let stale_backup = backup_dir_for(©); + tokio::fs::create_dir_all(&stale_backup).await.unwrap(); + tokio::fs::write(stale_backup.join("husk.rs"), b"stale\n") + .await + .unwrap(); + + swap_stage_into_place(&stage, ©).await.unwrap(); + assert_eq!( + tokio::fs::read(copy.join("src/lib.rs")).await.unwrap(), + b"new\n" + ); + assert!(!stage.exists(), "stage consumed by the swap"); + assert!(!stale_backup.exists(), "backup removed after the swap"); + } + + /// First-time swap: no pre-existing copy to park. The stage still lands + /// at the copy path. + #[tokio::test] + async fn test_swap_into_vacant_copy_path() { + let dir = tempfile::tempdir().unwrap(); + let copy = dir.path().join("cfg-if-1.0.4"); + let stage = stage_dir_for(©); + tokio::fs::create_dir_all(&stage).await.unwrap(); + tokio::fs::write(stage.join("lib.rs"), b"new\n").await.unwrap(); + + swap_stage_into_place(&stage, ©).await.unwrap(); + assert_eq!(tokio::fs::read(copy.join("lib.rs")).await.unwrap(), b"new\n"); + assert!(!backup_dir_for(©).exists()); + assert!(!stage.exists()); + } + /// AUDIT B1 (same destroy class, fresh path): when the pre-existing /// config entry already points at THIS copy (wiring out of sync only /// because the lock went corrupt post-vendor), a detach failure's unwind