From 7c103947bd4f40538be0788f63919770b89821fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:27:57 +0000 Subject: [PATCH 1/8] Start fix for #723, #945 Assisted-by: Claude Code:claude-opus-5-5 From bb3a25d84db580fb2fc2b86cdf518512a9311111 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:36:03 +0000 Subject: [PATCH 2/8] Test PyPI hosted takeovers that strand a package Add regression tests for the vendored -> hosted takeover of a uv project whose lock resolved another version than the patch (#723, direct and transitive) and of a Poetry 0.12 lock (#945). Each case runs wet and --dry-run and expects the takeover to be refused before the revert, keeping the vendored patch. On main they fail: the wet run leaves the package unpatched and the dry run reports a clean takeover. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/mode_migration_pypi.rs | 238 ++++++++++++++++-- 1 file changed, 222 insertions(+), 16 deletions(-) diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index b8c92adbe..459011524 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -799,8 +799,25 @@ async fn vendor_check_fails_after_hatch_dependency_reset() { /// revert — the vendored patch, ledger entry and wheel are kept — and the /// dry run must predict that refusal instead of a clean takeover. async fn assert_unreachable_takeover_refused(root: &Path, wired: &str, dry_run: bool) { - let before = std::fs::read_to_string(root.join(wired)).unwrap(); - let root_before = std::fs::read_to_string(root.join("requirements.txt")).unwrap(); + assert_takeover_refused( + root, + &[wired, "requirements.txt"], + "redirect_requirements_takeover_unreachable", + dry_run, + ) + .await; +} + +/// A vendored → hosted takeover the hosted rewriter cannot carry through +/// is refused BEFORE the revert, in the wet run and the dry run alike: +/// the refusal `code` is named, nothing is redirected, the run exits 0, +/// and every wiring file in `files`, the ledger entry and the vendored +/// wheel are kept byte for byte. +async fn assert_takeover_refused(root: &Path, files: &[&str], code_name: &str, dry_run: bool) { + let before: Vec = files + .iter() + .map(|f| std::fs::read_to_string(root.join(f)).unwrap()) + .collect(); let state = root.join(".socket/vendor/state.json"); let server = MockServer::start().await; mount_hosted_api(&server, true).await; @@ -820,25 +837,19 @@ async fn assert_unreachable_takeover_refused(root: &Path, wired: &str, dry_run: !text.contains("redirect_takeover_unpatched"), "the package is never stranded: {env:#}" ); - assert!( - text.contains("redirect_requirements_takeover_unreachable"), - "the refusal is named: {env:#}" - ); + assert!(text.contains(code_name), "the refusal is named: {env:#}"); assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); assert_eq!( code, 0, "a refused takeover keeps the package vendored: {env:#}" ); - assert_eq!( - std::fs::read_to_string(root.join(wired)).unwrap(), - before, - "{wired}: the vendored line is kept" - ); - assert_eq!( - std::fs::read_to_string(root.join("requirements.txt")).unwrap(), - root_before, - "requirements.txt is untouched" - ); + for (f, before) in files.iter().zip(&before) { + assert_eq!( + &std::fs::read_to_string(root.join(f)).unwrap(), + before, + "{f}: the vendored wiring is kept" + ); + } assert!( std::fs::read_to_string(&state).unwrap().contains(UUID), "the ledger entry is kept" @@ -915,3 +926,198 @@ async fn dry_run_previews_root_pin_takeover() { ); assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); } + +/// uv.lock resolving `six` 1.17.0, either as a direct `six>=1.15` or +/// through `python-dateutil`. Vendoring the manifest's `six@1.16.0` pins +/// the lock entry down to 1.16.0, so the revert brings 1.17.0 back. +const UV_LOCK_DRIFTED_DIRECT: &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.15" }] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] +"#; + +const UV_LOCK_DRIFTED_TRANSITIVE: &str = r#"version = 1 +revision = 2 +requires-python = ">=3.9" + +[[package]] +name = "demo" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "python-dateutil" }, +] + +[package.metadata] +requires-dist = [{ name = "python-dateutil", specifier = "==2.9.0.post0" }] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] +"#; + +/// #723: vendor a uv project whose lock resolves `six` 1.17.0 while the +/// manifest patches `six@1.16.0`; vendored uv pins the lock down to the +/// patch version. +fn drifted_uv_project(dependency: &str, lock: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let (tmp, root) = project(); + std::fs::write( + root.join("pyproject.toml"), + format!( + "[project]\nname = \"demo\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"{dependency}\"]\n" + ), + ) + .unwrap(); + std::fs::write(root.join("uv.lock"), lock).unwrap(); + vendor_project(&root, &["uv.lock", "pyproject.toml"]); + (tmp, root) +} + +fn drifted_uv_direct() -> (tempfile::TempDir, std::path::PathBuf) { + drifted_uv_project("six>=1.15", UV_LOCK_DRIFTED_DIRECT) +} + +fn drifted_uv_transitive() -> (tempfile::TempDir, std::path::PathBuf) { + drifted_uv_project("python-dateutil==2.9.0.post0", UV_LOCK_DRIFTED_TRANSITIVE) +} + +#[tokio::test] +async fn uv_pinned_down_direct_takeover_is_refused_before_revert() { + let (_tmp, root) = drifted_uv_direct(); + assert_takeover_refused( + &root, + &["uv.lock", "pyproject.toml"], + "redirect_uv_takeover_version_unreachable", + false, + ) + .await; +} + +#[tokio::test] +async fn dry_run_predicts_uv_pinned_down_direct_takeover_refusal() { + let (_tmp, root) = drifted_uv_direct(); + assert_takeover_refused( + &root, + &["uv.lock", "pyproject.toml"], + "redirect_uv_takeover_version_unreachable", + true, + ) + .await; +} + +#[tokio::test] +async fn uv_pinned_down_transitive_takeover_is_refused_before_revert() { + let (_tmp, root) = drifted_uv_transitive(); + assert_takeover_refused( + &root, + &["uv.lock", "pyproject.toml"], + "redirect_uv_takeover_version_unreachable", + false, + ) + .await; +} + +#[tokio::test] +async fn dry_run_predicts_uv_pinned_down_transitive_takeover_refusal() { + let (_tmp, root) = drifted_uv_transitive(); + assert_takeover_refused( + &root, + &["uv.lock", "pyproject.toml"], + "redirect_uv_takeover_version_unreachable", + true, + ) + .await; +} + +/// A Poetry 0.12 lock: no `lock-version`, hashes in `[metadata.hashes]`. +const POETRY_0_LOCK: &str = r#"[[package]] +category = "main" +description = "Python 2 and 3 compatibility utilities" +name = "six" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +version = "1.16.0" + +[metadata] +content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01" +python-versions = ">=3.9" + +[metadata.hashes] +six = ["sha256:WHEEL_SHA", "sha256:SDIST_SHA"] +"#; + +/// #945: vendored mode supports a Poetry 0.12 lock, hosted mode refuses +/// every one of them. +fn poetry_0_project() -> (tempfile::TempDir, std::path::PathBuf) { + let (tmp, root) = project(); + let files = stage_poetry(&root); + std::fs::write( + root.join("poetry.lock"), + POETRY_0_LOCK + .replace("WHEEL_SHA", WHEEL_SHA) + .replace("SDIST_SHA", SDIST_SHA), + ) + .unwrap(); + vendor_project(&root, files); + (tmp, root) +} + +#[tokio::test] +async fn poetry_0_lock_takeover_is_refused_before_revert() { + let (_tmp, root) = poetry_0_project(); + assert_takeover_refused( + &root, + &["poetry.lock", "pyproject.toml"], + "redirect_poetry_lock_unsupported", + false, + ) + .await; +} + +#[tokio::test] +async fn dry_run_predicts_poetry_0_lock_takeover_refusal() { + let (_tmp, root) = poetry_0_project(); + assert_takeover_refused( + &root, + &["poetry.lock", "pyproject.toml"], + "redirect_poetry_lock_unsupported", + true, + ) + .await; +} From 8be3951d00340297d85198a149c6f1bbdc79f19b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:42:13 +0000 Subject: [PATCH 3/8] Keep uv/Poetry packages vendored on bad takeover `scan --mode hosted` over a vendored uv or Poetry project reverted the vendored wiring first and only then asked the hosted rewriter to pin the package. When the rewriter could not, the package ended up in neither mode and the next install got the unpatched release, while --dry-run promised a clean takeover. The PyPI takeover gate now checks the hosted rewriter's reach before the revert, wet and dry run alike, and keeps the package vendored: - uv: vendored mode pins the lock entry down to the patch's version. If the recorded pre-vendor entry is at another version, the revert brings it back and hosted mode can't pin it. Refused with redirect_uv_takeover_version_unreachable (#723). - Poetry: hosted mode refuses every Poetry 0.x lock. Refused with redirect_poetry_lock_unsupported (#945). The requirements.txt check moves into the same core preflight. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 5 +- .../src/commands/scan/hosted.rs | 27 +- .../src/patch/redirect/mod.rs | 2 + .../src/patch/redirect/pypi_takeover.rs | 231 ++++++++++++++++++ 4 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index dff38f2c8..3ea164f89 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. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). 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`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is refused BEFORE its revert, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). 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` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — 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. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). 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`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is refused BEFORE its revert, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are gated the same way, from the ledger entry and the lock on disk: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is refused with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is refused with `redirect_poetry_lock_unsupported`. 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` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — 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, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; 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). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). @@ -1266,7 +1266,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `pypi_{poetry,pdm,pipenv}_no_lockfile` | `failed` | vendor (pypi): a lock-less tool marker with no `requirements.txt` fallback — run ` lock`. | | `pypi_poetry_integrity_unverified` | `skipped` (warning) | vendor (pypi / poetry): the lock was written by Poetry < 1.4 (0.12 `[metadata.hashes]`, lock 1.0/1.1, or a 2.0 lock without a `@generated by Poetry X.Y.Z` header — 1.3 wrote those). That installer does not verify local wheel hashes (the committed wheel bytes are the protection) and does not replace an already-installed package at the same version; recreate the virtualenv or `pip uninstall` the package before `poetry install`, or upgrade Poetry. | | `redirect_poetry_stale_install_risk` | `redirect.warnings[]` (warning) | scan `--mode hosted` (poetry): same writer test as above — a warm virtualenv keeps the upstream package after the redirect on Poetry < 1.4 (1.4+ re-installs from the new source); fresh installs pick up the patched wheel. Emitted once per rewritten lock, only on the run that rewrites it. | -| `redirect_poetry_entry_not_found` / `redirect_poetry_missing_sha256` / `redirect_poetry_lock_unsupported` | `redirect.warnings[]` (warning) | scan `--mode hosted` (poetry): the lock has no `[[package]]` at the granted version (uv-parity twin of `redirect_uv_entry_not_found`); the grant carries no SHA-256 (gated once per dep, not per lock); the lock is refused — Poetry 0.12 layout (URL sources ignored), an unsupported `lock-version`, a forked package listed at several versions, a user-authored `[package.source]` on another origin (an earlier Socket URL for the same wheel is superseded in place), a malformed `[metadata.files]`/`[metadata.hashes]`, or a wheel whose filename does not match the locked package. Exit code and `status` unchanged (hosted-refusal posture). | +| `redirect_poetry_entry_not_found` / `redirect_poetry_missing_sha256` / `redirect_poetry_lock_unsupported` | `redirect.warnings[]` (warning) | scan `--mode hosted` (poetry): the lock has no `[[package]]` at the granted version (uv-parity twin of `redirect_uv_entry_not_found`); the grant carries no SHA-256 (gated once per dep, not per lock); the lock is refused — Poetry 0.12 layout (URL sources ignored), an unsupported `lock-version`, a forked package listed at several versions, a user-authored `[package.source]` on another origin (an earlier Socket URL for the same wheel is superseded in place), a malformed `[metadata.files]`/`[metadata.hashes]`, or a wheel whose filename does not match the locked package. Exit code and `status` unchanged (hosted-refusal posture). A vendored → hosted takeover over a Poetry 0.x lock is refused with this code BEFORE the revert (wet and `--dry-run`): the purl stays vendored and patched and is skipped with this code as `redirect.skipped[].reason`. | | `redirect_pdm_refused` / `redirect_pdm_legacy_sync_required` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pdm): the `pdm.lock` rewrite was refused — an unsupported `[metadata] lock_version` (the identity-losing `3.1` / `4.0`–`4.2` formats or an untested future format), an unsupported `strategy`, a package listed at several versions (fork) or absent, a user-authored `url`/`path`/VCS/`editable` source, hash-less or malformed `files`, or a wheel whose filename does not match the locked package (`redirect_pdm_refused`); or the lock was written in format `2` (PDM 0.12–1.4), whose upstream freshness bug lets `pdm install` regenerate the lock — use `pdm sync` (`redirect_pdm_legacy_sync_required`). A refused uuid is withheld from every other PyPI rewriter when `pdm.lock` is the install driver, and its patch is not confirmed. Exit code and `status` unchanged (hosted-refusal posture). | | `redirect_bun_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the text lock's `lockfileVersion` is not 0, 1 or 2 (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2 — the shared gate's text, identical to vendored's `vendor_lockfile_version_unsupported`), or its `packages` section is not bun's single-line grammar. Nothing rewritten; exit 0 (hosted-refusal posture). | | `redirect_bun_workspace_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): a lockfileVersion-0 lock (Bun 1.1.39–1.1.45 `--save-text-lockfile`) holds `workspace:` packages; frozen installs of that grammar cannot keep the hosted tuple. Detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen installs; delete bun.lock and re-run `bun install` with Bun >= 1.2 (which writes lockfileVersion 1, accepted by hosted mode) — a plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root -> member); otherwise it keeps version 0 or fails to resolve" (measured: Bun 1.2.0 keeps 0, 1.2.23–1.4.2 exit 1 "failed to resolve" on a root that does not depend on its members). Version-1/2 workspace locks are rewritten. Exit 0. | @@ -1274,6 +1274,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | | `redirect_vlt_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` has a `lockfileVersion` other than absent, `0` or `1` (decided on the raw JSON token), is not a JSON object, starts with a UTF-8 BOM, or its `nodes` section is not vlt's one-node-per-line layout. Nothing rewritten; also refuses a vendored → hosted takeover of a `flavor: "vlt"` entry before its revert (`redirect.skipped[].reason`). Exit 0. | | `redirect_requirements_takeover_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / requirements.txt): a vendored → hosted takeover of a package that vendored mode wired through a pin in a `-r` include, or through a `(transitive)` line it appended to the root `requirements.txt`. Hosted mode only rewrites an existing pin in the root `requirements.txt`, so the takeover is refused before the revert (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), move the pin from the include into the root `requirements.txt` and delete it from the include (or, for a `(transitive)` line, add an exact `==` pin to the root file), then re-run `scan --mode hosted`. | +| `redirect_uv_takeover_version_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / uv): a vendored → hosted takeover of a package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the lock entry down to the patch's version, for example when the lock resolved a newer release). Reverting would bring the lock's own version back, and hosted mode only pins the version the lock resolves, so the takeover is refused before the revert (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy: make the project resolve the patch's version (for example an exact `==` requirement), re-lock, then re-run `scan --mode hosted`. | | `redirect_vlt_missing_sha512` / `redirect_vlt_entry_not_found` / `redirect_vlt_entry_vendored` / `redirect_vlt_unsupported_lock_key` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): the grant has no sha512 / the lock has no default-registry node for `name@version` / the only match is a vendored `file` node under `.socket/vendor/npm//` / a default-registry instance is outside vlt's node-line grammar or still unpatched after the splice. Per dep; none of the dep's instances is written. `redirect_vlt_missing_sha512` and `redirect_vlt_unsupported_lock_key` refuse the dep: it is never confirmed, whichever lock drives (a sibling lock may still carry its rewritten URL). `redirect_vlt_entry_not_found` and `redirect_vlt_entry_vendored` only say `vlt-lock.json` does not wire it: while vlt drives it is not confirmed; otherwise a sibling lock's rules may confirm it. Exit 0. | | `redirect_vlt_custom_registry_skipped` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): same-`name@version` nodes under a named alias, a scoped registry or jsr, or git, remote-tarball or local-directory nodes of the same package name (vlt records no version for those; a remote tarball whose `-.tgz` leaf names another version does not count), were left untouched (hosted mode only redirects vlt's default registry). The dep is still redirected, but the run's `--vex` does not attest it, and neither does a later `vex` from the lock alone. | | `redirect_vlt_lockfile_version_missing` / `redirect_vlt_old_lockfile_ignored` / `redirect_vlt_scalar_registry_ignored` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): the lock has no `lockfileVersion` (vlt ≥ 1.0.0-rc.15 re-resolves it) / a legacy default-registry id without `"modifiers"` in `vlt.json` (vlt 0.0.0-16 … 0.0.0-24 ignore the lock) / a scalar `registry` option that vlt 1.0.0-rc.7 … rc.29 honor over the lock. The deps stay redirected, but the run's `--vex` does not attest them. | diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e6e48a140..73df5c711 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1822,10 +1822,29 @@ async fn vendored_takeover( } else { std::collections::HashMap::new() }; + // A PyPI entry is gated on the hosted rewriter's reach after the + // revert: requirements.txt pins only the root file (#699), uv pins + // only the version the restored lock resolves (#723), and Poetry + // refuses every 0.x lock (#945). Checked once per purl, here, because + // the uv and Poetry checks read the ledger and the lock on disk. + let mut pypi_takeover_refusals: std::collections::HashMap< + String, + socket_patch_core::patch::redirect::RewriteWarning, + > = std::collections::HashMap::new(); + for (c, entry) in &takeover { + let Some(entry) = entry.as_ref().filter(|_| c.purl.starts_with("pkg:pypi/")) else { + continue; + }; + if let Err(warning) = + socket_patch_core::patch::redirect::preflight_pypi_takeover(&common.cwd, entry).await + { + pypi_takeover_refusals.insert(c.purl.clone(), warning); + } + } // The takeover refusal (if any) for one candidate: bun gates every // npm purl, berry and vlt only their own vendored entries, Gradle each - // of its own purls, and a requirements.txt entry is gated on the hosted - // rewriter's reach (it pins only the root file, #699). Berry also runs + // of its own purls, and a PyPI entry on the hosted rewriter's reach + // (`pypi_takeover_refusals` above). Berry also runs // the rewriter's per-dep grant gate (a grant without the berry cache // checksum is skipped by the rewriter, so reverting first would leave // the package in neither mode). A refused purl is never dispatched (see @@ -1837,9 +1856,7 @@ async fn vendored_takeover( return gradle_takeover_refusals.get(&c.purl).cloned(); } if c.purl.starts_with("pkg:pypi/") { - return entry.and_then(|e| { - socket_patch_core::patch::redirect::preflight_requirements_takeover(e).err() - }); + return entry.and_then(|_| pypi_takeover_refusals.get(&c.purl).cloned()); } if !c.purl.starts_with("pkg:npm/") { return None; diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 72c8b2967..e2afacc6c 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -64,6 +64,8 @@ use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin}; #[cfg(test)] mod pnpm_equivalence_tests; mod poetry; +mod pypi_takeover; +pub use pypi_takeover::preflight_pypi_takeover; #[cfg(test)] mod python_lock_equivalence_tests; mod requirements; diff --git a/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs b/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs new file mode 100644 index 000000000..33c1e7cbb --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs @@ -0,0 +1,231 @@ +//! Reach checks for the PyPI vendored → hosted takeover. +//! +//! The takeover reverts a purl's vendored wiring and only then asks the +//! hosted rewriter to pin it. Whatever the hosted rewriter would refuse +//! after that revert has to be refused BEFORE it, or the package lands in +//! neither mode: the vendored wheel is deleted and the next install gets +//! the unpatched upstream release. Each check here reads only the ledger +//! entry and the lock as it is on disk, so the dry run predicts the same +//! refusal the wet run makes. + +use std::path::Path; + +use toml_edit::{DocumentMut, Item}; + +use super::requirements::preflight_requirements_takeover; +use super::RewriteWarning; +use crate::utils::fs::read_regular_to_string; +use crate::utils::poetry_lock::lock_version; +use crate::vendor::state::VendorEntry; + +/// Refuse a PyPI takeover the hosted rewriter cannot carry through once +/// the vendored wiring is reverted. `root` is the project root the ledger +/// entry's wiring paths are relative to. +pub async fn preflight_pypi_takeover( + root: &Path, + entry: &VendorEntry, +) -> Result<(), RewriteWarning> { + if entry.ecosystem != "pypi" { + return Ok(()); + } + match entry.flavor.as_deref() { + Some("requirements") => preflight_requirements_takeover(entry), + Some("uv") => preflight_uv_takeover(entry), + Some("poetry") => preflight_poetry_takeover(root, entry).await, + _ => Ok(()), + } +} + +/// Vendored uv pins the lock's `[[package]]` unit to the patch's version +/// even when the lock resolved another one (#723). The revert restores the +/// recorded pre-vendor unit, and the hosted uv rewriter only pins an entry +/// whose `version` equals the patch's (`matching_package`), so a recorded +/// unit at another version can never be taken over. +fn preflight_uv_takeover(entry: &VendorEntry) -> Result<(), RewriteWarning> { + let Some((_, patch_version)) = entry.base_purl.rsplit_once('@') else { + return Ok(()); + }; + for record in entry.wiring.iter().filter(|r| r.kind == "uv_lock_package") { + let Some(original) = record.original.as_ref().and_then(|v| v.as_str()) else { + continue; + }; + let Some(locked) = recorded_unit_version(original) else { + continue; + }; + if locked != patch_version { + return Err(RewriteWarning { + code: "redirect_uv_takeover_version_unreachable".into(), + detail: format!( + "{} is vendored over {}'s {} entry for version {locked}, which vendored \ + mode pinned down to the patch's {patch_version}; reverting it brings \ + {locked} back, and hosted mode only pins the version the lock resolves, \ + so it is kept vendored (not switched to hosted). To switch it: make the \ + project resolve {patch_version} (for example an exact `=={patch_version}` \ + requirement), re-lock, then re-run `scan --mode hosted`", + entry.base_purl, record.file, record.file, + ), + }); + } + } + Ok(()) +} + +/// The `version` of a recorded `[[package]]` unit, or None when the +/// fragment doesn't parse (the revert's own drift handling owns that case). +fn recorded_unit_version(unit: &str) -> Option { + let doc: DocumentMut = unit.parse().ok()?; + doc.get("package") + .and_then(Item::as_array_of_tables) + .and_then(|units| units.get(0)) + .and_then(|unit| unit.get("version")) + .and_then(Item::as_str) + .map(str::to_string) +} + +/// Hosted mode refuses every Poetry 0.x lock (Poetry 0.12 ignores URL +/// sources), which vendored mode supports (#945). The revert never changes +/// the lock's format, so the lock on disk decides. +async fn preflight_poetry_takeover(root: &Path, entry: &VendorEntry) -> Result<(), RewriteWarning> { + let lock_file = entry + .wiring + .iter() + .map(|r| r.file.as_str()) + .find(|f| *f == "poetry.lock" || f.ends_with("/poetry.lock")) + .unwrap_or("poetry.lock"); + let Ok(text) = read_regular_to_string(&root.join(lock_file)).await else { + return Ok(()); + }; + let Ok(doc) = text.parse::() else { + return Ok(()); + }; + if lock_version(&doc) != Ok("0") { + return Ok(()); + } + Err(RewriteWarning { + code: "redirect_poetry_lock_unsupported".into(), + detail: format!( + "{lock_file}: Poetry 0.x ignores URL sources; hosted patches require Poetry >= 1.0, \ + so {} is kept vendored (not switched to hosted). To switch it: upgrade the \ + project to Poetry >= 1.0 and re-lock, then re-run `scan --mode hosted`", + entry.base_purl + ), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vendor::state::{VendorArtifact, WiringAction, WiringRecord}; + + fn entry(flavor: &str, wiring: Vec) -> VendorEntry { + VendorEntry { + ecosystem: "pypi".into(), + base_purl: "pkg:pypi/six@1.16.0".into(), + uuid: "u".into(), + artifact: VendorArtifact { + yarn_berry10c0: None, + path: ".socket/vendor/pypi/u/six-1.16.0-py2.py3-none-any.whl".into(), + sha256: "0".repeat(64), + size: None, + platform_locked: None, + file_inventory: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + detached: false, + record: None, + flavor: Some(flavor.into()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + } + } + + fn uv_package(original_version: &str) -> WiringRecord { + WiringRecord { + file: "uv.lock".into(), + kind: "uv_lock_package".into(), + action: WiringAction::Rewritten, + key: Some("six".into()), + original: Some(serde_json::Value::String(format!( + "[[package]]\nname = \"six\"\nversion = \"{original_version}\"\n\ + source = {{ registry = \"https://pypi.org/simple\" }}" + ))), + new: None, + } + } + + /// #723: the recorded pre-vendor unit resolved another version, so the + /// revert would leave hosted mode nothing to pin. + #[test] + fn uv_pinned_down_entry_is_refused() { + let err = preflight_uv_takeover(&entry("uv", vec![uv_package("1.17.0")])).unwrap_err(); + assert_eq!(err.code, "redirect_uv_takeover_version_unreachable"); + assert!(err.detail.contains("1.17.0"), "{}", err.detail); + } + + #[test] + fn uv_entry_at_the_patch_version_is_admitted() { + assert!(preflight_uv_takeover(&entry("uv", vec![uv_package("1.16.0")])).is_ok()); + } + + #[test] + fn uv_entry_without_a_parseable_original_is_admitted() { + let mut record = uv_package("1.16.0"); + record.original = Some(serde_json::Value::String("not = [toml".into())); + assert!(preflight_uv_takeover(&entry("uv", vec![record])).is_ok()); + } + + const POETRY_0: &str = "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\n\n\ + [metadata]\ncontent-hash = \"x\"\npython-versions = \">=3.9\"\n\n\ + [metadata.hashes]\nsix = []\n"; + const POETRY_2: &str = "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\n\n\ + [metadata]\nlock-version = \"2.1\"\npython-versions = \">=3.9\"\ncontent-hash = \"x\"\n"; + + fn poetry_entry() -> VendorEntry { + entry( + "poetry", + vec![WiringRecord { + file: "poetry.lock".into(), + kind: "poetry_lock_package".into(), + action: WiringAction::Rewritten, + key: Some("six".into()), + original: None, + new: None, + }], + ) + } + + /// #945: hosted mode refuses every Poetry 0.x lock. + #[tokio::test] + async fn poetry_0_lock_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("poetry.lock"), POETRY_0).unwrap(); + let err = preflight_pypi_takeover(tmp.path(), &poetry_entry()) + .await + .unwrap_err(); + assert_eq!(err.code, "redirect_poetry_lock_unsupported"); + } + + #[tokio::test] + async fn poetry_2_lock_is_admitted() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("poetry.lock"), POETRY_2).unwrap(); + assert!(preflight_pypi_takeover(tmp.path(), &poetry_entry()) + .await + .is_ok()); + } + + #[tokio::test] + async fn other_flavors_are_admitted() { + let tmp = tempfile::tempdir().unwrap(); + assert!( + preflight_pypi_takeover(tmp.path(), &entry("pdm", Vec::new())) + .await + .is_ok() + ); + } +} From d11b01c41586ce68a711d246a0aba9df92841715 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:58:03 +0000 Subject: [PATCH 4/8] Route Gradle digests through utils::digest Port of #878. The digest guard test in socket-patch-core fails on main because the Gradle cache crawler, jar comparator and Maven sidecar hash inline. This keeps CI green on this branch and becomes a no-op once #878 lands. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/crawlers/gradle_cache.rs | 9 ++++----- crates/socket-patch-core/src/patch/jvm_jar.rs | 7 ++----- crates/socket-patch-core/src/patch/sidecars/maven.rs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } From 0874e9d0a66edde2d7299faf55dbdc096d242579 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:03:15 +0000 Subject: [PATCH 5/8] Make takeover remedies revert vendoring first The uv and Poetry takeover refusals told the user to re-lock while the package was still vendored. That can't clear the uv refusal (the recorded pre-vendor entry never changes) and makes the later revert see drift. Both remedies now start with `socket-patch vendor --revert`, say that it reverts every vendored package, and only then re-lock and re-run hosted mode, matching the requirements.txt refusal. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +-- .../src/patch/redirect/pypi_takeover.rs | 26 +++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 3ea164f89..ac281a9d3 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1266,7 +1266,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `pypi_{poetry,pdm,pipenv}_no_lockfile` | `failed` | vendor (pypi): a lock-less tool marker with no `requirements.txt` fallback — run ` lock`. | | `pypi_poetry_integrity_unverified` | `skipped` (warning) | vendor (pypi / poetry): the lock was written by Poetry < 1.4 (0.12 `[metadata.hashes]`, lock 1.0/1.1, or a 2.0 lock without a `@generated by Poetry X.Y.Z` header — 1.3 wrote those). That installer does not verify local wheel hashes (the committed wheel bytes are the protection) and does not replace an already-installed package at the same version; recreate the virtualenv or `pip uninstall` the package before `poetry install`, or upgrade Poetry. | | `redirect_poetry_stale_install_risk` | `redirect.warnings[]` (warning) | scan `--mode hosted` (poetry): same writer test as above — a warm virtualenv keeps the upstream package after the redirect on Poetry < 1.4 (1.4+ re-installs from the new source); fresh installs pick up the patched wheel. Emitted once per rewritten lock, only on the run that rewrites it. | -| `redirect_poetry_entry_not_found` / `redirect_poetry_missing_sha256` / `redirect_poetry_lock_unsupported` | `redirect.warnings[]` (warning) | scan `--mode hosted` (poetry): the lock has no `[[package]]` at the granted version (uv-parity twin of `redirect_uv_entry_not_found`); the grant carries no SHA-256 (gated once per dep, not per lock); the lock is refused — Poetry 0.12 layout (URL sources ignored), an unsupported `lock-version`, a forked package listed at several versions, a user-authored `[package.source]` on another origin (an earlier Socket URL for the same wheel is superseded in place), a malformed `[metadata.files]`/`[metadata.hashes]`, or a wheel whose filename does not match the locked package. Exit code and `status` unchanged (hosted-refusal posture). A vendored → hosted takeover over a Poetry 0.x lock is refused with this code BEFORE the revert (wet and `--dry-run`): the purl stays vendored and patched and is skipped with this code as `redirect.skipped[].reason`. | +| `redirect_poetry_entry_not_found` / `redirect_poetry_missing_sha256` / `redirect_poetry_lock_unsupported` | `redirect.warnings[]` (warning) | scan `--mode hosted` (poetry): the lock has no `[[package]]` at the granted version (uv-parity twin of `redirect_uv_entry_not_found`); the grant carries no SHA-256 (gated once per dep, not per lock); the lock is refused — Poetry 0.12 layout (URL sources ignored), an unsupported `lock-version`, a forked package listed at several versions, a user-authored `[package.source]` on another origin (an earlier Socket URL for the same wheel is superseded in place), a malformed `[metadata.files]`/`[metadata.hashes]`, or a wheel whose filename does not match the locked package. Exit code and `status` unchanged (hosted-refusal posture). A vendored → hosted takeover over a Poetry 0.x lock is refused with this code BEFORE the revert (wet and `--dry-run`): the purl stays vendored and patched and is skipped with this code as `redirect.skipped[].reason`. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), upgrade to Poetry >= 1.0 and re-lock, then re-run `scan --mode hosted`. | | `redirect_pdm_refused` / `redirect_pdm_legacy_sync_required` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pdm): the `pdm.lock` rewrite was refused — an unsupported `[metadata] lock_version` (the identity-losing `3.1` / `4.0`–`4.2` formats or an untested future format), an unsupported `strategy`, a package listed at several versions (fork) or absent, a user-authored `url`/`path`/VCS/`editable` source, hash-less or malformed `files`, or a wheel whose filename does not match the locked package (`redirect_pdm_refused`); or the lock was written in format `2` (PDM 0.12–1.4), whose upstream freshness bug lets `pdm install` regenerate the lock — use `pdm sync` (`redirect_pdm_legacy_sync_required`). A refused uuid is withheld from every other PyPI rewriter when `pdm.lock` is the install driver, and its patch is not confirmed. Exit code and `status` unchanged (hosted-refusal posture). | | `redirect_bun_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the text lock's `lockfileVersion` is not 0, 1 or 2 (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2 — the shared gate's text, identical to vendored's `vendor_lockfile_version_unsupported`), or its `packages` section is not bun's single-line grammar. Nothing rewritten; exit 0 (hosted-refusal posture). | | `redirect_bun_workspace_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): a lockfileVersion-0 lock (Bun 1.1.39–1.1.45 `--save-text-lockfile`) holds `workspace:` packages; frozen installs of that grammar cannot keep the hosted tuple. Detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen installs; delete bun.lock and re-run `bun install` with Bun >= 1.2 (which writes lockfileVersion 1, accepted by hosted mode) — a plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root -> member); otherwise it keeps version 0 or fails to resolve" (measured: Bun 1.2.0 keeps 0, 1.2.23–1.4.2 exit 1 "failed to resolve" on a root that does not depend on its members). Version-1/2 workspace locks are rewritten. Exit 0. | @@ -1274,7 +1274,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | | `redirect_vlt_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` has a `lockfileVersion` other than absent, `0` or `1` (decided on the raw JSON token), is not a JSON object, starts with a UTF-8 BOM, or its `nodes` section is not vlt's one-node-per-line layout. Nothing rewritten; also refuses a vendored → hosted takeover of a `flavor: "vlt"` entry before its revert (`redirect.skipped[].reason`). Exit 0. | | `redirect_requirements_takeover_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / requirements.txt): a vendored → hosted takeover of a package that vendored mode wired through a pin in a `-r` include, or through a `(transitive)` line it appended to the root `requirements.txt`. Hosted mode only rewrites an existing pin in the root `requirements.txt`, so the takeover is refused before the revert (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), move the pin from the include into the root `requirements.txt` and delete it from the include (or, for a `(transitive)` line, add an exact `==` pin to the root file), then re-run `scan --mode hosted`. | -| `redirect_uv_takeover_version_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / uv): a vendored → hosted takeover of a package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the lock entry down to the patch's version, for example when the lock resolved a newer release). Reverting would bring the lock's own version back, and hosted mode only pins the version the lock resolves, so the takeover is refused before the revert (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy: make the project resolve the patch's version (for example an exact `==` requirement), re-lock, then re-run `scan --mode hosted`. | +| `redirect_uv_takeover_version_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / uv): a vendored → hosted takeover of a package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the lock entry down to the patch's version, for example when the lock resolved a newer release). Reverting would bring the lock's own version back, and hosted mode only pins the version the lock resolves, so the takeover is refused before the revert (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), make the project resolve the patch's version (for example an exact `==` requirement) and re-lock, then re-run `scan --mode hosted`. | | `redirect_vlt_missing_sha512` / `redirect_vlt_entry_not_found` / `redirect_vlt_entry_vendored` / `redirect_vlt_unsupported_lock_key` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): the grant has no sha512 / the lock has no default-registry node for `name@version` / the only match is a vendored `file` node under `.socket/vendor/npm//` / a default-registry instance is outside vlt's node-line grammar or still unpatched after the splice. Per dep; none of the dep's instances is written. `redirect_vlt_missing_sha512` and `redirect_vlt_unsupported_lock_key` refuse the dep: it is never confirmed, whichever lock drives (a sibling lock may still carry its rewritten URL). `redirect_vlt_entry_not_found` and `redirect_vlt_entry_vendored` only say `vlt-lock.json` does not wire it: while vlt drives it is not confirmed; otherwise a sibling lock's rules may confirm it. Exit 0. | | `redirect_vlt_custom_registry_skipped` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): same-`name@version` nodes under a named alias, a scoped registry or jsr, or git, remote-tarball or local-directory nodes of the same package name (vlt records no version for those; a remote tarball whose `-.tgz` leaf names another version does not count), were left untouched (hosted mode only redirects vlt's default registry). The dep is still redirected, but the run's `--vex` does not attest it, and neither does a later `vex` from the lock alone. | | `redirect_vlt_lockfile_version_missing` / `redirect_vlt_old_lockfile_ignored` / `redirect_vlt_scalar_registry_ignored` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): the lock has no `lockfileVersion` (vlt ≥ 1.0.0-rc.15 re-resolves it) / a legacy default-registry id without `"modifiers"` in `vlt.json` (vlt 0.0.0-16 … 0.0.0-24 ignore the lock) / a scalar `registry` option that vlt 1.0.0-rc.7 … rc.29 honor over the lock. The deps stay redirected, but the run's `--vex` does not attest them. | diff --git a/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs b/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs index 33c1e7cbb..d9068d556 100644 --- a/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs @@ -59,9 +59,11 @@ fn preflight_uv_takeover(entry: &VendorEntry) -> Result<(), RewriteWarning> { "{} is vendored over {}'s {} entry for version {locked}, which vendored \ mode pinned down to the patch's {patch_version}; reverting it brings \ {locked} back, and hosted mode only pins the version the lock resolves, \ - so it is kept vendored (not switched to hosted). To switch it: make the \ - project resolve {patch_version} (for example an exact `=={patch_version}` \ - requirement), re-lock, then re-run `scan --mode hosted`", + so it is kept vendored (not switched to hosted). To switch it: run \ + `socket-patch vendor --revert` (this reverts EVERY vendored package in the \ + project, not just this one), make the project resolve {patch_version} (for \ + example an exact `=={patch_version}` requirement) and re-lock, then re-run \ + `scan --mode hosted`", entry.base_purl, record.file, record.file, ), }); @@ -105,8 +107,10 @@ async fn preflight_poetry_takeover(root: &Path, entry: &VendorEntry) -> Result<( code: "redirect_poetry_lock_unsupported".into(), detail: format!( "{lock_file}: Poetry 0.x ignores URL sources; hosted patches require Poetry >= 1.0, \ - so {} is kept vendored (not switched to hosted). To switch it: upgrade the \ - project to Poetry >= 1.0 and re-lock, then re-run `scan --mode hosted`", + so {} is kept vendored (not switched to hosted). To switch it: run \ + `socket-patch vendor --revert` (this reverts EVERY vendored package in the \ + project, not just this one), upgrade the project to Poetry >= 1.0 and re-lock, \ + then re-run `scan --mode hosted`", entry.base_purl ), }) @@ -165,6 +169,17 @@ mod tests { let err = preflight_uv_takeover(&entry("uv", vec![uv_package("1.17.0")])).unwrap_err(); assert_eq!(err.code, "redirect_uv_takeover_version_unreachable"); assert!(err.detail.contains("1.17.0"), "{}", err.detail); + assert_remedy_reverts_first(&err.detail); + } + + /// Re-locking while the package is still vendored can't clear the + /// refusal and makes the later revert see drift, so the remedy must + /// unwind the vendored wiring first. + fn assert_remedy_reverts_first(detail: &str) { + let revert = detail.find("vendor --revert").expect(detail); + let relock = detail.find("re-lock").expect(detail); + assert!(revert < relock, "{detail}"); + assert!(detail.contains("EVERY vendored package"), "{detail}"); } #[test] @@ -208,6 +223,7 @@ mod tests { .await .unwrap_err(); assert_eq!(err.code, "redirect_poetry_lock_unsupported"); + assert_remedy_reverts_first(&err.detail); } #[tokio::test] From a6f28dcae9066fb626afc764bf2909b4be0928ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 13:13:26 +0000 Subject: [PATCH 6/8] Remove stray conflict-marker file from merge Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wk9XBZsw4FpBuPb4DpKRPv --- <<<<<<< HEAD...[12] | 1 - 1 file changed, 1 deletion(-) delete mode 100644 <<<<<<< HEAD...[12] diff --git a/<<<<<<< HEAD...[12] b/<<<<<<< HEAD...[12] deleted file mode 100644 index 48082f72f..000000000 --- a/<<<<<<< HEAD...[12] +++ /dev/null @@ -1 +0,0 @@ -12 From b9350a8c194e3ef3e35b78eb25fd17280b5e6d1f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 13:36:31 +0000 Subject: [PATCH 7/8] Compare the uv takeover version decoded Ledger purls keep the API's percent-encoding, so a PEP 440 local version (+) arrives as %2B and never equals the lock's version, refusing a reachable takeover. Decode it as matching_package does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wk9XBZsw4FpBuPb4DpKRPv --- .../src/patch/redirect/pypi_takeover.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs b/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs index d9068d556..032c503e5 100644 --- a/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs @@ -42,9 +42,12 @@ pub async fn preflight_pypi_takeover( /// whose `version` equals the patch's (`matching_package`), so a recorded /// unit at another version can never be taken over. fn preflight_uv_takeover(entry: &VendorEntry) -> Result<(), RewriteWarning> { - let Some((_, patch_version)) = entry.base_purl.rsplit_once('@') else { + let Some((_, raw_version)) = entry.base_purl.rsplit_once('@') else { return Ok(()); }; + // Ledger purls keep the API's encoding (a PEP 440 local `+` is `%2B`); + // the lock and `matching_package` compare the decoded version. + let patch_version = crate::utils::purl::percent_decode_purl_component(raw_version); for record in entry.wiring.iter().filter(|r| r.kind == "uv_lock_package") { let Some(original) = record.original.as_ref().and_then(|v| v.as_str()) else { continue; @@ -52,7 +55,7 @@ fn preflight_uv_takeover(entry: &VendorEntry) -> Result<(), RewriteWarning> { let Some(locked) = recorded_unit_version(original) else { continue; }; - if locked != patch_version { + if locked != *patch_version { return Err(RewriteWarning { code: "redirect_uv_takeover_version_unreachable".into(), detail: format!( @@ -187,6 +190,13 @@ mod tests { assert!(preflight_uv_takeover(&entry("uv", vec![uv_package("1.16.0")])).is_ok()); } + #[test] + fn uv_entry_at_an_encoded_local_patch_version_is_admitted() { + let mut e = entry("uv", vec![uv_package("1.16.0+socket.1")]); + e.base_purl = "pkg:pypi/six@1.16.0%2Bsocket.1".into(); + assert!(preflight_uv_takeover(&e).is_ok()); + } + #[test] fn uv_entry_without_a_parseable_original_is_admitted() { let mut record = uv_package("1.16.0"); From 97d152ce39aa11ae46bf60fa8f0eaf32ae475057 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 13:57:01 +0000 Subject: [PATCH 8/8] Drop stale digest pending-list entries Port of #1016. main routes the Gradle cache crawler, jar comparator and Maven sidecar through utils::digest but still lists them as pending, so production_digests_go_through_the_helpers fails on main and every branch off it. No-op once #1016 lands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wk9XBZsw4FpBuPb4DpKRPv --- crates/socket-patch-core/src/utils/digest.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/socket-patch-core/src/utils/digest.rs b/crates/socket-patch-core/src/utils/digest.rs index 105225e5e..630adefa1 100644 --- a/crates/socket-patch-core/src/utils/digest.rs +++ b/crates/socket-patch-core/src/utils/digest.rs @@ -135,9 +135,6 @@ mod tests { /// when you move it onto the helpers above; the test fails on a stale /// entry as well as on a new inline copy. const PENDING_INLINE_DIGESTS: &[&str] = &[ - "crawlers/gradle_cache.rs", - "patch/jvm_jar.rs", - "patch/sidecars/maven.rs", "utils/group_commit.rs", "vendor/jvm/mod.rs", "vendor/maven_repo.rs",