From 1eba427a36d799fcf7c8a897abadce543b509e51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:12:45 +0000 Subject: [PATCH 1/8] Consolidate vendored apply/revert/repair into one VendoredBackend vendor, scan/get --mode vendored, vendor --revert, rollback's vendored leg, remove and repair now go through one VendoredBackend { apply, revert, repair } over the shared engine (vendor_records_reusing, dispatch_revert_one_opts). The boxed_* scan shims collapse into one boxed_vendor_step; the engine future stays boxed inside apply for the Windows 1 MiB main-thread stack. repair no longer re-synthesizes vendor ledger entries from lockfiles. A lockfile reference with no ledger entry fails with vendor_ledger_missing (artifact-level event: uuid + details.{ecosystem,path}); the remedy is restoring state.json from version control. Missing or corrupt artifacts are re-vendored through the same engine as vendor, so the patch service's prebuilt artifact is downloaded first under --vendor-source auto, with the local build as the fallback. The fingerprint post-verify, set-aside of corrupt bytes and carried-inventory refresh are kept. Removed with the rebuild: repair_vendor.rs, gem Gemfile wiring reconstruction, and registry_fetch::fetch_npm_unverified. The packing code (npm_pack, pypi_wheel, berry_zip, registry_fetch, prestage) stays: depscan does not call it (verified against depscan master 784013d6), but it is the CLI's own --vendor-source build/auto fallback. Tests for the reconstruction path are replaced by vendor_ledger_missing pins per flavor; CLI_CONTRACT, README, CHANGELOG and the v5 plan are updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DMhWChtaNX5FJYNDq3NBJa --- .github/workflows/bun-compatibility.yml | 4 +- .github/workflows/vlt-compatibility.yml | 4 +- CHANGELOG.md | 24 + README.md | 12 +- crates/socket-patch-cli/CLI_CONTRACT.md | 70 +- crates/socket-patch-cli/src/commands/get.rs | 41 +- crates/socket-patch-cli/src/commands/mod.rs | 2 +- .../socket-patch-cli/src/commands/remove.rs | 23 +- .../socket-patch-cli/src/commands/repair.rs | 35 +- .../src/commands/repair_vendor.rs | 2658 ----------------- .../socket-patch-cli/src/commands/rollback.rs | 114 +- .../socket-patch-cli/src/commands/scan/mod.rs | 2 +- .../src/commands/scan/vendor_flow.rs | 378 +-- .../socket-patch-cli/src/commands/vendor.rs | 230 +- .../src/commands/vendored_backend/mod.rs | 262 ++ .../src/commands/vendored_backend/repair.rs | 1492 +++++++++ .../coverage_fix_repair_vendor_predelete.rs | 104 +- .../tests/covgap_commands_repair_vendor.rs | 794 +---- .../tests/covgap_commands_rollback.rs | 14 +- .../tests/covgap_commands_scan_vendor_flow.rs | 2 +- .../socket-patch-cli/tests/e2e_bun_lockb.rs | 28 +- .../tests/e2e_vendor_composer_build.rs | 6 +- .../tests/in_process_vendor/vlt.rs | 42 +- .../tests/repair_vendor_e2e.rs | 1009 +------ .../tests/repair_vendor_flavors_e2e.rs | 270 +- .../tests/repair_vendor_flavors_e2e/vlt.rs | 75 +- .../tests/scan_vendor_step_error_e2e.rs | 2 +- crates/socket-patch-core/src/vendor/gem.rs | 649 +--- .../src/vendor/registry_fetch.rs | 83 +- docs/design/v5-plan.md | 53 + 30 files changed, 2455 insertions(+), 6027 deletions(-) delete mode 100644 crates/socket-patch-cli/src/commands/repair_vendor.rs create mode 100644 crates/socket-patch-cli/src/commands/vendored_backend/mod.rs create mode 100644 crates/socket-patch-cli/src/commands/vendored_backend/repair.rs diff --git a/.github/workflows/bun-compatibility.yml b/.github/workflows/bun-compatibility.yml index d5982448..41ac781b 100644 --- a/.github/workflows/bun-compatibility.yml +++ b/.github/workflows/bun-compatibility.yml @@ -43,7 +43,7 @@ on: - 'crates/socket-patch-cli/src/commands/scan/**' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-cli/src/commands/vendor.rs' - - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/vendored_backend/**' - 'crates/socket-patch-cli/src/commands/remove.rs' # Main runs are the only rust-cache writers (save-if below), so a # path-filtered push trigger is what seeds the cache the PR builds restore @@ -75,7 +75,7 @@ on: - 'crates/socket-patch-cli/src/commands/scan/**' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-cli/src/commands/vendor.rs' - - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/vendored_backend/**' - 'crates/socket-patch-cli/src/commands/remove.rs' workflow_dispatch: inputs: diff --git a/.github/workflows/vlt-compatibility.yml b/.github/workflows/vlt-compatibility.yml index 6fd588bd..65839664 100644 --- a/.github/workflows/vlt-compatibility.yml +++ b/.github/workflows/vlt-compatibility.yml @@ -37,7 +37,7 @@ on: - 'crates/socket-patch-cli/src/commands/remove.rs' - 'crates/socket-patch-cli/src/commands/setup.rs' - 'crates/socket-patch-cli/src/commands/vendor.rs' - - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/vendored_backend/**' - 'crates/socket-patch-cli/src/commands/get.rs' - 'crates/socket-patch-cli/src/commands/vlt_preflight.rs' - 'crates/socket-patch-cli/src/commands/scan/**' @@ -74,7 +74,7 @@ on: - 'crates/socket-patch-cli/src/commands/remove.rs' - 'crates/socket-patch-cli/src/commands/setup.rs' - 'crates/socket-patch-cli/src/commands/vendor.rs' - - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' + - 'crates/socket-patch-cli/src/commands/vendored_backend/**' - 'crates/socket-patch-cli/src/commands/get.rs' - 'crates/socket-patch-cli/src/commands/vlt_preflight.rs' - 'crates/socket-patch-cli/src/commands/scan/**' diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a44ba3..3a4ef0fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,30 @@ into the new version's section — see docs/releasing.md. ### Changed (BREAKING) +- **`repair` no longer rebuilds the vendor ledger from lockfiles.** A + lockfile that references `.socket/vendor///` with no entry in + `.socket/vendor/state.json` now fails with `vendor_ledger_missing` (an + artifact-level `failed` event with `uuid` and `details.{ecosystem,path}`; + exit 1) instead of re-synthesizing the entry (`details.ledgerRestored` is + gone). The rewired lockfile cannot supply the pre-vendor originals a + revert needs, so the remedy is restoring `state.json` from version + control (or `git checkout -- ` and re-vendoring). The unverified + npm "rebuild from the wired integrity" rung and the gem Gemfile wiring + reconstruction went with it; `rollback`'s missing-ledger error now asks + for `state.json` to be restored instead of naming `repair`. +- **`repair` re-vendors broken artifacts the way `vendor` does.** Missing + or corrupt vendored artifacts go through the same vendored backend as + `vendor` / `scan --mode vendored` / `get --mode vendored`, so under the + default `--vendor-source auto` the patch service's prebuilt artifact is + downloaded again, with a local build as the fallback (and the only + source under `--offline` / `--vendor-source build`). The result is still + verified against the ledger fingerprint before it counts as `rebuilt`. + Failure details are now `vendor`'s own (for example "no installed + package found on disk"), and a drifted installed copy of a gem or pypi + release variant is no longer force-overwritten by repair — it fails the + same installed-variant check `vendor` applies. Internally, `vendor`, `scan`/`get --mode vendored`, `vendor --revert`, + `rollback`'s vendored leg, `remove` and `repair` now share one + `VendoredBackend { apply, revert, repair }`. - **Vendored runs refuse lock-text failures before downloading them.** `scan --mode vendored` and `get --mode vendored` evaluate the vendor backends' pure lock-text gates — pnpm, yarn classic and yarn berry diff --git a/README.md b/README.md index af7fc5af..064efd48 100644 --- a/README.md +++ b/README.md @@ -457,7 +457,7 @@ need the network and refuse to run with `--offline`. | [`remove`](#remove) | The single-patch form of `rollback`: restore, unwind, drop the record and GC for one PURL/UUID | | [`vendor --revert`](#vendor) | **Un-vendors wholesale**: restores the recorded original lockfile fragments byte-for-byte and removes the `.socket/vendor/` artifacts | | [`scan --prune`](#scan) | Agent mode: **reconciles, doesn't reverse** — drops manifest entries for packages that have left the project and garbage-collects orphan blob/diff/archive files | -| [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, and cleans up unused ones | +| [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, re-vendors missing/corrupt vendored artifacts, and cleans up unused ones | And `setup --remove` reverts the install hooks that `setup` added. @@ -480,7 +480,7 @@ And `setup --remove` reverts the install hooks that `setup` added. | [`setup`](#setup) | Wire install hooks (npm, Python, Bundler, Composer) that re-apply patches after install | | [`rollback`](#rollback) | Undo patches in every mode: restore original files and unwind hosted or vendored lockfile wiring | | [`remove`](#remove) | Remove one patch by PURL or UUID (rolls back first) | -| [`repair`](#repair) | Download missing patch artifacts, rebuild vendored artifacts, clean up unused ones (alias: `gc`) | +| [`repair`](#repair) | Download missing patch artifacts, re-vendor broken vendored artifacts, clean up unused ones (alias: `gc`) | `socket-patch --update` updates the CLI itself (see [Updating](#updating)). @@ -1167,14 +1167,18 @@ socket-patch remove "pkg:npm/lodash@4.17.20" --json ### `repair` -Download missing blobs, rebuild missing or corrupt vendored artifacts, and clean up unused +Download missing blobs, re-vendor missing or corrupt vendored artifacts, and clean up unused blobs. Alias: `gc` `repair` cleans up the `.socket/` directory without running a scan — useful when you've manually adjusted the manifest, recovered from a partial-failure state, or just want to -free space. It also rebuilds missing or corrupt vendored artifacts. For the combined +free space. It also re-vendors missing or corrupt vendored artifacts the same way `vendor` +does (the patch service's prebuilt artifact first, a local build as the fallback), checked +against `.socket/vendor/state.json`. `repair` does not recreate a lost `state.json`: if a +lockfile points into `.socket/vendor/` and the ledger has no entry for it, `repair` fails with +`vendor_ledger_missing` — restore `state.json` from version control. For the combined agent-mode workflow (discover + apply + GC in one pass), use `scan --sync` instead. Like every other mutating command, `repair` takes the `.socket/apply.lock` advisory lock diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 55a3983a..7ca687f1 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -17,7 +17,7 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth | `setup` | — | Agent mode: wire automatic-patching install hooks (npm/pypi/gem/composer) | | `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored/hosted lockfile wiring, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | | `remove` | — | Agent mode: remove a patch from manifest (rolls back first); requires positional `identifier` | -| `repair` | `gc` | Agent mode: download missing blobs, rebuild missing/corrupt vendored artifacts, and clean up unused ones (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | +| `repair` | `gc` | Agent mode: download missing blobs, re-vendor missing/corrupt vendored artifacts (never re-synthesizing a lost ledger), and clean up unused ones (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex` → `vendor`, with `list` to inspect), then the agent-mode (in-place patching) commands. @@ -651,37 +651,36 @@ into memory via the patch-view endpoint. A vendored project's `.socket/` holds o by an agent-mode manifest, or as the `{"patches": {}}` husk left after a legacy record migrated into the ledger). -**Vendored artifact repair (v3.5)**: `repair` health-checks every ledger entry — per-file +**Vendored artifact repair (v5.0)**: `repair` health-checks every ledger entry — per-file afterHashes inside the artifact plus, for file-shaped artifacts (`.tgz`/`.whl`), the whole file against the ledger's recorded sha256 (the rewired lock integrity references those exact bytes) — -and REBUILDS missing/corrupt artifacts through the normal vendor backends. The wired hot paths -rebuild the artifact only: lockfiles stay byte-identical and the ledger entry is not re-recorded -(the first run's entry holds the only pre-vendor originals). Pristine sources follow the same -ladder as vendor: the installed copy first (works under `--offline`), then a lockfile-verified -registry fetch, then the pre-vendor registry fragment recovered from the ledger's wiring -`original`s (`recover_lock_entry`) — always integrity-verified fail-closed, and the rebuilt -artifact is re-verified against the recorded fingerprint before the run counts it (`rebuilt` -event; a mismatch removes the artifact and fails with `vendor_artifact_rebuild_failed`). -Lockfile references to `.socket/vendor///...` with NO ledger coverage (the ledger was -deleted wholesale) are RECONSTRUCTED: the uuid comes from the path (the recovery rule above), the -record from the manifest — or the patch API, yielding an entry with the record embedded (the same -`detached: true` + `record` shape every `scan`/`get --mode vendored` entry has) -— and a fresh ledger entry is persisted with the rebuilt artifact's fingerprint. When nothing is -installed and the ledger is gone, npm-family reconstruction has one more rung: the REWIRED -lockfile still records the integrity of the packed vendored tarball, so the pristine copy is -fetched (unverified, conventional registry URL, `SOCKET_NPM_REGISTRY` honored) and the -deterministically REBUILT artifact must reproduce that wired integrity — a tampered pristine -source changes the rebuilt bytes and fails closed (`vendor_artifact_rebuild_failed`, nothing -kept). Reconstructed entries carry no pre-vendor wiring originals, so a later `--revert` degrades -to the documented `vendor_lock_entry_drifted` guidance (re-resolve with the package manager). Because of this -phase, `repair` no longer errors with `manifest_not_found` when the project has a vendor ledger -or vendor-path lockfile references — it runs the vendored phase alone. A **hosted-only** project -(no manifest, no vendor ledger, no vendored references — only `.socket/vendor/redirect-state.json`) -is a no-op: `repair` exits 0 with a `redirect_only_project` skip pointing at `scan --mode hosted` -(hosted redirects have no local artifacts to repair), rather than the `manifest_not_found` error a -bare directory still gets. Step 1's source download -likewise skips vendored-in-sync manifest entries (their content lives in the committed artifact), -so repairing a vendored project never re-litters `.socket/blobs`. `--dry-run` previews +and RE-VENDORS missing/corrupt artifacts through the same vendored backend `vendor`, `scan --mode +vendored` and `get --mode vendored` use. The artifact therefore comes from the same place a fresh +vendor gets it: under the default `--vendor-source auto` the patch service's prebuilt artifact is +downloaded again, with a local build from a lockfile-verified pristine source as the fallback +(and the only source under `--offline` / `--vendor-source build`). The wired hot paths rebuild +the artifact only: lockfiles stay byte-identical and the ledger entry keeps its recorded +pre-vendor originals. The re-vendored artifact is verified against the ledger fingerprint before +the run counts it (`rebuilt` event; a mismatch removes the artifact and fails with +`vendor_artifact_rebuild_failed`). A corrupt artifact is moved aside for the rebuild and put back +when nothing replaced it. + +**The ledger is not rebuilt from lockfiles (v5.0).** A lockfile reference to +`.socket/vendor///...` with NO ledger entry (state.json deleted or never committed) +fails with `vendor_ledger_missing` (an artifact-level `failed` event carrying `uuid` and +`details.{ecosystem,path}`; exit 1) — the pre-vendor originals a revert needs cannot be recovered +from the rewired lockfile. Recovery: restore `.socket/vendor/state.json` from version control and +re-run `repair`, or restore the lockfile (`git checkout -- `) and re-vendor. Earlier +releases re-synthesized such entries (`details.ledgerRestored`); ledgers they wrote keep working. + +Because of this phase, `repair` does not error with `manifest_not_found` when the project has a +vendor ledger or vendor-path lockfile references — it runs the vendored phase alone. A +**hosted-only** project (no manifest, no vendor ledger, no vendored references — only +`.socket/vendor/redirect-state.json`) is a no-op: `repair` exits 0 with a `redirect_only_project` +skip pointing at `scan --mode hosted` (hosted redirects have no local artifacts to repair), rather +than the `manifest_not_found` error a bare directory still gets. Step 1's source download skips +vendored manifest entries and lockfile-referenced uuids (their content lives in the committed +artifact), so repairing a vendored project never re-litters `.socket/blobs`. `--dry-run` previews (`details.wouldRebuild`); `--offline` rebuilds only from fully local sources and fails per-entry otherwise; `vendor`/`scan --vendor` re-runs get the same rebuild for wired-but-broken artifacts (`vendor_artifact_rebuilt` warning) and recover registry resolutions for missing committed @@ -935,7 +934,7 @@ worse, lets a warm cache silently serve unpatched bytes): A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatched and cleans up the local state, in phases under one `apply.lock` acquisition: -1. **State discovery.** A missing manifest is no longer fatal when the vendor or redirect ledger holds work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` project is manifest-less). The **truly-empty** project — all three stores absent — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors naming `socket-patch repair` (reconstruct the ledger, then roll back). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent leg still restores files; an unreadable redirect ledger skips only the hosted leg (`redirect_state_unreadable` warning; v5.0 distinguishes a ledger that cannot be READ — EACCES, a directory or FIFO squatting on the path — which is reported as such and left in place with a fix-the-permissions remedy, from MALFORMED JSON, which is quarantined to `redirect-state.json.corrupt` with the restore remedy). Either drives `partial_failure` exit 1; an emergency restore is never blocked by an unrelated corrupt ledger. When the ONLY state on disk is an unreadable ledger, the run fails closed naming the store. +1. **State discovery.** A missing manifest is no longer fatal when the vendor or redirect ledger holds work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` project is manifest-less). The **truly-empty** project — all three stores absent — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent leg still restores files; an unreadable redirect ledger skips only the hosted leg (`redirect_state_unreadable` warning; v5.0 distinguishes a ledger that cannot be READ — EACCES, a directory or FIFO squatting on the path — which is reported as such and left in place with a fix-the-permissions remedy, from MALFORMED JSON, which is quarantined to `redirect-state.json.corrupt` with the restore remedy). Either drives `partial_failure` exit 1; an emergency restore is never blocked by an unrelated corrupt ledger. When the ONLY state on disk is an unreadable ledger, the run fails closed naming the store. 2. **Agent leg** — the existing in-place restore machinery, unchanged (v5.0 presentation: the human `No patches found in manifest` line prints only for an unscoped run with no work in ANY leg — a run whose work is all vendored/hosted stays quiet about the manifest): multi-copy restore, release-variant narrowing, the before-blob gate (+ on-demand download; a gate abort still exits 1 with per-package `missing_blob` failure results **and** skips manifest cleanup + GC entirely — nothing was restored, and the retry's revert data must survive), local-go redirect drop, and the `not_installed` exit-0 asymmetry verbatim. Vendor-owned purls are still excluded here (see the vendored-mode section) — they are handled by the next leg instead of being punted to other commands. 3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor//` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (`vendoredKept`, exit 1 — the system is still patched); a failure is recorded and other entries proceed. 4. **Hosted leg** — see "Hosted unwind coverage" below. @@ -1123,7 +1122,7 @@ Env-only knobs (no CLI flag) read by the vendor auto-fetch / artifact-rebuild pa | Env var | Default | Notes | |---|---|---| -| `SOCKET_NPM_REGISTRY` | `https://registry.npmjs.org` | Base for conventional npm tarball URLs (vendor auto-fetch + the npm-family lockfile-integrity reconstruction rung in `repair`). | +| `SOCKET_NPM_REGISTRY` | `https://registry.npmjs.org` | Base for conventional npm tarball URLs (vendor auto-fetch, including `repair`'s local-build fallback). | | `SOCKET_CRATES_REGISTRY` | `https://static.crates.io/crates` | crates.io static `.crate` download host. | | `SOCKET_GOPROXY` | `https://proxy.golang.org` | Go module proxy. Wins over the standard `GOPROXY` env var, whose first element is used otherwise. When that element is `off` or `direct`, or the module matches `GONOPROXY` (default `GOPRIVATE`), go would not ask a proxy, so the pristine fetch is refused (`vendor_fetch_unverifiable` + the calm `package_not_installed` skip) instead of falling back to `proxy.golang.org`. | | `SOCKET_MAVEN_REGISTRY` | `https://repo1.maven.org/maven2` | maven2 base for the fallback upstream-pom download. | @@ -1220,7 +1219,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `failed` | every command | A specific patch attempt failed. `errorCode` + `error` set. | | `removed` | `gc`/`repair`, `remove`, `rollback` | Data was removed from `.socket/` (or files rolled back). `bytes` optional. | | `verified` | `apply --dry-run`, `scan --dry-run` | The patch *would* apply cleanly. `files` lists previewed changes. | -| `rebuilt` | `repair` | A missing/corrupt vendored artifact was rebuilt in place (or its lost ledger entry restored — `details.ledgerRestored`). `summary.rebuilt` counts these (the field is omitted while zero). | +| `rebuilt` | `repair` | A missing/corrupt vendored artifact was re-vendored in place (v5.0: never a lost ledger entry — see `vendor_ledger_missing`). `summary.rebuilt` counts these (the field is omitted while zero). | ### Stable `errorCode` tags @@ -1259,7 +1258,8 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `already_vendored` | `skipped` | vendor: artifact + wiring already in sync for this patch uuid. | | `unsafe_coordinates` | `failed` | vendor: purl/uuid would escape `.socket/vendor/` (tampered manifest/state); refused before any write. | | `revert_failed` | `failed` | vendor --revert: a recorded entry could not be reverted. | -| `vendor_wiring_unknown_revert_blocked` | `skipped` (beside the `failed`/`revert_failed` event) | vendor --revert: the ledger entry was reconstructed by `repair` without wiring records and the live lockfile still resolves through the artifact — the revert refuses (fail-closed) instead of deleting a tarball the lock points at. Recovery: `socket-patch repair`, then restore the pre-vendor lock (or re-lock without the override) and re-run the revert. repair: an npm ledger entry whose `flavor` this release does not know (written by a newer socket-patch) is skipped, never health-checked or rebuilt, and the artifact, wiring and ledger stay as found (a lone `skipped` event; the run's exit is unaffected). Recovery: upgrade socket-patch. | +| `vendor_ledger_missing` | `failed` (artifact-level: `uuid` + `details.{ecosystem,path}`, no purl) | repair (v5.0): a lockfile references `.socket/vendor///` but the vendor ledger has no entry for it; repair no longer rebuilds ledger entries from lockfiles. Recovery: restore `.socket/vendor/state.json` from version control and re-run `repair`, or `git checkout -- ` and re-vendor. | +| `vendor_wiring_unknown_revert_blocked` | `skipped` (beside the `failed`/`revert_failed` event) | vendor --revert: the ledger entry was reconstructed by a pre-v5 `repair` without wiring records and the live lockfile still resolves through the artifact — the revert refuses (fail-closed) instead of deleting a tarball the lock points at. Recovery: `socket-patch repair`, then restore the pre-vendor lock (or re-lock without the override) and re-run the revert. repair: an npm ledger entry whose `flavor` this release does not know (written by a newer socket-patch) is skipped, never health-checked or rebuilt, and the artifact, wiring and ledger stay as found (a lone `skipped` event; the run's exit is unaffected). Recovery: upgrade socket-patch. | | `ecosystem_not_setup` | `skipped` | vex: the patch is applied and byte-verified but its ecosystem has no install hook configured and is not declared in the manifest's `setup.manual`, so it is omitted from the document (Property 7). | | `stale_install` | `skipped` | vex (in-run `scan --mode hosted --vex`): a hosted stale-install probe found positively unpatched installed bytes, so the purl is omitted even under `--vex-no-verify` (see the gem / Python stale-install guards). | | `record_unavailable` | `skipped` | vex (manifest-less): a lockfile-wired patch has no local record (manifest, redirect ledger, vendor ledger) and none could be fetched — `--offline`, transport error, 404, or a refused (paid) patch. Omitted, never attested from the `socket-patch.vendor.json` marker. | @@ -1302,7 +1302,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_flavor_changed` | `failed` | vendor (npm): the purl's vendor ledger entry was written for another lockfile `flavor` than the one the router now detects (for example `npm` → `vlt` after switching package managers). Remedy: `socket-patch vendor --revert` it first, then re-vendor. Refused before any write. | | `vendor_artifact_gitignored` | `failed` | vendor (vlt): inside a git work tree, `git check-ignore --no-index` reports the new artifact's uuid directory as ignored by a rule its own `.gitignore` cannot override (such as a root `.socket/` rule; the detail names the rule). Remedy: drop that rule for `.socket/vendor/`. Refused before any write. | | `vendor_artifact_gitignore_unchecked` | warning | vendor (vlt): git is installed but could not answer the ignore check for the written vendored directory (it failed to start, ran past 30 s, or `rev-parse` / `check-ignore` exited with an error); the package is vendored and the detail names what failed. Remedy: make sure no ignore rule covers `.socket/` before committing. Git absent, or a project outside any work tree, raises nothing. | -| `vendor_ledger_entry_missing` | `failed` | vendor (vlt): the only installed copy is vlt's link to a committed vendored directory, but the vendor ledger has no entry for the package; run `socket-patch repair` to restore it. Replaces the `package_not_installed` skip. | +| `vendor_ledger_entry_missing` | `failed` | vendor (vlt): the only installed copy is vlt's link to a committed vendored directory, but the vendor ledger has no entry for the package; restore `.socket/vendor/state.json` from version control (v5.0: `repair` no longer re-synthesizes it). Replaces the `package_not_installed` skip. | | `vendor_artifact_missing` | `skipped` (warning) / `failed` | vendor: the committed artifact is gone — the registry resolution is recovered from the ledger and the artifact rebuilt (warning); repair `--offline` with no local source surfaces it as the per-entry failure instead. | | `vendor_artifact_corrupt` | `failed` | repair `--offline`: the committed artifact fails verification (member afterHashes or the ledger's whole-file sha256) and no local source can rebuild it. Online repairs rebuild instead. | | `vendor_artifact_reused` | `skipped` (verbose note) | vendor / scan `--vendor` (pypi): the wiring was dropped by a relock but the committed wheel the ledger vouches for verified, so it was re-wired as-is — no service download, no rebuild; the lock pins the first run's sha again. | diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 76fe2a8b..368d4f91 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -3869,36 +3869,28 @@ async fn run_get_vendored( )) .await }; - let mut has_errors = dl_code != 0; fold_narrowing_into_result(&mut result, narrow_skips, narrow_warnings); // The vendor step (scan's, verbatim): apply lock, in-memory staging // seeded with the blobs fetched above, the engine over exactly the // records fetched above (moved in — nothing here needs them afterwards) - // and over this run's client. A per-patch download failure does not - // skip it (scan parity). - match super::scan::boxed_scan_vendor_step( - &args.common, + // and over this run's client, then the run's telemetry. A per-patch + // download failure does not skip it (scan parity). + match super::scan::boxed_vendor_step(super::scan::VendorStep { + common: &args.common, records, - blobs, - api_client.clone(), + seed: blobs, + client: api_client.clone(), use_public_proxy, - ) + report_empty: true, + prior: None, + download_errors: dl_code != 0, + telemetry_token, + telemetry_org, + }) .await { - Ok((vendor_errors, venv)) => { - has_errors |= vendor_errors; - // Telemetry follows the RUN outcome, not the vendor step alone: - // a download-phase refusal/failure exits 1 and must not report - // a successful vendoring of zero patches (scan's arms agree). - crate::commands::vendor::track_outcomes_for_vendor( - has_errors, - &venv, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; + Ok((has_errors, venv)) => { if args.common.json { result["status"] = serde_json::json!(if has_errors { "partial_failure" @@ -3912,13 +3904,6 @@ async fn run_get_vendored( i32::from(has_errors) } Err((code, message, venv)) => { - socket_patch_core::telemetry::track_patch_vendor_failed( - &message, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; if args.common.json { // A vendor envelope built before the failure (events // included) must reach the JSON consumer even though the diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index ddda23e2..ea528e36 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -7,7 +7,7 @@ pub mod list; pub(crate) mod lock_cli; pub mod remove; pub mod repair; -pub(crate) mod repair_vendor; +pub(crate) mod vendored_backend; pub mod rollback; pub mod scan; pub mod setup; diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 9ce95401..1b2dcb72 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -16,9 +16,10 @@ use std::time::Duration; use super::get::short_uuid; use super::rollback::{ - pin_before_hash_blobs, revert_vendor_entry, rollback_patches_inner, run_hosted_leg, - sweep_failure, sweep_unused_artifacts, HostedLegOutcome, InnerSelection, VendorRevertStep, + pin_before_hash_blobs, rollback_patches_inner, run_hosted_leg, sweep_failure, + sweep_unused_artifacts, HostedLegOutcome, InnerSelection, }; +use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::lock_cli::acquire_or_emit; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status}; @@ -1179,9 +1180,19 @@ async fn revert_vendored_matches( keep_artifact: args.preserve_state, }; let mut leg = RemoveVendorLeg::default(); - for key in keys { - let result = revert_vendor_entry(&args.common.cwd, key, state, opts).await; - for w in &result.warnings { + // Stops at the first hard failure: remove aborts there, leaving the + // remaining matches (and the manifest) untouched. + let reverted = VendoredBackend::new(&args.common, None) + .revert(keys, state, opts, true) + .await; + for RevertedEntry { + key, + warnings, + step, + .. + } in reverted + { + for w in &warnings { if loud { eprintln!("Warning ({}): {}", w.code, w.detail); } @@ -1190,7 +1201,7 @@ async fn revert_vendored_matches( .with_reason(w.code, w.detail.clone()), ); } - match result.step { + match step { VendorRevertStep::Missing => {} VendorRevertStep::Failed(why) => { track_patch_remove_failed( diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 4fb8ed4d..da1cb391 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -83,7 +83,7 @@ pub async fn run(args: RepairArgs) -> i32 { let mut has_vendor_traces = tokio::fs::metadata(&state_file).await.is_ok(); if !has_vendor_traces { let refs = - crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd).await; + crate::commands::vendored_backend::repair::scan_vendor_references(&args.common.cwd).await; has_vendor_traces = !refs.is_empty(); vendor_references = Some(refs); } @@ -145,7 +145,7 @@ pub async fn run(args: RepairArgs) -> i32 { // scanned this ledger-less project. let vendor_references = match vendor_references { Some(refs) => refs, - None => crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd).await, + None => crate::commands::vendored_backend::repair::scan_vendor_references(&args.common.cwd).await, }; // The API client is built lazily: `repair_inner` constructs it only on @@ -415,9 +415,10 @@ async fn repair_inner( let ledger = socket_patch_core::vendor::load_state(&args.common.cwd).await; let no_entries = std::collections::HashMap::new(); let vendor_entries = ledger.as_ref().map(|s| &s.entries).unwrap_or(&no_entries); - // Lockfile vendor references count as vendored even before the ledger - // is reconstructed, so a no-ledger repair doesn't download sources for - // entries the vendored phase is about to own. + // Lockfile vendor references count as vendored even with no ledger + // entry: the committed artifact is the patch, so a no-ledger repair + // must not litter `.socket/` with sources for it (the vendored phase + // reports the missing ledger instead). let referenced_uuids: std::collections::HashSet = vendor_references .iter() .map(|(_, uuid, _)| uuid.clone()) @@ -530,19 +531,25 @@ async fn repair_inner( } } - // Step 1.5: vendored artifacts — health-check the ledger (and any - // lockfile vendor references with no ledger coverage) and rebuild - // missing/corrupt artifacts. Runs under `--download-only` too: + // Step 1.5: vendored artifacts — health-check the ledger and re-vendor + // missing/corrupt artifacts through the vendored backend (the patch + // service first, like `vendor`); lockfile vendor references with no + // ledger entry are reported. Runs under `--download-only` too: // restoring artifacts IS repair's download half. The reference scan // and ledger load above are handed over, not repeated. - let vendor_rebuilt = crate::commands::repair_vendor::repair_vendored_artifacts_with_references( + let vendor_rebuilt = crate::commands::vendored_backend::VendoredBackend::new( &args.common, - manifest.as_ref(), - &socket_dir, + None, + ) + .repair( + crate::commands::vendored_backend::repair::RepairRequest { + manifest: manifest.as_ref(), + socket_dir: &socket_dir, + references: &vendor_references, + ledger, + client: client.as_ref(), + }, &mut env, - &vendor_references, - ledger, - client.as_ref(), ) .await; if !quiet && vendor_rebuilt > 0 { diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs deleted file mode 100644 index 662f5cd1..00000000 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ /dev/null @@ -1,2658 +0,0 @@ -//! `repair`'s vendored-artifact phase: rebuild committed vendor artifacts -//! that are referenced (ledger entry and/or rewired lockfile) but missing -//! or corrupt on disk. -//! -//! Detection is the core health check ([`check_vendored_artifact`]: per-file -//! afterHashes + the whole-file ledger sha256 for file-shaped artifacts). -//! Rebuilds re-dispatch the normal vendor backends — their wired hot paths -//! rebuild the ARTIFACT only and never touch lockfiles or re-record ledger -//! originals — fed by the same pristine-source ladder as `vendor` (installed -//! copy → lockfile-verified registry fetch → ledger-recovered pre-vendor -//! fragment), with patch content staged in memory. -//! -//! Lockfile references with NO ledger coverage (`.socket/vendor` deleted -//! wholesale, state.json included) are RECONSTRUCTED: the uuid is recovered -//! from the lockfile path itself (the contract's uuid-in-path rule), the -//! record from the manifest (or the patch API, yielding a detached entry), -//! and a fresh ledger entry is re-synthesized so sweep/GC/revert know the -//! artifact again — stamped with the lockfile FLAVOR the reference was -//! found in (npm family and pypi), so a later `vendor --revert` routes to the -//! backend whose unwired-revert guard probes the right lockfile. WIRING reconstruction is -//! per-ecosystem: gem recognizes -//! its own Gemfile/lock wiring and rebuilds full revert-capable records -//! ([`socket_patch_core::vendor::gem::reconstruct_gem_wiring`]); the other -//! ecosystems' pre-vendor originals are registry integrity material no -//! offline source can reproduce, so their entries keep empty wiring and the -//! gap is surfaced loudly (`vendor_wiring_unknown`, riding the envelope's -//! run-level `warnings[]` — the entry itself repaired fine, so it must not -//! ride `events[]` as a `skipped` consumers count) — a gem `--revert` of -//! such an entry refuses instead of stranding the pair edit. Existing gem -//! entries with EMPTY wiring (persisted by pre-reconstruction repairs) are -//! backfilled the same way during the ledger-driven pass while healthy. -//! -//! Dir-shaped rebuilds are always LOCAL (the pristine ladder + the recorded -//! patch), while `vendor` may have used the patch service's prebuilt -//! artifact (a converter-generated stub gemspec the local build cannot -//! reproduce): a rebuild whose patched members verify but whose tree -//! differs from the recorded fileInventory refreshes the inventory from -//! the verified rebuild (`vendor_inventory_refreshed`) instead of failing -//! deterministically on every repair. -//! -//! Reconstruction never fingerprints the LIVE artifact into the restored -//! ledger (trust-on-first-use: a tampered unpatched file would become the -//! canonical tree later repairs enforce and VEX attests). A surviving -//! artifact is only restored as-is when an independent anchor vouches for -//! its exact bytes (the rewired npm-family lockfile integrity); otherwise -//! its fingerprint is derived from a member-verified local rebuild, and -//! when no trustworthy pristine source exists the entry is restored -//! fingerprint-less with `vendor_inventory_unverified` — the legacy -//! member-only state — never from the unverifiable live tree. - -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; -use socket_patch_core::constants::SOCKET_DIR; -use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; -use socket_patch_core::patch::copy_tree::remove_tree; -use socket_patch_core::utils::fs::read_regular_to_string; -use socket_patch_core::utils::purl::{ - normalize_purl, percent_decode_purl_component, strip_purl_qualifiers, -}; -use socket_patch_core::vendor::state::{VendorArtifact, WiringRecord}; -use socket_patch_core::vendor::{ - self, artifact_is_file_shaped, check_vendored_artifact, compute_dir_inventory, file_sha256_hex, - lock_inventory, parse_vendor_path, registry_fetch, ArtifactHealth, VendorEntry, VendorOutcome, - VendorState, VendorWarning, -}; -use socket_patch_core::vex::time::now_rfc3339; - -use crate::args::GlobalArgs; -use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; -use crate::commands::vendor::{ - dispatch_vendor_one, ecosystem_in_scope, fetch_pristine_package, persist_vendor_entry, - record_warning, PristineFetch, -}; -use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; -use crate::json_envelope::{Envelope, PatchAction, PatchEvent, RunWarning}; -use crate::ui::plural; - -/// One broken vendored unit queued for rebuild. -struct Candidate { - purl: String, - entry: VendorEntry, - record: PatchRecord, - detached: bool, - /// True when the ledger entry was re-synthesized from a lockfile - /// reference (it must be persisted after a successful rebuild). - reconstructed: bool, - reason: &'static str, - /// True for a healthy-by-members RECONSTRUCTED entry with no - /// independent integrity anchor (dir-shaped trees; file artifacts no - /// npm-family lock records an integrity for): the live bytes must never - /// be fingerprinted into the restored ledger (trust-on-first-use), so - /// the fingerprint is derived from a member-verified local rebuild — - /// and every pre-rebuild failure falls back to a fingerprint-less - /// restore plus a `vendor_inventory_unverified` warning instead of a - /// hard failure (the artifact itself still verifies member-wise). - soft: bool, -} - -/// Files the vendor backends rewire — the search space for -/// `.socket/vendor///` references when the ledger is gone. -/// The Python locks the root LISTS (`pylock*.toml`, `*.py.lock` + script) -/// and the requirements `-r` include tree are appended at scan time. -const WIRING_FILES: &[&str] = &[ - "vlt-lock.json", - "package-lock.json", - "npm-shrinkwrap.json", - "pnpm-lock.yaml", - "yarn.lock", - "bun.lock", - "package.json", - "Cargo.toml", - "Cargo.lock", - // Pre-v5 vendored cargo wiring (migrated into Cargo.toml on re-run). - ".cargo/config.toml", - ".cargo/config", - "go.mod", - "composer.json", - "composer.lock", - "Gemfile", - "Gemfile.lock", - "uv.lock", - "pyproject.toml", - "poetry.lock", - "pdm.lock", - "Pipfile.lock", - "requirements.txt", -]; - -/// Scan the wiring-bearing files for vendored-artifact references, -/// returning deduped `(ecosystem, uuid, artifact relpath)` triples. Pure -/// text scan plus native binary Bun resolution records and the canonical -/// path parser — the same recovery rule the CLI contract documents. -pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, String, String)> { - let mut seen: HashSet<(String, String)> = HashSet::new(); - let mut out = Vec::new(); - if !project_root.join("bun.lock").exists() { - if let Ok(paths) = - socket_patch_core::vendor::bun_lock::binary_vendor_paths(project_root).await - { - for path in paths { - if let Some(parts) = parse_vendor_path(&path) { - if seen.insert((parts.eco.to_string(), parts.uuid.clone())) { - let rel = - format!(".socket/vendor/{}/{}/{}", parts.eco, parts.uuid, parts.leaf); - out.push((parts.eco, parts.uuid, rel)); - } - } - } - } - } - - let mut files: Vec = WIRING_FILES - .iter() - .map(|file| (*file).to_string()) - .collect(); - files.extend(vendor::vlt_lock::vlt_importer_package_jsons(project_root).await); - if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(project_root) { - for path in paths { - if let Some(script) = - socket_patch_core::utils::python_lock::script_of_lock(&path).map(str::to_string) - { - files.push(script); - } - files.push(path); - } - } - // The requirements planner writes a vendored pin where the original pin - // was — possibly inside a `-r` include — so the root requirements.txt - // alone would miss it (and the orphan sweep, which reuses this scan, - // would delete the include-referenced wheel). An unreadable include - // tree degrades to the root file, matching the per-file tolerance - // below. - if let Ok(includes) = socket_patch_core::vendor::requirements_include_names(project_root).await - { - files.extend(includes); - } - files.sort(); - files.dedup(); - for file in files { - // FIFO-safe: a pipe under a wiring-file name must be skipped, not - // waited on forever in open(2). - let Ok(text) = read_regular_to_string(&project_root.join(file)).await else { - continue; - }; - let mut rest = text.as_str(); - while let Some(idx) = rest.find(".socket") { - let slice = &rest[idx..]; - // `:` ends a reference too: pnpm snapshot keys are - // `name@file::` and yaml mappings suffix the path with a - // colon — npm names/versions never contain one. - let end = slice - .find([ - '"', '\'', '`', ' ', '\t', '\n', '\r', ',', ')', ']', '}', ';', ':', - ]) - .unwrap_or(slice.len()); - let candidate = slice[..end].replace('\\', "/"); - if let Some(parts) = parse_vendor_path(&candidate) { - if seen.insert((parts.eco.to_string(), parts.uuid.clone())) { - out.push(( - parts.eco.to_string(), - parts.uuid.clone(), - candidate.trim_start_matches("./").to_string(), - )); - } - } - rest = &rest[idx + ".socket".len()..]; - } - } - out.sort(); - out -} - -fn synth_entry(eco: &str, uuid: &str, artifact_path: &str, base_purl: &str) -> VendorEntry { - VendorEntry { - ecosystem: eco.to_string(), - base_purl: base_purl.to_string(), - uuid: uuid.to_string(), - artifact: VendorArtifact { - path: artifact_path.to_string(), - sha256: String::new(), - size: None, - platform_locked: None, - file_inventory: None, - }, - wiring: Vec::new(), - lock: None, - took_over_go_patches: false, - detached: false, - record: None, - flavor: None, - uv: None, - pnpm: None, - poetry: None, - pdm: None, - pipenv: None, - } -} - -/// The npm lockfile FLAVOR whose lock carries the -/// `.socket/vendor/npm//` reference, for stamping onto a -/// re-synthesized ledger entry. The strings are `VendorEntry::flavor`'s -/// stable vocabulary (guarded by npm_flavor's `flavor_strings_are_stable` -/// test). Stamping matters: `revert_npm_any` routes by flavor, and each -/// backend's unwired-revert guard probes ITS OWN lockfile — a -/// pnpm-reconstructed entry left at flavor-None would be guarded against -/// package-lock.json instead of pnpm-lock.yaml. Locks are checked in the -/// vendor router's own precedence order (vlt > bun > pnpm > yarn > npm) for the -/// pathological multi-lock case; content sniffs mirror -/// `detect_npm_lock_flavor` (crate-private to core, so re-derived here). -/// `None` when genuinely unknowable — no recognizable lock carries the -/// reference, or the referencing lock's grammar is unrecognized — which -/// routes to the package-lock backend, whose guard also fails closed on -/// unwired entries. -async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) -> Option { - if eco == "pypi" { - let needle = format!(".socket/vendor/pypi/{uuid}/"); - let mut files = - socket_patch_core::utils::python_lock::python_lock_paths(project_root).ok()?; - // uv.lock outranks the standalone locks (the vendor backend's own - // precedence): a pylock EXPORTED from the wired project lock must not - // relabel the entry `python-lock`. Alphabetical order would. - files.sort_by_key(|file| file != "uv.lock"); - for file in files { - if read_regular_to_string(&project_root.join(&file)) - .await - .ok() - .is_some_and(|text| text.contains(&needle)) - { - return Some( - if file == "uv.lock" { - "uv" - } else { - "python-lock" - } - .to_string(), - ); - } - } - return None; - } - if eco != "npm" { - return None; - } - let needle = format!(".socket/vendor/npm/{uuid}/"); - let read = |name: &'static str| async move { - read_regular_to_string(&project_root.join(name)).await.ok() - }; - if let Some(text) = read("vlt-lock.json").await { - if text.contains(&needle) { - return vendor::vlt_lock::vlt_lock_sniff_ok(&text).then(|| "vlt".to_string()); - } - } - if read("bun.lock").await.is_some_and(|t| t.contains(&needle)) { - return Some("bun".to_string()); - } - if !project_root.join("bun.lock").exists() - && socket_patch_core::vendor::bun_lock::binary_vendor_paths(project_root) - .await - .is_ok_and(|paths| paths.iter().any(|p| p.contains(&needle))) - { - return Some("bun".into()); - } - if let Some(text) = read("pnpm-lock.yaml").await { - if text.contains(&needle) { - // Same version allowlist as core's `sniff_lock_grammar`. - return match text - .lines() - .find_map(|l| l.strip_prefix("lockfileVersion:")) - .map(|v| v.trim().trim_matches(['\'', '"'])) - { - Some("9.0") => Some("pnpm".to_string()), - Some("5.4") | Some("6.0") => Some("pnpm-legacy".to_string()), - _ => None, - }; - } - } - if let Some(text) = read("yarn.lock").await { - if text.contains(&needle) { - // Same head sniff as core's `sniff_yarn_lock` (BOM skipped, - // CRLF-tolerant); berry wins. - let head: Vec<&str> = text - .strip_prefix('\u{feff}') - .unwrap_or(&text) - .lines() - .take(30) - .collect(); - return if head.iter().any(|l| l.starts_with("__metadata:")) { - Some("yarn-berry".to_string()) - } else if head.iter().any(|l| l.trim() == "# yarn lockfile v1") { - Some("yarn-classic".to_string()) - } else { - None - }; - } - } - for name in ["npm-shrinkwrap.json", "package-lock.json"] { - if read(name).await.is_some_and(|t| t.contains(&needle)) { - return Some("package-lock".to_string()); - } - } - None -} - -/// What wiring a re-synthesized ledger entry could recover. -enum WiringReconstruction { - /// The backend recognized its own wiring in the live project files: - /// full revert-capable records, plus any degradation notes to surface. - Wired(Vec, Vec), - /// No wiring recoverable — unsupported ecosystem, or files vendor's - /// grammar does not recognize. The entry keeps empty wiring and the - /// gap is surfaced loudly. - Unknown(String), -} - -/// Per-ecosystem wiring reconstruction for a no-ledger repair. gem is the -/// one ecosystem whose wiring is fully self-describing (the pair edit's -/// originals are derivable from its own emitted forms); the npm family and -/// the rest record pre-vendor REGISTRY integrity fragments that no offline -/// source can reproduce — never guessed at. -async fn reconstruct_entry_wiring( - project_root: &Path, - entry: &VendorEntry, -) -> WiringReconstruction { - match entry.ecosystem.as_str() { - "gem" => match vendor::gem::reconstruct_gem_wiring(project_root, entry).await { - Ok((wiring, notes)) => WiringReconstruction::Wired(wiring, notes), - Err(detail) => WiringReconstruction::Unknown(detail), - }, - _ => WiringReconstruction::Unknown( - "this ecosystem's pre-vendor lock fragments are not offline-recoverable".to_string(), - ), - } -} - -/// Record one artifact that cannot be repaired. An error, so the line -/// prints even under `--silent` (`json` mutes it: the envelope carries it). -fn fail(env: &mut Envelope, json: bool, purl: &str, code: &str, detail: String) { - if !json { - eprintln!("{}", format_repair_failure(purl, &detail)); - } - env.record(PatchEvent::new(PatchAction::Failed, purl.to_string()).with_error(code, detail)); - env.mark_partial_failure(); -} - -/// Report every candidate whose patch content this run could not obtain: -/// a soft one is restored without a fingerprint (counted as rebuilt), any -/// other fails with its own reason code. Shared by the two staging arms — -/// "nothing could be staged" (the whole pass ends here) and "these purls -/// could not, while others staged fine" (the pass continues without them). -/// `unrebuildable` names candidates that already failed earlier and must -/// not be reported twice. -fn report_no_local_source( - env: &mut Envelope, - common: &GlobalArgs, - candidates: &[Candidate], - unrebuildable: &HashSet, - rebuilt: &mut usize, -) { - for c in candidates { - if unrebuildable.contains(&c.purl) { - continue; - } - if c.soft { - soft_restore_without_fingerprint( - env, - common, - &c.purl, - &c.entry, - "its patch content has no local source to rebuild from", - ); - *rebuilt += 1; - continue; - } - fail( - env, - common.json, - &c.purl, - c.reason, - format!( - "the vendored artifact at {} is broken and its patch content has \ - no local source ({})", - c.entry.artifact.path, - if common.offline { - "--offline prevents fetching it" - } else { - "download failed" - } - ), - ); - } -} - -/// `Error: Cannot repair vendored artifact for : `. -fn format_repair_failure(purl: &str, detail: &str) -> String { - format!( - "Error: Cannot repair vendored artifact for {}: {detail}", - normalize_purl(purl) - ) -} - -/// The `repair --dry-run` preview of vendored rebuilds: a heading, then -/// ` - (: )` per artifact. `items` are -/// `(purl, reason code, artifact path)`. -fn format_rebuild_preview(items: &[(String, &str, &str)]) -> Vec { - let mut lines = vec![format!( - "Would rebuild {}:", - plural(items.len(), "vendored artifact", "vendored artifacts") - )]; - lines.extend(items.iter().map(|(purl, reason, path)| { - format!(" - {purl} ({}: {path})", rebuild_reason_label(reason)) - })); - lines -} - -/// Plain words for a rebuild candidate's reason code. -fn rebuild_reason_label(code: &str) -> &str { - match code { - "vendor_artifact_missing" => "missing", - "vendor_artifact_corrupt" => "corrupt", - "vendor_inventory_unverified" => "unverified", - other => other, - } -} - -/// The npm-family lockfiles and the vlt importers' package.json files as -/// they are now, for the unverified-source rebuild's put-back. Read through -/// the FIFO-safe opener: a FIFO or device at one of these paths is left out -/// of the snapshot at once instead of blocking the repair in open(2), like -/// any other file that cannot be read. -async fn snapshot_npm_wiring_files(cwd: &Path) -> Vec<(PathBuf, Option>)> { - let mut names: Vec = [ - "vlt-lock.json", - "package-lock.json", - "npm-shrinkwrap.json", - "pnpm-lock.yaml", - "yarn.lock", - "bun.lock", - "bun.lockb", - ] - .iter() - .map(|n| (*n).to_string()) - .collect(); - names.extend(vendor::vlt_lock::vlt_importer_package_jsons(cwd).await); - let mut snap = Vec::new(); - for name in names { - let p = cwd.join(name); - if let Ok(bytes) = socket_patch_core::utils::fs::read_regular_to_bytes(&p).await { - snap.push((p, Some(bytes))); - } - } - snap -} - -/// A soft (healthy-by-members, unanchored) reconstruction whose trustworthy -/// rebuild cannot proceed: the entry stays restored WITHOUT a whole-file -/// fingerprint — the legacy member-only state pass 1 keeps warning about -/// (`vendor_inventory_missing` for gems) — and the gap is surfaced, instead -/// of either failing the repair or canonizing the unverifiable live tree. -/// The entry itself was already persisted by the pre-rebuild restore. -fn soft_restore_without_fingerprint( - env: &mut Envelope, - common: &GlobalArgs, - purl: &str, - entry: &VendorEntry, - why: &str, -) { - let artifact_path = entry.artifact.path.as_str(); - // A vlt lock rewired to the vendored dir keeps no registry resolution, - // so `vendor` alone has no pristine copy to re-vendor from. - let remedy = if entry.flavor.as_deref() == Some(vendor::vlt_lock::FLAVOR) { - "restore the registry version spec in the package.json files that name the vendored \ - dir, run `vlt install`, then run `socket-patch vendor` to re-vendor and record one" - } else { - "run `socket-patch vendor` to re-vendor and record one" - }; - record_warning( - env, - purl, - &VendorWarning::new( - "vendor_inventory_unverified", - format!( - "the ledger entry was reconstructed but its artifact has no independent \ - integrity anchor and {why}; the entry was restored without a whole-file \ - fingerprint (only the patched members were verified) — {remedy}" - ), - ), - common, - ); - env.record( - PatchEvent::new(PatchAction::Rebuilt, purl.to_string()).with_details(serde_json::json!({ - "path": artifact_path, - "ledgerRestored": true, - "artifactRebuilt": false, - })), - ); -} - -/// `vendor_wiring_unknown` advises about what a FUTURE `vendor --revert` -/// can restore — the entry itself was restored/verified fine, so the -/// advisory rides the envelope's run-level `warnings[]` (the documented -/// carrier for non-fatal advisories) rather than a per-purl `skipped` -/// event, which consumers count as work not done. The purl is baked into -/// `detail` by the callers so attribution survives the run-level move. -fn warn_wiring_unknown(env: &mut Envelope, common: &GlobalArgs, detail: String) { - if !common.silent && !common.json { - eprintln!("Warning (vendor_wiring_unknown): {detail}"); - } - env.warnings.push(RunWarning { - code: "vendor_wiring_unknown".to_string(), - detail, - }); -} - -/// Best-effort removal of a vendored uuid dir after a failed post-verify -/// (never leave unverifiable bytes behind). Prunes the emptied -/// `.socket/vendor//` (and `vendor/`) husks like every other artifact -/// removal, stopping at `.socket/`; a sibling unit or the ledger keeps them. -async fn remove_vendor_dir(cwd: &Path, eco: &str, uuid: &str) { - if let Some(rel) = vendor::path::vendor_uuid_dir_rel(eco, uuid) { - let _ = socket_patch_core::utils::socket_dir::remove_tree_and_prune( - &cwd.join(rel), - &cwd.join(SOCKET_DIR), - ) - .await; - } -} - -/// Move the live uuid dir aside (same parent, `.pre-rebuild`) so the -/// backends' rebuild-on-MISSING trigger fires while the bytes stay -/// recoverable: the dispatch can still refuse or fail — the in-hand -/// installed copy may itself be broken in ways no pre-rebuild rung probes -/// — and a failed dispatch replaced nothing, so the artifact -/// (member-healthy for a soft candidate, corrupt-but-diagnosable for a -/// pass-1 one) must be restorable instead of leaving the wired lockfiles -/// pointing at a bare ENOENT (see the NOTE above the staging step). -/// Returns `(live, kept)` for [`restore_aside_vendor_dir`]; on a rename -/// failure falls back to plain removal (the rebuild trigger must fire) -/// and returns `None`. -async fn set_aside_vendor_dir(cwd: &Path, eco: &str, uuid: &str) -> Option<(PathBuf, PathBuf)> { - let rel = vendor::path::vendor_uuid_dir_rel(eco, uuid)?; - let live = cwd.join(&rel); - let kept = cwd.join(format!("{rel}.pre-rebuild")); - // A crashed earlier run's leftover must not wedge the rename. - let _ = remove_tree(&kept).await; - if tokio::fs::rename(&live, &kept).await.is_ok() { - Some((live, kept)) - } else { - let _ = remove_tree(&live).await; - None - } -} - -/// Put the pre-rebuild bytes back after a dispatch that produced no -/// replacement (clearing any partial husk the failed backend left first). -async fn restore_aside_vendor_dir(live: &Path, kept: &Path) { - let _ = remove_tree(live).await; - let _ = tokio::fs::rename(kept, live).await; -} - -/// Crash recovery for [`set_aside_vendor_dir`]'s transient: a run killed -/// between the move-aside and the backend's replacement leaves -/// `.socket/vendor//.pre-rebuild` as the ONLY copy of bytes the -/// rewired lockfiles still point at, with the live path a bare ENOENT. Put -/// every such leftover back where the wiring expects it before pass 1 -/// classifies the unit (it then re-derives corrupt/soft/healthy from the -/// restored bytes exactly as the crashed run did). A leftover whose live -/// sibling EXISTS is left alone: the live dir may be the completed -/// replacement or a partial husk, and only the health pass can tell — a -/// unit it condemns is set aside again, which clears the leftover. Wet -/// runs only; scope-gated like every other unit; best-effort throughout. -async fn restore_orphaned_pre_rebuild_dirs(common: &GlobalArgs) { - const SUFFIX: &str = ".pre-rebuild"; - let vendor_root = common.cwd.join(".socket/vendor"); - let Ok(mut ecos) = tokio::fs::read_dir(&vendor_root).await else { - return; - }; - while let Ok(Some(eco_dir)) = ecos.next_entry().await { - let eco = eco_dir.file_name().to_string_lossy().into_owned(); - if !ecosystem_in_scope(common, &eco) || !eco_dir.path().is_dir() { - continue; - } - let Ok(mut units) = tokio::fs::read_dir(eco_dir.path()).await else { - continue; - }; - while let Ok(Some(unit)) = units.next_entry().await { - let name = unit.file_name().to_string_lossy().into_owned(); - let Some(uuid) = name.strip_suffix(SUFFIX) else { - continue; - }; - let live = eco_dir.path().join(uuid); - if unit.path().is_dir() && tokio::fs::symlink_metadata(&live).await.is_err() { - let _ = tokio::fs::rename(unit.path(), &live).await; - } - } - } -} - -/// The vendored-artifact phase of `repair`. Runs between the download and -/// cleanup phases (and under `--download-only` — restoring artifacts IS -/// repair's job). `manifest` is `None` when the project has no -/// `.socket/manifest.json` (detached/reconstruction-only repairs). -/// Returns the number of artifacts rebuilt (for the human summary line); -/// failures are carried by `env` (`Failed` events + partial-failure status). -/// -/// `references` is [`scan_vendor_references`]'s `(ecosystem, uuid, -/// artifact relpath)` output for `common.cwd` and `ledger` the caller's -/// `load_state` outcome — both taken by repair.rs under the apply lock -/// this phase runs under (the lockfiles and ledger they describe are the -/// ones the reconstruction below rewires), so neither is re-read here. An -/// unreadable ledger fails this phase loudly (`vendor_state_unreadable`); -/// the caller's own degrade-to-empty policy for its download scoping is -/// its own. `run_client` is the run's API client when the caller already -/// built one (repair.rs's lazily built download-phase `client`): the uuid lookups and the -/// staging fetch reuse it instead of constructing a second (or third) one -/// and re-printing its token advisory; `None` builds lazily on first need. -pub(crate) async fn repair_vendored_artifacts_with_references( - common: &GlobalArgs, - manifest: Option<&PatchManifest>, - socket_dir: &Path, - env: &mut Envelope, - references: &[(String, String, String)], - ledger: std::io::Result, - run_client: Option<&ApiClient>, -) -> usize { - let quiet = common.json || common.silent; - let mut rebuilt = 0usize; - - if !common.dry_run { - restore_orphaned_pre_rebuild_dirs(common).await; - } - - let mut state = match ledger { - Ok(s) => s, - Err(e) => { - // Errors print even under --silent; without this line the - // run exits 1 after a clean-looking repair report. - if !common.json { - eprintln!( - "{}", - crate::commands::vendor::format_state_unreadable(&e.to_string()) - ); - } - env.record( - PatchEvent::artifact(PatchAction::Failed) - .with_error("vendor_state_unreadable", e.to_string()), - ); - env.mark_partial_failure(); - return rebuilt; - } - }; - - // ── Pass 1: ledger-driven health check ─────────────────────────────── - // Shared across both passes so the API client (and its one-time - // token-shape stderr advisory) is constructed at most once per run — - // seeded from the run's client when the caller has one. - let mut api_client: Option = run_client.cloned(); - let mut candidates: Vec = Vec::new(); - let mut ledger_purls: Vec = state.entries.keys().cloned().collect(); - ledger_purls.sort(); - for purl in &ledger_purls { - let entry = state.entries[purl].clone(); - if !ecosystem_in_scope(common, &entry.ecosystem) { - continue; - } - // `detached` is the "no manifest owner" flag. The manifest-driven - // standalone `vendor` embeds the record too, so an embedded record - // does not imply detached: a manifest-owned entry keeps taking the - // manifest's record (a manifest that moved on to a newer patch uuid - // must still surface as vendor_uuid_mismatch below, never repair the - // stale artifact from the embedded copy), and the embedded copy - // stands in only when there is no manifest at all. - let record = match (entry.detached, &entry.record, manifest) { - (true, Some(r), _) => r.clone(), - (_, _, Some(m)) => { - match m - .patches - .get(purl) - .cloned() - .or_else(|| m.patches.values().find(|r| r.uuid == entry.uuid).cloned()) - { - Some(r) => r, - // Dropped from the manifest: the vendor reconcile owns - // reverting it — not repair's call. - None => continue, - } - } - // No manifest at all: the embedded copy, else (a ledger written - // before standalone `vendor` embedded records) recover the record - // from the API below, like a reconstruction. - (_, Some(r), None) => r.clone(), - (_, None, None) => { - match fetch_record_by_uuid(common, &mut api_client, &entry.uuid).await { - Some((_, r)) => r, - None => { - fail( - env, - common.json, - purl, - "vendor_artifact_unrepairable", - format!( - "no manifest record for patch {} and the patch view could not \ - be fetched (offline or API failure)", - entry.uuid - ), - ); - continue; - } - } - } - }; - if record.uuid != entry.uuid { - env.record( - PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( - "vendor_uuid_mismatch", - "the manifest's patch uuid moved on; run `socket-patch vendor` (or \ - `scan --mode vendored`) to re-vendor", - ), - ); - continue; - } - // Pre-v5 cargo wiring in `.cargo/config*`: move it into the root - // Cargo.toml (the v5 location) and record the move in the ledger — - // or restore the manifest entry a pre-v5 multi-version vendor lost — - // and tag an untagged copy + lock entry with the patch uuid. - let entry = if entry.ecosystem == "cargo" { - match vendor::cargo::migrate_legacy_wiring(&entry, &common.cwd, common.dry_run).await { - Ok(Some((migrated, warnings))) => { - for warning in &warnings { - record_warning(env, purl, warning, common); - } - if common.dry_run { - entry - } else if persist_vendor_entry( - common, - env, - &mut state, - purl, - migrated.clone(), - entry.detached, - &record, - ) - .await - { - continue; - } else { - migrated - } - } - Ok(None) => entry, - Err(detail) => { - record_warning( - env, - purl, - &VendorWarning::new( - "cargo_legacy_wiring_kept", - format!( - "the vendored wiring for {} could not be written into \ - Cargo.toml ({detail}); any pre-v5 .cargo/config wiring was \ - left in place", - normalize_purl(purl) - ), - ), - common, - ); - entry - } - } - } else { - entry - }; - let health = check_vendored_artifact(&common.cwd, &entry, &record).await; - if health == ArtifactHealth::Healthy || workspace_copy_issue(&health) { - let mut healed = entry.clone(); - match repair_workspace_copies(&common.cwd, &mut healed, common.dry_run).await { - Ok(true) => { - if common.dry_run { - env.record( - PatchEvent::new(PatchAction::Verified, purl.clone()).with_details( - serde_json::json!({ - "vendorArtifact": true, "wouldRestoreWorkspaceArtifacts": true, - }), - ), - ); - } else if !persist_vendor_entry( - common, - env, - &mut state, - purl, - healed, - entry.detached, - &record, - ) - .await - { - env.record( - PatchEvent::new(PatchAction::Rebuilt, purl.clone()).with_details( - serde_json::json!({ - "path": entry.artifact.path, "workspaceArtifactsRestored": true, - "artifactRebuilt": false, - }), - ), - ); - rebuilt += 1; - } - continue; - } - Ok(false) => {} - Err(detail) => { - fail( - env, - common.json, - purl, - "vendor_artifact_unrepairable", - detail, - ); - continue; - } - } - if workspace_copy_issue(&health) { - continue; - } - } - match health { - ArtifactHealth::Healthy => { - // vlt's `/.gitignore` and `.gitattributes` are not - // part of the artifact: a missing or edited one is simply - // rewritten. - if entry.ecosystem == "npm" - && entry.flavor.as_deref() == Some(vendor::vlt_lock::FLAVOR) - && !common.dry_run - { - if let Err(e) = - vendor::vlt_lock::restore_vlt_uuid_metadata(&entry, &common.cwd).await - { - fail( - env, - common.json, - purl, - "vendor_artifact_unrepairable", - format!("cannot restore the vendored dir's .gitignore: {e}"), - ); - continue; - } - } - // Dir-shaped artifacts from pre-inventory vendors: the - // health check above could only verify the PATCHED members - // — unpatched-file drift is invisible until a re-vendor - // records the whole-tree inventory. Name the gap for gem - // only: vlt also records inventories but has no - // pre-inventory entries to warn about, and the other - // dir-shaped backends (cargo/golang/composer) don't record - // one, so a re-vendor there records nothing and the advice - // would be permanent per-run noise. - if entry.ecosystem == "gem" - && !artifact_is_file_shaped(&entry.artifact.path) - && entry.artifact.file_inventory.is_none() - { - record_warning( - env, - purl, - &VendorWarning::new( - "vendor_inventory_missing", - format!( - "the ledger entry for {} records no file inventory \ - (pre-inventory vendor); only the patched members were \ - verified — re-vendor to make unpatched-file drift \ - detectable", - normalize_purl(purl) - ), - ), - common, - ); - } - // Empty-wiring gem entries (pre-reconstruction repairs - // persisted these): backfill full revert-capable wiring - // from the live pair via the same recognizers the - // no-ledger reconstruction trusts, so `vendor --revert` - // stops refusing with manual cleanup steps. - if entry.ecosystem == "gem" && entry.wiring.is_empty() { - match vendor::gem::reconstruct_gem_wiring(&common.cwd, &entry).await { - Ok((wiring, notes)) => { - if common.dry_run { - env.record( - PatchEvent::new(PatchAction::Verified, purl.clone()) - .with_details(serde_json::json!({ - "vendorArtifact": true, - "wouldRestoreWiring": true, - })), - ); - continue; - } - for w in ¬es { - record_warning(env, purl, w, common); - } - let mut healed = entry.clone(); - healed.wiring = wiring; - let detached = healed.detached; - if persist_vendor_entry( - common, env, &mut state, purl, healed, detached, &record, - ) - .await - { - continue; - } - env.record( - PatchEvent::new(PatchAction::Rebuilt, purl.clone()).with_details( - serde_json::json!({ - "path": entry.artifact.path, - "wiringRestored": true, - "artifactRebuilt": false, - }), - ), - ); - rebuilt += 1; - } - Err(detail) => { - warn_wiring_unknown( - env, - common, - format!( - "the ledger entry for {} records no pre-vendor wiring \ - originals and they cannot be reconstructed from the \ - live files ({detail}); `vendor --revert` cannot \ - restore the project files for this entry", - normalize_purl(purl) - ), - ); - } - } - } - } - ArtifactHealth::StaleUuid => { - env.record( - PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( - "vendor_uuid_mismatch", - "a re-vendor is pending for this package; run `socket-patch vendor`", - ), - ); - } - ArtifactHealth::Unverifiable { reason } => { - fail( - env, - common.json, - purl, - "vendor_artifact_unrepairable", - format!("the ledger entry cannot be verified ({reason}); fix state.json"), - ); - } - ArtifactHealth::UnknownFlavor { flavor } => { - record_warning( - env, - purl, - &VendorWarning::new( - "vendor_wiring_unknown_revert_blocked", - format!( - "{} was vendored for the npm flavor `{flavor}`, which this \ - socket-patch release does not understand; left untouched — \ - upgrade socket-patch", - normalize_purl(purl) - ), - ), - common, - ); - } - health @ (ArtifactHealth::Missing | ArtifactHealth::Corrupt { .. }) => { - let reason = if matches!(health, ArtifactHealth::Missing) { - "vendor_artifact_missing" - } else { - "vendor_artifact_corrupt" - }; - let detached = entry.detached; - candidates.push(Candidate { - purl: purl.clone(), - entry, - record, - detached, - reconstructed: false, - reason, - soft: false, - }); - } - } - } - - // ── Pass 2: lockfile references with no ledger coverage ───────────── - let covered: HashSet<(String, String)> = state - .entries - .values() - .map(|e| (e.ecosystem.clone(), e.uuid.clone())) - .collect(); - for (eco, uuid, relpath) in references.iter().cloned() { - if covered.contains(&(eco.clone(), uuid.clone())) || !ecosystem_in_scope(common, &eco) { - continue; - } - // The record: manifest by uuid first, else the patch API (the entry - // is then detached — exactly the manifest-less vendoring shape). - let (purl, record, detached) = - match manifest.and_then(|m| m.patches.iter().find(|(_, r)| r.uuid == uuid)) { - Some((p, r)) => (p.clone(), r.clone(), false), - None => match fetch_record_by_uuid(common, &mut api_client, &uuid).await { - Some((purl, r)) => (purl, r, true), - None => { - fail( - env, - common.json, - &format!("pkg:{eco}/unknown@{uuid}"), - "vendor_artifact_missing", - format!( - "the lockfile references .socket/vendor/{eco}/{uuid}/ but the \ - vendor ledger is gone and the patch view could not be fetched \ - (offline or API failure); restore .socket/vendor/state.json or \ - re-run online" - ), - ); - continue; - } - }, - }; - let mut entry = synth_entry(&eco, &uuid, &relpath, strip_purl_qualifiers(&purl)); - // Stamp the flavor the reference was found in (knowable right here: - // the scan above read specific lockfiles), so `vendor --revert` - // routes to the backend whose unwired-revert guard probes the RIGHT - // lockfile. Genuinely unknowable stays None (guarded fallback). - entry.flavor = detect_reference_flavor(&common.cwd, &eco, &uuid).await; - entry.detached = detached; - if detached { - entry.record = Some(record.clone()); - } - // Wiring reconstruction (fail-closed): gem rebuilds full - // revert-capable records from its own recognizable pair edit; the - // rest keep empty wiring with the gap surfaced loudly — reverting - // such an entry cannot restore the project files. - match reconstruct_entry_wiring(&common.cwd, &entry).await { - WiringReconstruction::Wired(wiring, notes) => { - entry.wiring = wiring; - for w in ¬es { - record_warning(env, &purl, w, common); - } - } - WiringReconstruction::Unknown(detail) => { - warn_wiring_unknown( - env, - common, - format!( - "the ledger entry for {} was reconstructed without pre-vendor \ - wiring originals ({detail}); `vendor --revert` cannot restore \ - the project files for this entry", - normalize_purl(&purl) - ), - ); - } - } - match check_vendored_artifact(&common.cwd, &entry, &record).await { - health if health == ArtifactHealth::Healthy || workspace_copy_issue(&health) => { - // The re-synthesized entry records no sha256/fileInventory, - // so the health check above verified only the patched - // members — whole-file drift (an altered UNPATCHED member) - // is invisible to it. The live bytes must therefore NEVER be - // fingerprinted into the restored ledger: that would be - // trust-on-first-use, canonizing a tampered tree that later - // repairs enforce and VEX attests. Only an INDEPENDENT - // anchor can vouch for the exact bytes — the rewired - // npm-family lockfile integrity, when one records this - // artifact. A "surviving" artifact that no longer matches it - // leaves the package manager broken, so it must be rebuilt, - // never blessed into the reconstructed ledger. - let mut anchored = false; - if let Some(wired) = - lock_inventory::wired_vendor_integrity(&common.cwd, &entry.artifact.path).await - { - let name = npm_coords(&entry.base_purl) - .map(|(n, _)| n) - .unwrap_or_default(); - let intact = match tokio::fs::read(common.cwd.join(&entry.artifact.path)).await - { - Ok(bytes) => { - registry_fetch::artifact_matches_integrity(&bytes, &name, &wired) - .is_ok() - } - Err(_) => false, - }; - if !intact { - candidates.push(Candidate { - purl, - entry, - record, - detached, - reconstructed: true, - reason: "vendor_artifact_corrupt", - soft: false, - }); - continue; - } - anchored = true; - } - if common.dry_run { - let mut details = serde_json::json!({ - "vendorArtifact": true, - "wouldRestoreLedgerEntry": true, - "path": relpath, - }); - if workspace_copy_issue(&health) { - details["wouldRestoreWorkspaceArtifacts"] = serde_json::Value::Bool(true); - } - if !anchored { - // The fingerprint would come from a rebuild, never - // the live tree. - details["wouldRebuild"] = serde_json::Value::Bool(true); - } - env.record( - PatchEvent::new(PatchAction::Verified, purl.clone()).with_details(details), - ); - continue; - } - if anchored { - // The artifact bytes are exactly what the rewired - // lockfile's integrity records; only the ledger was - // lost. Restore the entry (sha/size recomputed from the - // VERIFIED bytes) so GC/sweep/revert know the artifact - // again — without it the next `scan --prune` would sweep - // the uuid dir as an orphan. - fill_artifact_fingerprint(&common.cwd, &mut entry).await; - if let Err(detail) = - repair_workspace_copies(&common.cwd, &mut entry, false).await - { - fail( - env, - common.json, - &purl, - "vendor_artifact_unrepairable", - detail, - ); - continue; - } - let save_failed = persist_vendor_entry( - common, env, &mut state, &purl, entry, detached, &record, - ) - .await; - if save_failed { - continue; - } - env.record( - PatchEvent::new(PatchAction::Rebuilt, purl.clone()).with_details( - serde_json::json!({ - "path": relpath, - "ledgerRestored": true, - "artifactRebuilt": false, - }), - ), - ); - rebuilt += 1; - continue; - } - // No anchor (dir-shaped trees — gem, cargo —, file - // artifacts absent from every npm-family lock): queue a - // SOFT rebuild. The canonical fingerprint is derived from a - // member-verified local rebuild (pristine source + the - // recorded patch, the same dispatch as every other rebuild - // here); when no trustworthy pristine source exists the - // entry is restored WITHOUT a fingerprint — the legacy - // member-only state pass 1 keeps warning about — instead of - // canonizing the live tree. - candidates.push(Candidate { - purl, - entry, - record, - detached, - reconstructed: true, - reason: "vendor_inventory_unverified", - soft: true, - }); - } - ArtifactHealth::Unverifiable { reason } - if reason == "vendor_workspace_artifact_invalid" => - { - fail(env, common.json, &purl, "vendor_artifact_unrepairable", - "workspace tarball paths cannot be validated; fix the binary lock or symbolic links before repairing".into()); - } - _ => { - candidates.push(Candidate { - purl, - entry, - record, - detached, - reconstructed: true, - reason: "vendor_artifact_missing", - soft: false, - }); - } - } - } - - if candidates.is_empty() { - return rebuilt; - } - - // ── Dry run: preview only ──────────────────────────────────────────── - if common.dry_run { - if !quiet { - let items: Vec<(String, &str, &str)> = candidates - .iter() - .map(|c| { - let purl = normalize_purl(&c.purl).into_owned(); - (purl, c.reason, c.entry.artifact.path.as_str()) - }) - .collect(); - println!(); - for line in format_rebuild_preview(&items) { - println!("{line}"); - } - } - for c in &candidates { - env.record( - PatchEvent::new(PatchAction::Verified, c.purl.clone()).with_details( - serde_json::json!({ - "vendorArtifact": true, - "wouldRebuild": true, - "reason": c.reason, - "path": c.entry.artifact.path, - }), - ), - ); - } - return rebuilt; - } - - if !quiet { - println!(); - println!( - "Rebuilding {}...", - plural( - candidates.len(), - "broken vendored artifact", - "broken vendored artifacts" - ) - ); - } - - // ── Soft reconstructions: restore the ledger entry FIRST ───────────── - // Fingerprint-less: the restore must survive even when no trustworthy - // rebuild source turns up below, and the fingerprint slot is only ever - // refilled from a member-verified rebuild — never the live tree. The - // early persist also lets the rebuild's own persist carry the - // reconstructed wiring originals forward by identity. - let mut unrebuildable: HashSet = HashSet::new(); - for c in &candidates { - if c.soft - && persist_vendor_entry( - common, - env, - &mut state, - &c.purl, - c.entry.clone(), - c.detached, - &c.record, - ) - .await - { - // The state write failed (Failed event already recorded): - // nothing below could persist either. - unrebuildable.insert(c.purl.clone()); - } - } - - // NOTE: corrupt artifacts are NOT deleted here. Clearing waits until - // the rebuild loop below, where the patch sources and a pristine - // package source are both in hand (and even there it is a MOVE-ASIDE, - // restored when the dispatch fails) — see the comment there. Destroying - // the corrupt copy before the rebuild-source ladder runs would, on any - // no-source outcome (--offline, node_modules gone, fetch failure), - // convert a corrupt-but-diagnosable integrity-mismatch state into a - // bare ENOENT on the next install (the lock still points at the - // artifact) and erase the forensic evidence of the tamper. - - // ── Patch content (in memory, like all vendor flows) ──────────────── - let records_map: HashMap = candidates - .iter() - .map(|c| (c.purl.clone(), c.record.clone())) - .collect(); - let synth = PatchManifest { - patches: records_map, - setup: None, - }; - // The ledger this pass already holds feeds the staging harvest; repair's - // download phase writes blobs to disk (harvested from socket_dir), so - // there is no in-memory seed. - let staged = match stage_vendor_sources_in_memory( - common, - &synth, - socket_dir, - &common.cwd, - Ok(&state.entries), - HashMap::new(), - api_client.as_ref(), - ) - .await - { - MemStageOutcome::Ready(s) => s, - MemStageOutcome::Unavailable => { - report_no_local_source(env, common, &candidates, &unrebuildable, &mut rebuilt); - return rebuilt; - } - }; - // Staging could obtain SOME candidates' content but not others'. The - // ones it could not get the same report the all-unavailable arm above - // gives, and leave the pass; the rest are still rebuilt. - if !staged.unavailable().is_empty() { - let (stuck, rest): (Vec, Vec) = candidates - .into_iter() - .partition(|c| staged.unavailable().iter().any(|(purl, _)| purl == &c.purl)); - report_no_local_source(env, common, &stuck, &unrebuildable, &mut rebuilt); - candidates = rest; - if candidates.is_empty() { - return rebuilt; - } - } - let sources = staged.as_patch_sources(); - - // ── Pristine package sources ───────────────────────────────────────── - let purls: Vec = candidates.iter().map(|c| c.purl.clone()).collect(); - let partitioned = partition_purls(&purls, common.ecosystems.as_deref()); - let crawler_options = common.crawler_options(); - // Ledger keys are the manifest spelling — QUALIFIED for release-variant - // ecosystems (gem `?platform=`, pypi `?artifact_id=`, maven - // `?classifier=&ext=`) — while the crawler knows only base purls. A - // base-keyed result map would make the `contains_key(&c.purl)` checks - // below miss every installed - // qualified-key package and fall through to a needless registry fetch - // (or, offline, a spurious unrepairable / fingerprint-less restore). - // The rollback variant fans each base path back out to every qualified - // caller purl — the same fix `vendor_records` carries. - let mut all_packages = find_packages_for_rollback(&partitioned, &crawler_options, quiet).await; - crate::commands::vendor::drop_vendored_installs(&common.cwd, &mut all_packages); - let inventory = lock_inventory::inventory_project(&common.cwd).await; - let client = registry_fetch::build_registry_client(); - let mut holders: Vec = Vec::new(); - // Reconstructed npm candidates fetched UNVERIFIED from the conventional - // registry: their rebuilt tarball MUST match the integrity the rewired - // lockfile records (the trust anchor) before anything is persisted. - let mut must_verify: HashMap = HashMap::new(); - for c in &candidates { - if unrebuildable.contains(&c.purl) { - continue; - } - if all_packages.contains_key(&c.purl) { - // Installed copy: works offline too. But for a RECONSTRUCTED - // entry the copy is an unverified source — the ledger that - // recorded the artifact sha is gone, so the rewired lockfile's - // integrity is the ONLY trust anchor. A copy that drifted since - // vendoring (build-tool artifacts, edited unpatched files) packs - // into a tarball the package manager would reject on its next - // install; register the wired integrity so the rebuilt artifact - // is verified below, exactly like the unverified-registry rung. - if c.reconstructed { - if let Some(wired) = - lock_inventory::wired_vendor_integrity(&common.cwd, &c.entry.artifact.path) - .await - { - must_verify.insert(c.purl.clone(), wired); - } - } - continue; - } - if common.offline { - if c.soft { - soft_restore_without_fingerprint( - env, - common, - &c.purl, - &c.entry, - "the package is not installed and --offline prevents fetching a \ - pristine copy to rebuild from", - ); - rebuilt += 1; - } else { - fail( - env, - common.json, - &c.purl, - c.reason, - format!( - "the vendored artifact at {} is broken, the package is not installed, \ - and --offline prevents fetching a pristine copy", - c.entry.artifact.path - ), - ); - } - unrebuildable.insert(c.purl.clone()); - continue; - } - let pristine = - fetch_pristine_package(&common.cwd, &inventory, &client, &c.purl, Some(&c.entry)).await; - // The `Unverifiable` reason carries the precise, fragment-aware cause - // (e.g. a pdm/poetry/pipenv lock records the wheel hash but no fetchable - // registry URL) — surface it instead of the blanket "no recoverable - // registry fragment", which falsely implies the ledger recorded nothing. - let unverifiable_reason = match &pristine { - PristineFetch::Unverifiable(d) => Some(d.clone()), - _ => None, - }; - match pristine { - // Repair always rebuilds locally, so the pristine tree is read - // either way: materialise it right here, where an extraction - // failure is still the fetch failure it was before the write - // moved off the fetch. - PristineFetch::Fetched(fetched) => { - match fetched.dir().await.map(std::path::Path::to_path_buf) { - Ok(dir) => { - all_packages.insert(c.purl.clone(), dir); - holders.push(fetched); - } - Err(detail) => { - if c.soft { - soft_restore_without_fingerprint( - env, - common, - &c.purl, - &c.entry, - &format!("the pristine fetch failed ({detail})"), - ); - rebuilt += 1; - } else { - fail(env, common.json, &c.purl, "vendor_fetch_failed", detail); - } - unrebuildable.insert(c.purl.clone()); - } - } - } - PristineFetch::NoSource | PristineFetch::Unverifiable(_) => { - // Last rung (npm): the REWIRED lockfile still records the - // integrity of our packed tarball. Fetch the pristine copy - // unverified, rebuild deterministically, and verify the - // REBUILT artifact against that wired integrity below — - // end-to-end fail-closed without ledger or installed copy. - if c.entry.ecosystem == "npm" { - if let Some(wired) = - lock_inventory::wired_vendor_integrity(&common.cwd, &c.entry.artifact.path) - .await - { - if let Some((name, version)) = npm_coords(&c.entry.base_purl) { - match registry_fetch::fetch_npm_unverified(&name, &version, &client) - .await - { - Ok(fetched) => { - match fetched.dir().await.map(std::path::Path::to_path_buf) { - Ok(dir) => { - all_packages.insert(c.purl.clone(), dir); - holders.push(fetched); - must_verify.insert(c.purl.clone(), wired); - } - Err(d) => { - fail( - env, - common.json, - &c.purl, - "vendor_fetch_failed", - d, - ); - unrebuildable.insert(c.purl.clone()); - } - } - continue; - } - Err(registry_fetch::FetchError::Failed(d)) - | Err(registry_fetch::FetchError::Unverifiable(d)) => { - fail(env, common.json, &c.purl, "vendor_fetch_failed", d); - unrebuildable.insert(c.purl.clone()); - continue; - } - } - } - } - } - if c.soft { - soft_restore_without_fingerprint( - env, - common, - &c.purl, - &c.entry, - "no verifiable pristine source exists to rebuild from (the package \ - is not installed, the lockfile is rewired to the vendored artifact, \ - and the reconstructed entry records no recoverable registry \ - fragment)", - ); - rebuilt += 1; - unrebuildable.insert(c.purl.clone()); - continue; - } - let detail = if c.entry.artifact.platform_locked == Some(true) { - "the vendored wheel is platform-locked (compiled); reinstall the \ - package on this platform and re-run repair, or run `socket-patch \ - vendor` to rebuild it" - .to_string() - } else if let Some(reason) = unverifiable_reason { - reason - } else { - "no verifiable pristine source: no installed copy was found, the \ - lockfile is rewired to the (broken) vendored artifact, and the \ - ledger records no recoverable registry fragment" - .to_string() - }; - fail( - env, - common.json, - &c.purl, - "vendor_artifact_unrepairable", - detail, - ); - unrebuildable.insert(c.purl.clone()); - } - PristineFetch::Failed(detail) => { - if c.soft { - soft_restore_without_fingerprint( - env, - common, - &c.purl, - &c.entry, - &format!("the pristine fetch failed ({detail})"), - ); - rebuilt += 1; - } else { - fail(env, common.json, &c.purl, "vendor_fetch_failed", detail); - } - unrebuildable.insert(c.purl.clone()); - } - } - } - - // ── Rebuild via the normal backends ────────────────────────────────── - let vendored_at = now_rfc3339(); - let pipenv_version = tokio::sync::OnceCell::new(); - let installed_sites = socket_patch_core::vendor::pypi::InstalledSiteListings::default(); - for c in candidates { - if unrebuildable.contains(&c.purl) { - continue; - } - let Some(pkg_path) = all_packages.get(&c.purl).cloned() else { - continue; // failed above - }; - // Clear the live uuid dir only NOW — the patch sources and the - // pristine source are both in hand. The backends' wired hot paths - // rebuild on MISSING (one uniform trigger for every ecosystem), - // and the live bytes must never blend into the rebuild: - // - corrupt: the recorded fingerprint already condemned them; - // - soft: the healthy-by-members live tree is exactly what cannot - // be trusted — the fingerprint below derives from the - // member-verified rebuild, never the live bytes. - // Cleared by MOVE-ASIDE, not deletion: an in-hand source does not - // make the dispatch infallible (the installed copy may itself be - // broken in ways no pre-rebuild rung probes), and a dispatch that - // refuses or fails replaced nothing — the bytes go back rather - // than leaving the wired lockfiles pointing at a bare ENOENT and - // destroying the evidence the NOTE above the staging step keeps. - let aside = if c.soft || c.reason == "vendor_artifact_corrupt" { - set_aside_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await - } else { - None - }; - // For an unverified-source rebuild the rewired lockfile is the trust - // anchor: snapshot the wiring files so a failed post-verify can put - // them back byte-for-byte. The backend's re-wire may refresh the - // recorded integrity/checksum to the rebuilt tarball's — blessing - // exactly the drifted bytes the verify below is about to reject. - let wiring_snapshot: Option = - if must_verify.contains_key(&c.purl) { - let mut snap = match vendor::bun_lock::snapshot_binary_workspace_artifacts( - &common.cwd, - &c.entry, - ) { - Ok(snap) => snap, - Err(detail) => { - if let Some((live, kept)) = &aside { - restore_aside_vendor_dir(live, kept).await; - } - fail( - env, - common.json, - &c.purl, - "vendor_artifact_unrepairable", - detail, - ); - continue; - } - }; - snap.extend(snapshot_npm_wiring_files(&common.cwd).await); - Some(snap) - } else { - None - }; - let outcome = dispatch_vendor_one( - &c.purl, - pkg_path.as_path().into(), - &common.cwd, - &c.record, - &sources, - &vendored_at, - false, - false, - // Repair rebuilds locally from the recorded patch — no service. - None, - &pipenv_version, - &installed_sites, - ) - .await; - match outcome { - None => { - if let Some((live, kept)) = &aside { - restore_aside_vendor_dir(live, kept).await; - } - fail( - env, - common.json, - &c.purl, - "vendor_artifact_unrepairable", - "no vendor backend for this ecosystem in this build".to_string(), - ); - } - Some(VendorOutcome::Refused { code, detail }) => { - if let Some((live, kept)) = &aside { - restore_aside_vendor_dir(live, kept).await; - } - fail(env, common.json, &c.purl, code, detail); - } - Some(VendorOutcome::Done { - result, - entry, - warnings, - }) => { - if !result.success { - if let Some((live, kept)) = &aside { - restore_aside_vendor_dir(live, kept).await; - } - fail( - env, - common.json, - &c.purl, - "vendor_artifact_rebuild_failed", - result.error.unwrap_or_else(|| "rebuild failed".to_string()), - ); - continue; - } - // The rebuild replaced the artifact: the set-aside copy is - // condemned bytes now (post-verify failures below keep - // their existing nothing-kept contract). - if let Some((_, kept)) = &aside { - if let Some(w) = - vendor::vlt_lock::keep_vlt_links(&c.entry, kept, &common.cwd).await - { - record_warning(env, &c.purl, &w, common); - } - let _ = remove_tree(kept).await; - } - for w in &warnings { - // The Rebuilt event below carries the rebuild signal. - if w.code != "vendor_artifact_rebuilt" { - record_warning(env, &c.purl, w, common); - } - } - // Unverified pristine source: the rebuilt tarball must - // reproduce the integrity the rewired lockfile records. - if let Some(wired) = must_verify.get(&c.purl) { - let abs = common.cwd.join(&c.entry.artifact.path); - let verdict = match tokio::fs::read(&abs).await { - Ok(bytes) => { - let name = npm_coords(&c.entry.base_purl) - .map(|(n, _)| n) - .unwrap_or_default(); - registry_fetch::artifact_matches_integrity(&bytes, &name, wired) - } - Err(e) => Err(format!("cannot read the rebuilt artifact: {e}")), - }; - if let Err(detail) = verdict { - remove_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await; - // Put the trust anchor back exactly as it was: the - // backend's re-wire may have refreshed the recorded - // integrity to the rejected rebuild's. - if let Some(snap) = &wiring_snapshot { - for (path, bytes) in snap { - if let Some(bytes) = bytes { - let _ = tokio::fs::write(path, bytes).await; - } else { - let _ = tokio::fs::remove_file(path).await; - } - } - } - fail( - env, - common.json, - &c.purl, - "vendor_artifact_rebuild_failed", - format!( - "the rebuilt artifact does not match the integrity the \ - lockfile records ({detail}); the pristine source may have \ - been tampered with — nothing was kept" - ), - ); - continue; - } - } - // The entry whose recorded fingerprint the post-check must - // match: a backend-returned entry (drift healed / wiring - // re-recorded) wins; a reconstructed entry gets its - // fingerprint computed from the rebuilt bytes. - let from_backend = entry.is_some(); - let mut check_entry = entry.unwrap_or_else(|| c.entry.clone()); - // An artifact-only rebuild hands back a refreshed entry with - // no wiring of its own: re-attach the repaired entry's - // records (a reconstructed entry is not in the ledger yet, - // so the persist below has nothing to carry them from). - if from_backend { - vendor::carry_forward_wiring(&c.entry, &mut check_entry); - } - // The backend's refreshed entry already re-inventoried the - // member-verified rebuild; a changed inventory is the same - // provenance flip the post-verify refresh below reports. - if from_backend - && c.entry.artifact.file_inventory.is_some() - && check_entry.artifact.file_inventory != c.entry.artifact.file_inventory - { - record_warning( - env, - &c.purl, - &VendorWarning::new( - "vendor_inventory_refreshed", - INVENTORY_REFRESHED_DETAIL, - ), - common, - ); - } - if !from_backend && c.reconstructed { - fill_artifact_fingerprint(&common.cwd, &mut check_entry).await; - } - if (from_backend || c.reconstructed) - && persist_vendor_entry( - common, - env, - &mut state, - &c.purl, - check_entry.clone(), - c.detached, - &c.record, - ) - .await - { - continue; - } - // ── Fail-closed post-verify ────────────────────────────── - let mut health = - check_vendored_artifact(&common.cwd, &check_entry, &c.record).await; - // A dir-shaped rebuild whose PATCHED members all verify but - // whose tree differs from the recorded inventory: the entry - // recorded the OTHER build source's tree (the patch - // service's prebuilt artifact carries a converter-generated - // stub gemspec; repair always rebuilds locally). Failing - // here would delete the rebuild, strand the wired pair on a - // dead dir, and deterministically re-fail every later - // repair — so refresh the inventory from the verified - // rebuild instead, loudly. A backend entry whose inventory - // is the repaired entry's own (carried forward — the cargo - // backend records none) is the same case. - if (!from_backend - || check_entry.artifact.file_inventory == c.entry.artifact.file_inventory) - && !c.reconstructed - && matches!(&health, ArtifactHealth::Corrupt { reason } - if reason == "vendor_inventory_mismatch") - { - let abs = common - .cwd - .join(check_entry.artifact.path.replace('\\', "/")); - if let Ok(inv) = artifact_dir_inventory(&check_entry, &abs).await { - check_entry.artifact.file_inventory = Some(inv); - health = - check_vendored_artifact(&common.cwd, &check_entry, &c.record).await; - if health == ArtifactHealth::Healthy { - record_warning( - env, - &c.purl, - &VendorWarning::new( - "vendor_inventory_refreshed", - INVENTORY_REFRESHED_DETAIL, - ), - common, - ); - if persist_vendor_entry( - common, - env, - &mut state, - &c.purl, - check_entry.clone(), - c.detached, - &c.record, - ) - .await - { - continue; - } - } - } - } - match health { - ArtifactHealth::Healthy => { - if !quiet { - println!( - "Rebuilt {} ({})", - normalize_purl(&c.purl), - check_entry.artifact.path - ); - } - env.record( - PatchEvent::new(PatchAction::Rebuilt, c.purl.clone()).with_details( - serde_json::json!({ - "path": check_entry.artifact.path, - "reason": c.reason, - "ledgerRestored": c.reconstructed, - }), - ), - ); - rebuilt += 1; - } - other => { - // The deterministic rebuild did not reproduce the - // recorded artifact (e.g. a tampered ledger sha): - // remove it rather than leave unverifiable bytes. - remove_vendor_dir(&common.cwd, &check_entry.ecosystem, &check_entry.uuid) - .await; - fail( - env, - common.json, - &c.purl, - "vendor_artifact_rebuild_failed", - format!( - "the rebuilt artifact does not match the recorded \ - fingerprint ({other:?}); if state.json was edited, run \ - `socket-patch vendor` to re-vendor from scratch", - ), - ); - } - } - } - } - } - drop(holders); - rebuilt -} - -/// Detail of the `vendor_inventory_refreshed` advisory. -const INVENTORY_REFRESHED_DETAIL: &str = "the rebuilt artifact's patched files verify but its \ - tree differs from the recorded file inventory (the \ - entry was likely vendored from the patch service's \ - prebuilt artifact; repair rebuilds locally); the \ - inventory was refreshed from the verified rebuild — \ - run `socket-patch vendor` to restore the \ - service-built tree"; - -/// Compute and record the artifact fingerprint on a re-synthesized ledger -/// entry: sha256 + size for file-shaped artifacts, the whole-tree file -/// inventory for dir-shaped ones. An uninventoriable dir stays `None` — -/// the entry then behaves as pre-inventory (member-only verification). -async fn fill_artifact_fingerprint(project_root: &Path, entry: &mut VendorEntry) { - let norm = entry.artifact.path.replace('\\', "/"); - let abs = project_root.join(&norm); - if !artifact_is_file_shaped(&norm) { - entry.artifact.file_inventory = artifact_dir_inventory(entry, &abs).await.ok(); - return; - } - if let Some(hex) = file_sha256_hex(&abs).await { - entry.artifact.sha256 = hex; - } - if let Ok(meta) = tokio::fs::metadata(&abs).await { - entry.artifact.size = Some(meta.len()); - } -} - -/// A dir artifact's inventory: an npm dir (vlt's package dir) leaves out -/// its `node_modules/`, which holds vlt's links and is never part of it. -async fn artifact_dir_inventory( - entry: &VendorEntry, - abs: &Path, -) -> Result, String> { - if entry.ecosystem == "npm" { - vendor::compute_package_dir_inventory(abs).await - } else { - compute_dir_inventory(abs).await - } -} - -fn workspace_copy_issue(health: &ArtifactHealth) -> bool { - matches!(health, ArtifactHealth::Corrupt { reason } - if reason == "vendor_workspace_artifact_missing" || reason == "vendor_workspace_artifact_corrupt") -} - -/// Preserve package originals while adopting/rebuilding every member-relative -/// copy from a canonical tarball whose whole-file fingerprint is trusted. -async fn repair_workspace_copies( - root: &Path, - entry: &mut VendorEntry, - dry_run: bool, -) -> Result { - let (wiring, mut changed) = - vendor::bun_lock::repair_binary_workspace_artifacts(root, entry, dry_run).await?; - for record in wiring { - match entry - .wiring - .iter_mut() - .find(|previous| previous.kind == record.kind && previous.file == record.file) - { - Some(previous) if *previous != record => { - *previous = record; - changed = true; - } - Some(_) => {} - None => { - entry.wiring.push(record); - changed = true; - } - } - } - Ok(changed) -} - -/// Fetch one patch view by uuid (proxy-aware) and shape it as a manifest -/// record; `None` offline or on any API failure. `client_cache` holds the -/// one API client the whole vendored-artifact phase shares — construction -/// re-prints the token-shape stderr advisory, so N uuid lookups must not -/// print it N times. Built lazily: a run with nothing to look up never -/// constructs (or warns) at all. -async fn fetch_record_by_uuid( - common: &GlobalArgs, - client_cache: &mut Option, - uuid: &str, -) -> Option<(String, PatchRecord)> { - if common.offline { - return None; - } - if client_cache.is_none() { - *client_cache = Some( - get_api_client_with_overrides(common.api_client_overrides()) - .await - .0, - ); - } - let client = client_cache - .as_ref() - .expect("client_cache was just initialized above"); - let patch = client.fetch_patch(uuid).await.ok()??; - Some(crate::commands::get::record_from_patch_response(&patch)) -} - -/// `pkg:npm/@` → (name, version); the name may be scoped. -/// `base_purl` is stored verbatim percent-encoded (`pkg:npm/%40scope/…`), -/// so each component is decoded like the npm backend's own coordinate -/// parser — the registry fetch and the berry cache-checksum recipe both -/// need the decoded name. -fn npm_coords(base_purl: &str) -> Option<(String, String)> { - let rest = strip_purl_qualifiers(base_purl).strip_prefix("pkg:npm/")?; - let (name_raw, version_raw) = rest.rsplit_once('@')?; - if name_raw.is_empty() || version_raw.is_empty() { - return None; - } - let name = name_raw - .split('/') - .map(percent_decode_purl_component) - .collect::>() - .join("/"); - let version = percent_decode_purl_component(version_raw).into_owned(); - Some((name, version)) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The unverified-rebuild snapshot reads vlt-lock.json and the other - /// npm-family locks through the FIFO-safe opener: a FIFO at any of them - /// is left out of the snapshot at once instead of blocking the repair - /// in open(2), and the regular files are still captured. - #[cfg(unix)] - #[tokio::test] - async fn wiring_snapshot_skips_fifo_locks_instead_of_wedging() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let fifos = [root.join("vlt-lock.json"), root.join("package-lock.json")]; - for fifo in &fifos { - let c = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap(); - // SAFETY: plain libc call on a valid C string. - assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o644) }, 0); - } - std::fs::write(root.join("pnpm-lock.yaml"), b"lockfileVersion: '9.0'\n").unwrap(); - - let snap = match tokio::time::timeout( - std::time::Duration::from_secs(5), - snapshot_npm_wiring_files(root), - ) - .await - { - Ok(snap) => snap, - Err(_) => { - use std::os::unix::fs::OpenOptionsExt as _; - for fifo in &fifos { - let _ = std::fs::OpenOptions::new() - .write(true) - .custom_flags(libc::O_NONBLOCK) - .open(fifo); - } - panic!("the wiring snapshot must fail fast on FIFO locks"); - } - }; - let names: Vec = snap - .iter() - .map(|(p, _)| p.file_name().unwrap().to_string_lossy().into_owned()) - .collect(); - assert_eq!(names, ["pnpm-lock.yaml"]); - assert_eq!(snap[0].1.as_deref(), Some(&b"lockfileVersion: '9.0'\n"[..])); - } - - /// Build a local native binary resolution through the public binary - /// rewrite entry point, which shares the codec with vendor's backend. - fn native_binary_vendor_fixture(uuid: &str) -> Vec { - use socket_patch_core::patch::redirect::{ - rewrite_bun_binary, DepOverride, Integrity, RewriteResult, - }; - let bytes = - include_bytes!("../../../socket-patch-core/tests/fixtures/bun-lockb/1.1.45/bun.lockb"); - let mut result = RewriteResult::default(); - rewrite_bun_binary( - bytes, - &[DepOverride { - ecosystem: "npm".into(), - name: "minimist".into(), - namespace: None, - version: "1.2.2".into(), - token: String::new(), - patch_uuid: uuid.into(), - artifact_url: format!("./.socket/vendor/npm/{uuid}/minimist-1.2.2.tgz"), - berry_zip_url: None, - registry_override: None, - integrity: Integrity { - sha512: Some(format!("sha512-{}", "A".repeat(86) + "==")), - ..Default::default() - }, - }], - &mut result, - ); - assert!(result.warnings.is_empty(), "{:?}", result.warnings); - result.binary_files.remove("bun.lockb").unwrap() - } - - #[tokio::test] - async fn binary_bun_repair_recovers_live_references_and_flavor_without_a_ledger() { - let root = tempfile::tempdir().unwrap(); - let uuid = "11111111-1111-4111-8111-111111111111"; - tokio::fs::write( - root.path().join("bun.lockb"), - native_binary_vendor_fixture(uuid), - ) - .await - .unwrap(); - let references = scan_vendor_references(root.path()).await; - assert_eq!( - references, - vec![( - "npm".into(), - uuid.into(), - format!(".socket/vendor/npm/{uuid}/minimist-1.2.2.tgz") - )] - ); - assert_eq!( - detect_reference_flavor(root.path(), "npm", uuid) - .await - .as_deref(), - Some("bun") - ); - assert!(!root.path().join(".socket/vendor/state.json").exists()); - - // Text takes precedence even if the older binary still references - // an artifact. Reconstruction must not revive stale dependencies. - tokio::fs::write(root.path().join("bun.lock"), "{}\n") - .await - .unwrap(); - assert!(scan_vendor_references(root.path()).await.is_empty()); - assert_eq!( - detect_reference_flavor(root.path(), "npm", uuid).await, - None - ); - tokio::fs::remove_file(root.path().join("bun.lock")) - .await - .unwrap(); - tokio::fs::write(root.path().join("bun.lockb"), b"malformed") - .await - .unwrap(); - assert!(scan_vendor_references(root.path()).await.is_empty()); - assert_eq!( - detect_reference_flavor(root.path(), "npm", uuid).await, - None - ); - } - - /// A FIFO under a wiring-file name (here the paired `