From 2638b4963b3e766d4aabd3e126a531f1d845aa35 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 21:23:50 +0000 Subject: [PATCH 1/7] Start fix for #328 Assisted-by: Claude Code:claude-opus-5-5 From d1c2f316db8b637c5f4aa7c1f17ff0e63dd44bd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 21:34:14 +0000 Subject: [PATCH 2/7] Add failing PyPI vendored-to-hosted tests Switching a vendored Python project to hosted mode leaves it vendored: every PyPI hosted rewriter refuses the vendored source socket-patch wrote itself. Cover requirements.txt, Poetry, Pipenv, uv and Hatch (#328). Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/mode_migration_pypi.rs | 388 ++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 crates/socket-patch-cli/tests/mode_migration_pypi.rs diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs new file mode 100644 index 000000000..54690b1c5 --- /dev/null +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -0,0 +1,388 @@ +//! PyPI vendored → hosted mode takeover (#328): `scan --mode hosted` over a +//! project socket-patch itself vendored must revert its own vendored wiring +//! (the per-purl `vendor --revert` machinery) and then redirect, leaving the +//! project FULLY hosted. Before the fix the takeover gate admitted only +//! cargo / npm / golang purls, so every PyPI hosted rewriter saw the +//! vendored source socket-patch wrote as a user-authored one and refused it +//! (`redirected: 0`, exit 0, the project left vendored). +//! +//! One lane per Python lock socket-patch vendors — requirements.txt, +//! Poetry, Pipenv, uv and Hatch. Hermetic: the vendored wheel is built +//! from the staged manifest by the prebuilt fixture server, and the hosted +//! API + hosted wheel are a wiremock. + +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::io::Write as _; +use std::path::Path; +use std::process::Command; + +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"; + +/// 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() +} + +/// Stage `.socket/manifest.json` + the after-hash blob so `vendor` builds +/// the vendored wheel from the prebuilt fixture server, offline. +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 mode 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(); +} + +/// The built binary with every ambient `SOCKET_*` var scrubbed. An EMPTY +/// `VIRTUAL_ENV` keeps the installed-tree probes off the host's Python +/// (Ubuntu's apt ships a python3-six 1.16.0 whose bytes are not ours). +fn run_cli(root: &Path, args: &[&str], extra: &[(&str, &str)]) -> (i32, Value) { + let venv = root.join("../empty-venv"); + std::fs::create_dir_all(venv.join(if cfg!(windows) { + "Lib/site-packages" + } else { + "lib/python3.11/site-packages" + })) + .unwrap(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.args(args) + .arg("--json") + .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("VIRTUAL_ENV", &venv) + .env("PIPENV_IGNORE_VIRTUALENVS", "0") + .envs(extra.iter().copied()); + let fixture = (args.first() == Some(&"vendor")).then(|| { + let server = prebuilt_common::Server::project(root); + server.command(&mut cmd); + server + }); + let out = cmd.output().expect("spawn socket-patch"); + drop(fixture); + let stdout = String::from_utf8_lossy(&out.stdout); + let env = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "--json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code().unwrap_or(-1), env) +} + +/// The hosted API: discovery (`batch` / `by-package`), the grant naming the +/// hosted wheel, and the wheel itself (its METADATA feeds the lock +/// rewriters). Returns the hosted URL. +async fn mount_hosted_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(route)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(wheel)) + .mount(server) + .await; + hosted_url +} + +/// Vendor the staged project, then `scan --mode hosted` over it: the +/// takeover must report `redirect_takeover_reverted_vendored`, redirect the +/// purl, and leave every wiring file hosted with no `.socket/vendor/` +/// reference or artifact behind. `files` are the project files that carry +/// the wiring. +async fn assert_vendored_to_hosted(root: &Path, files: &[&str]) { + stage_manifest(root); + let (code, env) = run_cli(root, &["vendor"], &[]); + assert_eq!(code, 0, "vendor: {env:#}"); + let vendored: Vec = files + .iter() + .map(|f| std::fs::read_to_string(root.join(f)).unwrap()) + .collect(); + assert!( + vendored + .iter() + .any(|t| t.contains(&format!(".socket/vendor/pypi/{UUID}/"))), + "vendored first: {vendored:#?}" + ); + + let server = MockServer::start().await; + let hosted_url = mount_hosted_api(&server).await; + let (code, env) = run_cli( + root, + &[ + "scan", + "--mode", + "hosted", + "--yes", + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake-token", + "--patch-server-url", + &server.uri(), + ], + &[], + ); + assert_eq!(code, 0, "hosted scan over the vendored project: {env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert!( + env.to_string() + .contains("redirect_takeover_reverted_vendored"), + "the takeover is surfaced: {env:#}" + ); + let mut hosted_seen = false; + for f in files { + let text = std::fs::read_to_string(root.join(f)).unwrap(); + assert!( + !text.contains(".socket/vendor/"), + "{f}: no vendored residue after the takeover:\n{text}" + ); + hosted_seen |= text.contains(&hosted_url); + } + assert!(hosted_seen, "the hosted wheel is wired into {files:?}"); + assert!( + !root.join(format!(".socket/vendor/pypi/{UUID}")).exists(), + "the vendored artifact is reclaimed" + ); +} + +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) +} + +const POETRY_LOCK: &str = r#"# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:WHEEL_SHA"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:SDIST_SHA"}, +] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9" +content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" +"#; + +#[tokio::test] +async fn requirements_vendored_to_hosted() { + let (_tmp, root) = project(); + std::fs::write(root.join("requirements.txt"), "idna==3.7\nsix==1.16.0\n").unwrap(); + assert_vendored_to_hosted(&root, &["requirements.txt"]).await; +} + +#[tokio::test] +async fn requirements_sole_pin_vendored_to_hosted() { + let (_tmp, root) = project(); + std::fs::write(root.join("requirements.txt"), "six==1.16.0\n").unwrap(); + assert_vendored_to_hosted(&root, &["requirements.txt"]).await; +} + +#[tokio::test] +async fn poetry_vendored_to_hosted() { + let (_tmp, root) = project(); + std::fs::write( + root.join("pyproject.toml"), + "[tool.poetry]\nname = \"demo\"\nversion = \"0.1.0\"\ndescription = \"\"\nauthors = [\"x \"]\npackage-mode = false\n\n[tool.poetry.dependencies]\npython = \">=3.9\"\nsix = \"1.16.0\"\n", + ) + .unwrap(); + std::fs::write( + root.join("poetry.lock"), + POETRY_LOCK + .replace("WHEEL_SHA", WHEEL_SHA) + .replace("SDIST_SHA", SDIST_SHA), + ) + .unwrap(); + assert_vendored_to_hosted(&root, &["poetry.lock", "pyproject.toml"]).await; +} + +const PIPFILE: &str = "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nsix = \"==1.16.0\"\n\n[requires]\npython_version = \"3.11\"\n"; + +#[tokio::test] +async fn pipenv_vendored_to_hosted() { + let (_tmp, root) = project(); + std::fs::write(root.join("Pipfile"), PIPFILE).unwrap(); + let lock = json!({ + "_meta": { + "hash": { "sha256": "ab".repeat(32) }, + "pipfile-spec": 6, + "requires": { "python_version": "3.11" }, + "sources": [{ "name": "pypi", "url": "https://pypi.org/simple", "verify_ssl": true }] + }, + "default": { + "six": { + "hashes": [format!("sha256:{WHEEL_SHA}"), format!("sha256:{SDIST_SHA}")], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==1.16.0" + } + }, + "develop": {} + }); + let mut text = serde_json::to_string_pretty(&lock).unwrap(); + text.push('\n'); + std::fs::write(root.join("Pipfile.lock"), text).unwrap(); + assert_vendored_to_hosted(&root, &["Pipfile.lock"]).await; +} + +const UV_LOCK: &str = r#"version = 1 +revision = 2 +requires-python = ">=3.9" + +[[package]] +name = "demo" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "six" }, +] + +[package.metadata] +requires-dist = [{ name = "six", specifier = "==1.16.0" }] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:SDIST_SHA", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:WHEEL_SHA", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, +] +"#; + +#[tokio::test] +async fn uv_vendored_to_hosted() { + let (_tmp, root) = project(); + std::fs::write( + root.join("pyproject.toml"), + "[project]\nname = \"demo\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n", + ) + .unwrap(); + std::fs::write( + root.join("uv.lock"), + UV_LOCK + .replace("WHEEL_SHA", WHEEL_SHA) + .replace("SDIST_SHA", SDIST_SHA), + ) + .unwrap(); + assert_vendored_to_hosted(&root, &["uv.lock", "pyproject.toml"]).await; +} + +#[tokio::test] +async fn hatch_vendored_to_hosted() { + let (_tmp, root) = project(); + std::fs::write( + root.join("pyproject.toml"), + "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"demo\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n", + ) + .unwrap(); + assert_vendored_to_hosted(&root, &["pyproject.toml"]).await; +} From e10585531afc15436abafe8532acc29cf33104c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 21:43:19 +0000 Subject: [PATCH 3/7] Fix vendored Python projects not moving to hosted `scan --mode hosted` over a project socket-patch had vendored left it vendored and reported success: the takeover that reverts vendored wiring before redirecting only covered cargo, npm and Go, so the Python rewriters (requirements.txt, Poetry, Pipenv, uv, Hatch) refused socket-patch's own vendored source. PyPI packages now go through the same takeover. Two guards keep the takeover from leaving a package unpatched: - vendored wiring edited since vendoring is left in place by the revert, so the takeover now refuses and keeps the ledger entry instead of dropping it; - a package whose wiring was reverted but that the hosted rewrite then did not pin (e.g. hosted wheel metadata unavailable) now fails the run with redirect_takeover_unpatched instead of passing as success. Fixes #328 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/hosted.rs | 72 +++++++++- .../tests/mode_migration_pypi.rs | 123 +++++++++++++++--- 2 files changed, 179 insertions(+), 16 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e45450de5..1932ead68 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1018,6 +1018,11 @@ pub(crate) async fn run_redirect_selected( // report that outcome. Populated only under --dry-run. let mut confirmed = done.confirmed.clone(); confirmed.extend(dry_run_takeover); + // The takeover already reverted these purls' vendored wiring; one the + // rewrite then did not pin (a refused lock, unavailable wheel + // metadata) is left on the unpatched registry release in BOTH modes. + // That must never pass as success. + let stranded = stranded_takeovers(&takeover_migrated, &confirmed, common.dry_run); // Fetch the full patch view (file hashes + vulnerabilities) for each // CONFIRMED redirect and persist it so a post-install `socket-patch vex` @@ -1294,6 +1299,18 @@ pub(crate) async fn run_redirect_selected( warnings.extend(python_stale.warnings.iter().cloned()); warnings.extend(vlt_stale.warnings.iter().cloned()); warnings.extend(takeover_pre_warnings.iter().cloned()); + warnings.extend(stranded.iter().map(|purl| { + serde_json::json!({ + "code": "redirect_takeover_unpatched", + "detail": format!( + "{purl} was vendored and its vendored wiring was reverted, but the \ + hosted rewrite did not pin it (see the warnings above), so the \ + project now installs the UNPATCHED registry release — fix the \ + reported cause and re-run `scan --mode hosted`, or run `scan \ + --mode vendored` to vendor it again" + ), + }) + })); warnings.extend(takeover_warnings.iter().cloned()); warnings.extend(prune_warnings.iter().cloned()); @@ -1313,6 +1330,9 @@ pub(crate) async fn run_redirect_selected( common.dry_run, ); let mut result = build_redirect_json_envelope(scan_result.take(), redirect); + if !stranded.is_empty() { + result["status"] = serde_json::json!("partial_failure"); + } if let Some(gate) = &rollout { super::finish_rollout_json(gate.stage, &mut result); } @@ -1470,9 +1490,34 @@ pub(crate) async fn run_redirect_selected( e.print_embedded(common); } } + if vex_code == 0 && !stranded.is_empty() { + return 1; + } vex_code } +/// The purls a WET takeover migrated (vendored wiring reverted) that the +/// rewrite did not confirm as pinned. Empty under `--dry-run`, whose +/// takeover previews are counted as confirmed without a rewrite. +fn stranded_takeovers( + migrated: &[String], + confirmed: &[(String, String)], + dry_run: bool, +) -> Vec { + use socket_patch_core::utils::purl::{canonical_purl, strip_purl_qualifiers}; + if dry_run { + return Vec::new(); + } + let key = |purl: &str| canonical_purl(strip_purl_qualifiers(purl)); + let pinned: std::collections::HashSet = + confirmed.iter().map(|(purl, _)| key(purl)).collect(); + migrated + .iter() + .filter(|purl| !pinned.contains(&key(purl))) + .cloned() + .collect() +} + /// 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 @@ -1509,8 +1554,15 @@ async fn vendored_takeover( // for those locks even though the rewriters never see these purls. let mut dry_run_locks: std::collections::HashMap> = std::collections::HashMap::new(); + // PyPI: every Python rewriter (requirements.txt, Poetry, Pipenv, uv, + // Hatch, PDM, pylock) refuses a non-registry source as user-authored, + // including the vendored one socket-patch wrote itself, so a vendored + // purl must be reverted to its registry entry first (#328). let takeover_capable = |p: &str| { - p.starts_with("pkg:cargo/") || p.starts_with("pkg:npm/") || p.starts_with("pkg:golang/") + p.starts_with("pkg:cargo/") + || p.starts_with("pkg:npm/") + || p.starts_with("pkg:golang/") + || p.starts_with("pkg:pypi/") }; if !candidates.iter().any(|c| takeover_capable(&c.purl)) { // No takeover-capable candidates — nothing to reconcile. @@ -1744,6 +1796,24 @@ async fn vendored_takeover( })); continue; } + if outcome.kept_artifact { + // A wiring record drifted and was left in place, so the + // project may still resolve through the vendored artifact + // and the ledger entry holds the only recorded originals + // (the RevertOutcome contract): keep both and refuse, + // exactly as `vendor --revert` reports it skipped. + refused.push(purl.clone()); + out.pre_warnings.push(serde_json::json!({ + "code": "redirect_vendored_revert_failed", + "detail": format!( + "{purl} is vendored and part of its vendored wiring was edited \ + since vendoring, so it was left in place; NOT switched to \ + hosted — restore or remove that wiring (`socket-patch vendor \ + --revert` lists it), then re-run `scan --mode hosted`" + ), + })); + continue; + } // Drop the reverted entry from the in-memory ledger and // persist per purl so a crash mid-run leaves a ledger // matching the on-disk wiring. The entry stays dropped even diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 54690b1c5..fa02a9c95 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -131,8 +131,9 @@ fn run_cli(root: &Path, args: &[&str], extra: &[(&str, &str)]) -> (i32, Value) { /// The hosted API: discovery (`batch` / `by-package`), the grant naming the /// hosted wheel, and the wheel itself (its METADATA feeds the lock -/// rewriters). Returns the hosted URL. -async fn mount_hosted_api(server: &MockServer) -> String { +/// rewriters) — or, with `wheel_served: false`, a 404 for it. Returns the +/// hosted URL. +async fn mount_hosted_api(server: &MockServer, wheel_served: bool) -> String { let wheel = hosted_wheel(); let sha = hex::encode(Sha256::digest(&wheel)); let route = @@ -174,20 +175,21 @@ async fn mount_hosted_api(server: &MockServer) -> String { }))) .mount(server) .await; + let wheel_response = if wheel_served { + ResponseTemplate::new(200).set_body_bytes(wheel) + } else { + ResponseTemplate::new(404) + }; Mock::given(method("GET")) .and(path(route)) - .respond_with(ResponseTemplate::new(200).set_body_bytes(wheel)) + .respond_with(wheel_response) .mount(server) .await; hosted_url } -/// Vendor the staged project, then `scan --mode hosted` over it: the -/// takeover must report `redirect_takeover_reverted_vendored`, redirect the -/// purl, and leave every wiring file hosted with no `.socket/vendor/` -/// reference or artifact behind. `files` are the project files that carry -/// the wiring. -async fn assert_vendored_to_hosted(root: &Path, files: &[&str]) { +/// Stage the manifest and vendor the project; `files` carry the wiring. +fn vendor_project(root: &Path, files: &[&str]) { stage_manifest(root); let (code, env) = run_cli(root, &["vendor"], &[]); assert_eq!(code, 0, "vendor: {env:#}"); @@ -201,10 +203,12 @@ async fn assert_vendored_to_hosted(root: &Path, files: &[&str]) { .any(|t| t.contains(&format!(".socket/vendor/pypi/{UUID}/"))), "vendored first: {vendored:#?}" ); +} - let server = MockServer::start().await; - let hosted_url = mount_hosted_api(&server).await; - let (code, env) = run_cli( +/// `scan --mode hosted` against `server`. +fn hosted_scan(root: &Path, server: &MockServer) -> (i32, Value) { + let uri = server.uri(); + run_cli( root, &[ "scan", @@ -212,16 +216,28 @@ async fn assert_vendored_to_hosted(root: &Path, files: &[&str]) { "hosted", "--yes", "--api-url", - &server.uri(), + &uri, "--org", ORG, "--api-token", "fake-token", "--patch-server-url", - &server.uri(), + &uri, ], &[], - ); + ) +} + +/// Vendor the staged project, then `scan --mode hosted` over it: the +/// takeover must report `redirect_takeover_reverted_vendored`, redirect the +/// purl, and leave every wiring file hosted with no `.socket/vendor/` +/// reference or artifact behind. `files` are the project files that carry +/// the wiring. +async fn assert_vendored_to_hosted(root: &Path, files: &[&str]) { + vendor_project(root, files); + let server = MockServer::start().await; + let hosted_url = mount_hosted_api(&server, true).await; + let (code, env) = hosted_scan(root, &server); assert_eq!(code, 0, "hosted scan over the vendored project: {env:#}"); assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); assert!( @@ -386,3 +402,80 @@ async fn hatch_vendored_to_hosted() { .unwrap(); assert_vendored_to_hosted(&root, &["pyproject.toml"]).await; } + +/// The uv lock rewrite needs the hosted wheel's METADATA, fetched only +/// after the takeover reverted the vendored wiring. When it is unavailable +/// the package is left on the unpatched registry release in both modes, so +/// the run must fail loudly instead of reporting success. +#[tokio::test] +async fn uv_takeover_without_wheel_metadata_fails_loudly() { + let (_tmp, root) = project(); + std::fs::write( + root.join("pyproject.toml"), + "[project]\nname = \"demo\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n", + ) + .unwrap(); + std::fs::write( + root.join("uv.lock"), + UV_LOCK + .replace("WHEEL_SHA", WHEEL_SHA) + .replace("SDIST_SHA", SDIST_SHA), + ) + .unwrap(); + vendor_project(&root, &["uv.lock"]); + let server = MockServer::start().await; + mount_hosted_api(&server, false).await; + let (code, env) = hosted_scan(&root, &server); + assert_eq!(code, 1, "a stranded takeover is a failure: {env:#}"); + assert_eq!(env["status"], "partial_failure", "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + assert!( + env.to_string().contains("redirect_takeover_unpatched"), + "the unpatched package is named: {env:#}" + ); +} + +/// A vendored requirements line edited since vendoring is left in place by +/// the revert (the artifact and ledger entry are kept). The takeover must +/// then refuse — keeping the ledger — rather than drop the entry and leave +/// the project half vendored with no record of it. +#[tokio::test] +async fn drifted_vendored_line_refuses_takeover() { + let (_tmp, root) = project(); + std::fs::write(root.join("requirements.txt"), "idna==3.7\nsix==1.16.0\n").unwrap(); + vendor_project(&root, &["requirements.txt"]); + let reqs = root.join("requirements.txt"); + let vendored = std::fs::read_to_string(&reqs).unwrap(); + let drifted = vendored.replacen( + &format!("six-1.16.0-py3-none-any.whl"), + "six-1.16.0-py3-none-any.whl ; python_version >= \"3\"", + 1, + ); + assert_ne!(drifted, vendored, "the fixture edits the vendored line"); + std::fs::write(&reqs, &drifted).unwrap(); + let state = root.join(".socket/vendor/state.json"); + assert!(state.exists()); + + let server = MockServer::start().await; + mount_hosted_api(&server, true).await; + let (code, env) = hosted_scan(&root, &server); + let text = env.to_string(); + assert!( + !text.contains("redirect_takeover_reverted_vendored"), + "no takeover is announced over drifted wiring: {env:#}" + ); + assert!(text.contains("redirect_vendored_revert_failed"), "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + assert_eq!( + code, 0, + "a refused takeover keeps the package vendored: {env:#}" + ); + assert!( + std::fs::read_to_string(&state).unwrap().contains(UUID), + "the ledger entry is kept" + ); + assert!( + root.join(format!(".socket/vendor/pypi/{UUID}")).exists(), + "the vendored artifact is kept" + ); +} From a0863a2e3bf4d5cd6a361e75f37bdce15627ad8a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 21:43:59 +0000 Subject: [PATCH 4/7] Document the PyPI vendored-to-hosted takeover Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 72bffac7a..03318392c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -161,7 +161,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — the environment variable, or `BUNDLE_GEMFILE:` in the bundler app config — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves a drifted wiring record in place (the backend keeps the artifact) is refused with `redirect_vendored_revert_failed`; the ledger entry and artifact are kept, and the package stays vendored and skipped. A taken-over package whose wiring was reverted but that the hosted rewrite then did not pin (a refused lock, or hosted wheel metadata unavailable) now installs the unpatched registry release in both modes. It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — the environment variable, or `BUNDLE_GEMFILE:` in the bundler app config — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). From 0be19d02d0bae3ee54fb3baa1b46a3d274a2e17e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 22:17:05 +0000 Subject: [PATCH 5/7] Report stranded and drifted takeovers honestly Follow-ups from review of the PyPI vendored-to-hosted takeover: - `--dry-run` now predicts the drifted-wiring refusal from the same drift and residual-reference signals the wet run uses, instead of previewing a takeover the wet run then refuses. - A takeover left unpatched no longer prints a "Migrated ... to hosted" line or "keep the hosted patches" next steps. - Its redirect_takeover_unpatched error also prints under --silent, so the exit 1 is explained. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/scan/hosted.rs | 66 ++++++-- .../tests/mode_migration_pypi.rs | 148 ++++++++++++++---- 3 files changed, 169 insertions(+), 47 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 03318392c..835685a6c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -161,7 +161,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves a drifted wiring record in place (the backend keeps the artifact) is refused with `redirect_vendored_revert_failed`; the ledger entry and artifact are kept, and the package stays vendored and skipped. A taken-over package whose wiring was reverted but that the hosted rewrite then did not pin (a refused lock, or hosted wheel metadata unavailable) now installs the unpatched registry release in both modes. It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — the environment variable, or `BUNDLE_GEMFILE:` in the bundler app config — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. A taken-over package whose wiring was reverted but that the hosted rewrite then did not pin (a refused lock, or hosted wheel metadata unavailable) now installs the unpatched registry release in both modes. It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — the environment variable, or `BUNDLE_GEMFILE:` in the bundler app config — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 1932ead68..9b5379b0b 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1364,7 +1364,9 @@ pub(crate) async fn run_redirect_selected( // line per sentence so CI can grep them. let width = std::io::IsTerminal::is_terminal(&std::io::stderr()).then(crate::ui::stderr_width); - for purl in &takeover_migrated { + // A stranded takeover was NOT migrated to hosted: its + // `redirect_takeover_unpatched` warning below says so instead. + for purl in takeover_migrated.iter().filter(|p| !stranded.contains(p)) { eprintln!("{}", format_takeover_line(purl, common.dry_run)); } // The files a takeover's revert touched (or, on --dry-run, @@ -1474,7 +1476,9 @@ pub(crate) async fn run_redirect_selected( if let Some(line) = rollout_line { println!("{line}"); } - let mut next_steps = if common.dry_run { + // "Commit … to keep the hosted patches" / "reinstall" would be + // wrong for a stranded takeover, whose warning names the remedy. + let mut next_steps = if common.dry_run || !stranded.is_empty() { Vec::new() } else { format_next_steps(&human_files, &rewrite.edits, !takeover_migrated.is_empty()) @@ -1489,6 +1493,21 @@ pub(crate) async fn run_redirect_selected( if let Some(e) = &vex_error { e.print_embedded(common); } + if common.silent { + for w in warnings + .iter() + .filter(|w| w["code"] == "redirect_takeover_unpatched") + { + eprintln!( + "{}", + format_warning( + "redirect_takeover_unpatched", + w["detail"].as_str().unwrap_or_default(), + None + ) + ); + } + } } if vex_code == 0 && !stranded.is_empty() { return 1; @@ -1750,6 +1769,11 @@ async fn vendored_takeover( // would refuse the still-vendored wiring. let outcome = crate::commands::vendor::dispatch_revert_one(entry, &common.cwd, true).await; + if outcome.success && revert_keeps_wiring(&outcome) { + refused.push(purl.clone()); + out.pre_warnings.push(drifted_takeover_warning(purl)); + continue; + } if !outcome.success { refused.push(purl.clone()); out.pre_warnings.push(serde_json::json!({ @@ -1796,22 +1820,14 @@ async fn vendored_takeover( })); continue; } - if outcome.kept_artifact { + if revert_keeps_wiring(&outcome) { // A wiring record drifted and was left in place, so the // project may still resolve through the vendored artifact // and the ledger entry holds the only recorded originals // (the RevertOutcome contract): keep both and refuse, // exactly as `vendor --revert` reports it skipped. refused.push(purl.clone()); - out.pre_warnings.push(serde_json::json!({ - "code": "redirect_vendored_revert_failed", - "detail": format!( - "{purl} is vendored and part of its vendored wiring was edited \ - since vendoring, so it was left in place; NOT switched to \ - hosted — restore or remove that wiring (`socket-patch vendor \ - --revert` lists it), then re-run `scan --mode hosted`" - ), - })); + out.pre_warnings.push(drifted_takeover_warning(purl)); continue; } // Drop the reverted entry from the in-memory ledger and @@ -1926,6 +1942,32 @@ async fn vendored_takeover( Ok(out) } +/// Whether a takeover revert left (or, on `--dry-run`, would leave) vendored +/// wiring in place: a drift-skipped record, or a reverted file that still +/// references the artifact dir. The backends compute both signals on dry +/// runs too, while `kept_artifact` itself is set only on wet runs. +fn revert_keeps_wiring(outcome: &socket_patch_core::vendor::RevertOutcome) -> bool { + outcome.kept_artifact + || outcome.drift_skipped() + || outcome + .warnings + .iter() + .any(|w| w.code == "vendor_revert_residual_reference") +} + +/// The refusal for a takeover whose vendored wiring drifted since vendoring. +fn drifted_takeover_warning(purl: &str) -> serde_json::Value { + serde_json::json!({ + "code": "redirect_vendored_revert_failed", + "detail": format!( + "{purl} is vendored and part of its vendored wiring was edited since \ + vendoring, so it is left in place; NOT switched to hosted — restore or \ + remove that wiring (`socket-patch vendor --revert` lists it), then re-run \ + `scan --mode hosted`" + ), + }) +} + /// What [`vendored_takeover`] did (or, on `--dry-run`, would do). #[derive(Default)] struct Takeover { diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index fa02a9c95..932d47aa7 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -90,6 +90,17 @@ fn stage_manifest(root: &Path) { /// `VIRTUAL_ENV` keeps the installed-tree probes off the host's Python /// (Ubuntu's apt ships a python3-six 1.16.0 whose bytes are not ours). fn run_cli(root: &Path, args: &[&str], extra: &[(&str, &str)]) -> (i32, Value) { + let mut json_args = args.to_vec(); + json_args.push("--json"); + let (code, stdout, stderr) = run_raw(root, &json_args, extra); + 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}") + }); + (code, env) +} + +/// [`run_cli`] without `--json`: `(exit code, stdout, stderr)`. +fn run_raw(root: &Path, args: &[&str], extra: &[(&str, &str)]) -> (i32, String, String) { let venv = root.join("../empty-venv"); std::fs::create_dir_all(venv.join(if cfg!(windows) { "Lib/site-packages" @@ -98,11 +109,7 @@ fn run_cli(root: &Path, args: &[&str], extra: &[(&str, &str)]) -> (i32, Value) { })) .unwrap(); let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); - cmd.args(args) - .arg("--json") - .arg("--cwd") - .arg(root) - .current_dir(root); + cmd.args(args).arg("--cwd").arg(root).current_dir(root); for (key, _) in std::env::vars() { if key.starts_with("SOCKET_") { cmd.env_remove(key); @@ -119,14 +126,11 @@ fn run_cli(root: &Path, args: &[&str], extra: &[(&str, &str)]) -> (i32, Value) { }); let out = cmd.output().expect("spawn socket-patch"); drop(fixture); - let stdout = String::from_utf8_lossy(&out.stdout); - let env = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { - panic!( - "--json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{}", - String::from_utf8_lossy(&out.stderr) - ) - }); - (out.status.code().unwrap_or(-1), env) + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) } /// The hosted API: discovery (`batch` / `by-package`), the grant naming the @@ -208,24 +212,24 @@ fn vendor_project(root: &Path, files: &[&str]) { /// `scan --mode hosted` against `server`. fn hosted_scan(root: &Path, server: &MockServer) -> (i32, Value) { let uri = server.uri(); - run_cli( - root, - &[ - "scan", - "--mode", - "hosted", - "--yes", - "--api-url", - &uri, - "--org", - ORG, - "--api-token", - "fake-token", - "--patch-server-url", - &uri, - ], - &[], - ) + run_cli(root, &hosted_scan_args(&uri), &[]) +} + +fn hosted_scan_args(uri: &str) -> Vec<&str> { + vec![ + "scan", + "--mode", + "hosted", + "--yes", + "--api-url", + uri, + "--org", + ORG, + "--api-token", + "fake-token", + "--patch-server-url", + uri, + ] } /// Vendor the staged project, then `scan --mode hosted` over it: the @@ -407,9 +411,9 @@ async fn hatch_vendored_to_hosted() { /// after the takeover reverted the vendored wiring. When it is unavailable /// the package is left on the unpatched registry release in both modes, so /// the run must fail loudly instead of reporting success. -#[tokio::test] -async fn uv_takeover_without_wheel_metadata_fails_loudly() { - let (_tmp, root) = project(); +/// A vendored uv project whose hosted wheel the API cannot serve. +async fn stranded_uv_project() -> (tempfile::TempDir, std::path::PathBuf, MockServer) { + let (tmp, root) = project(); std::fs::write( root.join("pyproject.toml"), "[project]\nname = \"demo\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n", @@ -425,6 +429,12 @@ async fn uv_takeover_without_wheel_metadata_fails_loudly() { vendor_project(&root, &["uv.lock"]); let server = MockServer::start().await; mount_hosted_api(&server, false).await; + (tmp, root, server) +} + +#[tokio::test] +async fn uv_takeover_without_wheel_metadata_fails_loudly() { + let (_tmp, root, server) = stranded_uv_project().await; let (code, env) = hosted_scan(&root, &server); assert_eq!(code, 1, "a stranded takeover is a failure: {env:#}"); assert_eq!(env["status"], "partial_failure", "{env:#}"); @@ -479,3 +489,73 @@ async fn drifted_vendored_line_refuses_takeover() { "the vendored artifact is kept" ); } + +/// Human output for a stranded takeover: no "Migrated … to hosted" progress +/// line and no "keep the hosted patches" next steps, only the warning. +#[tokio::test] +async fn stranded_takeover_human_output_is_not_a_migration() { + let (_tmp, root, server) = stranded_uv_project().await; + let uri = server.uri(); + let (code, stdout, stderr) = run_raw(&root, &hosted_scan_args(&uri), &[]); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + !stderr.contains("Migrated pkg:pypi/six@1.16.0"), + "a stranded package is not reported migrated:\n{stderr}" + ); + assert!( + !stdout.contains("keep the hosted patches") && !stdout.contains("Reinstall"), + "no next steps for a stranded takeover:\n{stdout}" + ); + assert!(stderr.contains("UNPATCHED"), "{stderr}"); +} + +/// `--silent` keeps errors: the stranded takeover's exit 1 is explained. +#[tokio::test] +async fn stranded_takeover_is_reported_under_silent() { + let (_tmp, root, server) = stranded_uv_project().await; + let uri = server.uri(); + let mut args = hosted_scan_args(&uri); + args.push("--silent"); + let (code, stdout, stderr) = run_raw(&root, &args, &[]); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("UNPATCHED") && stderr.contains("pkg:pypi/six@1.16.0"), + "the failure is diagnosable under --silent:\n{stderr}" + ); +} + +/// `--dry-run` predicts the drifted-wiring refusal instead of previewing a +/// takeover the wet run would refuse. +#[tokio::test] +async fn dry_run_predicts_drifted_takeover_refusal() { + let (_tmp, root) = project(); + std::fs::write(root.join("requirements.txt"), "idna==3.7\nsix==1.16.0\n").unwrap(); + vendor_project(&root, &["requirements.txt"]); + let reqs = root.join("requirements.txt"); + let vendored = std::fs::read_to_string(&reqs).unwrap(); + let drifted = vendored.replacen( + "six-1.16.0-py3-none-any.whl", + "six-1.16.0-py3-none-any.whl ; python_version >= \"3\"", + 1, + ); + std::fs::write(&reqs, &drifted).unwrap(); + + let server = MockServer::start().await; + mount_hosted_api(&server, true).await; + let uri = server.uri(); + let mut args = hosted_scan_args(&uri); + args.push("--dry-run"); + let (_, env) = run_cli(&root, &args, &[]); + let text = env.to_string(); + assert!( + !text.contains("redirect_would_revert_vendored"), + "no takeover is previewed over drifted wiring: {env:#}" + ); + assert!(text.contains("redirect_vendored_revert_failed"), "{env:#}"); + assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + assert_eq!( + std::fs::read_to_string(&reqs).unwrap(), + drifted, + "dry run writes nothing" + ); +} From 1a1f2e99f2ff150bca6eeeb695d7c182430d4ef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:29:36 +0000 Subject: [PATCH 6/7] Report a takeover whose ledger update failed as stranded When a vendored-to-hosted takeover reverts a package's wiring but the vendored ledger then cannot be updated, the package is refused and never redirected. Its vendored wiring and artifact are already gone, so it installs unpatched in both modes, yet the run exited 0 with status "success". It now counts as a stranded takeover: redirect_takeover_unpatched, status "partial_failure", exit 1, also printed under --silent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018kguGYyuuizF1dwKyH6oZh --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/scan/hosted.rs | 19 ++++++++-- .../tests/mode_migration_pypi.rs | 38 +++++++++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 835685a6c..c8613138f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -161,7 +161,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. A taken-over package whose wiring was reverted but that the hosted rewrite then did not pin (a refused lock, or hosted wheel metadata unavailable) now installs the unpatched registry release in both modes. It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — the environment variable, or `BUNDLE_GEMFILE:` in the bundler app config — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — the environment variable, or `BUNDLE_GEMFILE:` in the bundler app config — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 9b5379b0b..9a27b386b 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -797,6 +797,7 @@ pub(crate) async fn run_redirect_selected( pre_warnings: takeover_pre_warnings, dry_run: dry_run_takeover, migrated: takeover_migrated, + unrecorded: takeover_unrecorded, files: takeover_files, previews: dry_run_takeover_urls, } = match vendored_takeover(common, &mut candidates, &mut vendor_state, &mut skipped).await { @@ -1022,7 +1023,11 @@ pub(crate) async fn run_redirect_selected( // rewrite then did not pin (a refused lock, unavailable wheel // metadata) is left on the unpatched registry release in BOTH modes. // That must never pass as success. - let stranded = stranded_takeovers(&takeover_migrated, &confirmed, common.dry_run); + let mut stranded = stranded_takeovers(&takeover_migrated, &confirmed, common.dry_run); + // A takeover whose revert succeeded but whose ledger update failed is + // refused (never redirected), yet its vendored wiring and artifact are + // already gone: it is unpatched in both modes all the same. + stranded.extend(takeover_unrecorded); // Fetch the full patch view (file hashes + vulnerabilities) for each // CONFIRMED redirect and persist it so a post-install `socket-patch vex` @@ -1303,8 +1308,8 @@ pub(crate) async fn run_redirect_selected( serde_json::json!({ "code": "redirect_takeover_unpatched", "detail": format!( - "{purl} was vendored and its vendored wiring was reverted, but the \ - hosted rewrite did not pin it (see the warnings above), so the \ + "{purl} was vendored and its vendored wiring was reverted, but it \ + was not pinned to hosted (see the warnings above), so the \ project now installs the UNPATCHED registry release — fix the \ reported cause and re-run `scan --mode hosted`, or run `scan \ --mode vendored` to vendor it again" @@ -1844,8 +1849,10 @@ async fn vendored_takeover( if let Err(e) = socket_patch_core::vendor::save_state(&common.cwd, state).await { // The wiring is reverted but the ledger still claims it; // redirecting now would leave a ledger asserting wiring - // that is gone. Fail closed for this purl. + // that is gone. Fail closed for this purl — and since its + // vendored wiring is already gone, report it as stranded. refused.push(purl.clone()); + out.unrecorded.push(purl.clone()); out.pre_warnings.push(serde_json::json!({ "code": "redirect_vendored_revert_failed", "detail": format!( @@ -1982,6 +1989,10 @@ struct Takeover { /// Human output: the purls migrated (or, on --dry-run, to be migrated) /// from vendored to hosted. migrated: Vec, + /// Wet takeovers whose vendored wiring was reverted but whose ledger + /// update then failed: refused (never redirected), so unpatched in + /// both modes. + unrecorded: Vec, /// The files their revert touches (or would touch). Both modes count /// `rewritten ∪ files`, so the preview's file count matches the wet /// run's even for wiring files the hosted rewriter does not also diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 932d47aa7..b888a96c4 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -559,3 +559,41 @@ async fn dry_run_predicts_drifted_takeover_refusal() { "dry run writes nothing" ); } + +/// The revert succeeds but the vendored ledger cannot be updated (a +/// read-only `.socket/vendor/`): the wiring and wheel are already gone, +/// so the package is unpatched in both modes. That is a stranded takeover +/// (exit 1, `partial_failure`, `redirect_takeover_unpatched`), never a +/// success. +#[cfg(unix)] +#[tokio::test] +async fn ledger_update_failure_after_revert_is_stranded() { + use std::os::unix::fs::PermissionsExt as _; + let (_tmp, root) = project(); + std::fs::write(root.join("requirements.txt"), "six==1.16.0\n").unwrap(); + vendor_project(&root, &["requirements.txt"]); + let vendor_dir = root.join(".socket/vendor"); + let set_mode = |mode| { + std::fs::set_permissions(&vendor_dir, std::fs::Permissions::from_mode(mode)).unwrap() + }; + set_mode(0o555); + let probe = vendor_dir.join(".probe"); + if std::fs::write(&probe, b"").is_ok() { + // Permissions are not enforced (running as root): the ledger write + // cannot be made to fail this way. + let _ = std::fs::remove_file(&probe); + set_mode(0o755); + eprintln!("skipped: directory permissions are not enforced for this user"); + return; + } + + let server = MockServer::start().await; + mount_hosted_api(&server, true).await; + let (code, env) = hosted_scan(&root, &server); + set_mode(0o755); + let text = env.to_string(); + assert!(text.contains("redirect_vendored_revert_failed"), "{env:#}"); + assert!(text.contains("redirect_takeover_unpatched"), "{env:#}"); + assert_eq!(env["status"], "partial_failure", "{env:#}"); + assert_eq!(code, 1, "{env:#}"); +} From aada6847c51b74beb7d9397e644e2489e8e3be4d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:35:05 +0000 Subject: [PATCH 7/7] Update the ledger-save-failure takeover test to the stranded contract covgap_commands_scan_hosted pinned exit 0 for a takeover whose revert succeeded but whose ledger save failed. That case is now a stranded takeover: redirect_takeover_unpatched, status "partial_failure", exit 1. The test skips as root, so it only ran in CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018kguGYyuuizF1dwKyH6oZh --- .../tests/covgap_commands_scan_hosted.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index 35577532a..54ddf7441 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -2138,7 +2138,9 @@ async fn human_rush_run_prints_the_repo_state_stale_warning_line() { /// save_state failure AFTER a successful takeover revert: the wiring is gone /// but the vendored ledger still claims it, so the purl must fail CLOSED — /// `redirect_vendored_revert_failed` with the could-not-be-updated detail, a -/// `vendored_revert_failed` skip, and no redirect. Reached by making +/// `vendored_revert_failed` skip, and no redirect — and, since the package +/// is now unpatched in both modes, `redirect_takeover_unpatched` with +/// `partial_failure` and exit 1. Reached by making /// `.socket/vendor` itself read-only (0o555): the entry's empty wiring /// reverts trivially and its artifact dir under the still-writable /// `.socket/vendor/npm/` is removed, but persisting the now-empty ledger @@ -2185,7 +2187,14 @@ async fn ledger_save_failure_after_successful_revert_fails_closed() { let (code, doc) = scan_hosted_json(root, &server.uri(), &[], &[]); - assert_eq!(code, 0, "the fail-closed refusal still exits 0: {doc:#}"); + // The vendored wiring and artifact are already gone, so the package is + // unpatched in both modes: a stranded takeover, never a success. + assert_eq!(code, 1, "a stranded takeover exits 1: {doc:#}"); + assert_eq!(doc["status"], "partial_failure", "envelope: {doc:#}"); + assert!( + warning_detail(&doc, "redirect_takeover_unpatched").contains(PURL), + "the stranded package is named: {doc:#}" + ); let detail = warning_detail(&doc, "redirect_vendored_revert_failed"); assert!( detail.contains("could not be updated"),