From ec0472220762908a6c94aea89ca42807ca25eac6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:25:19 +0000 Subject: [PATCH 1/7] Start fix for #853, #944 Assisted-by: Claude Code:claude-opus-5-5 From 175a259189b5aab7730bb0c1e5049f02497ed506 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:54:49 +0000 Subject: [PATCH 2/7] Keep the hosted pin when a vendored takeover fails Switching a hosted-patched package to vendored mode (scan or get --mode vendored, or vendor) first restores the package's upstream registry entry, then runs the vendored backend. When that backend refused the package (a pnpm catalog dependency, a CRLF pnpm lock, a workspace exact-pin override, a uv inline sources table, ...), the restore had already been made, so the package ended up neither hosted nor vendored and the next install pulled the unpatched release. The takeover now takes a savepoint in the run's group commit before the restore. If the backend does not vendor the package, the restore is rolled back in memory before anything reaches disk: the hosted pin, lock and side config stay byte-for-byte, the purl fails with the backend's own code, and no "restored its upstream entry" advisory is printed. This covers every ecosystem's post-restore refusal, not just the pnpm and uv triggers that were reported. A restore that writes a file the group commit does not capture keeps the old behaviour. vendor --dry-run also previews the backend's refusal over the restored project, staged in a throwaway overlay that never reaches disk. Refs #853, #944 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/vendor.rs | 202 ++++++++- .../in_process_vendor_bun_takeover/vlt.rs | 39 +- .../tests/in_process_vendor_pnpm_takeover.rs | 411 ++++++++++++++++++ .../src/utils/group_commit.rs | 156 +++++++ 4 files changed, 768 insertions(+), 40 deletions(-) create mode 100644 crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 0289bf946..97e1a1aa3 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -121,6 +121,105 @@ fn linked_vendor_dir_refusal(project_root: &Path, purl: &str, uuid: &str) -> Opt .map(|link| vendor::path::vendor_dir_symlink_detail(&link)) } +/// A wet hosted → vendored takeover held open until the backend's outcome +/// is known: the group-commit savepoint taken before the upstream restore, +/// and what the restore reported, recorded only once the restore stands. +struct TakeoverUndo { + savepoint: Option, + advisories: Vec, + vlt_targets: Vec, +} + +impl TakeoverUndo { + /// The restore stands: record its advisories and queue the vlt heal. + fn settle( + self, + env: &mut Envelope, + common: &GlobalArgs, + candidate: &str, + vlt_takeover_targets: &mut HashMap< + String, + Vec, + >, + ) { + if !self.vlt_targets.is_empty() { + vlt_takeover_targets.insert(candidate.to_string(), self.vlt_targets); + } + for advisory in &self.advisories { + record_warning(env, candidate, advisory, common); + } + } +} + +/// The dry-run twin of the wet takeover's rollback: the vendored backend's +/// refusal over the project as `restore` would leave it, or `None` when it +/// would vendor (or the restored project cannot be previewed). The +/// restored text is staged in a throwaway group commit that is never +/// committed, so nothing reaches the disk: a restore touching a file the +/// overlay does not capture, or a binary lock (no staged text), is not +/// previewed. +#[allow(clippy::too_many_arguments)] +async fn takeover_dry_refusal( + restore: &socket_patch_core::patch::redirect::upstream::RestoreOutcome, + purl: &str, + pkg_path: PackageSource<'_>, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + force: bool, + service: Option<&VendorServiceConfig>, + pipenv_version: &tokio::sync::OnceCell>, + installed_sites: &vendor::pypi::InstalledSiteListings, +) -> Option<(&'static str, String)> { + if restore.reverted_files.is_empty() + || !restore.reverted_files.iter().all(|f| { + socket_patch_core::utils::group_commit::captures(f) + && restore.staged_text.contains_key(f) + }) + { + return None; + } + let probe = GroupCommit::begin(project_root); + for (rel, text) in &restore.staged_text { + let path = project_root.join(rel); + let staged = match text { + Some(text) => { + socket_patch_core::utils::fs::atomic_write_bytes_preserving_mode( + &path, + text.as_bytes(), + ) + .await + } + None => socket_patch_core::utils::fs::remove_file(&path).await, + }; + if staged.is_err() { + return None; + } + } + let outcome = Box::pin(dispatch_vendor_one( + purl, + pkg_path, + project_root, + record, + sources, + vendored_at, + true, + force, + service, + pipenv_version, + installed_sites, + )) + .await; + drop(probe); + match outcome { + Some(VendorOutcome::Refused { code, detail }) if !refusal_is_benign(code) => { + Some((code, detail)) + } + _ => None, + } +} + /// Dispatch one purl to its ecosystem backend. `pkg_path` is the crawler's /// installed location (site-packages root for pypi, the package dir /// otherwise), or a fetched artifact the backend materialises only if it @@ -2579,6 +2678,11 @@ pub(crate) async fn vendor_records_reusing( // vendor detach the PRISTINE registry entry to record. A purl // whose upstream entry cannot be restored is REFUSED; the cargo // backend's `hosted_redirect_live` guard backstops the rest. + // A wet takeover whose restore stayed in the group commit's + // overlay: the point to roll back to when the backend below + // does not vendor the purl, and the advisories that only hold + // once it does (see `TakeoverUndo`). + let mut takeover_undo: Option = None; if let Some(pin) = hosted_pin_of(candidate) { let origins = crate::commands::rollback::patch_server_origins(common); let restore_opts = socket_patch_core::patch::redirect::upstream::RestoreOptions { @@ -2664,12 +2768,22 @@ pub(crate) async fn vendor_records_reusing( ) }) .unwrap_or_default(); + let savepoint = group.as_ref().map(GroupCommit::savepoint); let restore = socket_patch_core::patch::redirect::upstream::restore_upstream( &common.cwd, std::slice::from_ref(pin), &restore_opts, ) .await; + // Undoable only when every file the restore wrote is still + // in the overlay (a `.socket/gradle/hosted-index.tsv` is + // written straight to disk). + let savepoint = savepoint.filter(|_| { + restore + .reverted_files + .iter() + .all(|f| socket_patch_core::utils::group_commit::captures(f)) + }); let refusal = restore .refused() .map(|(_, why)| why.to_string()) @@ -2690,15 +2804,41 @@ pub(crate) async fn vendor_records_reusing( ); continue; } - for (code, detail) in &restore.warnings { - record_warning( - env, - candidate, - &VendorWarning::new(code, detail.clone()), - common, - ); - } + let mut advisories: Vec = restore + .warnings + .iter() + .map(|(code, detail)| VendorWarning::new(code, detail.clone())) + .collect(); if common.dry_run { + // The refusal the backend would raise over the restored + // project, previewed here with the wet run's code (the + // wet run rolls the restore back on it, below). + if let Some((code, detail)) = takeover_dry_refusal( + &restore, + candidate, + pkg_source, + &common.cwd, + record, + sources, + &vendored_at, + force, + service, + &pipenv_version, + &installed_sites, + ) + .await + { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()) + .with_error(code, detail.clone()), + ); + report_vendor_failure(common, candidate, &detail); + continue; + } + for advisory in &advisories { + record_warning(env, candidate, advisory, common); + } record_warning( env, candidate, @@ -2726,23 +2866,25 @@ pub(crate) async fn vendor_records_reusing( continue; } } else { - if !targets.is_empty() { - vlt_takeover_targets.insert(candidate.clone(), targets); - } - record_warning( - env, - candidate, - &VendorWarning::new( - "vendor_takeover_reverted_redirect", - format!( - "{} was hosted; restored its upstream registry entry ({}) \ - before vendoring (mode takeover)", - normalize_purl(candidate), - restore.reverted_files.join(", ") - ), + advisories.push(VendorWarning::new( + "vendor_takeover_reverted_redirect", + format!( + "{} was hosted; restored its upstream registry entry ({}) \ + before vendoring (mode takeover)", + normalize_purl(candidate), + restore.reverted_files.join(", ") ), - common, - ); + )); + let undo = TakeoverUndo { + savepoint, + advisories, + vlt_targets: targets, + }; + if undo.savepoint.is_some() { + takeover_undo = Some(undo); + } else { + undo.settle(env, common, candidate, &mut vlt_takeover_targets); + } } } @@ -2830,6 +2972,18 @@ pub(crate) async fn vendor_records_reusing( status.finish(); let vendored = matches!(&outcome, Some(VendorOutcome::Done { result, .. }) if result.success); + if let Some(mut undo) = takeover_undo.take() { + // A takeover the backend did not carry through keeps the + // hosted pin: its restore is rolled back in the overlay, so + // the purl is never left un-hosted AND unvendored (#853, + // #944). One the backend recorded keeps the restore. + let recorded = + matches!(&outcome, Some(VendorOutcome::Done { entry, .. }) if entry.is_some()); + match (vendored || recorded, undo.savepoint.take(), group.as_ref()) { + (false, Some(savepoint), Some(group)) => group.rollback_to(savepoint), + _ => undo.settle(env, common, candidate, &mut vlt_takeover_targets), + } + } match outcome { None => { diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs index 89cb4f4a7..cfe446022 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs @@ -496,18 +496,22 @@ async fn vlt_hosted_artifact_preflight_refuses_before_the_vendored_revert() { assert!(root.join(rel()).join("index.js").is_file()); } -/// A vendor that fails after the takeover's upstream restore was persisted -/// (here a patch-service artifact failing its integrity check) still heals -/// the hosted store copy against the restored registry pin: the lock no -/// longer pins anything hosted, so no later run could find it again. +/// A vendor that fails after the takeover's upstream restore (here a +/// patch-service artifact failing its integrity check) rolls the restore +/// back (#853, #944): the purl stays hosted-patched, the lock and the +/// hosted store copy are left as hosted mode wrote them, and nothing asks +/// for a reinstall. #[tokio::test(flavor = "multi_thread")] -async fn vlt_failed_vendor_after_the_takeover_revert_still_heals_the_store() { +async fn vlt_failed_vendor_after_the_takeover_revert_keeps_the_hosted_pin() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let server = MockServer::start().await; mock_api(&server).await; hosted_project(root, &server).await; seed_manifest(root); + let lock = hosted::read(root, "vlt-lock.json"); + let pkg = hosted::read(root, "package.json"); + let store = hosted::store_dir(root, TILDE_ID).exists(); let service = MockServer::start().await; hosted::mock_reference_at( @@ -536,27 +540,30 @@ async fn vlt_failed_vendor_after_the_takeover_revert_still_heals_the_store() { assert_eq!(code, 1, "{env:#}\n{stderr}"); let codes = all_codes(&env); assert!( - codes.contains(&"vendor_takeover_reverted_redirect".to_string()), - "{env:#}" + !codes.contains(&"vendor_takeover_reverted_redirect".to_string()), + "the restore is rolled back, never reported: {env:#}" ); assert!( detail_of(&env, "apply_failed").contains("integrity"), "{env:#}" ); + assert!( + !codes.contains(&"redirect_vlt_reinstall_required".to_string()), + "{env:#}" + ); assert_eq!( - detail_of(&env, "redirect_vlt_reinstall_required"), - "restored registry pins for 1 packages; removed the patched installed copies, so \ - node_modules is incomplete until you run `vlt install` (or `vlt ci`)" + hosted::read(root, "vlt-lock.json"), + lock, + "the hosted pin stays" ); - assert_eq!(hosted::read(root, "vlt-lock.json"), registry_lock()); - assert_eq!(hosted::read(root, "package.json"), PACKAGE_JSON); + assert_eq!(hosted::read(root, "package.json"), pkg); assert!(vendor_entry(root).is_none()); assert_no_redirect_ledger(root); - assert!( - !hosted::store_dir(root, TILDE_ID).exists(), - "the hosted store copy is invalidated" + assert_eq!( + hosted::store_dir(root, TILDE_ID).exists(), + store, + "the hosted store copy is left alone" ); - assert!(!root.join("node_modules/.vlt-lock.json").exists()); } /// An optional hosted pin taken over by `scan --mode vendored`: the diff --git a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs new file mode 100644 index 000000000..2e2199ceb --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs @@ -0,0 +1,411 @@ +//! Hermetic hosted → vendored takeover tests through the built binary for +//! pnpm projects whose shape the pnpm vendored backend refuses although +//! hosted mode accepts it (#853): a `catalog:` dependency +//! (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` +//! (`vendor_lockfile_crlf_unsupported`) and a user exact-pin override in +//! `pnpm-workspace.yaml` (`vendor_override_conflict`). +//! +//! `scan`/`get --mode vendored` over such a hosted pin used to commit the +//! upstream restore FIRST and only then reach the backend's refusal, so +//! the run failed with the hosted pin already gone and the project went +//! back to installing the unpatched registry release. A refused takeover +//! must leave the hosted wiring byte-for-byte in place; a plain dependency +//! still takes over. +//! +//! The API and the npm registry are wiremock; no pnpm binary is needed. +//! Every child process gets the ambient `SOCKET_*` vars scrubbed and +//! telemetry hard-disabled; each test runs in its own tempdir. + +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::path::Path; +use std::process::Command; + +use base64::Engine as _; +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const NAME: &str = "left-pad"; +const VERSION: &str = "1.3.0"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const HOSTED_URL: &str = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; +const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; +const UPSTREAM_TARBALL: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; +const UPSTREAM_SHA512: &str = "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + +// ───────────────────────────── fixture ───────────────────────────── + +/// The project shapes under test. +#[derive(Clone, Copy, Debug)] +enum Shape { + /// `"left-pad": "catalog:"` resolved through the default catalog. + Catalog, + /// A plain dependency whose lock is converted to CRLF after hosting. + Crlf, + /// A plain dependency plus a user `overrides: { left-pad: 1.3.0 }`. + Override, + /// A plain dependency: the control both modes accept. + Plain, +} + +/// package.json, pnpm-workspace.yaml, the installed (unpatched) copy and +/// the pristine lockfileVersion 9.0 lock pnpm writes for `shape`. +fn write_pnpm_project(root: &Path, shape: Shape) { + let spec = match shape { + Shape::Catalog => "catalog:", + _ => VERSION, + }; + std::fs::write( + root.join("package.json"), + format!( + r#"{{"name":"c","version":"1.0.0","private":true,"dependencies":{{"{NAME}":"{spec}"}}}}"# + ), + ) + .unwrap(); + let workspace = match shape { + Shape::Catalog => format!("packages:\n - .\ncatalog:\n {NAME}: {VERSION}\n"), + Shape::Override => format!("overrides:\n {NAME}: {VERSION}\n"), + Shape::Crlf | Shape::Plain => String::new(), + }; + if !workspace.is_empty() { + std::fs::write(root.join("pnpm-workspace.yaml"), workspace).unwrap(); + } + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{"name":"{NAME}","version":"{VERSION}"}}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + + let mut lock = String::from( + "lockfileVersion: '9.0'\n\nsettings:\n autoInstallPeers: true\n excludeLinksFromLockfile: false\n\n", + ); + match shape { + Shape::Catalog => lock.push_str(&format!( + "catalogs:\n default:\n {NAME}:\n specifier: {VERSION}\n version: {VERSION}\n\n" + )), + Shape::Override => lock.push_str(&format!("overrides:\n {NAME}: {VERSION}\n\n")), + Shape::Crlf | Shape::Plain => {} + } + let specifier = match shape { + Shape::Catalog => "'catalog:'".to_string(), + _ => VERSION.to_string(), + }; + lock.push_str(&format!( + "importers:\n\n .:\n dependencies:\n {NAME}:\n specifier: {specifier}\n version: {VERSION}\n\n\ + packages:\n\n {NAME}@{VERSION}:\n resolution: {{integrity: {UPSTREAM_SHA512}}}\n\n\ + snapshots:\n\n {NAME}@{VERSION}: {{}}\n" + )); + std::fs::write(root.join("pnpm-lock.yaml"), lock).unwrap(); +} + +fn patch_record() -> Value { + json!({ + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(ORIG_INDEX), + "afterHash": compute_git_sha256_from_bytes(PATCHED_INDEX), + } + }, + "vulnerabilities": {}, + "description": "pnpm takeover fixture", + "license": "MIT", + "tier": "free" + }) +} + +fn patch_view() -> Value { + let mut view = patch_record(); + view["purl"] = json!(PURL); + view["publishedAt"] = json!("2024-01-01T00:00:00Z"); + view["files"]["package/index.js"]["blobContent"] = + json!(base64::engine::general_purpose::STANDARD.encode(PATCHED_INDEX)); + view +} + +/// The hosted-mode API (discovery + by-package + grant + view) for the one +/// patch over `PURL`, plus the npm registry's version document the +/// upstream restore re-resolves the pristine entry from. +async fn mock_api(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "pnpm takeover 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(json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { + UUID: { + "status": "granted", + "url": HOSTED_URL, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": HOSTED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(patch_view())) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": NAME, + "version": VERSION, + "dist": { "tarball": UPSTREAM_TARBALL, "integrity": UPSTREAM_SHA512 } + }))) + .mount(server) + .await; +} + +// ───────────────────────── subprocess runner ───────────────────────── + +/// Run the built binary with every ambient `SOCKET_*` var scrubbed and the +/// npm registry pointed at the mock. Returns `(exit_code, envelope)`. +fn run_json(cwd: &Path, registry: &str, args: &[&str]) -> (i32, Value) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.current_dir(cwd); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { + cmd.env_remove(key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NPM_REGISTRY", registry); + let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); + let out = cmd.output().expect("spawn socket-patch binary"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + if !stderr.trim().is_empty() { + println!("[{}] stderr:\n{stderr}", args.first().unwrap_or(&"?")); + } + let env: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{args:?} must emit a JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (out.status.code().unwrap_or(-1), env) +} + +/// `scan --mode ` (or, with `get`, `get --mode `) +/// against the mock API. +fn run_mode(cwd: &Path, api: &str, command: &str, mode: &str, extra: &[&str]) -> (i32, Value) { + let mut args = vec![command]; + if command == "get" { + args.push(PURL); + } + args.extend([ + "--mode", + mode, + "--json", + "--yes", + "--api-url", + api, + "--api-token", + "fake", + "--org", + ORG, + "--cwd", + cwd.to_str().unwrap(), + ]); + let fixture = (mode == "vendored").then(|| prebuilt_common::Server::view(patch_view())); + if let Some(fixture) = &fixture { + args.extend(["--vendor-url", &fixture.uri]); + } + args.extend_from_slice(extra); + run_json(cwd, api, &args) +} + +/// The vendor events: top-level for `vendor`, under `vendor` for the +/// `scan`/`get` envelopes that embed the vendor step. +fn events(envelope: &Value) -> Vec { + envelope["events"] + .as_array() + .or_else(|| envelope["vendor"]["events"].as_array()) + .cloned() + .unwrap_or_default() +} + +fn has_event_code(envelope: &Value, code: &str) -> bool { + events(envelope).iter().any(|e| e["errorCode"] == code) + || envelope.to_string().contains(&format!("\"{code}\"")) +} + +/// Every file hosted mode or the takeover may write. +const WIRING: &[&str] = &[ + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "package.json", + ".npmrc", +]; + +fn snapshot(root: &Path) -> Vec<(&'static str, Option>)> { + WIRING + .iter() + .map(|f| (*f, std::fs::read(root.join(f)).ok())) + .collect() +} + +/// The hosted project: pristine lock, then a real `scan --mode hosted` +/// (and, for [`Shape::Crlf`], the lock converted to CRLF as a +/// `core.autocrlf` checkout would leave it). Returns the hosted wiring. +fn host_project(root: &Path, api: &str, shape: Shape) -> Vec<(&'static str, Option>)> { + write_pnpm_project(root, shape); + let (code, env) = run_mode(root, api, "scan", "hosted", &[]); + assert_eq!(code, 0, "hosted scan must succeed for {shape:?}: {env:#}"); + let lock_path = root.join("pnpm-lock.yaml"); + let lock = std::fs::read_to_string(&lock_path).unwrap(); + assert!( + lock.contains(HOSTED_URL), + "hosted mode must pin the {shape:?} lock entry:\n{lock}\n{env:#}" + ); + if matches!(shape, Shape::Crlf) { + std::fs::write(&lock_path, lock.replace('\n', "\r\n")).unwrap(); + } + snapshot(root) +} + +/// A refused takeover leaves every byte of the hosted wiring in place and +/// writes no vendored state. +fn assert_still_hosted(root: &Path, hosted: &[(&'static str, Option>)], env: &Value) { + for ((file, before), (_, now)) in hosted.iter().zip(snapshot(root)) { + assert_eq!( + before.as_deref().map(String::from_utf8_lossy), + now.as_deref().map(String::from_utf8_lossy), + "{file} must keep the hosted wiring byte-for-byte: {env:#}" + ); + } + assert!( + !root.join(".socket/vendor/npm").exists(), + "a refused run must not leave a vendored artifact: {env:#}" + ); +} + +/// The vendored run over a hosted pin the backend refuses: failed with the +/// backend's own `code`, and never reported as un-hosted. +fn assert_refused(env: &Value, exit: i32, code: &str) { + assert_eq!(exit, 1, "the refusal fails the run: {env:#}"); + let failed = events(env) + .into_iter() + .find(|e| e["action"] == "failed" && e["errorCode"] == code) + .unwrap_or_else(|| panic!("expected a failed `{code}` event: {env:#}")); + assert_eq!(failed["purl"], PURL, "{env:#}"); + assert!( + !has_event_code(env, "vendor_takeover_reverted_redirect"), + "a refused purl must not be reported as restored: {env:#}" + ); +} + +async fn refused_takeover_keeps_hosted_pin(shape: Shape, command: &str, code: &str) { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let hosted = host_project(root, &server.uri(), shape); + + // The dry run writes nothing. + let (_, env) = run_mode(root, &server.uri(), command, "vendored", &["--dry-run"]); + assert_still_hosted(root, &hosted, &env); + + let (exit, env) = run_mode(root, &server.uri(), command, "vendored", &[]); + assert_refused(&env, exit, code); + assert_still_hosted(root, &hosted, &env); +} + +// ───────────────────────────── scenarios ───────────────────────────── + +/// #853: a `catalog:` dependency. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_pnpm_catalog_dep_keeps_the_hosted_pin() { + refused_takeover_keeps_hosted_pin(Shape::Catalog, "scan", "vendor_lock_entry_unsupported") + .await; +} + +/// #853: `get --mode vendored`, same catalog shape. +#[tokio::test(flavor = "multi_thread")] +async fn get_vendored_over_hosted_pnpm_catalog_dep_keeps_the_hosted_pin() { + refused_takeover_keeps_hosted_pin(Shape::Catalog, "get", "vendor_lock_entry_unsupported").await; +} + +/// #853: a CRLF `pnpm-lock.yaml` (a `core.autocrlf` checkout). +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_pnpm_crlf_lock_keeps_the_hosted_pin() { + refused_takeover_keeps_hosted_pin(Shape::Crlf, "scan", "vendor_lockfile_crlf_unsupported") + .await; +} + +/// #853: a user exact-pin override in `pnpm-workspace.yaml`. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_pnpm_workspace_override_keeps_the_hosted_pin() { + refused_takeover_keeps_hosted_pin(Shape::Override, "scan", "vendor_override_conflict").await; +} + +/// Control: a plain dependency is supported by both modes, so the takeover +/// still restores the registry entry and vendors it. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_pnpm_plain_dep_still_takes_over() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + host_project(root, &server.uri(), Shape::Plain); + + let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &["--dry-run"]); + assert_eq!(exit, 0, "{env:#}"); + + let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]); + assert_eq!(exit, 0, "the plain takeover must succeed: {env:#}"); + assert!( + has_event_code(&env, "vendor_takeover_reverted_redirect"), + "{env:#}" + ); + let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + assert!(!lock.contains(HOSTED_URL), "{lock}"); + assert!( + lock.contains(&format!(".socket/vendor/npm/{UUID}/")), + "the lock must point at the vendored artifact:\n{lock}" + ); +} diff --git a/crates/socket-patch-core/src/utils/group_commit.rs b/crates/socket-patch-core/src/utils/group_commit.rs index 7b93ada11..1003c37c6 100644 --- a/crates/socket-patch-core/src/utils/group_commit.rs +++ b/crates/socket-patch-core/src/utils/group_commit.rs @@ -88,6 +88,7 @@ const LEDGERS: [&str; 2] = [ ".socket/vendor/redirect-state.json", ]; +#[derive(Clone)] struct Captured { /// The file's bytes, `None` once removed. bytes: Option, @@ -108,6 +109,19 @@ enum Content { type Render = fn(&(dyn Any + Send + Sync)) -> std::io::Result>; +impl Clone for Content { + fn clone(&self) -> Self { + match self { + Content::Bytes(bytes) => Content::Bytes(bytes.clone()), + Content::Value { value, render, .. } => Content::Value { + value: Arc::clone(value), + render: *render, + rendered: OnceLock::new(), + }, + } + } +} + impl Content { fn bytes(&self) -> std::io::Result> { match self { @@ -715,6 +729,88 @@ impl Drop for GroupCommit { } } +/// The captured project files of an open [`GroupCommit`] at one point of +/// the run (see [`GroupCommit::savepoint`]). The two ledgers are not part +/// of it: the vendor loop owns their in-memory value and re-saves it +/// itself. +pub struct Savepoint { + files: BTreeMap, + after_commit: usize, + dirs_after_commit: usize, +} + +impl GroupCommit { + /// The captured state of every project file (the ledgers aside) right + /// now, for [`Self::rollback_to`]. A step that writes several commit + /// points and must be all-or-nothing (the hosted → vendored takeover: + /// restore the upstream entry, then vendor) takes one first, and rolls + /// back to it when a later part of the step refuses: nothing has + /// reached the disk yet, so the commit then never sees the abandoned + /// writes. + pub fn savepoint(&self) -> Savepoint { + let files = self + .overlay + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter(|(rel, _)| !is_ledger(rel)) + .map(|(rel, captured)| (rel.clone(), captured.clone())) + .collect(); + Savepoint { + files, + after_commit: self + .overlay + .after_commit + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len(), + dirs_after_commit: self + .overlay + .dirs_after_commit + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len(), + } + } + + /// Put every captured project file (the ledgers aside) back to its + /// state at `savepoint`: a file first captured since then is dropped + /// from the overlay (it reads from disk again), and the removals queued + /// since then are forgotten. + pub fn rollback_to(&self, savepoint: Savepoint) { + let mut files = self + .overlay + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + files.retain(|rel, _| is_ledger(rel) || savepoint.files.contains_key(rel)); + files.extend(savepoint.files); + drop(files); + self.overlay + .after_commit + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .truncate(savepoint.after_commit); + self.overlay + .dirs_after_commit + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .truncate(savepoint.dirs_after_commit); + } +} + +/// Whether a write of the project-relative `rel` under an open +/// [`GroupCommit`] is captured (held in memory until the commit) rather +/// than written straight to disk. A caller that relies on +/// [`GroupCommit::rollback_to`] (or on dropping a throwaway group) to undo +/// a write checks every path first: an uncaptured one would already be on +/// disk. +pub fn captures(rel: &str) -> bool { + let path = Path::new(rel); + path.components().all(|c| matches!(c, Component::Normal(_))) && is_captured(path) +} + fn is_ledger(rel: &Path) -> bool { LEDGERS.contains(&rel_string(rel).as_str()) } @@ -1162,6 +1258,66 @@ mod tests { } } + #[test] + fn captures_only_root_relative_commit_points() { + assert!(captures("pnpm-lock.yaml")); + assert!(captures("packages/a/package.json")); + assert!(!captures(".socket/gradle/hosted-index.tsv")); + assert!(!captures("../pnpm-lock.yaml")); + assert!(!captures("/abs/pnpm-lock.yaml")); + assert!(!captures("")); + } + + /// The hosted → vendored takeover's undo (#853, #944): writes made + /// after a savepoint are forgotten, earlier ones kept, the ledger left + /// alone, and the commit then writes only what survived. + #[tokio::test] + async fn rollback_to_savepoint_forgets_later_project_writes_only() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let lock = root.join("pnpm-lock.yaml"); + let ws = root.join("pnpm-workspace.yaml"); + let npmrc = root.join(".npmrc"); + let ledger = root.join(".socket/vendor/state.json"); + std::fs::create_dir_all(ledger.parent().unwrap()).unwrap(); + std::fs::write(&lock, b"hosted").unwrap(); + std::fs::write(&ws, b"trustLockfile: true\n").unwrap(); + std::fs::write(&ledger, b"{}").unwrap(); + let group = GroupCommit::begin(root); + super::super::fs::atomic_write_bytes(&npmrc, b"earlier") + .await + .unwrap(); + let savepoint = group.savepoint(); + super::super::fs::atomic_write_bytes_preserving_mode(&lock, b"upstream") + .await + .unwrap(); + super::super::fs::atomic_write_bytes(&npmrc, b"later") + .await + .unwrap(); + super::super::fs::remove_file(&ws).await.unwrap(); + super::super::fs::atomic_write_bytes(&ledger, b"{\"entries\":{}}") + .await + .unwrap(); + let tree = root.join("packages/gone"); + remove_dir_after_commit(&tree).await; + group.rollback_to(savepoint); + let read = |p: &Path| { + let p = p.to_path_buf(); + async move { super::super::fs::read_regular_to_bytes(&p).await.unwrap() } + }; + assert_eq!(read(&lock).await, b"hosted"); + assert_eq!(read(&ws).await, b"trustLockfile: true\n"); + assert_eq!(read(&npmrc).await, b"earlier"); + assert_eq!(read(&ledger).await, b"{\"entries\":{}}", "ledger kept"); + assert!(group.overlay.dirs_after_commit.lock().unwrap().is_empty()); + let mut changed = group.commit().await.unwrap(); + changed.sort(); + assert_eq!(changed, [".npmrc", ".socket/vendor/state.json"]); + assert_eq!(std::fs::read(&lock).unwrap(), b"hosted"); + assert_eq!(std::fs::read(&ws).unwrap(), b"trustLockfile: true\n"); + assert_eq!(std::fs::read(&npmrc).unwrap(), b"earlier"); + } + #[tokio::test] async fn reads_see_the_runs_writes_and_nothing_reaches_disk_until_commit() { let tmp = tempfile::tempdir().unwrap(); From e5bdabcc08b0b00a819fbc3ecb4aad6a33fcedf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:06:09 +0000 Subject: [PATCH 3/7] Test uv and requirements takeovers keep the pin Adds hermetic regression tests for #944. In both cases a hosted six pin is switched to vendored mode and refused after the takeover's upstream restore: - a uv project whose pyproject.toml names [tool.uv] sources as an inline table - a `uv pip compile --universal` requirements.txt that splits six across two exact pins by marker (#928's shape) Either way, six must stay patched: vendored, or still hosted with pyproject.toml, uv.lock and requirements.txt byte-for-byte as hosted mode wrote them. Both tests fail on main, where the restore was already made when the refusal came, and pass with the rollback. Refs #944 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/in_process_vendor_pypi_takeover.rs | 418 ++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 crates/socket-patch-cli/tests/in_process_vendor_pypi_takeover.rs diff --git a/crates/socket-patch-cli/tests/in_process_vendor_pypi_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_pypi_takeover.rs new file mode 100644 index 000000000..58f3f2a2f --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_pypi_takeover.rs @@ -0,0 +1,418 @@ +//! Hermetic hosted → vendored takeover tests through the built binary for +//! PyPI projects whose shape a vendored backend refuses although hosted +//! mode accepts it (#944): a uv project whose `pyproject.toml` carries an +//! inline `[tool.uv] sources = { … }` table, and a `uv pip compile +//! --universal` requirements.txt whose package is split by markers across +//! two exact pins (#928). +//! +//! `scan --mode vendored` over such a hosted pin used to commit the +//! upstream restore FIRST and only then reach the backend's refusal, so the +//! run failed with the hosted pin already gone: six was left neither +//! hosted nor vendored, and the next `uv sync` installed the unpatched +//! release. The package must stay patched in one mode or the other. +//! +//! The patch API, the hosted wheel and PyPI's JSON API are wiremock; no +//! Python toolchain is needed. + +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::io::Write as _; +use std::path::Path; +use std::process::Command; + +use base64::Engine as _; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const UUID: &str = "5c3e1a2b-7d4f-4e6a-9b8c-1d2e3f4a5b6c"; +const PURL: &str = "pkg:pypi/six@1.16.0"; +const WHEEL: &str = "six-1.16.0-py2.py3-none-any.whl"; +const ORIG: &[u8] = b"# six\nVERSION = '1.16.0'\n"; +const PATCHED: &[u8] = b"# six\nVERSION = '1.16.0'\nSOCKET_PATCHED = 1\n"; +const WHEEL_SHA: &str = "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"; +const SDIST_SHA: &str = "1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"; +const WHEEL_URL: &str = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl"; +const SDIST_URL: &str = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz"; + +// ───────────────────────────── fixture ───────────────────────────── + +/// The hosted wheel: a pure-Python wheel carrying the patched module. +fn hosted_wheel() -> Vec { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for (name, content) in [ + ("six.py", PATCHED), + ( + "six-1.16.0.dist-info/METADATA", + b"Metadata-Version: 2.1\nName: six\nVersion: 1.16.0\n\n".as_slice(), + ), + ( + "six-1.16.0.dist-info/WHEEL", + b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py2-none-any\nTag: py3-none-any\n" + .as_slice(), + ), + ( + "six-1.16.0.dist-info/RECORD", + b"six.py,,\nsix-1.16.0.dist-info/METADATA,,\nsix-1.16.0.dist-info/WHEEL,,\nsix-1.16.0.dist-info/RECORD,,\n" + .as_slice(), + ), + ] { + zip.start_file(name, opts).unwrap(); + zip.write_all(content).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +fn patch_view() -> Value { + json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "six.py": { + "beforeHash": compute_git_sha256_from_bytes(ORIG), + "afterHash": compute_git_sha256_from_bytes(PATCHED), + "blobContent": base64::engine::general_purpose::STANDARD.encode(PATCHED), + }}, + "vulnerabilities": {}, + "description": "pypi hosted → vendored takeover fixture", + "license": "MIT", + "tier": "free" + }) +} + +/// The patch API (discovery, grant, view), the hosted wheel, and PyPI's +/// JSON API document the upstream restore re-derives six's files from. +/// Returns the hosted wheel's URL. +async fn mount_api(server: &MockServer) -> String { + let wheel = hosted_wheel(); + let sha = hex::encode(Sha256::digest(&wheel)); + let route = + format!("/patch/pypi/six/1.16.0/33333333-3333-4333-8333-333333333333/{UUID}/{WHEEL}"); + let hosted_url = format!("{}{route}", server.uri()); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ "purl": PURL, "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", "cveIds": [], "ghsaIds": [], + "severity": "high", "title": "pypi takeover 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(json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { UUID: { + "status": "granted", "url": hosted_url, "purl": PURL, + "artifacts": [{ "kind": "tarball", "url": hosted_url, + "integrity": { "sha256": sha } }], + "registryOverride": null + }} + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(patch_view())) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(route)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(wheel)) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/pypi/six/1.16.0/json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "urls": [ + { "filename": WHEEL, "url": WHEEL_URL, "digests": { "sha256": WHEEL_SHA }, + "size": 11053, "upload_time_iso_8601": "2021-05-05T14:18:17.237Z" }, + { "filename": "six-1.16.0.tar.gz", "url": SDIST_URL, + "digests": { "sha256": SDIST_SHA }, + "size": 34041, "upload_time_iso_8601": "2021-05-05T14:18:18.379Z" }, + ]}))) + .mount(server) + .await; + hosted_url +} + +const UV_LOCK: &str = r#"version = 1 +revision = 2 +requires-python = ">=3.9" + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "demo" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "attrs" }, + { name = "six" }, +] + +[package.metadata] +requires-dist = [ + { name = "attrs", index = "https://pypi.org/simple" }, + { name = "six", specifier = "==1.16.0" }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "SDIST_URL", hash = "sha256:SDIST_SHA", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } +wheels = [ + { url = "WHEEL_URL", hash = "sha256:WHEEL_SHA", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, +] +"#; + +/// #944 trigger 1: a uv project whose `[tool.uv]` names its sources as an +/// inline table, which the uv vendored backend refuses and hosted mode +/// grows in place. Returns its wiring files. +fn stage_uv_inline_sources(root: &Path) -> &'static [&'static str] { + std::fs::write( + root.join("pyproject.toml"), + "[project]\nname = \"demo\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\n\ + dependencies = [\"six==1.16.0\", \"attrs\"]\n\n\ + [tool.uv]\nsources = { attrs = { index = \"pypi\" } }\n\n\ + [[tool.uv.index]]\nname = \"pypi\"\nurl = \"https://pypi.org/simple\"\n", + ) + .unwrap(); + std::fs::write( + root.join("uv.lock"), + UV_LOCK + .replace("SDIST_URL", SDIST_URL) + .replace("WHEEL_URL", WHEEL_URL) + .replace("WHEEL_SHA", WHEEL_SHA) + .replace("SDIST_SHA", SDIST_SHA), + ) + .unwrap(); + &["uv.lock", "pyproject.toml"] +} + +/// #944 trigger 2 (#928's shape): `uv pip compile --universal` output that +/// pins six to 1.16.0 below Python 3.12 and to 1.17.0 from it. +fn stage_requirements_marker_split(root: &Path) -> &'static [&'static str] { + std::fs::write( + root.join("requirements.txt"), + "six==1.16.0 ; python_full_version < '3.12'\n\ + six==1.17.0 ; python_full_version >= '3.12'\n", + ) + .unwrap(); + &["requirements.txt"] +} + +/// `.socket/manifest.json` plus the after-hash blob, from which the +/// prebuilt fixture server builds the vendored wheel. +fn stage_manifest(root: &Path) { + let after = compute_git_sha256_from_bytes(PATCHED); + let manifest = json!({ "patches": { PURL: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "six.py": { + "beforeHash": compute_git_sha256_from_bytes(ORIG), + "afterHash": after, + }}, + "vulnerabilities": {}, + "description": "pypi hosted → vendored takeover fixture", + "license": "MIT", + "tier": "free" + }}}); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(after), PATCHED).unwrap(); +} + +// ───────────────────────── subprocess runner ───────────────────────── + +/// The built binary with every ambient `SOCKET_*` var scrubbed, PyPI's JSON +/// API pointed at the mock, and a `VIRTUAL_ENV` holding the unpatched six +/// so the installed-tree probes stay off the host's Python. `vendored` runs +/// get the prebuilt fixture server, which builds the vendored wheel from +/// that installed copy. +fn run_scan(root: &Path, server: &MockServer, mode: &str, extra: &[&str]) -> (i32, Value) { + let venv = root.join("../venv"); + let site = venv.join(if cfg!(windows) { + "Lib/site-packages" + } else { + "lib/python3.11/site-packages" + }); + let info = site.join("six-1.16.0.dist-info"); + std::fs::create_dir_all(&info).unwrap(); + std::fs::write(site.join("six.py"), ORIG).unwrap(); + std::fs::write( + info.join("METADATA"), + "Metadata-Version: 2.1\nName: six\nVersion: 1.16.0\n\n", + ) + .unwrap(); + std::fs::write(info.join("RECORD"), "six.py,,\n").unwrap(); + let uri = server.uri(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.args([ + "scan", + "--mode", + mode, + "--json", + "--yes", + "--api-url", + &uri, + "--org", + ORG, + "--api-token", + "fake-token", + "--patch-server-url", + &uri, + ]) + .args(extra) + .arg("--cwd") + .arg(root) + .current_dir(root); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") { + cmd.env_remove(key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_PYPI_JSON_API", format!("{uri}/pypi")) + .env("VIRTUAL_ENV", &venv) + .env("PIPENV_IGNORE_VIRTUALENVS", "0"); + let fixture = (mode == "vendored").then(|| { + prebuilt_common::Server::project_with_env(root, &[("VIRTUAL_ENV", venv.to_str().unwrap())]) + }); + if let Some(fixture) = &fixture { + cmd.arg("--vendor-url").arg(&fixture.uri); + } + let out = cmd.output().expect("spawn socket-patch"); + drop(fixture); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let env = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("--json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (out.status.code().unwrap_or(-1), env) +} + +fn project() -> (tempfile::TempDir, std::path::PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("proj"); + std::fs::create_dir_all(&root).unwrap(); + (tmp, root) +} + +fn snapshot(root: &Path, files: &[&str]) -> Vec { + files + .iter() + .map(|f| std::fs::read_to_string(root.join(f)).unwrap()) + .collect() +} + +/// Host the staged project, then run `scan --mode vendored` (dry run +/// first) over it. Returns `(hosted wiring, exit, envelope)`. +async fn host_then_vendor(root: &Path, files: &[&str]) -> (Vec, i32, Value) { + let server = MockServer::start().await; + let hosted_url = mount_api(&server).await; + let (code, env) = run_scan(root, &server, "hosted", &[]); + assert_eq!(code, 0, "hosted scan: {env:#}"); + let hosted = snapshot(root, files); + assert!( + hosted.iter().any(|t| t.contains(&hosted_url)), + "hosted mode must pin six: {hosted:#?}\n{env:#}" + ); + + stage_manifest(root); + let (_, env) = run_scan(root, &server, "vendored", &["--dry-run"]); + assert_eq!( + snapshot(root, files), + hosted, + "the dry run writes nothing: {env:#}" + ); + + let (exit, env) = run_scan(root, &server, "vendored", &[]); + (hosted, exit, env) +} + +fn has_code(env: &Value, code: &str) -> bool { + env.to_string().contains(&format!("\"{code}\"")) +} + +// ───────────────────────────── scenarios ───────────────────────────── + +/// #944: a uv project with an inline `[tool.uv] sources` table, which the +/// uv vendored backend refuses after the takeover restored the PyPI entry. +/// Whatever refuses the purl after the restore (here the run can also stop +/// at the prebuilt download), the hosted pin in `pyproject.toml` and +/// `uv.lock` must stay byte-for-byte. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_uv_inline_sources_keeps_the_hosted_pin() { + let (_tmp, root) = project(); + let files = stage_uv_inline_sources(&root); + let (hosted, exit, env) = host_then_vendor(&root, files).await; + assert_eq!(exit, 1, "the refusal fails the run: {env:#}"); + assert!( + !has_code(&env, "vendor_takeover_reverted_redirect"), + "a refused purl must not be reported as restored: {env:#}" + ); + assert_eq!( + snapshot(&root, files), + hosted, + "the hosted pin stays byte-for-byte: {env:#}" + ); + assert!(!root.join(format!(".socket/vendor/pypi/{UUID}")).exists()); +} + +/// #944 (#928's shape): whatever the requirements backend decides about a +/// marker-split pin, six is never left un-hosted and unvendored: either the +/// takeover vendors it, or it fails and the hosted line stays as hosted +/// mode wrote it. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_marker_split_requirements_never_unpatches() { + let (_tmp, root) = project(); + let files = stage_requirements_marker_split(&root); + let (hosted, exit, env) = host_then_vendor(&root, files).await; + let now = snapshot(&root, files); + if exit == 0 { + assert!( + now[0].contains(&format!(".socket/vendor/pypi/{UUID}/")), + "a successful takeover wires the vendored wheel:\n{}\n{env:#}", + now[0] + ); + } else { + assert!( + !has_code(&env, "vendor_takeover_reverted_redirect"), + "a refused purl must not be reported as restored: {env:#}" + ); + assert_eq!(now, hosted, "the hosted pin stays byte-for-byte: {env:#}"); + } +} From 30158047dbac86916221e849685856dd3acc29c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:22:55 +0000 Subject: [PATCH 4/7] Route Gradle digests through utils::digest Ports the change from #878 so the digest-helper guard test, which is red on main, passes on this branch. It becomes a no-op once #878 lands. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/crawlers/gradle_cache.rs | 9 ++++----- crates/socket-patch-core/src/patch/jvm_jar.rs | 7 ++----- crates/socket-patch-core/src/patch/sidecars/maven.rs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } From f2b1c7f714dfab19cf6f656a6c536361a7c2f957 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:22:56 +0000 Subject: [PATCH 5/7] Test vendor --dry-run takeover refusal; document Adds a regression test: `vendor --dry-run` over a hosted pnpm catalog pin now previews the backend's refusal (vendor_lock_entry_unsupported) instead of promising the takeover, and the wet `vendor` keeps the hosted pin byte-for-byte. CLI_CONTRACT.md's "Takeover reconciliation" now states that a takeover the backend refuses is rolled back with the hosted wiring untouched, and that the scan / get --dry-run preview does not model the takeover yet. Refs #853, #944 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../tests/in_process_vendor_pnpm_takeover.rs | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index dff38f2c8..75e792d7e 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -120,7 +120,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. `rollback` / `remove` restore each hosted node's slots [2] and [3] from the npm registry, following the lock's own slot-[3] convention (see "Hosted unwind coverage"). Tested releases: `docs/testing/vlt-compatibility.md`. -**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. +**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). **A takeover the vendored backend does not carry through keeps the hosted pin (#853, #944)**: the wet run holds the restore in its group commit, and when the backend then refuses the purl — whatever the code: a pnpm `catalog:` dependency (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` (`vendor_lockfile_crlf_unsupported`), a workspace exact-pin override (`vendor_override_conflict`), a uv inline `[tool.uv] sources` table, a prebuilt download that fails, … — or its apply fails with nothing recorded, the restore is rolled back before anything reaches disk: the purl is reported `failed ` with the backend's own code and detail, neither `vendor_takeover_reverted_redirect` nor the restore's advisories are recorded for it, and the hosted wiring stays byte-for-byte (exit 1 / `partial_failure`), so the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed ` by running the backend's dry run over the restored project staged in memory (nothing written). A restore that writes a file outside the group commit's captured set (`.socket/gradle/hosted-index.tsv`) is not rolled back. The `scan` / `get --mode vendored --dry-run` preview does not model the takeover yet (it still lists such a purl `would_vendor`). `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. ### Scan modes (v5.0) @@ -1216,7 +1216,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `cargo_copy_untaggable` | `failed` (error prefix) | vendor / scan / get `--mode vendored` (cargo, v5.0): the copy's `Cargo.toml` has no literal `[package] version` string that can be rewritten byte-exactly (or it names another version); nothing is swapped in. A dry run over an already-vendored copy reports the same failure; a patch-service crate that cannot be tagged fails with `vendor_prebuilt_required`. | | `cargo_wiring_restored` | `skipped` (advisory note) | repair (v5.0): a vendored crate's Cargo.lock entry was detached with no Socket-owned `[patch]` pointing at its committed copy (a pre-v5 release overwrote its crate-named config key when a second version was vendored); the manifest entry is written back and the ledger updated (dry run: "would restore"). A `vendor` re-run heals the same state as a plain re-vendor. | | `cargo_manifest_unreadable` / `cargo_manifest_unparseable` / `cargo_manifest_symlink_unsupported` / `cargo_manifest_not_workspace_root` / `cargo_manifest_patch_source_alias` | `failed` | vendor / scan / get `--mode vendored` (cargo, v5.0): the workspace-root `Cargo.toml` cannot carry the vendored `[patch.crates-io]` entry (or cargo would ignore it there) — see the cargo caveat under "Vendored mode". Refused before any write. | -| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs. | +| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). | | `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | | `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | | `redirect_pnpm_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | diff --git a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs index 2e2199ceb..c1001cef3 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs @@ -409,3 +409,47 @@ async fn scan_vendored_over_hosted_pnpm_plain_dep_still_takes_over() { "the lock must point at the vendored artifact:\n{lock}" ); } + +/// `vendor --dry-run` over the hosted pin previews the backend's refusal of +/// the RESTORED project (staged in memory, never written) with the wet +/// run's code, instead of promising the takeover; the wet `vendor` then +/// keeps the hosted pin. +#[tokio::test(flavor = "multi_thread")] +async fn vendor_dry_run_over_hosted_pnpm_catalog_dep_previews_the_refusal() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + host_project(root, &server.uri(), Shape::Catalog); + let manifest = json!({ "patches": { PURL: patch_record() } }); + std::fs::create_dir_all(root.join(".socket/blobs")).unwrap(); + std::fs::write( + root.join(".socket/manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write( + root.join(".socket/blobs") + .join(compute_git_sha256_from_bytes(PATCHED_INDEX)), + PATCHED_INDEX, + ) + .unwrap(); + let hosted = snapshot(root); + let vendor = |extra: &[&str]| { + let mut args = vec!["vendor", "--json", "--cwd", root.to_str().unwrap()]; + args.extend_from_slice(extra); + run_json(root, &server.uri(), &args) + }; + + let (exit, env) = vendor(&["--dry-run"]); + assert_still_hosted(root, &hosted, &env); + assert_refused(&env, exit, "vendor_lock_entry_unsupported"); + assert!( + !has_event_code(&env, "vendor_would_revert_redirect"), + "the refused takeover is not promised: {env:#}" + ); + + let (exit, env) = vendor(&[]); + assert_refused(&env, exit, "vendor_lock_entry_unsupported"); + assert_still_hosted(root, &hosted, &env); +} From 8a796fbe5052ce7858ae53fcdd2d69f70ae4bc7f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:29:18 +0000 Subject: [PATCH 6/7] Fix CI: coverage map and hermetic test spawns docs/testing/vlt-coverage.json still listed the renamed vlt takeover test under the reinstall-advisory variant. That test now asserts the advisory is not emitted (the hosted pin stays), so drop it from that list. lint-ecosystems failed on the stale name. The two new takeover test files spawned the binary with a bare Command::new, which the spawn_env_hygiene ratchet rejects. They now use hermetic::binary_command(). Refs #853, #944 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/in_process_vendor_pnpm_takeover.rs | 10 +++------- .../tests/in_process_vendor_pypi_takeover.rs | 10 +++------- docs/testing/vlt-coverage.json | 1 - 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs index c1001cef3..1c009aed6 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs @@ -16,11 +16,12 @@ //! Every child process gets the ambient `SOCKET_*` vars scrubbed and //! telemetry hard-disabled; each test runs in its own tempdir. +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; use std::path::Path; -use std::process::Command; use base64::Engine as _; use serde_json::{json, Value}; @@ -208,13 +209,8 @@ async fn mock_api(server: &MockServer) { /// Run the built binary with every ambient `SOCKET_*` var scrubbed and the /// npm registry pointed at the mock. Returns `(exit_code, envelope)`. fn run_json(cwd: &Path, registry: &str, args: &[&str]) -> (i32, Value) { - let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + let mut cmd = hermetic::binary_command(); cmd.current_dir(cwd); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { - cmd.env_remove(key); - } - } cmd.env("SOCKET_TELEMETRY_DISABLED", "1") .env("SOCKET_NPM_REGISTRY", registry); let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); diff --git a/crates/socket-patch-cli/tests/in_process_vendor_pypi_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_pypi_takeover.rs index 58f3f2a2f..175c414ef 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_pypi_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_pypi_takeover.rs @@ -14,12 +14,13 @@ //! The patch API, the hosted wheel and PyPI's JSON API are wiremock; no //! Python toolchain is needed. +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; use std::io::Write as _; use std::path::Path; -use std::process::Command; use base64::Engine as _; use serde_json::{json, Value}; @@ -279,7 +280,7 @@ fn run_scan(root: &Path, server: &MockServer, mode: &str, extra: &[&str]) -> (i3 .unwrap(); std::fs::write(info.join("RECORD"), "six.py,,\n").unwrap(); let uri = server.uri(); - let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + let mut cmd = hermetic::binary_command(); cmd.args([ "scan", "--mode", @@ -299,11 +300,6 @@ fn run_scan(root: &Path, server: &MockServer, mode: &str, extra: &[&str]) -> (i3 .arg("--cwd") .arg(root) .current_dir(root); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") { - cmd.env_remove(key); - } - } cmd.env("SOCKET_TELEMETRY_DISABLED", "1") .env("SOCKET_PYPI_JSON_API", format!("{uri}/pypi")) .env("VIRTUAL_ENV", &venv) diff --git a/docs/testing/vlt-coverage.json b/docs/testing/vlt-coverage.json index ad42fcc42..051f286a6 100644 --- a/docs/testing/vlt-coverage.json +++ b/docs/testing/vlt-coverage.json @@ -148,7 +148,6 @@ ], "redirect_vlt_reinstall_required: hosted to vendored takeover": [ "vlt_hosted_then_vendored_takeover_keeps_an_optional_hosted_copy", - "vlt_failed_vendor_after_the_takeover_revert_still_heals_the_store", "vlt_pinned_matrix_hosted_then_vendored_optional_takeover" ], "vendor_vlt_reinstall_required: optional dependency rewired or still linked upstream": [ From 284d687872e26aafc113ab1416b9875a75eb8097 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 21:31:57 +0000 Subject: [PATCH 7/7] Roll back the takeover on every early exit Bugbot found two paths that stopped vendoring a purl after the upstream restore was already staged, but skipped the rollback: a restore whose flush failed partway, and an artifact redownload that failed. The group commit would still write the staged restore, so the package was left un-hosted and unvendored, which is the #853 / #944 bug by another route. Both paths now roll back to the savepoint, the same way a backend refusal does. Refs #853, #944 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/vendor.rs | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 97e1a1aa3..5034c2948 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -131,6 +131,15 @@ struct TakeoverUndo { } impl TakeoverUndo { + /// The purl is not vendored: roll the restore back in `group`'s + /// overlay, so the hosted pin stays and nothing of the restore is + /// reported. + fn abandon(self, group: Option<&GroupCommit>) { + if let (Some(savepoint), Some(group)) = (self.savepoint, group) { + group.rollback_to(savepoint); + } + } + /// The restore stands: record its advisories and queue the vlt heal. fn settle( self, @@ -2790,6 +2799,11 @@ pub(crate) async fn vendor_records_reusing( .next() .or_else(|| restore.flush_error.clone()); if let Some(detail) = refusal { + // A flush that failed partway may have staged some of + // the restore: put the hosted wiring back. + if let (Some(savepoint), Some(group)) = (savepoint, group.as_ref()) { + group.rollback_to(savepoint); + } has_errors = true; env.record( PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( @@ -2943,6 +2957,9 @@ pub(crate) async fn vendor_records_reusing( .with_error("vendor_redownload_failed", detail.clone()), ); report_vendor_failure(common, candidate, &detail); + if let Some(undo) = takeover_undo.take() { + undo.abandon(group.as_ref()); + } continue; } } @@ -2972,16 +2989,17 @@ pub(crate) async fn vendor_records_reusing( status.finish(); let vendored = matches!(&outcome, Some(VendorOutcome::Done { result, .. }) if result.success); - if let Some(mut undo) = takeover_undo.take() { + if let Some(undo) = takeover_undo.take() { // A takeover the backend did not carry through keeps the // hosted pin: its restore is rolled back in the overlay, so // the purl is never left un-hosted AND unvendored (#853, // #944). One the backend recorded keeps the restore. let recorded = matches!(&outcome, Some(VendorOutcome::Done { entry, .. }) if entry.is_some()); - match (vendored || recorded, undo.savepoint.take(), group.as_ref()) { - (false, Some(savepoint), Some(group)) => group.rollback_to(savepoint), - _ => undo.settle(env, common, candidate, &mut vlt_takeover_targets), + if vendored || recorded || undo.savepoint.is_none() || group.is_none() { + undo.settle(env, common, candidate, &mut vlt_takeover_targets); + } else { + undo.abandon(group.as_ref()); } }