From 841f8a4a9f4c2ac832c9bd498b8322046b7c66d1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 18 Aug 2026 20:48:03 -0400 Subject: [PATCH 1/3] =?UTF-8?q?test(vendor):=20RED=20=E2=80=94=20drift-ski?= =?UTF-8?q?pped=20revert=20must=20keep=20artifacts=20+=20ledger=20(residua?= =?UTF-8?q?l=20#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip the deliberate #131 pinning test classic_vendor_revert_after_hosted_overlay_is_drift_skipped_and_lossy into the fixed contract (..._keeps_blob): when every wiring record is drift-skipped ("left alone"), the blob dir must SURVIVE the revert and the keep must be surfaced (vendor_artifact_kept) — plus a recovery leg proving a second revert completes once the drift is undone. Same contract asserted across the npm-family twins' existing drift tests (npm_lock / pnpm_lock / yarn_berry_lock / bun_lock / yarn_classic_lock: revert_leaves_drifted_* now require the artifact dir kept + the keep warning), two new CLI orchestration tests in in_process_vendor.rs (run_revert and reconcile must retain the state.json entry, record a COUNTED Skipped vendor_revert_kept — summary.skipped was 0 even when the skip event was emitted — and never a Removed), and a new yarn-classic e2e capstone leg (vendored -> hosted overlay -> vendor --revert keeps the tarball, the kept tarball still installs after the drift is undone, and the follow-up revert completes to pristine). RED: 6 core + 2 CLI + 1 e2e tests fail on current main behavior (remove_tree runs unconditionally after the restore loop; success prunes the ledger entry). Co-Authored-By: Claude Fable 5 --- .../tests/e2e_vendor_yarn_classic_dev_flow.rs | 227 ++++++++++++++++++ .../tests/in_process_vendor.rs | 107 +++++++++ .../socket-patch-core/src/vendor/bun_lock.rs | 14 +- .../socket-patch-core/src/vendor/npm_lock.rs | 20 +- .../socket-patch-core/src/vendor/pnpm_lock.rs | 20 +- .../src/vendor/yarn_berry_lock.rs | 16 +- .../src/vendor/yarn_classic_lock.rs | 20 +- .../src/vendor/yarn_layering_tests.rs | 66 +++-- 8 files changed, 456 insertions(+), 34 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_dev_flow.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_dev_flow.rs index b9086494..125b38ef 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_dev_flow.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_dev_flow.rs @@ -456,3 +456,230 @@ fn yarn_classic_vendored_lock_survives_dev_install_resave() { ); eprintln!("FROZEN RE-ENTRY OK"); } + +// ── drift-skipped revert keeps artifacts (residual #131, E1 flow) ───── + +/// The strapi E1 flow, end-to-end with real yarn classic: vendored wiring, +/// then a hosted overlay re-resolves the lock block to a +/// `patch.socket.dev` URL (any resolution outside our uuid dir fails the +/// revert's ownership gate the same way), then `vendor --revert`. +/// +/// Contract (residual #131 fixed): the revert drift-skips the lock restore +/// — and must then ALSO keep the vendored artifacts and the ledger entry, +/// reporting a counted `Skipped` (`vendor_revert_kept`) instead of a +/// `Removed`. Previously it deleted `.socket/vendor/npm//` and pruned +/// state.json while yarn.lock stayed pointed at the hosted URL, destroying +/// the only copy of the tarball a recovery needs. The remediation legs +/// prove the point: undo the drift (restore the vendored lock — what a +/// future hosted revert would do by replaying its recorded originals) and +/// (a) a plain `yarn install` still delivers the patched bytes from the +/// KEPT tarball, (b) a second `vendor --revert` now completes fully and +/// the pristine registry lock installs again. +#[test] +fn yarn_classic_drift_skipped_revert_keeps_artifacts_and_recovers() { + if !has_corepack_pm(YARN_CLASSIC) { + println!( + "SKIP yarn_classic_drift_skipped_revert_keeps_artifacts_and_recovers: \ + `corepack {YARN_CLASSIC}` unavailable" + ); + return; + } + + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("package.json"), + format!( + r#"{{"name":"yarn-classic-drift-revert","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# + ), + ) + .unwrap(); + + // 1. Real fixture install (network allowed; private cache). + let cache = tmp.path().join("yarn-cache"); + let install = corepack( + &proj, + YARN_CLASSIC, + &["install", "--no-progress"], + &[("YARN_CACHE_FOLDER", cache.to_str().unwrap())], + ); + if !install.status.success() { + println!( + "SKIP yarn_classic_drift_skipped_revert_keeps_artifacts_and_recovers: fixture \ + `yarn install` failed (registry unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return; + } + + let installed_index = proj.join("node_modules").join(DEP).join("index.js"); + let orig = std::fs::read(&installed_index).expect("installed index.js"); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); + stage_patch(&proj, &purl, "package/index.js", &orig, &patched); + + let lock_path = proj.join("yarn.lock"); + let lock_pristine = std::fs::read_to_string(&lock_path).expect("yarn.lock after install"); + + // 2. Vendor (offline: blob staged locally). + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); + assert!(proj.join(&tgz_rel).is_file(), "vendored tarball missing"); + let lock_wired = std::fs::read_to_string(&lock_path).unwrap(); + + // 3. Simulate the hosted overlay: the wired `file:` resolution is + // replaced by a hosted patch.socket.dev URL (byte-surgical, exactly + // the strapi layering shape). + let wired_line = lock_wired + .lines() + .find(|l| { + l.trim_start() + .starts_with("resolved \"file:./.socket/vendor/") + }) + .expect("wired lock must carry the vendored resolved line") + .to_owned(); + let hosted_line = format!( + " resolved \"https://patch.socket.dev/patch/npm/{DEP}/{DEP_VERSION}/\ + 11111111-1111-1111-1111-111111111111/{UUID}/{DEP}-{DEP_VERSION}.tgz\"" + ); + let lock_hosted = lock_wired.replace(&wired_line, &hosted_line); + assert_ne!(lock_hosted, lock_wired, "overlay edit must hit"); + std::fs::write(&lock_path, &lock_hosted).unwrap(); + + // 4. `vendor --revert` over the hosted lock: drift-skip AND keep. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--revert", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "drift-skipped revert must exit 0.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("revert --json output is not JSON: {e}\nstdout:\n{stdout}")); + assert_eq!( + env["summary"]["skipped"], 1, + "the keep must be counted as one genuine skip: {env}" + ); + assert_eq!(env["summary"]["removed"], 0, "nothing removed: {env}"); + assert!( + env["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "skipped" && e["errorCode"] == "vendor_revert_kept"), + "counted vendor_revert_kept event expected: {env}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + lock_hosted, + "the hosted lock must be left byte-identical" + ); + assert!( + proj.join(&tgz_rel).is_file(), + "the vendored tarball must be KEPT — it is the only copy a recovery can use" + ); + let state: serde_json::Value = serde_json::from_slice( + &std::fs::read(proj.join(".socket/vendor/state.json")) + .expect("state.json must be kept alongside the artifacts"), + ) + .unwrap(); + assert!( + state["entries"][purl.as_str()].is_object(), + "the ledger entry must be kept: {state}" + ); + eprintln!("DRIFT-SKIP KEEP OK"); + + // 5. Remediation A: undo the drift (restore the vendored lock) — a + // fresh install must deliver the patched bytes from the KEPT tarball. + std::fs::write(&lock_path, &lock_wired).unwrap(); + let nm = proj.join("node_modules"); + std::fs::remove_dir_all(&nm).unwrap(); + let reinstall = corepack( + &proj, + YARN_CLASSIC, + &["install", "--no-progress"], + &[("YARN_CACHE_FOLDER", cache.to_str().unwrap())], + ); + assert!( + reinstall.status.success(), + "install from the kept vendored tarball must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&reinstall.stdout), + String::from_utf8_lossy(&reinstall.stderr), + ); + assert_eq!( + std::fs::read(&installed_index).unwrap(), + patched, + "the kept tarball must still deliver the patched bytes" + ); + eprintln!("KEPT-TARBALL INSTALL OK"); + + // 6. Remediation B: the same `vendor --revert` now completes fully. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--revert", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "completing revert must exit 0.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(env["summary"]["removed"], 1, "revert completes: {env}"); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + lock_pristine, + "the pre-vendor registry lock must be restored byte-for-byte" + ); + assert!( + !proj.join(".socket/vendor").exists(), + "the vendor tree is fully pruned once the revert completes" + ); + + // 7. The restored registry lock still installs cleanly (pristine bytes). + std::fs::remove_dir_all(&nm).unwrap(); + let final_install = corepack( + &proj, + YARN_CLASSIC, + &["install", "--no-progress"], + &[("YARN_CACHE_FOLDER", cache.to_str().unwrap())], + ); + assert!( + final_install.status.success(), + "post-revert registry install must succeed.\nstderr:\n{}", + String::from_utf8_lossy(&final_install.stderr), + ); + assert_eq!( + std::fs::read(&installed_index).unwrap(), + orig, + "the completed revert returns the project to pristine registry bytes" + ); + eprintln!("FULL RECOVERY OK"); +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 226bbaee..eebb68c5 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1956,3 +1956,110 @@ async fn scan_vendor_gem_detached_writes_no_manifest_and_reverts() { ); assert!(!fx.root().join(".socket/vendor").exists()); } + +// ───────────────────────────────────────────────────────────────────── +// 12. drift-skipped revert keeps artifacts + ledger (residual #131) +// ───────────────────────────────────────────────────────────────────── + +/// `vendor --revert` when EVERY recorded lock entry has drifted (a hosted +/// overlay or a registry re-resolve since vendoring — anything failing the +/// uuid-dir ownership gate): the backend leaves the lock alone, so the +/// orchestrator must ALSO keep the artifact dir and the state.json entry, +/// and account for it honestly — a COUNTED `Skipped` (`vendor_revert_kept`), +/// never a `Removed`. Undoing the drift and re-running `--revert` then +/// completes the revert fully. +/// +/// Previously (residual #131) this path deleted the artifact dir and pruned +/// the ledger entry while the lock stayed pointed elsewhere — destroying the +/// only pre-vendor originals a later restore (or the redirect ledger's +/// recorded `original` fragments) could use — and reported plain success +/// with `summary.skipped == 0`. +#[tokio::test] +async fn revert_after_drift_keeps_artifact_and_ledger_then_completes() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); + let vendored_lock = fx.lock_bytes(); + + // Third-party drift: the lock was re-resolved behind our back (same + // ownership-gate outcome as a hosted patch.socket.dev overlay). + std::fs::write(fx.lock_path(), &fx.original_lock).unwrap(); + + let (code, env) = vendor_cli(fx.root(), &["--revert"]); + assert_eq!( + code, 0, + "a drift-skip keep is a warning, not an error: {env:#}" + ); + let kept = find_event(&env, "skipped", Some("vendor_revert_kept")); + assert_eq!(kept["purl"], PURL, "{env:#}"); + assert_eq!( + env["summary"]["skipped"], 1, + "the keep must be COUNTED (one genuine skip, advisory warnings \ + excluded): {env:#}" + ); + assert_eq!( + env["summary"]["removed"], 0, + "nothing was removed, so nothing may be counted removed: {env:#}" + ); + assert!( + events(&env).iter().all(|e| e["action"] != "removed"), + "no Removed event for a kept entry: {env:#}" + ); + + // Artifacts + ledger survive; the drifted lock is left alone. + assert!(fx.tgz_path().is_file(), "artifact tarball must be kept"); + assert!(fx.marker_path().is_file(), "vendor marker must be kept"); + let state: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap(); + assert!( + state["entries"][PURL].is_object(), + "ledger entry must be kept: {state:#}" + ); + assert_eq!(fx.lock_bytes(), fx.original_lock, "drifted lock left alone"); + + // Undo the drift → the same command now completes the revert. + std::fs::write(fx.lock_path(), &vendored_lock).unwrap(); + let (code, env) = vendor_cli(fx.root(), &["--revert"]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(env["summary"]["removed"], 1, "{env:#}"); + assert_eq!( + fx.lock_bytes(), + fx.original_lock, + "lock restored byte-for-byte" + ); + assert!( + !fx.vendor_dir().exists(), + ".socket/vendor fully pruned once the revert completes" + ); +} + +/// The reconcile path (patch dropped from the manifest) runs the same +/// backend revert: a drifted entry must be kept — ledger entry retained, +/// counted `Skipped`, nothing removed — instead of silently pruning the +/// ledger and artifacts out from under the drifted lock. +#[tokio::test] +async fn reconcile_keeps_drifted_entry() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); + + // Drop the patch from the manifest AND drift the lock. + std::fs::write(fx.manifest_path(), b"{\"patches\": {}}\n").unwrap(); + std::fs::write(fx.lock_path(), &fx.original_lock).unwrap(); + + let (code, env) = vendor_cli(fx.root(), &[]); + assert_eq!(code, 0, "reconcile keep must exit 0: {env:#}"); + let kept = find_event(&env, "skipped", Some("vendor_revert_kept")); + assert_eq!(kept["purl"], PURL, "{env:#}"); + assert_eq!(env["summary"]["removed"], 0, "{env:#}"); + assert!( + !events(&env) + .iter() + .any(|e| e["errorCode"] == "vendor_reconciled"), + "a kept entry must not be reported reconciled: {env:#}" + ); + assert!(fx.tgz_path().is_file(), "artifact must be kept"); + let state: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap(); + assert!( + state["entries"][PURL].is_object(), + "ledger entry must be kept: {state:#}" + ); + assert_eq!(fx.lock_bytes(), fx.original_lock, "drifted lock left alone"); +} diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 4434e2c9..22c72216 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -1348,11 +1348,21 @@ mod tests { fx.read_lock().await.contains(drifted_line), "drifted entry left alone" ); + // Residual #131: a drift-skip keeps the artifact dir (the drifted + // entry's recorded original may still be needed later) and says so. assert!( - !fx.root() + fx.root() .join(format!(".socket/vendor/npm/{UUID}")) .exists(), - "artifact still removed" + "drift-skip must keep the artifact dir" + ); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_artifact_kept"), + "the keep must be surfaced: {:?}", + outcome.warnings ); } diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 25a60d31..a07e0bc7 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -2007,10 +2007,22 @@ mod tests { default_lock()["packages"]["node_modules/foo/node_modules/left-pad"], "non-drifted instance restored" ); - assert!(!fx - .root() - .join(format!(".socket/vendor/npm/{UUID}")) - .exists()); + // Residual #131: a drift-skip keeps the artifact dir (the drifted + // entry's recorded original may still be needed later) and says so. + assert!( + fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "drift-skip must keep the artifact dir" + ); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_artifact_kept"), + "the keep must be surfaced: {:?}", + outcome.warnings + ); } #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index db09dfb6..5f88283c 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -3976,10 +3976,22 @@ snapshots: ); // Non-drifted fragments still restored. assert!(after.contains(" left-pad@1.3.0:\n resolution: {integrity: sha512-XI5MPzVN")); - assert!(!fx - .root() - .join(format!(".socket/vendor/npm/{UUID}")) - .exists()); + // Residual #131: a drift-skip keeps the artifact dir (the drifted + // fragment's recorded original may still be needed later) and says so. + assert!( + fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "drift-skip must keep the artifact dir" + ); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_artifact_kept"), + "the keep must be surfaced: {:?}", + outcome.warnings + ); } #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 0e999d4b..66d639a7 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -1709,7 +1709,9 @@ __metadata: outcome.warnings ); // The drifted lock entry stays; the (still-ours) resolutions entry - // was removed; the artifact is gone. + // was removed; the artifact is KEPT (residual #131: the drifted + // entry's recorded original may still be needed later) and the keep + // is surfaced. let after = tokio::fs::read_to_string(fx.lock_path()).await.unwrap(); assert!( after.contains("left-pad@file:") @@ -1719,7 +1721,15 @@ __metadata: let pkg: Value = serde_json::from_slice(&tokio::fs::read(fx.pkg_path()).await.unwrap()).unwrap(); assert!(pkg.get("resolutions").is_none()); - assert!(!fx.tgz_path().exists()); + assert!(fx.tgz_path().exists(), "drift-skip must keep the artifact"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_artifact_kept"), + "the keep must be surfaced: {:?}", + outcome.warnings + ); // Manifest drift: the user repointed the resolutions entry. let fx = fixture().await; @@ -1757,6 +1767,8 @@ __metadata: tokio::fs::read(fx.lock_path()).await.unwrap(), fx.lock_bytes ); + // The manifest drift-skip keeps the artifact too (residual #131). + assert!(fx.tgz_path().exists(), "drift-skip must keep the artifact"); } #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index 8a631361..9be07cdb 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -1414,10 +1414,22 @@ left-pad@^1.3.0: after.contains("left-pad@^1.3.0, left-pad@~1.3.0:\n version \"1.3.0\"\n resolved \"https://registry.yarnpkg.com/"), "non-drifted block restored: {after}" ); - assert!(!fx - .root() - .join(format!(".socket/vendor/npm/{UUID}")) - .exists()); + // Residual #131: a drift-skip keeps the artifact dir (the drifted + // block's recorded original may still be needed later) and says so. + assert!( + fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "drift-skip must keep the artifact dir" + ); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_artifact_kept"), + "the keep must be surfaced: {:?}", + outcome.warnings + ); } #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs index fb31cfe1..a7fd922e 100644 --- a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs +++ b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs @@ -14,9 +14,9 @@ //! actual vendored blob bytes (the chain the forensics verified 48/48); //! 3. hosted-over-vendored layering records the vendored blocks as its //! `original`s (data-level reversibility) — and `vendor --revert` after -//! that overlay is CURRENTLY drift-skipped and lossy (blob deleted, lock -//! left hosted, exit success): pinned here so a future hosted `--revert` -//! must contend with these semantics deliberately; +//! that overlay is drift-skipped AND keeps the blob dir + surfaces the +//! keep (residual #131 fixed: deleting the blob while the lock stayed +//! hosted stranded the redirect ledger's recorded `original`s); //! 4. yarn berry locks containing builtin `patch:` resolution entries pass //! through both the vendor backend and the hosted redirect rewriter with //! those entries byte-identical — even when the redirected package IS the @@ -487,26 +487,28 @@ async fn classic_hosted_redirect_layers_over_vendored_wiring() { assert!(again.edits.is_empty(), "{:?}", again.edits); } -/// Incident guard 3, reverse direction — PINS CURRENT (lossy) BEHAVIOR: -/// `vendor --revert` after a hosted overlay finds every block re-resolved -/// (the hosted URL fails the `.socket/vendor/npm/` ownership gate), -/// warns `vendor_lock_entry_drifted`, leaves the lock byte-identical at the -/// hosted URLs — yet still DELETES the blob dir and reports success. If a -/// hosted --revert ships later and restores its recorded originals (the -/// vendored `file:` blocks), the lock would point at blobs this path already -/// deleted. A deliberate behavior change here should update this test. +/// Incident guard 3, reverse direction — the drift-skip KEEP contract +/// (residual #131, fixed): `vendor --revert` after a hosted overlay finds +/// every block re-resolved (the hosted URL fails the +/// `.socket/vendor/npm/` ownership gate), warns +/// `vendor_lock_entry_drifted`, leaves the lock byte-identical at the +/// hosted URLs — and KEEPS the blob dir, surfacing the keep honestly +/// (`vendor_artifact_kept`). The blob is the only surviving copy of what +/// the redirect ledger's recorded `original` (`file:` fragment) points at: +/// deleting it while claiming success planted a dangling replay hazard and +/// destroyed the pre-vendor originals a later restore needs. /// -/// RED-verified: asserting the lock was restored (registry URLs) fails; -/// asserting the blob survives fails. +/// This test previously PINNED the lossy behavior (blob deleted); it was +/// flipped when the drift-skip keep shipped. #[tokio::test] -async fn classic_vendor_revert_after_hosted_overlay_is_drift_skipped_and_lossy() { +async fn classic_vendor_revert_after_hosted_overlay_is_drift_skipped_and_keeps_blob() { let fx = classic_fx(CLASSIC_BEFORE); let entry = expect_done(fx.vendor().await).unwrap(); let vendored_text = fx.lock_text(); // Layer the hosted redirect over the vendored lock, on disk. let mut files = BTreeMap::new(); - files.insert("yarn.lock".to_string(), vendored_text); + files.insert("yarn.lock".to_string(), vendored_text.clone()); let result = rewrite_registry_redirect(&files, &[hosted_override()]); let hosted_text = result.files.get("yarn.lock").unwrap().clone(); std::fs::write(fx.lock_path(), hosted_text.as_bytes()).unwrap(); @@ -514,7 +516,7 @@ async fn classic_vendor_revert_after_hosted_overlay_is_drift_skipped_and_lossy() let outcome: RevertOutcome = revert_yarn_classic(&entry, fx.root(), false).await; - // CURRENT semantics, all four legs deliberate: + // The contract, all five legs deliberate: // 1. warning-only success (exit 0 at the CLI layer); assert!(outcome.success, "{:?}", outcome.error); // 2. per-block drift warning naming the key; @@ -535,12 +537,40 @@ async fn classic_vendor_revert_after_hosted_overlay_is_drift_skipped_and_lossy() hosted_text, "drift-skip must leave the hosted lock untouched" ); - // 4. the blob dir is deleted anyway — the lossy half. + // 4. the blob dir SURVIVES — "left alone" now holds end-to-end. + assert!( + fx.tgz_path().exists(), + "drift-skip must keep the blob: it is the only copy the redirect \ + ledger's recorded `original` still points at" + ); + // 5. the keep is surfaced honestly, so the caller (CLI) knows to keep + // the ledger entry too instead of pruning it. + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_artifact_kept"), + "keeping the artifact must be surfaced as a warning: {:?}", + outcome.warnings + ); + + // Recovery path: once the drift is undone (the vendored lock is + // restored — e.g. a hosted revert replayed its recorded originals), + // a second `vendor --revert` completes fully: lock back to the + // registry blocks, blob dir gone. + std::fs::write(fx.lock_path(), vendored_text.as_bytes()).unwrap(); + let outcome: RevertOutcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + fx.lock_text(), + CLASSIC_BEFORE, + "after the drift is undone, revert restores the pre-vendor lock" + ); assert!( !fx.root() .join(format!(".socket/vendor/npm/{UUID}")) .exists(), - "current behavior deletes the artifact dir even when every block drifted" + "the completed revert removes the artifact dir" ); } From 7a2013f1089774adea5c06f7774733d334ffe73a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 18 Aug 2026 20:54:46 -0400 Subject: [PATCH 2/3] fix(vendor): keep artifacts and ledger when revert drift-skips the lock restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Residual #131 (strapi campaign, adversarially re-confirmed on main): when vendor --revert found every recorded lock entry re-resolved (a hosted patch.socket.dev overlay, a registry re-resolve — anything failing the uuid-dir ownership gate), revert_recorded_block correctly left the lock alone with vendor_lock_entry_drifted warnings, but the backend still ran remove_tree(.socket/vendor/npm//) unconditionally after the restore loop, and run_revert/reconcile_dropped treated the warning-only success as "removed": PatchAction::Removed + state.entries.remove + save_state. End state: the lock still pointed at the drifted target while the artifacts, the ledger entry holding the only pre-vendor originals, and the tarball the redirect ledger's recorded `original` fragments reference were all destroyed — a silent-success data loss with a delayed broken-install hazard. Fix, per the orphan sweep's invariant (never delete what something still references): - RevertOutcome grows `kept_artifact` plus drift_skipped()/keep_artifact() helpers (vendor/mod.rs). keep_artifact() pushes an honest `vendor_artifact_kept` warning naming the kept dir and the remediation. - All five npm-family backends with the unconditional remove shape (yarn_classic_lock, npm_lock, pnpm_lock, yarn_berry_lock, bun_lock) now skip the remove_tree and mark the keep whenever any wiring record was drift-skipped. - run_revert and reconcile_dropped (cli vendor.rs) honor the signal: the state.json entry is retained and the outcome is a COUNTED Skipped event (`vendor_revert_kept`) recorded via env.record — fixing the sub-bug where summary.skipped stayed 0 while skip events were emitted — never a Removed; counts stay uninflated (#166 precedent: advisory warnings keep bypassing the counters). Human output gains a "Kept N drifted package(s)" line. - Other RevertOutcome construction sites gain `kept_artifact: false` (no behavior change outside the npm family). Once the drift is undone (vendored lock restored — e.g. a future hosted revert replaying its recorded originals), the next `vendor --revert` completes exactly as before: lock restored byte-for-byte, artifacts and ledger pruned. GREEN: the 6 flipped core tests, the 2 new CLI orchestration tests, and the new yarn-classic E1 e2e capstone (drift-skip keep -> kept tarball still installs -> follow-up revert completes to pristine) all pass; cargo test -p socket-patch-core clean; clippy --workspace --all-features clean. Co-Authored-By: Claude Fable 5 --- .../socket-patch-cli/src/commands/vendor.rs | 40 ++++++++++++++++++ .../socket-patch-core/src/vendor/bun_lock.rs | 10 +++++ crates/socket-patch-core/src/vendor/cargo.rs | 2 + crates/socket-patch-core/src/vendor/common.rs | 2 + .../src/vendor/composer_lock.rs | 3 ++ crates/socket-patch-core/src/vendor/gem.rs | 3 ++ .../src/vendor/maven_repo.rs | 3 ++ crates/socket-patch-core/src/vendor/mod.rs | 41 +++++++++++++++++++ .../socket-patch-core/src/vendor/npm_lock.rs | 10 +++++ .../src/vendor/nuget_feed.rs | 3 ++ .../socket-patch-core/src/vendor/pnpm_lock.rs | 10 +++++ .../src/vendor/pypi_pipenv.rs | 2 + .../src/vendor/pypi_requirements.rs | 2 + .../socket-patch-core/src/vendor/pypi_uv.rs | 3 ++ .../src/vendor/yarn_berry_lock.rs | 10 +++++ .../src/vendor/yarn_classic_lock.rs | 10 +++++ .../src/vendor/yarn_layering_tests.rs | 4 ++ 17 files changed, 158 insertions(+) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 3b925b5e..b8297ce1 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1317,6 +1317,20 @@ pub(crate) async fn reconcile_dropped( record_warning(env, &purl, w, common); } if outcome.success { + if outcome.kept_artifact { + // Drift-skip keep (residual #131): the backend left the + // drifted lock alone and kept the artifacts, so the ledger + // entry must survive too — and the genuine outcome is a + // COUNTED skip, not a removal. + env.record( + PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( + "vendor_revert_kept", + "patch no longer in manifest, but its lock entries drifted since \ + vendoring; artifacts and ledger entry kept", + ), + ); + continue; + } env.record( PatchEvent::new(PatchAction::Removed, purl.clone()) .with_reason("vendor_reconciled", "patch no longer in manifest"), @@ -1364,6 +1378,22 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { record_warning(env, purl, w, common); } if outcome.success { + if outcome.kept_artifact { + // Drift-skip keep (residual #131): the backend left the + // drifted lock alone and kept the artifacts, so the ledger + // entry must survive too — and the genuine outcome is a + // COUNTED skip, not a removal. (`record_warning` above + // already surfaced the per-record details as uncounted + // advisory events.) + env.record( + PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( + "vendor_revert_kept", + "lock entries drifted since vendoring; artifacts and ledger entry kept \ + — undo the drift and re-run `vendor --revert` to finish", + ), + ); + continue; + } env.record(PatchEvent::new(PatchAction::Removed, purl.clone())); if !common.dry_run { state.entries.remove(purl); @@ -1435,6 +1465,16 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { "{verb} {} vendored package(s); {} failed.", env.summary.removed, env.summary.failed ); + // In this command summary.skipped counts only genuine drift-skip + // keeps (advisory warnings are pushed uncounted by record_warning). + if env.summary.skipped > 0 { + println!( + "Kept {} drifted package(s): lock entries were re-resolved since vendoring, so \ + their artifacts and ledger entries were retained — undo the drift and re-run \ + `vendor --revert` to finish.", + env.summary.skipped + ); + } } if has_errors { diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 22c72216..03b936d4 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -342,6 +342,16 @@ pub(crate) async fn revert_bun( } } + // LOSSINESS GUARD (residual #131): when any wiring record was left + // alone ("drifted; left alone"), the uuid dir may hold the only copy of + // what the lock — or the redirect ledger's recorded originals — still + // points at. Keep it (and let the CLI keep the ledger entry) instead of + // deleting evidence out from under a lock we just refused to touch. + if outcome.drift_skipped() { + outcome.keep_artifact(&uuid_dir_rel); + return outcome; + } + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index a6ca7803..8ad5725d 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -933,6 +933,7 @@ pub async fn revert_cargo_vendor( // path-form) would break every --locked build with no breadcrumb. Err(e) => { return RevertOutcome { + kept_artifact: false, success: false, warnings: out.warnings, error: Some(format!("failed to restore the Cargo.lock entry: {e}")), @@ -943,6 +944,7 @@ pub async fn revert_cargo_vendor( if let Err(e) = cargo_config::drop_patch_entry(project_root, name, dry_run).await { return RevertOutcome { + kept_artifact: false, success: false, warnings: out.warnings, error: Some(format!("failed to update .cargo/config.toml: {e}")), diff --git a/crates/socket-patch-core/src/vendor/common.rs b/crates/socket-patch-core/src/vendor/common.rs index 0383643c..fe88678a 100644 --- a/crates/socket-patch-core/src/vendor/common.rs +++ b/crates/socket-patch-core/src/vendor/common.rs @@ -440,6 +440,7 @@ pub(crate) async fn revert_lock_fragment_splice( // reset them to umask defaults. if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, lock_text.as_bytes()).await { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(format!("cannot write {lock_file}: {e}")), @@ -447,6 +448,7 @@ pub(crate) async fn revert_lock_fragment_splice( } } RevertOutcome { + kept_artifact: false, success: true, warnings, error: None, diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index 86b0f41e..1e5f2cc5 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -427,6 +427,7 @@ pub async fn revert_composer( )), Err(e) => { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(e), @@ -438,6 +439,7 @@ pub async fn revert_composer( if !dry_run { if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), @@ -459,6 +461,7 @@ pub async fn revert_composer( )); RevertOutcome { + kept_artifact: false, success: true, warnings, error: None, diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 6484f5be..de369999 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -981,6 +981,7 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) )), Err(e) => { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(e), @@ -992,6 +993,7 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) if !dry_run { if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), @@ -1000,6 +1002,7 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) } RevertOutcome { + kept_artifact: false, success: true, warnings, error: None, diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index e118e8bb..a165b019 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -462,6 +462,7 @@ pub async fn revert_maven( )), Err(e) => { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(e), @@ -473,6 +474,7 @@ pub async fn revert_maven( if !dry_run { if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), @@ -481,6 +483,7 @@ pub async fn revert_maven( } RevertOutcome { + kept_artifact: false, success: true, warnings, error: None, diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index febc2bae..d03d6d38 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -568,6 +568,16 @@ pub struct RevertOutcome { pub success: bool, pub warnings: Vec, pub error: Option, + /// True when the backend deliberately KEPT the artifact uuid dir + /// because at least one wiring record was left alone during the + /// restore (a `vendor_lock_entry_drifted` skip — residual #131). The + /// entry's recorded pre-vendor originals and vendored blob may be the + /// only surviving inputs a later restore needs (the lockfile — or the + /// hosted redirect ledger's recorded `original` fragments — can still + /// point at them), so callers must ALSO keep the state.json entry + /// instead of pruning it, and report the package as skipped rather + /// than removed. Never set on failure or on dry runs. + pub kept_artifact: bool, } impl RevertOutcome { @@ -576,6 +586,7 @@ impl RevertOutcome { success: true, warnings: Vec::new(), error: None, + kept_artifact: false, } } @@ -584,8 +595,38 @@ impl RevertOutcome { success: false, warnings: Vec::new(), error: Some(error.into()), + kept_artifact: false, } } + + /// True when any wiring record was left alone during the restore — + /// every left-alone branch (ownership-gate drift, vanished block, + /// missing pre-vendor original, unknown kind/key, allowlist skip) + /// warns with the stable code `vendor_lock_entry_drifted`. + pub fn drift_skipped(&self) -> bool { + self.warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted") + } + + /// Mark the artifact dir as deliberately kept after a drift-skip and + /// surface it honestly. Backends call this INSTEAD of removing the + /// uuid dir when [`Self::drift_skipped`] is true: deleting it would be + /// unrecoverable, while keeping it is always recoverable (the orphan + /// sweep's invariant — never delete what something still references — + /// applies to the revert too). + pub fn keep_artifact(&mut self, uuid_dir_rel: &str) { + self.kept_artifact = true; + self.warnings.push(VendorWarning::new( + "vendor_artifact_kept", + format!( + "kept {uuid_dir_rel}: some recorded lock entries were left alone (see the \ + vendor_lock_entry_drifted warnings) and the vendored artifacts may still be \ + needed for a later restore; undo the drift (restore the vendored lock entries \ + or re-vendor) and re-run `vendor --revert` to finish cleaning up" + ), + )); + } } /// True iff this build can vendor this PURL's ecosystem. diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index a07e0bc7..5aa35a4a 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -455,6 +455,16 @@ pub async fn revert_npm(entry: &VendorEntry, project_root: &Path, dry_run: bool) } } + // LOSSINESS GUARD (residual #131): when any wiring record was left + // alone ("drifted; left alone"), the uuid dir may hold the only copy of + // what the lock — or the redirect ledger's recorded originals — still + // points at. Keep it (and let the CLI keep the ledger entry) instead of + // deleting evidence out from under a lock we just refused to touch. + if outcome.drift_skipped() { + outcome.keep_artifact(&uuid_dir_rel); + return outcome; + } + // Remove the whole validated uuid dir (tgz + marker + any @scope level) // in one tree delete — pruning by leaf would leave empty dirs behind. if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 0965667b..989da1e8 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -592,6 +592,7 @@ pub async fn revert_nuget( )), Err(e) => { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(e), @@ -603,6 +604,7 @@ pub async fn revert_nuget( if !dry_run { if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), @@ -611,6 +613,7 @@ pub async fn revert_nuget( } RevertOutcome { + kept_artifact: false, success: true, warnings, error: None, diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 5f88283c..6287ea54 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -590,6 +590,16 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool } } + // LOSSINESS GUARD (residual #131): when any wiring record was left + // alone ("drifted; left alone"), the uuid dir may hold the only copy of + // what the lock — or the redirect ledger's recorded originals — still + // points at. Keep it (and let the CLI keep the ledger entry) instead of + // deleting evidence out from under a lock we just refused to touch. + if outcome.drift_skipped() { + outcome.keep_artifact(&uuid_dir_rel); + return outcome; + } + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } diff --git a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs index 8ec52495..aea4ed13 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs @@ -409,6 +409,7 @@ pub(super) async fn revert_pipenv( let new_text = to_canonical_json(&lock); if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, new_text.as_bytes()).await { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(format!("cannot write {LOCK_FILE}: {e}")), @@ -416,6 +417,7 @@ pub(super) async fn revert_pipenv( } } RevertOutcome { + kept_artifact: false, success: true, warnings, error: None, diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index 32739968..7df3c248 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -315,6 +315,7 @@ pub(super) async fn revert_requirements( atomic_write_bytes_preserving_mode(&root.join(file), content.as_bytes()).await { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(format!("cannot write {file}: {e}")), @@ -336,6 +337,7 @@ pub(super) async fn revert_requirements( } RevertOutcome { + kept_artifact: false, success: true, warnings, error: None, diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 936a45e0..b164cfd4 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -700,6 +700,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // Reverse of the wire order: the lock first, then the pyproject. if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, lock_text.as_bytes()).await { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(format!("cannot write uv.lock: {e}")), @@ -709,6 +710,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - atomic_write_bytes_preserving_mode(&pyproject_path, pyproject_text.as_bytes()).await { return RevertOutcome { + kept_artifact: false, success: false, warnings, error: Some(format!("cannot write pyproject.toml: {e}")), @@ -716,6 +718,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - } } RevertOutcome { + kept_artifact: false, success: true, warnings, error: None, diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 66d639a7..a9ff40e3 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -628,6 +628,16 @@ pub async fn revert_yarn_berry( } } + // LOSSINESS GUARD (residual #131): when any wiring record was left + // alone ("drifted; left alone"), the uuid dir may hold the only copy of + // what the lock — or the redirect ledger's recorded originals — still + // points at. Keep it (and let the CLI keep the ledger entry) instead of + // deleting evidence out from under a lock we just refused to touch. + if outcome.drift_skipped() { + outcome.keep_artifact(&uuid_dir_rel); + return outcome; + } + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index 9be07cdb..ee465f79 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -349,6 +349,16 @@ pub async fn revert_yarn_classic( } } + // LOSSINESS GUARD (residual #131): when any wiring record was left + // alone ("drifted; left alone"), the uuid dir may hold the only copy of + // what the lock — or the redirect ledger's recorded originals — still + // points at. Keep it (and let the CLI keep the ledger entry) instead of + // deleting evidence out from under a lock we just refused to touch. + if outcome.drift_skipped() { + outcome.keep_artifact(&uuid_dir_rel); + return outcome; + } + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } diff --git a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs index a7fd922e..5c73fe52 100644 --- a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs +++ b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs @@ -545,6 +545,10 @@ async fn classic_vendor_revert_after_hosted_overlay_is_drift_skipped_and_keeps_b ); // 5. the keep is surfaced honestly, so the caller (CLI) knows to keep // the ledger entry too instead of pruning it. + assert!( + outcome.kept_artifact, + "RevertOutcome must carry the keep signal for the CLI" + ); assert!( outcome .warnings From 20f6e90264aace1f66f24a9c658537cf7fc5c8f6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 18 Aug 2026 21:38:46 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(vendor):=20drift-skip=20keep=20gate=20c?= =?UTF-8?q?onverges=20=E2=80=94=20already-reverted=20records=20are=20not?= =?UTF-8?q?=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keep gate (drift_skipped) keyed on vendor_lock_entry_drifted, but the npm-family backends emitted that same code for records a PREVIOUS partial revert had already restored or removed: a restored fragment's registry value fails the uuid ownership gate ("re-resolved since vendoring"), a removed berry resolutions entry hits "no longer exists; nothing to remove", and a rekeyed pnpm/berry block restored under its pre-vendor key misses the recorded-key lookup ("no longer exists; nothing to restore"). After any partial restore, every later `vendor --revert` therefore re-classified the converged records as drift and kept the artifact dir + ledger entry forever — the CLI's own "undo the drift and re-run `vendor --revert` to finish" remediation could never be satisfied. Teach every left-alone branch in the five keep-gate backends (npm, bun, pnpm, yarn classic, yarn berry) to recognize the ALREADY-CONVERGED state as a silent no-op before warning: - a live fragment equal to the recorded pre-vendor `original` (restored in place: npm/bun entries, classic/berry blocks, pnpm overrides / importer deps / snapshot refs, berry takeover resolutions); - for Added records with no original, the key being absent (berry resolutions entry/table, pnpm overrides in package.json / pnpm-workspace.yaml / the lock's overrides section); - for the rekeying restores (berry lock entries, pnpm packages/snapshots blocks), the recorded original block being live verbatim under its own pre-vendor key when the recorded key no longer matches. drift_skipped() now fires only on genuine third-party drift the user can still undo, so the kept artifacts + ledger entry are released the moment the drift is undone — run 2 completes the revert and prunes everything. Tests: each backend's drift unit test gains a second-run leg (undo ONLY the drift; already-reverted records must be silent and the revert must complete), the CLI keep tests now drift to a value that is neither ours nor the original, and a new CLI test pins that a lock manually restored to the pre-vendor original converges (counted Removed, ledger pruned) on the first revert. Co-Authored-By: Claude Fable 5 --- .../tests/in_process_vendor.rs | 71 +++++++++- .../socket-patch-core/src/vendor/bun_lock.rs | 32 +++++ crates/socket-patch-core/src/vendor/mod.rs | 10 ++ .../socket-patch-core/src/vendor/npm_lock.rs | 37 +++++ .../socket-patch-core/src/vendor/pnpm_lock.rs | 133 +++++++++++++++++- .../src/vendor/yarn_berry_lock.rs | 66 ++++++++- .../src/vendor/yarn_classic_lock.rs | 51 ++++++- 7 files changed, 387 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index eebb68c5..2753d003 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1981,8 +1981,17 @@ async fn revert_after_drift_keeps_artifact_and_ledger_then_completes() { let vendored_lock = fx.lock_bytes(); // Third-party drift: the lock was re-resolved behind our back (same - // ownership-gate outcome as a hosted patch.socket.dev overlay). - std::fs::write(fx.lock_path(), &fx.original_lock).unwrap(); + // ownership-gate outcome as a hosted patch.socket.dev overlay). The + // drifted value must be neither ours nor the recorded pre-vendor + // original — a lock already back at the original is CONVERGED, and the + // revert completes instead of keeping (see + // `revert_completes_when_lock_already_matches_the_original`). + let mut drifted: Value = serde_json::from_slice(&vendored_lock).unwrap(); + drifted["packages"]["node_modules/left-pad"]["resolved"] = + Value::String("https://example.com/their-fork.tgz".to_string()); + let mut drifted_lock = serde_json::to_vec_pretty(&drifted).unwrap(); + drifted_lock.push(b'\n'); + std::fs::write(fx.lock_path(), &drifted_lock).unwrap(); let (code, env) = vendor_cli(fx.root(), &["--revert"]); assert_eq!( @@ -2013,7 +2022,7 @@ async fn revert_after_drift_keeps_artifact_and_ledger_then_completes() { state["entries"][PURL].is_object(), "ledger entry must be kept: {state:#}" ); - assert_eq!(fx.lock_bytes(), fx.original_lock, "drifted lock left alone"); + assert_eq!(fx.lock_bytes(), drifted_lock, "drifted lock left alone"); // Undo the drift → the same command now completes the revert. std::fs::write(fx.lock_path(), &vendored_lock).unwrap(); @@ -2040,9 +2049,16 @@ async fn reconcile_keeps_drifted_entry() { let fx = npm_fixture(); assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); - // Drop the patch from the manifest AND drift the lock. + // Drop the patch from the manifest AND drift the lock (to a value that + // is neither ours nor the recorded pre-vendor original — genuine + // third-party drift). std::fs::write(fx.manifest_path(), b"{\"patches\": {}}\n").unwrap(); - std::fs::write(fx.lock_path(), &fx.original_lock).unwrap(); + let mut drifted: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); + drifted["packages"]["node_modules/left-pad"]["resolved"] = + Value::String("https://example.com/their-fork.tgz".to_string()); + let mut drifted_lock = serde_json::to_vec_pretty(&drifted).unwrap(); + drifted_lock.push(b'\n'); + std::fs::write(fx.lock_path(), &drifted_lock).unwrap(); let (code, env) = vendor_cli(fx.root(), &[]); assert_eq!(code, 0, "reconcile keep must exit 0: {env:#}"); @@ -2061,5 +2077,48 @@ async fn reconcile_keeps_drifted_entry() { state["entries"][PURL].is_object(), "ledger entry must be kept: {state:#}" ); - assert_eq!(fx.lock_bytes(), fx.original_lock, "drifted lock left alone"); + assert_eq!(fx.lock_bytes(), drifted_lock, "drifted lock left alone"); +} + +/// KEEP-GATE LIVENESS: a lock whose entry already equals the recorded +/// pre-vendor original is CONVERGED, not drifted — the user (or an earlier +/// partial revert) already restored it, e.g. via `git checkout`. The revert +/// must complete fully — artifacts removed, ledger pruned, counted +/// `Removed` — never keep the entry with "undo the drift" advice that can +/// never be satisfied. +#[tokio::test] +async fn revert_completes_when_lock_already_matches_the_original() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); + + // The user restored the pre-vendor lock by hand (git checkout). + std::fs::write(fx.lock_path(), &fx.original_lock).unwrap(); + + let (code, env) = vendor_cli(fx.root(), &["--revert"]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!( + env["summary"]["removed"], 1, + "the converged revert completes: {env:#}" + ); + assert_eq!( + env["summary"]["skipped"], 0, + "nothing was kept, so nothing may be counted skipped: {env:#}" + ); + assert!( + events(&env) + .iter() + .all(|e| e["errorCode"] != "vendor_revert_kept" + && e["errorCode"] != "vendor_lock_entry_drifted"), + "an already-restored entry is converged, not drifted: {env:#}" + ); + assert_eq!(fx.lock_bytes(), fx.original_lock, "lock stays restored"); + assert!( + !fx.vendor_dir().exists(), + ".socket/vendor fully pruned once the revert converges" + ); + let state_gone = !fx.state_path().exists() + || serde_json::from_slice::(&std::fs::read(fx.state_path()).unwrap()) + .map(|s| !s["entries"][PURL].is_object()) + .unwrap_or(true); + assert!(state_gone, "ledger entry pruned once the revert converges"); } diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 03b936d4..14c2eee9 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -393,6 +393,14 @@ fn revert_one_record( (parsed.key == key).then_some((start + 1 + off, parsed)) }); if let Some((idx, parsed)) = located { + // ALREADY CONVERGED: the live line equals the recorded pre-vendor + // original — an earlier partial revert (or the user, by hand) + // already restored this record. Not drift: stay silent so the + // drift-skip keep gate can converge instead of re-flagging the + // restored line forever. + if rec.original.as_ref().and_then(Value::as_str) == Some(lines[idx].as_str()) { + return; + } // Ours iff the line is exactly what we wrote, or its tuple still // points into OUR uuid dir (a re-serialized but unmoved entry). let exact = Some(lines[idx].as_str()) == rec.new.as_ref().and_then(Value::as_str); @@ -1374,6 +1382,30 @@ mod tests { "the keep must be surfaced: {:?}", outcome.warnings ); + + // KEEP-GATE LIVENESS: undo the drift (repoint the entry back at the + // vendored tuple) — the same revert must then complete fully + // instead of ratcheting the keep forever. + let healed = fx.read_lock().await.replace(drifted_line, new_line); + tokio::fs::write(fx.root().join(BUN_LOCK), &healed) + .await + .unwrap(); + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "no drift left after the undo: {:?}", + outcome.warnings + ); + assert!(!outcome.kept_artifact); + assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK, "lock byte-restored"); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "artifact pruned once the revert converges" + ); } #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index d03d6d38..b4e6509c 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -603,6 +603,16 @@ impl RevertOutcome { /// every left-alone branch (ownership-gate drift, vanished block, /// missing pre-vendor original, unknown kind/key, allowlist skip) /// warns with the stable code `vendor_lock_entry_drifted`. + /// + /// LIVENESS CONTRACT: backends must NOT emit that code for a record + /// whose live state already equals its reverted state (the fragment + /// equals `rec.original`, or — for Added records with no original — + /// the key is absent). A previous partial revert leaves records in + /// exactly that state; re-classifying them as drift would keep the + /// artifacts and ledger entry forever, and the CLI's "undo the drift + /// and re-run `vendor --revert`" remediation could never be satisfied. + /// Such records are silent no-ops, so this gate fires only on genuine + /// third-party drift that the user can still undo. pub fn drift_skipped(&self) -> bool { self.warnings .iter() diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 5aa35a4a..0926179f 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -729,6 +729,15 @@ fn revert_one_record( return; }; + // ALREADY CONVERGED: the live fragment equals the recorded pre-vendor + // original — an earlier partial revert (or the user, by hand) already + // restored this record. Not drift: stay silent so the drift-skip keep + // gate can converge (re-warning here would keep the artifacts + ledger + // entry forever, with remediation advice that can never be satisfied). + if rec.original.as_ref() == Some(&*live) { + return; + } + // Ours iff resolved is exactly what we wrote, or still points into OUR // uuid dir (a re-serialized but unmoved entry). let live_resolved = live.get("resolved").and_then(Value::as_str); @@ -1989,6 +1998,7 @@ mod tests { // The user re-resolved the DIRECT instance behind our back. let mut live = fx.read_lock().await; + let vendored_direct = live["packages"]["node_modules/left-pad"].clone(); live["packages"]["node_modules/left-pad"]["resolved"] = json!("https://example.com/their-fork.tgz"); tokio::fs::write(fx.lock_path(), serialize_json(&live, " ").unwrap()) @@ -2033,6 +2043,33 @@ mod tests { "the keep must be surfaced: {:?}", outcome.warnings ); + + // KEEP-GATE LIVENESS: undo ONLY the drift (repoint the direct + // instance back at the vendored tarball). The nested instance the + // first revert already restored must now read as CONVERGED, not + // drifted — otherwise every later revert would re-classify it as + // drift and keep the artifacts + ledger entry forever. + let mut healed = fx.read_lock().await; + healed["packages"]["node_modules/left-pad"] = vendored_direct; + tokio::fs::write(fx.lock_path(), serialize_json(&healed, " ").unwrap()) + .await + .unwrap(); + + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "the already-restored instance is converged, not drifted: {:?}", + outcome.warnings + ); + assert!(!outcome.kept_artifact); + assert_eq!(fx.read_lock().await, default_lock(), "lock fully restored"); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "artifact pruned once the revert converges" + ); } #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 6287ea54..6be64b42 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -623,6 +623,13 @@ async fn revert_workspace( let text = match tokio::fs::read_to_string(&path).await { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // ALREADY CONVERGED: for an Added override (no recorded + // original) the reverted state is "no override" — a missing + // file trivially satisfies it (an earlier partial revert may + // have deleted the scaffold we created). Not drift. + if rec.original.is_none() { + return Ok(()); + } warnings.push(drifted(format!( "{PNPM_WORKSPACE} is missing; the pnpm >= 11 override cannot be removed" ))); @@ -676,6 +683,12 @@ fn revert_ws_record( return; }; let Some((start, end, indent)) = ws_overrides_section(lines) else { + // ALREADY CONVERGED: an Added override's reverted state is "no + // override" — the section being gone satisfies it (an earlier + // partial revert removed our key and pruned the section). Not drift. + if rec.original.is_none() { + return; + } warnings.push(drifted(format!( "{PNPM_WORKSPACE} overrides section is gone; `{key}` not removed" ))); @@ -688,6 +701,11 @@ fn revert_ws_record( if k != key { continue; } + // ALREADY CONVERGED: a takeover entry already restored to the + // user's recorded pin. Not drift. + if rec.original.as_ref().and_then(Value::as_str) == Some(rest.as_str()) { + return; + } let ours = Some(rest.as_str()) == rec.new.as_ref().and_then(Value::as_str) || parse_vendor_path(&rest).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if !ours { @@ -707,6 +725,11 @@ fn revert_ws_record( *dirty = true; return; } + // ALREADY CONVERGED: an Added override's reverted state is "no + // override" — an earlier partial revert already removed our key. + if rec.original.is_none() { + return; + } warnings.push(drifted(format!( "{PNPM_WORKSPACE} override `{key}` no longer exists; nothing to remove" ))); @@ -1801,12 +1824,29 @@ fn revert_pkg_record( .and_then(|p| p.get_mut("overrides")) .and_then(Value::as_object_mut); let Some(overrides) = overrides else { + // ALREADY CONVERGED: an Added override's reverted state is "no + // override" — the table being gone satisfies it (an earlier + // partial revert removed our key and dropped the empty table). + // Not drift: stay silent so the drift-skip keep gate can converge. + if rec.original.is_none() { + return; + } warnings.push(drifted(format!( "pnpm.overrides is gone; `{key}` not removed" ))); return; }; let live = overrides.get(key).and_then(Value::as_str); + // ALREADY CONVERGED: the live state already equals the reverted state — + // key absent for an Added entry, or the user's recorded pin restored in + // place for a takeover. Not drift. + let converged = match rec.original.as_ref().and_then(Value::as_str) { + Some(orig) => live == Some(orig), + None => live.is_none() && !overrides.contains_key(key), + }; + if converged { + return; + } let ours = live.is_some_and(|v| { Some(v) == rec.new.as_ref().and_then(Value::as_str) || parse_vendor_path(v).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid) @@ -1867,6 +1907,13 @@ fn revert_overrides_line( warnings: &mut Vec, ) { let Some((start, end)) = section_bounds(lines, "overrides") else { + // ALREADY CONVERGED: an Added override's reverted state is "no + // override" — the section being gone satisfies it (an earlier + // partial revert removed our line and pruned the section). Not + // drift: stay silent so the drift-skip keep gate can converge. + if rec.original.is_none() { + return; + } warnings.push(drifted(format!( "overrides section is gone; `{key}` not removed" ))); @@ -1886,9 +1933,19 @@ fn revert_overrides_line( } } let Some((idx, repr, rest)) = ours_at else { + // ALREADY CONVERGED: an Added override's reverted state is "no + // override" — an earlier partial revert already removed our line. + if rec.original.is_none() { + return; + } warnings.push(drifted(format!("overrides entry `{key}` no longer exists"))); return; }; + // ALREADY CONVERGED: a takeover entry already restored to the user's + // recorded pin. Not drift. + if rec.original.as_ref().and_then(Value::as_str) == Some(rest.as_str()) { + return; + } let ours = Some(rest.as_str()) == rec.new.as_ref().and_then(Value::as_str) || parse_vendor_path(&rest).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if !ours { @@ -1953,9 +2010,20 @@ fn revert_importer_dep( continue; } let (spec_idx, ver_idx, _) = dep_field_lines(lines, k + 1, importer.end); - let (Some((si, _)), Some((vi, live_ver))) = (spec_idx, ver_idx) else { + let (Some((si, live_spec)), Some((vi, live_ver))) = (spec_idx, ver_idx) else { break; }; + // ALREADY CONVERGED: both fields already equal the recorded + // pre-vendor original — an earlier partial revert (or the + // user, by hand) already restored this record. Not drift: + // stay silent so the drift-skip keep gate can converge. + if let Some(original) = rec.original.as_ref() { + if original.get("specifier").and_then(Value::as_str) == Some(live_spec.as_str()) + && original.get("version").and_then(Value::as_str) == Some(live_ver.as_str()) + { + return; + } + } let new_ver = rec .new .as_ref() @@ -2058,6 +2126,20 @@ fn revert_block( *dirty = true; return; } + // ALREADY CONVERGED: an earlier partial revert restored this record — + // the splice rekeys the block back to its pre-vendor key, so the + // recorded `file:` key no longer matches while the original block is + // live verbatim. Not drift: stay silent so the drift-skip keep gate can + // converge instead of keeping the artifacts forever. + if let Some(orig) = rec.original.as_ref().and_then(value_lines) { + let mut j = start + 1; + while let Some(block) = next_block(lines, j, end) { + if lines[block.header..block.end] == orig[..] { + return; + } + j = block.end; + } + } warnings.push(drifted(format!( "{section} entry `{new_key}` no longer exists; nothing to restore" ))); @@ -2096,6 +2178,12 @@ fn revert_snapshot_ref( if d != dep { continue; } + // ALREADY CONVERGED: the live ref already equals the recorded + // pre-vendor original — an earlier partial revert (or the + // user, by hand) already restored it. Not drift. + if rec.original.as_ref().and_then(Value::as_str) == Some(rest.as_str()) { + return; + } let ours = Some(rest.as_str()) == rec.new.as_ref().and_then(Value::as_str) || parse_vendor_path(&rest).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if !ours { @@ -4002,6 +4090,49 @@ snapshots: "the keep must be surfaced: {:?}", outcome.warnings ); + + // KEEP-GATE LIVENESS: undo ONLY the drift (repoint the importer dep + // back at the vendored spec). Everything the first revert already + // restored — the rekeyed packages/snapshots blocks and the removed + // overrides — must now read as CONVERGED, not drifted; otherwise + // every later revert would re-classify it as drift and keep the + // artifacts + ledger entry forever. + let healed = after.replace( + " left-pad:\n specifier: 1.3.1\n version: 1.3.1\n", + &format!( + " left-pad:\n specifier: file:{rel}\n version: file:{rel}\n", + rel = fx.rel_tgz() + ), + ); + assert_ne!(healed, after, "the undo edit must hit"); + tokio::fs::write(fx.root().join(PNPM_LOCK), &healed) + .await + .unwrap(); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "already-reverted fragments are converged, not drifted: {:?}", + outcome.warnings + ); + assert!(!outcome.kept_artifact); + assert_eq!( + fx.read(PNPM_LOCK).await, + P1_BEFORE_LOCK, + "lock byte-restored" + ); + assert_eq!( + fx.read(PACKAGE_JSON).await, + P1_BEFORE_PKG, + "package.json byte-restored" + ); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "artifact pruned once the revert converges" + ); } #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index a9ff40e3..e46e25d4 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -679,17 +679,38 @@ fn revert_resolution_record( return; }; let Some(res_obj) = obj.get_mut("resolutions").and_then(Value::as_object_mut) else { + // ALREADY CONVERGED: for an Added entry (no recorded original) the + // reverted state IS "no resolutions entry" — an earlier partial + // revert already removed it (dropping the then-empty table). Not + // drift: stay silent so the drift-skip keep gate can converge. + if rec.original.is_none() { + return; + } + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("resolutions entry `{key}` no longer exists; nothing to remove"), + )); + return; + }; + let live = res_obj.get(key).and_then(Value::as_str); + let Some(live) = live else { + // ALREADY CONVERGED (same as the missing-table case above): our + // Added entry is already gone. + if rec.original.is_none() { + return; + } warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", format!("resolutions entry `{key}` no longer exists; nothing to remove"), )); return; }; - let ours = res_obj - .get(key) - .and_then(Value::as_str) - .and_then(parse_vendor_path) - .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + // ALREADY CONVERGED: a takeover entry already restored to the user's + // recorded pin. Not drift. + if rec.original.as_ref().and_then(Value::as_str) == Some(live) { + return; + } + let ours = parse_vendor_path(live).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if !ours { warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", @@ -1741,6 +1762,41 @@ __metadata: outcome.warnings ); + // KEEP-GATE LIVENESS: undo ONLY the lock drift (repoint the + // resolution line back at the vendored locator). The resolutions + // entry the first revert already removed must now read as CONVERGED + // (Added record + key absent), not drifted — otherwise every later + // revert would hit the "no longer exists; nothing to remove" branch + // and keep the artifacts + ledger entry forever. + let vendored_resolution = text + .lines() + .find(|l| l.starts_with(" resolution: \"left-pad@file:")) + .expect("the vendored lock must carry our resolution line") + .to_string(); + let healed = after.replace(" resolution: \"left-pad@npm:1.3.0\"", &vendored_resolution); + assert_ne!(healed, after, "the undo edit must hit"); + tokio::fs::write(fx.lock_path(), &healed).await.unwrap(); + + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "the already-removed resolutions entry is converged, not drifted: {:?}", + outcome.warnings + ); + assert!(!outcome.kept_artifact); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "lock restored byte-for-byte" + ); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "artifact pruned once the revert converges" + ); + // Manifest drift: the user repointed the resolutions entry. let fx = fixture().await; let (_, entry, _) = expect_done(fx.vendor(false).await); diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index ee465f79..e4f29482 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -399,15 +399,35 @@ pub(super) fn revert_recorded_block( )); return false; } + // The recorded pre-vendor block (key line first), used both for the + // restore and for the ALREADY-CONVERGED checks below. + let orig_lines = rec.original.as_ref().and_then(json_to_lines); let edit = { let blocks = scan_blocks(text); let Some(block) = blocks.iter().find(|b| b.key == key) else { + // ALREADY CONVERGED: an earlier partial revert restored this + // record, and the restore rekeyed the block (berry's `file:` + // locator key reverts to the pre-vendor descriptor), so the + // recorded key no longer matches while the original block is + // live verbatim. Not drift: stay silent so the drift-skip keep + // gate can converge instead of keeping the artifacts forever. + if let Some(orig) = orig_lines.as_ref() { + if blocks.iter().any(|b| &b.lines == orig) { + return false; + } + } warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", format!("{noun} `{key}` no longer exists; nothing to restore"), )); return false; }; + // ALREADY CONVERGED: the live block equals the recorded pre-vendor + // original — an earlier partial revert (or the user, by hand) + // already restored it in place. Not drift. + if orig_lines.as_ref() == Some(&block.lines) { + return false; + } // Ownership gate: the live block's vendor field must still point // into OUR uuid dir — anything else means a third party re-resolved // it. @@ -421,7 +441,7 @@ pub(super) fn revert_recorded_block( )); return false; } - let Some(original) = rec.original.as_ref().and_then(json_to_lines) else { + let Some(original) = orig_lines else { // The record rewrote one of our own earlier edits, so there is // no pre-vendor fragment to restore (by design). Surface it // instead of guessing a registry URL. @@ -1440,6 +1460,35 @@ left-pad@^1.3.0: "the keep must be surfaced: {:?}", outcome.warnings ); + + // KEEP-GATE LIVENESS: undo ONLY the drift (repoint the alias block + // back at the vendored tarball). The block the first revert already + // restored must now read as CONVERGED, not drifted — otherwise + // every later revert would re-classify it as drift and keep the + // artifacts + ledger entry forever. + let healed = after.replace(theirs, &ours); + assert_ne!(healed, after, "the undo edit must hit"); + tokio::fs::write(fx.lock_path(), healed).await.unwrap(); + + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "the already-restored block is converged, not drifted: {:?}", + outcome.warnings + ); + assert!(!outcome.kept_artifact); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "lock restored byte-for-byte" + ); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "artifact pruned once the revert converges" + ); } #[tokio::test]