diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index a3b24ef8..68c8855b 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -315,26 +315,30 @@ pub(super) async fn run_redirect( } }; - // Cross-mode takeover (cargo): a purl this run is about to redirect may - // still be VENDORED — a committed `[patch.crates-io]` path entry, a + // Cross-mode takeover: a purl this run is about to redirect may still be + // VENDORED — for cargo a committed `[patch.crates-io]` path entry, a // detached Cargo.lock entry, a committed copy, and a vendored ledger - // entry. The hosted rewriters know nothing about that wiring, so - // redirecting on top of it would leave BOTH wirings in place and cargo - // then refuses every `--locked` build over the now-unused `[patch]` - // entry while this run reports success. A takeover must leave the - // project FULLY hosted: revert each such purl's vendored state first - // (the exact per-purl machinery `vendor --revert` runs — restore the - // lock originals from the ledger, drop the `[patch]` entry, remove the - // committed tree and the ledger entry), and only then redirect. This - // ordering also hands the redirect the PRISTINE crates.io lock fragment - // to record as its own revert original, keeping the originals chain - // intact across repeated mode migrations. A purl whose vendored state - // cannot be cleanly reverted (revert failure, or vendored wiring with a - // missing/corrupt ledger) is REFUSED — skipped with an actionable - // error — never half-migrated. + // entry; for the npm family a `file:./.socket/vendor/…` lock resolution + // (plus a berry `resolutions` pin) and its committed tarball. The hosted + // rewriters know nothing about that wiring: cargo then refuses every + // `--locked` build over the now-unused `[patch]` entry while this run + // reports success, and the npm rewriters either hijack the vendored + // resolution while the vendored ledger still claims it (yarn classic) + // or fail-closed refuse the `file:` protocol entirely (yarn berry). A + // takeover must leave the project FULLY hosted: revert each such purl's + // vendored state first (the exact per-purl machinery `vendor --revert` + // runs — restore the lock originals from the ledger, drop the vendored + // wiring, remove the committed artifact and the ledger entry), and only + // then redirect. This ordering also hands the redirect the PRISTINE + // registry lock fragment to record as its own revert original, keeping + // the originals chain intact across repeated mode migrations. A purl + // whose vendored state cannot be cleanly reverted (revert failure, or + // vendored wiring with a missing/corrupt ledger) is REFUSED — skipped + // with an actionable error — never half-migrated. + let takeover_capable = |p: &str| p.starts_with("pkg:cargo/") || p.starts_with("pkg:npm/"); let mut takeover_pre_warnings: Vec = Vec::new(); - if !candidates.iter().any(|(p, ..)| p.starts_with("pkg:cargo/")) { - // No cargo candidates — nothing to reconcile. + if !candidates.iter().any(|(p, ..)| takeover_capable(p)) { + // No takeover-capable candidates — nothing to reconcile. } else { use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); @@ -343,7 +347,7 @@ pub(super) async fn run_redirect( socket_patch_core::vendor::cargo_config::read_patch_entries(&args.common.cwd).await; let mut refused: Vec = Vec::new(); for (purl, _uuid, ..) in &candidates { - if !purl.starts_with("pkg:cargo/") { + if !takeover_capable(purl) { continue; } let stripped = strip_purl_qualifiers(purl); @@ -433,7 +437,13 @@ pub(super) async fn run_redirect( // this crate is nevertheless present, the ledger is missing or // corrupt — the originals needed to revert are unrecoverable, // so redirecting on top would wedge the project. Refuse. - let name = parse_purl_simple(purl).map(|(_, name, _)| name); + // (Cargo-only probe: `.cargo/config.toml` `[patch]` entries. + // An npm purl in this state falls through to the rewriters' + // own per-flavor diagnostics.) + let name = purl + .starts_with("pkg:cargo/") + .then(|| parse_purl_simple(purl).map(|(_, name, _)| name)) + .flatten(); let wired = name .as_deref() .is_some_and(|n| patch_entries.get(n).is_some_and(|i| i.socket_owned)); @@ -460,17 +470,20 @@ pub(super) async fn run_redirect( })); } } - let refused_names: std::collections::HashSet<(String, String)> = candidates + let refused_names: std::collections::HashSet<(String, String, String)> = candidates .iter() .filter(|(p, ..)| refused.contains(p)) - .filter_map(|(p, ..)| { - parse_purl_simple(p).map(|(_, name, version)| (name, version)) - }) + .filter_map(|(p, ..)| parse_purl_simple(p)) .collect(); candidates.retain(|(p, ..)| !refused.contains(p)); overrides.retain(|o| { - o.ecosystem != "cargo" - || !refused_names.contains(&(o.name.clone(), o.version.clone())) + // Overrides built here carry the full coordinate in `name` + // (namespace unset) — the same shape parse_purl_simple emits. + let coord = match o.namespace.as_deref() { + Some(ns) if !ns.is_empty() => format!("{ns}/{}", o.name), + _ => o.name.clone(), + }; + !refused_names.contains(&(o.ecosystem.clone(), coord, o.version.clone())) }); } } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 3b925b5e..a5895ac8 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -920,10 +920,11 @@ pub(crate) async fn vendor_records( // it still claims must revert the hosted edits FIRST (see the hook in the // dispatch loop below). Loaded once; mutated + persisted per reverted // purl. A MALFORMED ledger is held as the hard error it is: this loop - // WRITES the ledger for cargo takeovers, and with its records unreadable - // a claimed purl is indistinguishable from an unclaimed one — so every - // cargo purl fails closed with the corruption surfaced (non-cargo purls - // never touch the redirect ledger here and proceed). + // WRITES the ledger for takeovers, and with its records unreadable a + // claimed purl is indistinguishable from an unclaimed one — so every + // purl of a takeover-capable ecosystem (cargo, npm) fails closed with + // the corruption surfaced (other purls never touch the redirect ledger + // here and proceed). let (mut redirect_ledger, redirect_ledger_corrupt) = match socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await { Ok(state) => (state, None), @@ -979,18 +980,24 @@ pub(crate) async fn vendor_records( } matched.insert(candidate.clone()); - // Cross-mode takeover (cargo): vendoring over a LIVE hosted - // redirect must first revert the hosted edits from the redirect - // ledger — `[patch.crates-io]` only patches crates-io-sourced + // Cross-mode takeover: vendoring over a LIVE hosted redirect + // must first revert the hosted edits from the redirect ledger. + // Cargo: `[patch.crates-io]` only patches crates-io-sourced // deps, so vendoring on top of the `registry = "socket-patch-…"` // pin leaves the project unbuildable in BOTH modes while this - // run reports success — and the pre-revert also hands the vendor - // detach the PRISTINE crates.io lock fragment to record as the - // ledger's unrecoverable originals (not the hosted values). A - // purl whose hosted edits cannot be cleanly reverted is REFUSED; - // the backend's own fail-closed guard (`hosted_redirect_live`) + // run reports success. npm family: the vendor rewire happens to + // succeed either way, but without the pre-revert the vendor + // ledger records the grant-tokenized HOSTED lock fragment as its + // unrecoverable pre-vendor original (so `vendor --revert` lands + // back on an expiring hosted URL with no CLI path to registry + // state) and the superseded redirect records/edits survive + // forever as a stale-ledger replay hazard. In every ecosystem + // the pre-revert hands the vendor detach the PRISTINE registry + // lock fragment to record as the ledger's originals. A purl + // whose hosted edits cannot be cleanly reverted is REFUSED; the + // cargo backend's own fail-closed guard (`hosted_redirect_live`) // backstops states with no usable ledger at all. - if candidate.starts_with("pkg:cargo/") { + if socket_patch_core::patch::redirect::redirect_revert_supported(candidate) { if let Some(corrupt) = &redirect_ledger_corrupt { has_errors = true; env.record( @@ -1028,7 +1035,7 @@ pub(crate) async fn vendor_records( ); } else if claimed { let ledger = redirect_ledger.as_mut().expect("claimed implies Some"); - match socket_patch_core::patch::redirect::revert_cargo_redirect_purl( + match socket_patch_core::patch::redirect::revert_redirect_purl( &common.cwd, ledger, candidate, @@ -1060,17 +1067,22 @@ pub(crate) async fn vendor_records( ); continue; } + let reverted_what = if candidate.starts_with("pkg:cargo/") { + "the hosted edits (Cargo.toml registry pin, Cargo.lock \ + source/checksum, registries block)" + } else { + "the hosted lockfile edits back to their pre-redirect \ + registry values" + }; record_warning( env, candidate, &VendorWarning::new( "vendor_takeover_reverted_redirect", format!( - "{} was hosted-redirected; reverted the hosted \ - edits (Cargo.toml registry pin, Cargo.lock \ - source/checksum, registries block) and dropped \ - the redirect-ledger record before vendoring \ - (mode takeover)", + "{} was hosted-redirected; reverted {reverted_what} \ + and dropped the redirect-ledger record before \ + vendoring (mode takeover)", normalize_purl(candidate) ), ), diff --git a/crates/socket-patch-cli/tests/mode_migration_npm.rs b/crates/socket-patch-cli/tests/mode_migration_npm.rs new file mode 100644 index 00000000..3bae4c80 --- /dev/null +++ b/crates/socket-patch-cli/tests/mode_migration_npm.rs @@ -0,0 +1,823 @@ +//! Real-yarn mode-migration e2e: hosted ⇄ vendored takeovers on the npm +//! family must leave the project FULLY in the new mode — or refuse. +//! +//! Twin of `mode_migration_cargo.rs` (the file that pins the C1–C7 cargo +//! takeover bug class from #196) for the yarn classic + berry lock flavors. +//! Pre-fix, the vendor dispatch loop's cross-mode pre-revert was hard-gated +//! `candidate.starts_with("pkg:cargo/")`, so vendoring an npm purl over a +//! LIVE hosted redirect: +//! (a) recorded the HOSTED patch.socket.dev lock fragment as the vendor +//! ledger's unrecoverable pre-vendor "original" (not the pristine +//! registry fragment), +//! (b) left the redirect ledger's records + edits in place forever, so the +//! `vendor_supersedes_redirect` warning's promised auto-reconcile never +//! converged, and +//! (c) made `vendor --revert` land back on the (grant-tokenized, expiring) +//! hosted wiring with no CLI path back to registry state. +//! +//! Each scenario drives the REAL binary against a real `corepack yarn` +//! (network used for the registry fixture install only; the hosted patch +//! server is wiremock) and proves the terminal state with a fresh-checkout +//! install plus the marker probe. +//! +//! Skips (println) when `corepack` / the pinned yarn flavor is unavailable or +//! the registry is unreachable for the fixture install; all assertions after +//! that are hard. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/cache_env.rs"] +mod cache_env; + +const ORG: &str = "test-org"; +const DEP: &str = "left-pad"; +const DEP_VERSION: &str = "1.3.0"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +/// Vendored patch uuid (the `.socket/vendor/npm//` path level). +const UUID_V: &str = "3c4d5e6f-7a8b-4c1d-8e2f-0123456789ab"; +/// Hosted patch uuid (embedded in the hosted artifact URL). +const UUID_H: &str = "8d9e0f1a-2b3c-4d4e-8f5a-6b7c8d9e0f1a"; +const TOKEN: &str = "44444444-4444-4444-8444-444444444444"; +const MARKER: &str = "/* SOCKET-PATCHED */\n"; +const GHSA: &str = "GHSA-migr-npm-test"; +const YARN_CLASSIC: &str = "yarn@1.22.22"; +const YARN_BERRY: &str = "yarn@4.12.0"; + +// ── self-contained helpers (harness patterns shared with the redirect / +// vendor yarn capstones) ────────────────────────────────────────────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +/// Probe corepack from a NEUTRAL temp dir: a `packageManager` field in an +/// ancestor `package.json` makes corepack refuse to run a different package +/// manager, which would spuriously fail the gate. +fn has_corepack_pm(pm: &str) -> bool { + let Ok(probe) = tempfile::tempdir() else { + return false; + }; + let mut cmd = Command::new("corepack"); + cmd.args([pm, "--version"]) + .current_dir(probe.path()) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cache_env::isolate(&mut cmd); + cmd.stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Remove ambient `SOCKET_*` (except the hermetic `SOCKET_NO_CONFIG`) and +/// every `YARN_*` var. Seed-then-scrub for `YARN_NODE_LINKER` (mirrors +/// `e2e_redirect_yarn_berry_build.rs`): berry lets any yarnrc setting be +/// overridden by env, so an ambient `YARN_NODE_LINKER=pnp` would silently +/// build a PnP tree and void the node_modules probes. +fn scrub_socket_env(cmd: &mut Command) { + cmd.env("YARN_NODE_LINKER", "pnp"); + for (k, _) in std::env::vars_os() { + let key = k.to_string_lossy(); + if (key.starts_with("SOCKET_") || key.starts_with("YARN_")) && key != "SOCKET_NO_CONFIG" { + cmd.env_remove(&k); + } + } + cmd.env_remove("VIRTUAL_ENV"); + cmd.env_remove("YARN_NODE_LINKER"); +} + +fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> Output { + let mut cmd = Command::new("corepack"); + cmd.arg(pm).args(args).current_dir(cwd); + // Scrub FIRST, then the hermetic flags, then per-call env (last wins). + scrub_socket_env(&mut cmd); + cache_env::isolate(&mut cmd); + cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") + // No global mirror/cache: the fresh-checkout legs must not be able to + // reuse archives another leg parked in `~/.yarn/berry`. + .env("YARN_ENABLE_GLOBAL_CACHE", "false"); + for (k, v) in extra_env { + cmd.env(k, v); + } + cmd.output().expect("failed to run corepack") +} + +fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + scrub_socket_env(&mut cmd); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +fn git_sha256(content: &[u8]) -> String { + compute_git_sha256_from_bytes(content) +} + +/// Write `.socket/manifest.json` + the after-hash blob so `vendor --offline` +/// runs fully offline (npm-family file keys carry the `package/` prefix). +fn stage_patch(proj: &Path, before: &[u8], after: &[u8]) { + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = serde_json::json!({ + "patches": { PURL: { + "uuid": UUID_V, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": git_sha256(before), + "afterHash": git_sha256(after), + }}, + "vulnerabilities": { GHSA: { + "cves": ["CVE-2026-99999"], + "summary": "migration vuln", "severity": "high", "description": "d", + }}, + "description": "migration patch", "license": "MIT", "tier": "free", + }} + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(after)), after).unwrap(); +} + +/// Build a patched npm tarball (`package/` prefix, marker-prepended index.js) +/// from the installed dep directory. Built in-process with ONLY regular-file +/// entries — yarn classic rejects the directory/AppleDouble entries a system +/// `tar -czf` emits. +fn build_patched_tgz(installed_dir: &Path, patched_index: &[u8], out_tgz: &Path) { + fn collect_files(root: &Path, dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let ft = entry.file_type().unwrap(); + if ft.is_dir() { + collect_files(root, &entry.path(), out); + } else if ft.is_file() { + out.push(entry.path().strip_prefix(root).unwrap().to_path_buf()); + } + } + } + let mut files = Vec::new(); + collect_files(installed_dir, installed_dir, &mut files); + files.sort(); + + let gz = flate2::write::GzEncoder::new( + std::fs::File::create(out_tgz).unwrap(), + flate2::Compression::default(), + ); + let mut builder = tar::Builder::new(gz); + for rel in files { + let bytes = if rel == Path::new("index.js") { + patched_index.to_vec() + } else { + std::fs::read(installed_dir.join(&rel)).unwrap() + }; + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_mtime(0); + header.set_cksum(); + let entry_path = Path::new("package").join(&rel); + builder + .append_data(&mut header, entry_path, bytes.as_slice()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap(); +} + +/// Hex sha1 of `bytes` — the `resolved "…#"` fragment yarn classic +/// verifies against the fetched tarball. +fn sha1_hex(bytes: &[u8]) -> String { + use sha1::Digest as _; + hex::encode(sha1::Sha1::digest(bytes)) +} + +/// `sha512-` SRI of `bytes` — the classic `integrity` line. +fn sha512_sri(bytes: &[u8]) -> String { + use base64::Engine as _; + use sha2::Digest as _; + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(sha2::Sha512::digest(bytes)) + ) +} + +/// BOOTSTRAP (berry only): resolve the patched tarball with a real yarn +/// (`resolutions` pointing at `file:./patched.tgz`) so yarn writes the exact +/// `checksum: 10c0/` for that tarball's cache zip — the value the hosted +/// mock must hand back. `None` if the bootstrap install could not run. +fn bootstrap_berry_checksum(tmp: &Path, patched_tgz: &Path) -> Option { + let boot = tmp.join("berry-bootstrap"); + std::fs::create_dir_all(&boot).unwrap(); + std::fs::copy(patched_tgz, boot.join("patched.tgz")).unwrap(); + std::fs::write( + boot.join("package.json"), + format!( + r#"{{"name":"berry-bootstrap","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}},"resolutions":{{"{DEP}":"file:./patched.tgz"}}}}"# + ), + ) + .unwrap(); + std::fs::write( + boot.join(".yarnrc.yml"), + "nodeLinker: node-modules\nenableGlobalCache: false\n", + ) + .unwrap(); + let global = tmp.join("berry-bootstrap-global"); + let out = corepack( + &boot, + YARN_BERRY, + &["install"], + &[("YARN_GLOBAL_FOLDER", global.to_str().unwrap())], + ); + if !out.status.success() { + println!( + "SKIP mode_migration_npm: bootstrap yarn install failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + return None; + } + let lock = std::fs::read_to_string(boot.join("yarn.lock")).ok()?; + let checksum = lock + .lines() + .map(str::trim) + .find(|l| l.starts_with("checksum: 10c0/"))? + .trim_start_matches("checksum: ") + .to_string(); + Some(checksum) +} + +/// Mount the full hosted-mode mock set (discovery + reference + view + +/// download) for patch UUID_H over PURL. `berry_checksum` adds the +/// `yarn-berry-zip` artifact the berry rewriter requires. Returns the hosted +/// tarball URL. +async fn mount_hosted_mocks( + server: &MockServer, + tgz: &[u8], + orig: &[u8], + patched: &[u8], + berry_checksum: Option<&str>, +) -> String { + let hosted_url = format!( + "{}/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID_H}/{DEP}-{DEP_VERSION}.tgz", + server.uri() + ); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID_H, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "npm migration fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID_H, "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + let mut artifacts = vec![serde_json::json!({ + "kind": "tarball", "url": hosted_url, + "integrity": { "sha512": sha512_sri(tgz), "sha1": sha1_hex(tgz) } + })]; + if let Some(checksum) = berry_checksum { + artifacts.push(serde_json::json!({ + "kind": "yarn-berry-zip", "url": hosted_url, + "integrity": { "yarnBerry10c0": checksum } + })); + } + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + UUID_H: { + "status": "granted", + "url": hosted_url, + "purl": PURL, + "artifacts": artifacts, + "registryOverride": null + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID_H}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID_H, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(orig), + "afterHash": compute_git_sha256_from_bytes(patched), + } + }, + "vulnerabilities": { + GHSA: { + "cves": ["CVE-2026-3333"], + "summary": "migration vuln", "severity": "high", "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!( + "/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID_H}/{DEP}-{DEP_VERSION}.tgz" + ))) + .respond_with( + ResponseTemplate::new(200).set_body_raw(tgz.to_vec(), "application/octet-stream"), + ) + .mount(server) + .await; + hosted_url +} + +fn run_hosted_scan(proj: &Path, server_uri: &str) -> (i32, String, String) { + run_socket( + proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + server_uri, + "--org", + ORG, + "--api-token", + "fake", + ], + ) +} + +fn read(proj: &Path, rel: &str) -> String { + std::fs::read_to_string(proj.join(rel)).unwrap_or_default() +} + +/// The classic/berry fixture project after a REAL `corepack yarn install`. +struct YarnFixture { + tmp: tempfile::TempDir, + proj: PathBuf, + orig: Vec, + patched: Vec, +} + +/// package.json + (berry: .yarnrc.yml) + real install. `None` = skip. +fn stage_yarn_fixture(tag: &str, pm: &str, berry: bool) -> Option { + if !has_corepack_pm(pm) { + println!("SKIP mode_migration_npm ({tag}): `corepack {pm}` unavailable"); + return None; + } + 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":"mode-migration-npm","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# + ), + ) + .unwrap(); + let extra_env: Vec<(String, String)>; + if berry { + std::fs::write( + proj.join(".yarnrc.yml"), + "nodeLinker: node-modules\nenableGlobalCache: false\n", + ) + .unwrap(); + let global = tmp.path().join("yarn-global"); + extra_env = vec![( + "YARN_GLOBAL_FOLDER".into(), + global.to_str().unwrap().to_string(), + )]; + } else { + let cache = tmp.path().join("yarn-cache"); + extra_env = vec![( + "YARN_CACHE_FOLDER".into(), + cache.to_str().unwrap().to_string(), + )]; + } + let env_refs: Vec<(&str, &str)> = extra_env + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + let args: &[&str] = if berry { + &["install"] + } else { + &["install", "--no-progress"] + }; + let install = corepack(&proj, pm, args, &env_refs); + if !install.status.success() { + println!( + "SKIP mode_migration_npm ({tag}): fixture `yarn install` failed (registry \ + unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return None; + } + let orig = std::fs::read(proj.join("node_modules").join(DEP).join("index.js")) + .expect("installed index.js"); + assert!( + !orig.starts_with(MARKER.as_bytes()), + "pristine install must not carry the marker" + ); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + Some(YarnFixture { + tmp, + proj, + orig, + patched, + }) +} + +/// Copy ONLY the committable files to a fresh dir (the fresh-checkout proof). +fn fresh_checkout(proj: &Path, tmp: &Path, tag: &str, berry: bool) -> PathBuf { + let fresh = tmp.join(format!("fresh-{tag}")); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(proj.join("package.json"), fresh.join("package.json")).unwrap(); + std::fs::copy(proj.join("yarn.lock"), fresh.join("yarn.lock")).unwrap(); + if berry { + std::fs::copy(proj.join(".yarnrc.yml"), fresh.join(".yarnrc.yml")).unwrap(); + } + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + fresh +} + +/// Assertions shared by the classic and berry hosted→vendored legs: +/// the redirect ledger is fully reconciled, the vendor ledger's recorded +/// originals are the PRISTINE registry fragments, a fresh checkout installs +/// the patched bytes, and `vendor --revert` restores the registry lock +/// byte-identically. +fn assert_pure_vendored_and_round_trip( + fx: &YarnFixture, + tag: &str, + berry: bool, + hosted_url: &str, + lock_pristine: &[u8], + pkg_json_pristine: &str, + vendor_stdout: &str, +) { + let proj = &fx.proj; + + // The takeover is surfaced on the vendor envelope (C7 twin). + assert!( + vendor_stdout.contains("vendor_takeover_reverted_redirect"), + "takeover advisory missing from the vendor envelope ({tag}): {vendor_stdout}" + ); + + // (b) The superseded redirect ledger is DROPPED — records and edits both + // — so the vendor_supersedes_redirect warning can never fire again and + // no stale hosted originals survive as a revert replay hazard. + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "the emptied redirect ledger must be removed ({tag}): {}", + read(proj, ".socket/vendor/redirect-state.json") + ); + + // The lock is FULLY vendored: no hosted URL residue. + let lock = read(proj, "yarn.lock"); + assert!( + !lock.contains(hosted_url) && !lock.contains("__archiveUrl"), + "the hosted wiring must be gone from yarn.lock ({tag}):\n{lock}" + ); + assert!( + lock.contains(".socket/vendor/npm/"), + "the vendored wiring must be present ({tag}):\n{lock}" + ); + + // (a) The vendor ledger's recorded lock originals are the PRISTINE + // registry fragments — the only offline-recoverable home of the registry + // resolution — not the grant-tokenized hosted values. Classic locks + // resolve to the registry URL; berry locks to the bare + // `name@npm:` resolution (no URL). + let state = read(proj, ".socket/vendor/state.json"); + if berry { + assert!( + state.contains(&format!("resolution: \\\"{DEP}@npm:{DEP_VERSION}\\\"")), + "the vendor ledger must record the registry (npm:) original \ + resolution ({tag}): {state}" + ); + } else { + assert!( + state.contains("registry.yarnpkg.com") || state.contains("registry.npmjs.org"), + "the vendor ledger must record the registry originals ({tag}): {state}" + ); + } + assert!( + !state.contains("/patch/npm/") && !state.contains("__archiveUrl"), + "the vendor ledger must NOT record the hosted fragment as its \ + original ({tag}): {state}" + ); + + // Fresh checkout installs the PATCHED bytes from the committed artifact. + let fresh = fresh_checkout(proj, fx.tmp.path(), tag, berry); + let ci = if berry { + let fresh_global = fx.tmp.path().join(format!("fresh-global-{tag}")); + corepack( + &fresh, + YARN_BERRY, + &["install", "--immutable", "--check-cache"], + &[("YARN_GLOBAL_FOLDER", fresh_global.to_str().unwrap())], + ) + } else { + let fresh_cache = fx.tmp.path().join(format!("fresh-cache-{tag}")); + corepack( + &fresh, + YARN_CLASSIC, + &["install", "--frozen-lockfile", "--offline", "--no-progress"], + &[("YARN_CACHE_FOLDER", fresh_cache.to_str().unwrap())], + ) + }; + assert!( + ci.status.success(), + "fresh-checkout vendored install must succeed ({tag}).\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert!( + installed.starts_with(MARKER.as_bytes()), + "fresh vendored install must carry the PATCHED bytes ({tag})" + ); + + // (c) Round trip: `vendor --revert` restores the REGISTRY lock + // byte-identically (pre-fix it restored the hosted fragment, with no CLI + // path back to registry state). + let (code, stdout, stderr) = run_socket( + proj, + &[ + "vendor", + "--revert", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "revert failed ({tag}): {stdout}\n{stderr}"); + assert_eq!( + std::fs::read(proj.join("yarn.lock")).unwrap(), + lock_pristine, + "yarn.lock must be restored byte-identical to the pre-hosted \ + REGISTRY pristine ({tag}); got:\n{}", + read(proj, "yarn.lock") + ); + assert_eq!( + read(proj, "package.json"), + pkg_json_pristine, + "package.json restored ({tag})" + ); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert ({tag})" + ); +} + +// ── hosted → vendored takeover, yarn classic ──────────────────────────────── +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn classic_hosted_then_vendored_takeover_round_trips_to_registry() { + let Some(fx) = stage_yarn_fixture("classic", YARN_CLASSIC, false) else { + return; + }; + let proj = fx.proj.clone(); + let lock_pristine = std::fs::read(proj.join("yarn.lock")).unwrap(); + let pkg_json_pristine = read(&proj, "package.json"); + assert!( + String::from_utf8_lossy(&lock_pristine).contains("# yarn lockfile v1"), + "fixture must be a classic v1 lock" + ); + + // A: hosted redirect. + let tgz_path = fx.tmp.path().join("patched.tgz"); + build_patched_tgz(&proj.join("node_modules").join(DEP), &fx.patched, &tgz_path); + let tgz = std::fs::read(&tgz_path).unwrap(); + let server = MockServer::start().await; + let hosted_url = mount_hosted_mocks(&server, &tgz, &fx.orig, &fx.patched, None).await; + let (code, stdout, stderr) = run_hosted_scan(&proj, &server.uri()); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + let lock = read(&proj, "yarn.lock"); + assert!(lock.contains(&hosted_url), "hosted wiring present:\n{lock}"); + assert!( + proj.join(".socket/vendor/redirect-state.json").exists(), + "hosted ledger written" + ); + + // B: vendor over the live hosted redirect — the takeover. + stage_patch(&proj, &fx.orig, &fx.patched); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); + let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); + assert_eq!(envelope["summary"]["applied"], 1, "{stdout}"); + + assert_pure_vendored_and_round_trip( + &fx, + "classic", + false, + &hosted_url, + &lock_pristine, + &pkg_json_pristine, + &stdout, + ); +} + +// ── hosted → vendored takeover, yarn berry (E5 twin) ──────────────────────── +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn berry_hosted_then_vendored_takeover_round_trips_to_registry() { + let Some(fx) = stage_yarn_fixture("berry", YARN_BERRY, true) else { + return; + }; + let proj = fx.proj.clone(); + let lock_pristine = std::fs::read(proj.join("yarn.lock")).unwrap(); + let pkg_json_pristine = read(&proj, "package.json"); + + // A: hosted redirect (berry needs the bootstrap-resolved 10c0 checksum). + let tgz_path = fx.tmp.path().join("patched.tgz"); + build_patched_tgz(&proj.join("node_modules").join(DEP), &fx.patched, &tgz_path); + let tgz = std::fs::read(&tgz_path).unwrap(); + let Some(checksum) = bootstrap_berry_checksum(fx.tmp.path(), &tgz_path) else { + return; + }; + let server = MockServer::start().await; + let hosted_url = + mount_hosted_mocks(&server, &tgz, &fx.orig, &fx.patched, Some(&checksum)).await; + let (code, stdout, stderr) = run_hosted_scan(&proj, &server.uri()); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + let lock = read(&proj, "yarn.lock"); + assert!( + lock.contains("::__archiveUrl="), + "hosted wiring present:\n{lock}" + ); + + // B: vendor over the live hosted redirect — the takeover. + stage_patch(&proj, &fx.orig, &fx.patched); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); + let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); + assert_eq!(envelope["summary"]["applied"], 1, "{stdout}"); + + assert_pure_vendored_and_round_trip( + &fx, + "berry", + true, + &hosted_url, + &lock_pristine, + &pkg_json_pristine, + &stdout, + ); +} + +// ── vendored → hosted takeover, yarn classic (reverse direction) ──────────── +// The hosted scan must revert the vendored wiring + ledger entry + committed +// artifact FIRST (per purl, the exact `vendor --revert` machinery), then +// redirect — leaving the project purely hosted with the redirect ledger's +// originals recording the PRISTINE registry fragments. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn classic_vendored_then_hosted_takeover_leaves_pure_hosted() { + let Some(fx) = stage_yarn_fixture("classic-rev", YARN_CLASSIC, false) else { + return; + }; + let proj = fx.proj.clone(); + + // A: vendor (offline). + stage_patch(&proj, &fx.orig, &fx.patched); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); + assert!( + read(&proj, ".socket/vendor/state.json").contains(PURL), + "vendored ledger claims the purl" + ); + + // B: hosted redirect over the vendored state — the takeover. + let tgz_path = fx.tmp.path().join("patched.tgz"); + build_patched_tgz(&proj.join("node_modules").join(DEP), &fx.patched, &tgz_path); + let tgz = std::fs::read(&tgz_path).unwrap(); + let server = MockServer::start().await; + let hosted_url = mount_hosted_mocks(&server, &tgz, &fx.orig, &fx.patched, None).await; + let (code, stdout, stderr) = run_hosted_scan(&proj, &server.uri()); + assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); + let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); + assert_eq!(envelope["redirect"]["redirected"], 1, "{stdout}"); + assert!( + stdout.contains("redirect_takeover_reverted_vendored"), + "takeover warning missing: {stdout}" + ); + + // The project is FULLY hosted: no vendored ledger claim, no committed + // artifact, no `file:` lock residue; the hosted wiring is present and its + // ledger records the PRISTINE registry originals. + assert!( + !read(&proj, ".socket/vendor/state.json").contains(PURL), + "the displaced vendored ledger entry must be dropped: {}", + read(&proj, ".socket/vendor/state.json") + ); + assert!( + !proj.join(format!(".socket/vendor/npm/{UUID_V}")).exists(), + "the orphaned committed artifact must be removed" + ); + let lock = read(&proj, "yarn.lock"); + assert!(lock.contains(&hosted_url), "lock points hosted:\n{lock}"); + assert!( + !lock.contains(".socket/vendor/"), + "no vendored residue in the lock:\n{lock}" + ); + let ledger = read(&proj, ".socket/vendor/redirect-state.json"); + assert!( + ledger.contains("registry.yarnpkg.com") || ledger.contains("registry.npmjs.org"), + "the redirect ledger's originals must be the pristine registry \ + fragments (originals chain intact across migrations): {ledger}" + ); + + // Fresh checkout installs the patched bytes from the hosted tarball. + let fresh = fresh_checkout(&proj, fx.tmp.path(), "classic-rev", false); + let fresh_cache = fx.tmp.path().join("fresh-cache-classic-rev"); + let ci = corepack( + &fresh, + YARN_CLASSIC, + &["install", "--frozen-lockfile", "--no-progress"], + &[("YARN_CACHE_FOLDER", fresh_cache.to_str().unwrap())], + ); + assert!( + ci.status.success(), + "fresh-checkout hosted install must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert!( + installed.starts_with(MARKER.as_bytes()), + "hosted install must carry the PATCHED bytes" + ); +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 4ebb1150..a86dbb61 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -31,7 +31,10 @@ pub use state::{ load_redirect_state, persist_redirect_state, save_redirect_state, CorruptRedirectState, RedirectState, REDIRECT_STATE_REL, }; -pub use takeover::{revert_cargo_redirect_purl, CargoRedirectRevert}; +pub use takeover::{ + redirect_revert_supported, revert_cargo_redirect_purl, revert_npm_redirect_purl, + revert_redirect_purl, CargoRedirectRevert, RedirectRevert, +}; /// One ecosystem's integrity hashes (mirrors the TS `PatchArtifactIntegrity`). #[derive(Debug, Clone, Default, Deserialize)] diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index 325de7d3..2a9835fe 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -1,15 +1,28 @@ -//! Cross-mode takeover: per-purl revert of a HOSTED cargo redirect, driven by -//! the redirect ledger's recorded [`FileEdit`]s. +//! Cross-mode takeover: per-purl revert of a HOSTED redirect, driven by the +//! redirect ledger's recorded [`FileEdit`]s. //! //! The vendored flows (`vendor`, `scan --mode vendored`) call this BEFORE //! vendoring a package the hosted redirect ledger still claims, so a -//! hosted→vendored migration leaves the project FULLY in vendored mode: -//! Cargo.toml loses its `registry = "socket-patch-…"` pin, Cargo.lock gets its -//! original crates.io `source`/`checksum` back (so the subsequent vendor -//! detach records the PRISTINE originals in the vendor ledger, not the hosted -//! values), and the now-unused `[registries.socket-patch-…]` block is dropped. -//! Without this, `[patch.crates-io]` cannot even apply (it only patches -//! crates-io-sourced deps) and the project is unbuildable in both modes. +//! hosted→vendored migration leaves the project FULLY in vendored mode. +//! +//! Cargo: Cargo.toml loses its `registry = "socket-patch-…"` pin, Cargo.lock +//! gets its original crates.io `source`/`checksum` back (so the subsequent +//! vendor detach records the PRISTINE originals in the vendor ledger, not the +//! hosted values), and the now-unused `[registries.socket-patch-…]` block is +//! dropped. Without this, `[patch.crates-io]` cannot even apply (it only +//! patches crates-io-sourced deps) and the project is unbuildable in both +//! modes. +//! +//! npm family (package-lock/npm-shrinkwrap, yarn classic, yarn berry, pnpm): +//! each recorded lock edit's `original` fragment is replayed over its `new` +//! fragment. Here the follow-up vendor rewire happens to succeed either way +//! (the vendored wiring replaces whatever resolution is present), but +//! WITHOUT the pre-revert the vendor ledger records the grant-tokenized +//! hosted fragment as its unrecoverable pre-vendor "original" (so `vendor +//! --revert` restores an expiring hosted URL with no CLI path back to +//! registry state), and the superseded redirect records/edits survive +//! forever — a stale ledger that VEX/audits keep reading and a replay hazard +//! for any later redirect revert. //! //! FAIL CLOSED: a file that matches neither the recorded redirected fragment //! nor the recorded original has drifted — the revert refuses (`Err`) rather @@ -32,13 +45,43 @@ use crate::utils::purl::{normalize_purl, parse_cargo_purl, strip_purl_qualifiers use super::state::RedirectState; use super::FileEdit; -/// What [`revert_cargo_redirect_purl`] rewrote. +/// What a redirect revert rewrote. #[derive(Debug, Default)] -pub struct CargoRedirectRevert { +pub struct RedirectRevert { /// Repo-relative files this revert actually rewrote or removed. pub reverted_files: Vec, } +/// Pre-rename alias (the struct was cargo-only before the npm-family port). +pub type CargoRedirectRevert = RedirectRevert; + +/// Does [`revert_redirect_purl`] have an implementation for this purl's +/// ecosystem? Callers (the vendor dispatch loop's cross-mode takeover gate) +/// must consult this instead of hardcoding `pkg:cargo/`. +pub fn redirect_revert_supported(purl: &str) -> bool { + purl.starts_with("pkg:cargo/") || purl.starts_with("pkg:npm/") +} + +/// Revert every hosted-redirect edit the ledger records for `purl`, then +/// drop that purl's record and edits from `state`. The caller persists the +/// mutated ledger (see `persist_redirect_state`). Dispatches per ecosystem; +/// purls outside [`redirect_revert_supported`] are refused (fail closed). +pub async fn revert_redirect_purl( + project_root: &Path, + state: &mut RedirectState, + purl: &str, +) -> Result { + if purl.starts_with("pkg:cargo/") { + revert_cargo_redirect_purl(project_root, state, purl).await + } else if purl.starts_with("pkg:npm/") { + revert_npm_redirect_purl(project_root, state, purl).await + } else { + Err(format!( + "no hosted-redirect revert implementation for {purl}" + )) + } +} + /// Read a project file, distinguishing missing (`Ok(None)`) from unreadable. async fn read_rel(project_root: &Path, rel: &str) -> Result, String> { match tokio::fs::read_to_string(project_root.join(rel)).await { @@ -111,7 +154,7 @@ pub async fn revert_cargo_redirect_purl( project_root: &Path, state: &mut RedirectState, purl: &str, -) -> Result { +) -> Result { let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); let target = canon(purl); let Some(record_key) = state.records.keys().find(|k| canon(k) == target).cloned() else { @@ -163,7 +206,7 @@ pub async fn revert_cargo_redirect_purl( .map(|(i, _)| i) .collect(); - let mut out = CargoRedirectRevert::default(); + let mut out = RedirectRevert::default(); let mut staged: Staged = Staged::new(); // Newest-first: the hosted flow appends edits, so reverse index order // unwinds re-redirect chains correctly (each step's `original` is the @@ -284,6 +327,387 @@ pub async fn revert_cargo_redirect_purl( Ok(out) } +/// `pkg:npm/@` (canonical, percent-decoded form) → +/// `(name, version)`; the name keeps its `@scope/` namespace. +fn parse_npm_purl(canon: &str) -> Option<(&str, &str)> { + let rest = canon.strip_prefix("pkg:npm/")?; + let (name, version) = rest.rsplit_once('@')?; + (!name.is_empty() && !version.is_empty()).then_some((name, version)) +} + +/// The npm-family text-fragment edit kinds: `original`/`new` hold the whole +/// lock fragment as a string, and the revert is a `replacen(new, original)`. +const NPM_TEXT_KINDS: [&str; 3] = [ + "redirect_yarn_classic_entry", + "redirect_yarn_berry_entry", + "redirect_pnpm_resolution", +]; + +/// Revert every hosted-redirect edit the ledger records for `purl` (an npm +/// package), then drop that purl's record and edits from `state`. The caller +/// persists the mutated ledger (see `persist_redirect_state`). +/// +/// Same fail-closed contract as [`revert_cargo_redirect_purl`]: every inverse +/// is resolved against a staged view and NOTHING reaches disk until all of +/// them have resolved, so a drift refusal leaves the project byte-identical +/// across ALL the files the ledger claims. +pub async fn revert_npm_redirect_purl( + project_root: &Path, + state: &mut RedirectState, + purl: &str, +) -> Result { + let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let target = canon(purl); + let Some(record_key) = state.records.keys().find(|k| canon(k) == target).cloned() else { + return Err(format!( + "the redirect ledger records no hosted redirect for {purl}" + )); + }; + let Some((name, version)) = parse_npm_purl(&target) else { + return Err(format!("not an npm purl: {purl}")); + }; + let (name, version) = (name.to_string(), version.to_string()); + let lock_key = format!("{name}@{version}"); + + // The package-lock/shrinkwrap files any `redirect_npm_lock_entry` edits + // touch, parsed once from disk: an ALIAS install (`npm i alias@npm:name`) + // keys its entry by the alias, so ownership is resolved through the + // entry's `name` field — exactly how the rewriter matched it (the rewrite + // never touches name/version, so the probe is symmetric). + let mut disk_locks: BTreeMap> = BTreeMap::new(); + for e in &state.edits { + if e.kind == "redirect_npm_lock_entry" && !disk_locks.contains_key(&e.path) { + let parsed = read_rel(project_root, &e.path) + .await? + .and_then(|c| serde_json::from_str::(&c).ok()); + disk_locks.insert(e.path.clone(), parsed); + } + } + + // Claim this purl's edits. Text-fragment kinds and the berry/classic/pnpm + // rewriters key edits by `@`; the legacy npm v2 + // `dependencies` tree keys by bare name; the v3 `packages` map keys by + // the lock path. The package-lock JSON kinds carry no version in their + // key, so ownership is version-discriminated the way the rewriter + // matched (entry `name`+`version`, mod.rs) — name-only would claim a + // SIBLING purl's edits (left-pad@1.2.0 vs @1.3.0 both hosted-redirected, + // or `npm i name@npm:other` aliasing another package onto this key path) + // and replaying those silently un-hosts the other purl while dropping + // its edits. A bun.lock edit that may belong to this purl is a hard + // refusal: bun edits key by the lock's package key (not name@version) + // and their revert is not implemented, so vendoring over one would drop + // the record while stranding its edits — half a takeover. + let mut mine: Vec = Vec::new(); + for (i, e) in state.edits.iter().enumerate() { + let key = e.key.as_deref().unwrap_or_default(); + let claimed = match e.kind.as_str() { + k if NPM_TEXT_KINDS.contains(&k) => key == lock_key, + "redirect_npm_lock_dep" => key == name && edit_references_version(e, &version), + "redirect_npm_lock_entry" => { + let key_name = key + .rsplit_once("node_modules/") + .map(|(_, n)| n) + .unwrap_or(key); + match disk_locks + .get(&e.path) + .and_then(|l| l.as_ref()) + .and_then(|l| l.get("packages")) + .and_then(|p| p.get(key)) + { + // The entry is live: attribute it exactly the way the + // rewriter matched it — effective name (the `name` field + // npm writes for alias installs, else the key's trailing + // path; the rewrite never touches either, so the probe + // is symmetric) AND version. + Some(entry) => { + let entry_name = entry + .get("name") + .and_then(Value::as_str) + .unwrap_or(key_name); + entry_name == name + && match entry.get("version").and_then(Value::as_str) { + Some(v) => v == version, + // Version field gone (hand-edited lock): fall + // back to the recorded URLs, erring toward + // claiming — the replay itself fails closed + // on any value mismatch. + None => edit_references_version(e, &version), + } + } + // Entry (or the whole lock) gone: keep the fail-closed + // "no longer exists" refusal for edits attributable to + // this purl by key path + recorded URLs; a sibling + // version's edit is not ours to claim. + None => key_name == name && edit_references_version(e, &version), + } + } + "redirect_bun_lock_package" => { + let probe = format!("\"{name}@"); + let holds = |v: &Option| { + v.as_ref() + .and_then(Value::as_str) + .is_some_and(|s| s.contains(&probe)) + }; + if holds(&e.new) || holds(&e.original) { + return Err(format!( + "the redirect ledger records a bun.lock hosted redirect \ + for {name}, which this revert cannot replay yet; \ + restore the registry wiring manually (or re-lock with \ + `bun install`), remove the ledger entry, then re-run" + )); + } + false + } + _ => false, + }; + if claimed { + mine.push(i); + } + } + + let mut out = RedirectRevert::default(); + let mut staged: Staged = Staged::new(); + // Newest-first: the hosted flow appends edits, so reverse index order + // unwinds re-redirect chains correctly (each step's `original` is the + // previous step's `new`). + for &i in mine.iter().rev() { + let edit = state.edits[i].clone(); + if NPM_TEXT_KINDS.contains(&edit.kind.as_str()) { + let (Some(new), Some(orig)) = ( + edit.new.as_ref().and_then(Value::as_str), + edit.original.as_ref().and_then(Value::as_str), + ) else { + return Err(format!( + "the redirect ledger edit for {name} in {} records no \ + original fragment; cannot revert the hosted redirect", + edit.path + )); + }; + let Some(content) = staged_read(&staged, project_root, &edit.path).await? else { + return Err(format!( + "{} no longer exists; cannot revert the recorded hosted \ + redirect for {lock_key}", + edit.path + )); + }; + if content.contains(new) { + staged.insert(edit.path.clone(), Some(content.replacen(new, orig, 1))); + out.reverted_files.push(edit.path.clone()); + } else if content.contains(orig) { + // Already at (or unwound to) the pre-redirect fragment. + } else { + return Err(format!( + "the {} entry for {lock_key} has drifted from the recorded \ + hosted redirect (neither the redirected nor the original \ + fragment is present); refusing to touch it — re-run \ + `scan --mode hosted` to normalize the redirect, or \ + restore the registry wiring manually, then re-run", + edit.path + )); + } + } else { + revert_npm_json_edit(project_root, &mut staged, &edit, &name, &version, &mut out) + .await?; + } + } + + // Every inverse resolved — only now does any of it reach disk, so a + // refusal above left the project exactly as it was found. + flush_staged(project_root, &staged).await?; + + // Only after every inverse applied cleanly: drop this purl's edits and + // record from the ledger (the caller persists it). + let drop: HashSet = mine.into_iter().collect(); + let mut idx = 0usize; + state.edits.retain(|_| { + let keep = !drop.contains(&idx); + idx += 1; + keep + }); + state.records.remove(&record_key); + Ok(out) +} + +/// Does one of this edit's recorded `resolved` URLs reference `version`? +/// +/// Version discriminator for the package-lock JSON edit kinds, whose keys +/// carry no version (`redirect_npm_lock_dep` keys by bare name, +/// `redirect_npm_lock_entry` by lock path): both the hosted artifact URL +/// (`…/npm///…/-.tgz`) and the registry +/// tarball URL (`…/-/-.tgz`) embed the version behind a +/// `//` or `-.tgz` delimiter, so sibling versions of the +/// same package never +/// match each other (`/1.3.0/` is not a substring of `/11.3.0/`, nor +/// `-1.3.0.tgz` of `-11.3.0.tgz`). Checked against `new` and `original` so +/// every link of a re-redirect chain (each hosted URL names this purl's +/// version) attributes correctly. A false positive here is safe — the +/// replay itself fails closed on any value mismatch — while name-only +/// claiming silently un-hosts the sibling purl. +fn edit_references_version(edit: &FileEdit, version: &str) -> bool { + let path_seg = format!("/{version}/"); + let tarball = format!("-{version}.tgz"); + [&edit.new, &edit.original].into_iter().any(|v| { + v.as_ref() + .and_then(|o| o.get("resolved")) + .and_then(Value::as_str) + .is_some_and(|s| s.contains(&path_seg) || s.contains(&tarball)) + }) +} + +/// Replay one recorded package-lock JSON edit (`redirect_npm_lock_entry` / +/// `redirect_npm_lock_dep`) through the staged view. +async fn revert_npm_json_edit( + project_root: &Path, + staged: &mut Staged, + edit: &FileEdit, + name: &str, + version: &str, + out: &mut RedirectRevert, +) -> Result<(), String> { + let Some(content) = staged_read(staged, project_root, &edit.path).await? else { + return Err(format!( + "{} no longer exists; cannot revert the recorded hosted redirect \ + for {name}@{version}", + edit.path + )); + }; + let mut lock: Value = serde_json::from_str(&content).map_err(|e| { + format!( + "{} is not valid JSON ({e}); cannot revert the recorded hosted \ + redirect for {name}@{version}", + edit.path + ) + })?; + let key = edit.key.as_deref().unwrap_or_default(); + let changed = match edit.kind.as_str() { + "redirect_npm_lock_entry" => { + let Some(entry) = lock.get_mut("packages").and_then(|p| p.get_mut(key)) else { + return Err(format!( + "the {} entry `{key}` for {name}@{version} no longer \ + exists; cannot revert the recorded hosted redirect", + edit.path + )); + }; + replay_resolved_integrity(entry, edit, &edit.path, key)? + } + "redirect_npm_lock_dep" => { + let Some(deps) = lock.get_mut("dependencies").and_then(Value::as_object_mut) else { + return Err(format!( + "{} no longer holds a `dependencies` tree; cannot revert \ + the recorded hosted redirect for {name}@{version}", + edit.path + )); + }; + let mut any_found = false; + let mut changed = false; + revert_v2_deps( + deps, + name, + version, + edit, + &edit.path, + &mut any_found, + &mut changed, + )?; + if !any_found { + return Err(format!( + "the {} `dependencies` entry for {name}@{version} no \ + longer exists; cannot revert the recorded hosted redirect", + edit.path + )); + } + changed + } + other => { + return Err(format!( + "no revert implementation for redirect edit kind `{other}`" + )); + } + }; + if changed { + staged.insert(edit.path.clone(), Some(super::serialize_json(&lock))); + out.reverted_files.push(edit.path.clone()); + } + Ok(()) +} + +/// Replace an entry's `resolved`/`integrity` with the edit's recorded +/// originals. `Ok(false)` when the entry already holds the originals; +/// `Err` (drift, fail closed) when it holds neither the recorded redirected +/// values nor the originals. +fn replay_resolved_integrity( + entry: &mut Value, + edit: &FileEdit, + path: &str, + key: &str, +) -> Result { + let field = |v: &Option, f: &str| -> Value { + v.as_ref() + .and_then(|o| o.get(f)) + .cloned() + .unwrap_or(Value::Null) + }; + let orig_res = field(&edit.original, "resolved"); + let orig_int = field(&edit.original, "integrity"); + let cur = |f: &str| entry.get(f).cloned().unwrap_or(Value::Null); + if cur("resolved") == orig_res && cur("integrity") == orig_int { + return Ok(false); // already at (or unwound to) the pre-redirect values + } + if cur("resolved") != field(&edit.new, "resolved") + || cur("integrity") != field(&edit.new, "integrity") + { + return Err(format!( + "the {path} entry `{key}` has drifted from the recorded hosted \ + redirect (neither the redirected nor the original \ + resolved/integrity is present); refusing to touch it — re-run \ + `scan --mode hosted` to normalize the redirect, or restore the \ + registry wiring manually, then re-run" + )); + } + let Some(obj) = entry.as_object_mut() else { + return Err(format!("the {path} entry `{key}` is not an object")); + }; + for (f, orig) in [("resolved", orig_res), ("integrity", orig_int)] { + if orig.is_null() { + obj.remove(f); + } else { + obj.insert(f.to_string(), orig); + } + } + Ok(true) +} + +/// Recursive twin of the rewriter's `rewrite_npm_v2_deps` walk: replay the +/// edit's originals over every legacy `dependencies` node for this +/// name+version. Bundled nodes mirror the rewriter's skip — they were never +/// rewritten, so their registry-shaped (or absent) values must not read as +/// drift. +fn revert_v2_deps( + deps: &mut serde_json::Map, + name: &str, + version: &str, + edit: &FileEdit, + path: &str, + any_found: &mut bool, + changed: &mut bool, +) -> Result<(), String> { + for (dep_name, entry) in deps.iter_mut() { + if dep_name == name + && entry.get("version").and_then(Value::as_str) == Some(version) + && entry.get("bundled").and_then(Value::as_bool) != Some(true) + { + *any_found = true; + if replay_resolved_integrity(entry, edit, path, dep_name)? { + *changed = true; + } + } + if let Some(nested) = entry.get_mut("dependencies").and_then(Value::as_object_mut) { + revert_v2_deps(nested, name, version, edit, path, any_found, changed)?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -514,4 +938,519 @@ mod tests { .expect_err("no record"); assert!(err.contains("records no hosted redirect"), "{err}"); } + + // ── npm family ─────────────────────────────────────────────────────── + + const NPM_PURL: &str = "pkg:npm/left-pad@1.3.0"; + const NPM_URL: &str = + "http://127.0.0.1:5555/patch/npm/left-pad/1.3.0/tok/6b7c/left-pad-1.3.0.tgz"; + + fn npm_dep_for(name: &str, version: &str) -> crate::patch::redirect::DepOverride { + serde_json::from_value(serde_json::json!({ + "ecosystem": "npm", + "name": name, + "version": version, + "token": "tok", + "patchUuid": UUID, + "artifactUrl": format!( + "http://127.0.0.1:5555/patch/npm/{name}/{version}/tok/6b7c/{name}-{version}.tgz" + ), + "integrity": { + "sha512": format!("sha512-{}==", "B".repeat(86)), + "sha1": "1".repeat(40), + "yarnBerry10c0": format!("10c0/{}", "b".repeat(128)), + }, + })) + .unwrap() + } + + fn npm_dep() -> crate::patch::redirect::DepOverride { + npm_dep_for("left-pad", "1.3.0") + } + + /// Run the real hosted rewriter over one pristine lock (redirecting every + /// purl in `deps`), write its output to a tempdir, and return the + /// resulting ledger — the exact state the takeover revert consumes in + /// production. + async fn npm_redirected_fixture_multi( + rel: &str, + pristine: &str, + deps: &[(&str, crate::patch::redirect::DepOverride)], + ) -> (tempfile::TempDir, RedirectState) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut files: BTreeMap = BTreeMap::new(); + files.insert(rel.to_string(), pristine.to_string()); + let overrides: Vec<_> = deps.iter().map(|(_, d)| d.clone()).collect(); + let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &overrides); + let rewritten = rewrite + .files + .get(rel) + .unwrap_or_else(|| panic!("rewriter must rewrite {rel}: {:?}", rewrite.warnings)); + tokio::fs::write(root.join(rel), rewritten).await.unwrap(); + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + for (purl, _) in deps { + state.records.insert(purl.to_string(), record()); + } + (tmp, state) + } + + /// Run the real hosted rewriter over one pristine lock, write its output + /// to a tempdir, and return the resulting ledger — the exact state the + /// takeover revert consumes in production. + async fn npm_redirected_fixture( + rel: &str, + pristine: &str, + ) -> (tempfile::TempDir, RedirectState) { + npm_redirected_fixture_multi(rel, pristine, &[(NPM_PURL, npm_dep())]).await + } + + fn classic_pristine() -> String { + "# yarn lockfile v1\n\n\nleft-pad@1.3.0:\n version \"1.3.0\"\n resolved \ + \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a\"\n \ + integrity sha512-original==\n" + .to_string() + } + + fn berry_pristine() -> String { + "# This file is generated by running \"yarn install\" inside your project.\n\n\ + __metadata:\n version: 8\n cacheKey: 10c0\n\n\ + \"left-pad@npm:1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n \ + checksum: 10c0/cccc\n languageName: node\n linkType: hard\n" + .to_string() + } + + /// Pristine package-lock (lockfileVersion 2: BOTH the v3 `packages` map + /// and the legacy v2 `dependencies` tree), serialized exactly as the + /// rewriter serializes, so the revert round-trip is byte-comparable. + fn package_lock_pristine() -> String { + let lock = serde_json::json!({ + "name": "app", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-pristine==" + } + }, + "dependencies": { + "left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-pristine==" + } + } + }); + format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()) + } + + #[test] + fn revert_supported_gate_covers_cargo_and_npm_only() { + assert!(redirect_revert_supported("pkg:cargo/cfg-if@1.0.4")); + assert!(redirect_revert_supported("pkg:npm/left-pad@1.3.0")); + assert!(redirect_revert_supported("pkg:npm/%40scope/x@1.0.0")); + assert!(!redirect_revert_supported("pkg:gem/rack@3.0.0")); + assert!(!redirect_revert_supported("pkg:pypi/flask@2.0.0")); + } + + #[tokio::test] + async fn npm_classic_lock_round_trips_and_drops_ledger_entries() { + let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; + let root = tmp.path(); + let wired = tokio::fs::read_to_string(root.join("yarn.lock")) + .await + .unwrap(); + assert!(wired.contains(NPM_URL), "fixture is hosted-wired: {wired}"); + + let out = revert_redirect_purl(root, &mut state, NPM_PURL) + .await + .expect("revert succeeds"); + assert_eq!(out.reverted_files, vec!["yarn.lock".to_string()]); + assert_eq!( + tokio::fs::read_to_string(root.join("yarn.lock")) + .await + .unwrap(), + classic_pristine(), + "yarn.lock restored byte-identical" + ); + assert!(state.records.is_empty(), "record dropped"); + assert!(state.edits.is_empty(), "edits dropped"); + } + + #[tokio::test] + async fn npm_berry_lock_round_trips_and_drops_ledger_entries() { + let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &berry_pristine()).await; + let root = tmp.path(); + let wired = tokio::fs::read_to_string(root.join("yarn.lock")) + .await + .unwrap(); + assert!( + wired.contains("::__archiveUrl="), + "fixture is hosted-wired: {wired}" + ); + + revert_redirect_purl(root, &mut state, NPM_PURL) + .await + .expect("revert succeeds"); + assert_eq!( + tokio::fs::read_to_string(root.join("yarn.lock")) + .await + .unwrap(), + berry_pristine(), + "yarn.lock restored byte-identical" + ); + assert!(state.records.is_empty(), "record dropped"); + assert!(state.edits.is_empty(), "edits dropped"); + } + + #[tokio::test] + async fn npm_package_lock_v2_round_trips_both_trees() { + let (tmp, mut state) = + npm_redirected_fixture("package-lock.json", &package_lock_pristine()).await; + let root = tmp.path(); + assert_eq!(state.edits.len(), 2, "packages + dependencies edits"); + let wired = tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(); + assert!(wired.contains(NPM_URL), "fixture is hosted-wired: {wired}"); + + revert_npm_redirect_purl(root, &mut state, NPM_PURL) + .await + .expect("revert succeeds"); + assert_eq!( + tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(), + package_lock_pristine(), + "package-lock.json restored byte-identical (both trees)" + ); + assert!(state.records.is_empty(), "record dropped"); + assert!(state.edits.is_empty(), "edits dropped"); + } + + /// Pristine package-lock (lockfileVersion 2, both trees) holding TWO + /// versions of left-pad — the sibling-purl fixture the claim matcher + /// must not cross-claim. + fn two_version_lock_pristine() -> String { + let lp = |v: &str| { + serde_json::json!({ + "version": v, + "resolved": format!("https://registry.npmjs.org/left-pad/-/left-pad-{v}.tgz"), + "integrity": format!("sha512-pristine-{v}=="), + }) + }; + let lock = serde_json::json!({ + "name": "app", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + "node_modules/a": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/a/-/a-1.0.0.tgz", + "integrity": "sha512-a==" + }, + "node_modules/a/node_modules/left-pad": lp("1.2.0"), + "node_modules/left-pad": lp("1.3.0"), + }, + "dependencies": { + "a": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/a/-/a-1.0.0.tgz", + "integrity": "sha512-a==", + "dependencies": { "left-pad": lp("1.2.0") } + }, + "left-pad": lp("1.3.0"), + } + }); + format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()) + } + + /// Two hosted-redirected VERSIONS of the same package: taking over one + /// purl must not claim (and silently un-host) the sibling's lock edits — + /// the package-lock JSON edit keys carry no version, so a name-only + /// matcher replays the sibling's `original` back over its live hosted + /// wiring and drops its edits while its ledger record survives edit-less. + #[tokio::test] + async fn npm_two_versions_takeover_of_one_leaves_the_siblings_redirect_intact() { + let sibling_purl = "pkg:npm/left-pad@1.2.0"; + let (tmp, mut state) = npm_redirected_fixture_multi( + "package-lock.json", + &two_version_lock_pristine(), + &[ + (NPM_PURL, npm_dep()), + (sibling_purl, npm_dep_for("left-pad", "1.2.0")), + ], + ) + .await; + let root = tmp.path(); + // 2 edits per purl: one v3 `packages` entry + one v2 `dependencies` + // node each. + assert_eq!(state.edits.len(), 4, "{:?}", state.edits); + let sibling_url = npm_dep_for("left-pad", "1.2.0").artifact_url.clone(); + let wired = tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(); + assert!(wired.contains(NPM_URL) && wired.contains(&sibling_url)); + + revert_npm_redirect_purl(root, &mut state, NPM_PURL) + .await + .expect("takeover of 1.3.0 succeeds without touching 1.2.0"); + + let lock = tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(); + assert!(!lock.contains(NPM_URL), "1.3.0 un-hosted: {lock}"); + assert!( + lock.contains("left-pad/-/left-pad-1.3.0.tgz"), + "1.3.0 back on the registry: {lock}" + ); + assert_eq!( + lock.matches(&sibling_url).count(), + 2, + "1.2.0 still hosted-wired in BOTH trees: {lock}" + ); + assert!( + state.records.contains_key(sibling_purl) && !state.records.contains_key(NPM_PURL), + "only 1.3.0's record dropped: {:?}", + state.records.keys() + ); + assert_eq!( + state.edits.len(), + 2, + "1.2.0 keeps its two edits: {:?}", + state.edits + ); + + // The sibling's own takeover still round-trips the file to pristine. + revert_npm_redirect_purl(root, &mut state, sibling_purl) + .await + .expect("takeover of 1.2.0 succeeds"); + assert_eq!( + tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(), + two_version_lock_pristine(), + "package-lock.json restored byte-identical" + ); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + + /// `npm i left-pad@npm:other` keys package `other` under the lock path + /// `node_modules/left-pad`: taking over left-pad must not claim that + /// entry's edit through the key name (the entry's `name` field exonerates + /// it, exactly as the rewriter matched), while an alias install OF + /// left-pad (`npm i mylp@npm:left-pad`) must still be claimed through + /// the `name` field. + #[tokio::test] + async fn npm_alias_collision_takeover_claims_by_entry_name_not_key_path() { + let other_purl = "pkg:npm/other@1.3.0"; + let lock = serde_json::json!({ + "name": "app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + // Alias of ANOTHER package onto this key path — same version + // on purpose, so only the name field can exonerate it. + "node_modules/left-pad": { + "name": "other", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/other/-/other-1.3.0.tgz", + "integrity": "sha512-pristine-other==" + }, + // Alias OF the target package: claimed via the name field. + "node_modules/mylp": { + "name": "left-pad", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-pristine-1.3.0==" + }, + "node_modules/b/node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-pristine-1.3.0==" + }, + }, + }); + let pristine = format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()); + let (tmp, mut state) = npm_redirected_fixture_multi( + "package-lock.json", + &pristine, + &[ + (NPM_PURL, npm_dep()), + (other_purl, npm_dep_for("other", "1.3.0")), + ], + ) + .await; + let root = tmp.path(); + assert_eq!(state.edits.len(), 3, "{:?}", state.edits); + let other_url = npm_dep_for("other", "1.3.0").artifact_url.clone(); + + revert_npm_redirect_purl(root, &mut state, NPM_PURL) + .await + .expect("takeover of left-pad succeeds without touching `other`"); + + let lock = tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(); + assert!( + !lock.contains(NPM_URL), + "both left-pad entries (path-keyed AND alias-keyed) un-hosted: {lock}" + ); + assert!( + lock.contains(&other_url), + "`other` (aliased onto node_modules/left-pad) still hosted-wired: {lock}" + ); + assert!( + state.records.contains_key(other_purl) && !state.records.contains_key(NPM_PURL), + "{:?}", + state.records.keys() + ); + assert_eq!( + state.edits.len(), + 1, + "other keeps its edit: {:?}", + state.edits + ); + + revert_npm_redirect_purl(root, &mut state, other_purl) + .await + .expect("takeover of other succeeds"); + assert_eq!( + tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(), + pristine, + "package-lock.json restored byte-identical" + ); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + + /// The version-scoped claim must not soften the fail-closed contract: a + /// lock entry that VANISHED after being redirected still refuses (its + /// edit is attributed by key path + recorded URLs), never a silent + /// record-drop that strands the edit. + #[tokio::test] + async fn npm_missing_lock_entry_still_fails_closed() { + let lock = serde_json::json!({ + "name": "app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-pristine==" + }, + }, + }); + let pristine = format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()); + let (tmp, mut state) = npm_redirected_fixture("package-lock.json", &pristine).await; + let root = tmp.path(); + // A third party pruned the entry from the lock after the redirect. + let mut on_disk: Value = serde_json::from_str( + &tokio::fs::read_to_string(root.join("package-lock.json")) + .await + .unwrap(), + ) + .unwrap(); + on_disk + .get_mut("packages") + .and_then(Value::as_object_mut) + .unwrap() + .remove("node_modules/left-pad") + .expect("fixture entry present"); + tokio::fs::write( + root.join("package-lock.json"), + serde_json::to_string_pretty(&on_disk).unwrap(), + ) + .await + .unwrap(); + let edits_before = state.edits.len(); + + let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL) + .await + .expect_err("vanished entry must refuse"); + assert!(err.contains("no longer exists"), "{err}"); + assert!(!state.records.is_empty(), "ledger keeps the record"); + assert_eq!(state.edits.len(), edits_before, "ledger keeps the edits"); + } + + #[tokio::test] + async fn npm_refuses_on_drifted_lock_fail_closed() { + let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; + let root = tmp.path(); + // A third party re-resolved the entry to a shape the ledger never saw. + let drifted = classic_pristine().replace( + "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a", + "https://corp.example/left-pad-1.3.0.tgz#dead", + ); + tokio::fs::write(root.join("yarn.lock"), &drifted) + .await + .unwrap(); + let records_before = state.records.len(); + let edits_before = state.edits.len(); + + let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL) + .await + .expect_err("drifted lock must refuse"); + assert!(err.contains("drifted"), "{err}"); + // The ledger keeps everything on refusal, and the file is untouched. + assert_eq!(state.records.len(), records_before); + assert_eq!(state.edits.len(), edits_before); + assert_eq!( + tokio::fs::read_to_string(root.join("yarn.lock")) + .await + .unwrap(), + drifted + ); + } + + #[tokio::test] + async fn npm_missing_record_is_an_error() { + let tmp = tempfile::tempdir().unwrap(); + let mut state = RedirectState::new(); + let err = revert_npm_redirect_purl(tmp.path(), &mut state, NPM_PURL) + .await + .expect_err("no record"); + assert!(err.contains("records no hosted redirect"), "{err}"); + } + + #[tokio::test] + async fn npm_bun_lock_edit_is_a_fail_closed_refusal() { + // The bun revert is not implemented; a ledger claiming this purl via + // a bun.lock edit must refuse rather than drop the record while + // stranding the edit. + let tmp = tempfile::tempdir().unwrap(); + let mut state = RedirectState::new(); + state.records.insert(NPM_PURL.to_string(), record()); + state.edits.push(FileEdit { + path: "bun.lock".into(), + kind: "redirect_bun_lock_package".into(), + action: "rewritten".into(), + key: Some("left-pad".into()), + original: Some(Value::String( + " \"left-pad\": [\"left-pad@1.3.0\", \"reg\", {}, \"sha512-p==\"],".into(), + )), + new: Some(Value::String(format!( + " \"left-pad\": [\"left-pad@{NPM_URL}\", {{}}, \"sha512-h==\"]," + ))), + }); + let err = revert_npm_redirect_purl(tmp.path(), &mut state, NPM_PURL) + .await + .expect_err("bun edits must refuse"); + assert!(err.contains("bun.lock"), "{err}"); + assert!(!state.records.is_empty(), "ledger keeps the record"); + assert!(!state.edits.is_empty(), "ledger keeps the edit"); + } }