diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 8d3b0766b..8ebbb1c37 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` — `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. 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` — `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 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 e117d72ad..1b0fdff6b 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -794,6 +794,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 { @@ -1015,6 +1016,15 @@ 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 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` @@ -1295,6 +1305,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 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" + ), + }) + })); warnings.extend(takeover_warnings.iter().cloned()); warnings.extend(prune_warnings.iter().cloned()); @@ -1314,6 +1336,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); } @@ -1345,7 +1370,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, @@ -1455,7 +1482,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()) @@ -1470,10 +1499,50 @@ 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; } 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 @@ -1510,8 +1579,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. @@ -1699,6 +1775,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!({ @@ -1745,6 +1826,16 @@ async fn vendored_takeover( })); continue; } + 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(drifted_takeover_warning(purl)); + 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 @@ -1759,8 +1850,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!( @@ -1857,6 +1950,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 { @@ -1871,6 +1990,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/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"), 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..b888a96c4 --- /dev/null +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -0,0 +1,599 @@ +//! 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 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" + } else { + "lib/python3.11/site-packages" + })) + .unwrap(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + 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); + } + } + 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); + ( + 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 +/// hosted wheel, and the wheel itself (its METADATA feeds the lock +/// 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 = + 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; + 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(wheel_response) + .mount(server) + .await; + hosted_url +} + +/// 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:#}"); + 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:#?}" + ); +} + +/// `scan --mode hosted` against `server`. +fn hosted_scan(root: &Path, server: &MockServer) -> (i32, Value) { + let uri = server.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 +/// 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!( + 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; +} + +/// 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. +/// 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", + ) + .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; + (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:#}"); + 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" + ); +} + +/// 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" + ); +} + +/// 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:#}"); +}