diff --git a/.github/workflows/bun-compatibility.yml b/.github/workflows/bun-compatibility.yml index ee246458..0089fd78 100644 --- a/.github/workflows/bun-compatibility.yml +++ b/.github/workflows/bun-compatibility.yml @@ -16,7 +16,11 @@ on: paths: - '.github/actions/upload-artifact/**' - '.github/workflows/bun-compatibility.yml' - - 'scripts/backtest-bun.py' + - 'scripts/backtest-bun*.py' + - 'scripts/probe-bun-historical-linux.py' + - 'scripts/bun-historical-shas.json' + - 'crates/socket-patch-cli/tests/e2e_bun_lockb.rs' + - 'crates/socket-patch-core/tests/fixtures/bun-lockb/**' - 'docs/testing/bun-compatibility.md' - 'Cargo.lock' - 'crates/socket-patch-core/src/vendor/**' @@ -45,12 +49,14 @@ on: branches: [main] paths: - '.github/workflows/bun-compatibility.yml' - - 'scripts/backtest-bun.py' + - 'scripts/backtest-bun*.py' + - 'scripts/probe-bun-historical-linux.py' + - 'scripts/bun-historical-shas.json' + - 'crates/socket-patch-cli/tests/e2e_bun_lockb.rs' + - 'crates/socket-patch-core/tests/fixtures/bun-lockb/**' - 'Cargo.lock' - 'crates/socket-patch-core/src/vendor/**' - - 'crates/socket-patch-core/src/patch/redirect/mod.rs' - - 'crates/socket-patch-core/src/patch/redirect/replay.rs' - - 'crates/socket-patch-core/src/patch/redirect/takeover.rs' + - 'crates/socket-patch-core/src/patch/redirect/**' - 'crates/socket-patch-cli/src/commands/get.rs' - 'crates/socket-patch-cli/src/commands/scan/**' - 'crates/socket-patch-cli/src/commands/rollback.rs' @@ -125,12 +131,15 @@ jobs: needs: build strategy: fail-fast: false + # Each job runs three cells against the public patch service. Bound + # concurrent jobs so the full release matrix does not flood that API. + max-parallel: 6 matrix: os: [ubuntu-latest, macos-latest, windows-latest] # Every lock-format and behaviour boundary the CLI has to survive: # 0.8.1 / 1.0.0 / 1.0.36 / 1.1.0 / 1.1.38 binary bun.lockb only # 1.1.39 first text lock (lockfileVersion 0, opt-in) - # 1.1.43 first `--lockfile-only` (the lockb migration recipe) + # 1.1.43 first `--lockfile-only` # 1.1.45 last v0 writer # 1.2.0 / 1.2.23 / 1.3.0 text default, lockfileVersion 1 # 1.3.9 / 1.3.10 URL/local tarball sha512 enforcement boundary @@ -326,4 +335,67 @@ jobs: native-bun/captures/**/cli-output.json native-bun/captures/**/tree/** native-bun/captures/**/*.log + native-bun/attempts/** + retention-days: 14 + + + binary: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-22.04, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Cache cargo + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + # Keep binaries linked against different glibc versions separate. + key: bun-native-binary-${{ matrix.os }} + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + + - name: Native binary writer and reader acceptance + shell: bash + env: + MATRIX_OS: ${{ matrix.os }} + run: | + versions=() + # Windows releases begin at 1.1.0. Ubuntu 22.04 and macOS exercise + # every schema boundary, including the earliest format-1 writer. + if [ "$RUNNER_OS" = "Windows" ]; then + versions=(--versions 1.1.0 1.1.38 1.1.45 1.2.0 1.2.23 1.3.0 1.3.14 1.4.2) + elif [ "$MATRIX_OS" = "ubuntu-latest" ]; then + # Bun 0.5.9/0.6.x segfault during pristine HTTP initialization + # on the Ubuntu 24.04 runner; the complete matrix remains + # required on Ubuntu 22.04, with modern releases on both hosts. + versions=(--versions 0.8.1 1.0.0 1.0.36 1.1.0 1.1.38 1.1.45 1.2.0 1.2.23 1.3.0 1.3.14 1.4.2) + fi + python3 scripts/backtest-bun-lockb.py \ + --tools native-binary/tools \ + --output native-binary/results \ + --jobs 2 "${versions[@]}" + + - name: Diagnose historical Linux runtime failures + if: failure() && runner.os == 'Linux' + run: >- + python3 scripts/probe-bun-historical-linux.py + --tools native-binary/tools + --output native-binary/results/linux-diagnostics + + - name: Upload binary acceptance results + if: always() + uses: ./.github/actions/upload-artifact + with: + name: bun-binary-results-${{ matrix.os }} + path: native-binary/results retention-days: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 60efa363..ded4b6c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ into the new version's section — see docs/releasing.md. ### Changed (BREAKING) +- **Binary Bun lockfiles are patched natively in place.** Hosted and vendored + modes read and rewrite `bun.lockb` formats 1–3 directly, including mode + changes, repair, and scoped rollback. Binary-to-text conversion, migration + ledger replay, and their warning codes and tests have been removed. - **`rollback` is now the full-state dual of `scan`.** `scan` and `rollback` are the batch primaries (`get`↔`remove` stay the single-patch duals): a bare `rollback` restores the SYSTEM to unpatched across all three modes — @@ -248,12 +252,8 @@ into the new version's section — see docs/releasing.md. table, staged all-or-nothing per ecosystem group, covering gem, golang, pypi, composer, bun, and the non-package rideshare edits (pnpm `trustLockfile` auto-config — pristine scaffold deleted, modified - scaffold keeps the file and loses only the owned line). The bun.lockb - migration marker restores the binary lock from the bytes the ledger - captured (`redirect_bun_lockb_restored`; the generated `bun.lock` is kept) - and warns `redirect_bun_lockb_unrestorable` naming git history only when - the ledger holds no bytes and the file is absent, or a different - `bun.lockb` has appeared since; + scaffold keeps the file and loses only the owned line). Native `bun.lockb` + package snapshots restore binary resolutions directly; maven and nuget fail closed with `hosted_revert_unsupported` guidance (their structured-metadata edits keep their ledger records; re-run `scan --mode hosted` or restore from VCS). Refused groups keep their @@ -272,7 +272,7 @@ into the new version's section — see docs/releasing.md. an existing vendored patch, including during dry-run. Vendored preflight exemptions require live local lock tuples; a ledger retained by `rollback --preserve-state` cannot bypass a refusal or hide it in a preview. - Symlinked `bun.lockb` files are refused before migration so their links + Symlinked `bun.lockb` files are refused before patching so their links survive, and `vendor --silent` keeps refusal diagnostics on stderr. - **Bun projects: every text-lock generation is accepted, vendored refusals @@ -301,7 +301,7 @@ into the new version's section — see docs/releasing.md. `.socket/vendor/state.json` met by that preflight is reported as `vendor_state_unreadable` rather than a Bun lock code. `scan --mode vendored`, `get --mode vendored` (search and uuid paths) and `--detached` runs now - preflight the Bun lock BEFORE any download: a binary-only, unreadable, + preflight the Bun lock BEFORE any download: a malformed binary, unreadable, unsupported-version or pre-version-2 workspace lock marks the npm patches `failed` with the vendor refusal code and detail, fetches nothing and records no patch — the `scan` / `get ` path still writes an unchanged @@ -312,15 +312,10 @@ into the new version's section — see docs/releasing.md. `package_not_installed`). The refusals stay visible under `--silent` (code-tagged stderr line), `--dry-run` previews them as the additive `would_refuse` action (the human `scan` and `get` previews both print the - `[would-refuse]` lines), the `bun.lockb` refusal carries one remedy on every - path (`bun install --save-text-lockfile`, Bun ≥ 1.1.39), and a `scan` on a - `bun.lockb`-only project warns `bun_lockb_unsupported` instead of reporting - a clean empty inventory — the detail names a shadowed sibling - `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml` and the - delete-the-stale-lockb remedy when one exists, and the warning is kept in - hosted mode too (beside the driver's own `redirect_bun_lockb_*` outcome on - the run that migrates) instead of being dropped on every non-empty hosted - run. Hosted → vendored + `[would-refuse]` lines). Valid binary locks are inventoried and patched + directly without a Bun runtime; malformed binary locks report + `bun_lockb_invalid`, `redirect_bun_lockb_invalid`, or + `vendor_bun_lockb_invalid` at the corresponding entry point. Hosted → vendored takeover now works for bun — `scan`/`get --mode vendored` and `vendor` over a hosted-redirected `bun.lock` claim and replay that purl's hosted edit instead of refusing @@ -329,21 +324,10 @@ into the new version's section — see docs/releasing.md. several hosted bun records; on a lock the vendored backend refuses (a pre-version-2 workspace lock) `vendor` and its dry run report the refusal BEFORE the hosted revert, leaving the purl hosted-patched instead of - un-hosting it and then refusing. The hosted `bun.lockb` migration is truthful: - `bun` is resolved on absolute `PATH` entries (Windows `bun.cmd` shims - included, spawned directly — the standard library quotes batch-shim paths - with spaces and metacharacters correctly), a stale `bun.lockb` beside a - live npm / yarn / pnpm lock is left alone (`redirect_bun_lockb_sibling_lock`; - the redirect follows the sibling lock) instead of converting the project to - `bun.lock`, a `bun.lockb` that is not a regular file is refused before - `bun` is spawned, a `bun.lockb` that Bun 1.1.43–1.1.45 keep beside the new text - lock is removed by the CLI so the ledger's `removed` edit is true, the - pre-migration bytes ride the ledger and `rollback` restores `bun.lockb` - (`redirect_bun_lockb_restored`; the generated `bun.lock` is kept), Bun - 1.1.39–1.1.42 — which accept the flags but write nothing — get - `redirect_bun_lockb_manual_migration` instead of a false "unavailable", and - a failed spawn's `redirect_bun_lockb_unsupported` carries bun's output tail. - The hosted rewrite keeps CRLF on the rewritten `bun.lock` line. Real-Bun + un-hosting it and then refusing. Native `bun.lockb` edits preserve the + dependency graph and unrelated package metadata while updating binary + pointers, tarball integrity, and the package metadata hash. The hosted text + rewrite keeps CRLF on the rewritten `bun.lock` line. Real-Bun coverage now runs in CI: the hermetic hosted and vendored suites on Linux, macOS and Windows (Bun 1.4.2, plus 1.1.45 and 1.2.23 lock-era legs), and the production native matrix — 16 releases from 0.8.1 to 1.4.2 in hosted, diff --git a/README.md b/README.md index e2fd00f3..cce2b42e 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,15 @@ Mode support varies by ecosystem — e.g. Go can't do hosted, Rush monorepos can vendored. See the full **[mode × ecosystem matrix](docs/ecosystems.md#mode--ecosystem-matrix)** for details and per-ecosystem caveats. +### Bun compatibility + +Both text `bun.lock` and binary `bun.lockb` support hosted and vendored +patches, mode switching, repair, and rollback. Binary locks are read and +patched natively: Socket Patch does not need Bun installed to discover or +rewrite them, and does not convert them to text. If both filenames exist, +`bun.lock` takes precedence. See [Bun compatibility](docs/testing/bun-compatibility.md) +for the tested versions, workspace behavior, and installer integrity limits. + ### Pipenv compatibility Hosted mode rewrites every `Pipfile.lock` category that pins the patched diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 7a088f0d..b4420f0e 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -94,7 +94,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers. -**Takeover reconciliation (npm family, bun included)**: vendoring over a hosted-redirected purl (`vendor`, `scan --mode vendored`, `get --mode vendored`) first REVERTS that purl's hosted lockfile edits to their pre-redirect registry values through the per-purl redirect revert, drops the purl's record + package edits from `redirect-state.json`, and then vendors — so the vendor ledger records the PRISTINE registry fragment as its wiring `original` and `vendor --revert` lands back on registry state, never on an expiring hosted URL. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; the human path prints `Warning (vendor_takeover_reverted_redirect): …`). `--dry-run` PROBES the same revert against an in-memory ledger clone instead of promising it: a clean probe reports `vendor_would_revert_redirect`, and a drifted lock or an undecidable ledger edit surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose hosted edits cannot be cleanly reverted fails `redirect_revert_failed` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place, the remedy in the detail). **bun** participates like every other npm-family flavor: its `redirect_bun_lock_package` edits are claimed by the recorded line's spec — the registry spec `@`, or a hosted URL whose tarball leaf is `-.tgz` — so a sibling version's or an aliased sibling's edit is neither claimed nor a refusal, and only an edit that mentions the package without being a bun packages-entry line refuses (remedy: an unscoped `socket-patch rollback`, whose whole-ledger replay unwinds bun.lock hosted edits; never hand-edit the ledger). The same claim rule serves scoped `rollback ` / `remove ` of one of several hosted bun records (see "Hosted unwind coverage"). Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on bun locks the target mode accepts. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the per-purl hosted revert, so a hosted-redirected purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a `bun.lockb`-only or unsupported-version lock → its code) is reported `failed ` with the hosted wiring, the redirect ledger and `bun.lock` byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. +**Takeover reconciliation (npm family, bun included)**: vendoring over a hosted-redirected purl (`vendor`, `scan --mode vendored`, `get --mode vendored`) first REVERTS that purl's hosted lockfile edits to their pre-redirect registry values through the per-purl redirect revert, drops the purl's record + package edits from `redirect-state.json`, and then vendors — so the vendor ledger records the PRISTINE registry fragment as its wiring `original` and `vendor --revert` lands back on registry state, never on an expiring hosted URL. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; the human path prints `Warning (vendor_takeover_reverted_redirect): …`). `--dry-run` PROBES the same revert against an in-memory ledger clone instead of promising it: a clean probe reports `vendor_would_revert_redirect`, and a drifted lock or an undecidable ledger edit surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose hosted edits cannot be cleanly reverted fails `redirect_revert_failed` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place, the remedy in the detail). **bun** participates like every other npm-family flavor: binary `redirect_bun_lockb_package` snapshots are claimed by their recorded package identity and restore individual binary resolutions; its text `redirect_bun_lock_package` edits are claimed by the recorded line's spec — the registry spec `@`, or a hosted URL whose tarball leaf is `-.tgz` — so a sibling version's or an aliased sibling's edit is neither claimed nor a refusal, and only an edit that mentions the package without being a bun packages-entry line refuses (remedy: an unscoped `socket-patch rollback`, whose whole-ledger replay unwinds bun.lock hosted edits; never hand-edit the ledger). The same claim rule serves scoped `rollback ` / `remove ` of one of several hosted bun records (see "Hosted unwind coverage"). Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on bun locks the target mode accepts. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the per-purl hosted revert, so a hosted-redirected purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring, the redirect ledger and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. `scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + the `updates` array + the `redirectState` state block below). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. @@ -120,7 +120,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **`bun.lockb` auto-migration**: a binary `bun.lockb` with no text lock beside it is migrated to text before the read — only when an npm override was granted (with nothing to redirect the user's lockfile format is never touched) and never on `--dry-run` (`redirect_bun_lockb_would_migrate`, nothing spawned). Pre-spawn refusals leave the file untouched: a symlinked `bun.lockb` is rejected with `redirect_symlinked_file_unsupported` (exit 1, including dry-run), since byte restoration cannot recreate the link; a `bun.lockb` that is not a regular file (a FIFO, socket or directory squatting the name) is `redirect_bun_lockb_unsupported` with detail "bun.lockb is not a regular file; refusing to migrate it" and `bun` is never spawned (it would block on the same FIFO); and a `bun.lockb` beside a live sibling lock (`package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` or `pnpm-lock.yaml`) is left alone with the warning `redirect_bun_lockb_sibling_lock` (also on `--dry-run`, in place of `redirect_bun_lockb_would_migrate`) — the recipe would have converted an npm / yarn / pnpm project into a `bun.lock` project; the redirect follows the sibling lock as before. `scan`'s run-level `bun_lockb_unsupported` layout warning is kept in hosted mode as well (see that code's row): on the run that migrates it rides beside the driver's `redirect_bun_lockb_*` outcome, and when no npm override is granted — the driver never touches the file then — it is the only mention that the binary lock was skipped. The CLI spawns the `bun` resolved on ABSOLUTE `PATH` entries only (a relative entry would run a `bun` planted in the scanned repository; on Windows `PATHEXT` finds the npm-global `bun.cmd` / `.bat` shim, which is spawned directly — the Rust standard library launches batch shims through `cmd.exe` with correct quoting, so a shim under a path with spaces and `(x86)`-style metacharacters works) as `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` (offline, fails closed on drift; bun's chatter never reaches stdout). The recipe works from Bun 1.1.43: 1.1.43–1.1.45 write a version-0 `bun.lock` and KEEP `bun.lockb`, ≥ 1.2 write version 1 and delete it — the CLI removes a surviving `bun.lockb` itself, so the ledger's `redirect_bun_lockb_migrated` / action `removed` FileEdit is always true and Bun ≤ 1.1.38 can never silently install the unpatched bytes from a stale binary lock beside the redirected text lock; the edit's `original` carries the pre-migration bytes as standard base64 (locks up to 8 MiB; larger ones are recorded without bytes), which `rollback` restores (see "Hosted unwind coverage"). Bun 1.1.39–1.1.42 accept the flags, exit 0 and write NO text lock (`--frozen-lockfile` suppresses the save there; Bun ≤ 1.1.38 has no text lockfile at all): `redirect_bun_lockb_manual_migration`, whose detail names the manual step (`bun install --save-text-lockfile`, Bun ≥ 1.1.39) and re-run. A missing or unspawnable `bun`, a non-zero exit (the detail carries bun's output tail) or a surviving `bun.lockb` that cannot be removed (the text lock bun wrote is dropped again) is `redirect_bun_lockb_unsupported`; the binary lock is never parsed. A migration whose rewrite then lands nothing in the new `bun.lock` is undone — `bun.lockb` bytes restored, generated `bun.lock` removed, no ledger record — and reported `redirect_bun_lockb_migration_reverted` (`redirect_bun_lockb_migrated_without_redirect` when that restore itself fails; git history is the restore path); the rewriter's own warning says why nothing landed. The `redirect_npm_no_lockfile` noise is suppressed on a `bun.lockb` project. Every bun code above rides `redirect.warnings[]` with the hosted-refusal posture (exit 0, `redirected: 0`); all of it is additive (MINOR). Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock` / `bun.lockb`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** — this run's fetched records first, then the redirect ledger's persisted ones, so a transiently failed `/patches/view` fetch cannot retire the warning (it re-fires on every re-scan until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten but the ledger fallback could otherwise judge an already-redirected project. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. @@ -134,7 +134,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — this is the per-advisory selector hosted/vendored previously lacked (the old workaround, `get --save-only` then `vendor`, still works but is superseded). Semantics: * **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids` only) all identical to `scan --mode hosted`. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N package(s) to the hosted patch server?` (scan hosted has no prompt; get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual). -* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture (writes ONLY `.socket/manifest.json`; blobs held in memory; the nested apply never runs), then scan's vendor step runs — apply lock, **whole-manifest scope including `reconcile_dropped`**: every manifest record is verified/re-vendored and records whose patches left the manifest may have their vendored state reverted, exactly like `scan --mode vendored` (a stderr `[note]` names the count of other affected records; this blast radius is deliberate parity, stated loudly). JSON: get's envelope (with `applied` dropped — structurally zero under save-only) gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (the pre-failure reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patch(es)?`. Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths, `scan --mode vendored`, and `--detached` runs: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — `bun.lockb` with no `bun.lock` → `vendor_bun_lockb_unsupported`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]`, the download phase still writes `.socket/manifest.json` (unchanged — an empty `{"patches": {}}` on a fresh project; a record seeded for another purl survives, re-serialized), the vendor step still runs (no event for the refused purl — unless `.socket/manifest.json` already held its record, in which case the vendor step's own preflight, shared with `vendor`, reports it `failed` with the same code and leaves any hosted wiring untouched), exit `partial_failure`/1. **`--detached`**: the same `download.patches[]` records with `download.downloaded: 0`, and no manifest at all (previously the view was fetched first and, for an alias install, the engine misreported `package_not_installed`). **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the record save and the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path, `--detached` and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, detached parity, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. +* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture (writes ONLY `.socket/manifest.json`; blobs held in memory; the nested apply never runs), then scan's vendor step runs — apply lock, **whole-manifest scope including `reconcile_dropped`**: every manifest record is verified/re-vendored and records whose patches left the manifest may have their vendored state reverted, exactly like `scan --mode vendored` (a stderr `[note]` names the count of other affected records; this blast radius is deliberate parity, stated loudly). JSON: get's envelope (with `applied` dropped — structurally zero under save-only) gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (the pre-failure reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patch(es)?`. Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths, `scan --mode vendored`, and `--detached` runs: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]`, the download phase still writes `.socket/manifest.json` (unchanged — an empty `{"patches": {}}` on a fresh project; a record seeded for another purl survives, re-serialized), the vendor step still runs (no event for the refused purl — unless `.socket/manifest.json` already held its record, in which case the vendor step's own preflight, shared with `vendor`, reports it `failed` with the same code and leaves any hosted wiring untouched), exit `partial_failure`/1. **`--detached`**: the same `download.patches[]` records with `download.downloaded: 0`, and no manifest at all (previously the view was fetched first and, for an alias install, the engine misreported `package_not_installed`). **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the record save and the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path, `--detached` and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, detached parity, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. * **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` records (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, which remains pinned to the fuzzy package-name path. PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `not_installed` — consumers dispatch on the per-record `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `(release_narrowing)`-prefixed strings in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found patches:` listing deliberately shows ALL found patches (pre-narrowing, main's behavior) with the `[skip]` lines following; machine output (the prompt count, the JSON envelope) uses the kept set. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines. * **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached`, no `--prune`; get does not run scan's pre-confirm vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Plain agent-mode `get` continues to ignore `--dry-run` (pre-existing; hosted/vendored honor it — see below). @@ -571,7 +571,8 @@ to **six flavors**. | npm / yarn berry (node-modules linker) | (same tarball) | root `package.json` `resolutions` + `yarn.lock` entry with `checksum: 10c0/` of the berry cache-zip (reproduced from the tarball offline). **PnP is refused** (`.pnp.*` → different artifact pipeline) | `yarn install --immutable --check-cache`, cold cache. Refused if `__metadata.cacheKey ≠ 10c0` or a non-default `compressionLevel` | | npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) | `pnpm install --frozen-lockfile --offline`, cold store (integrity-verified; byte-stable on pnpm 9 & 10). Other lockfileVersions: 5.4/6.0 route to the legacy backend below; anything else refused | | npm / pnpm LEGACY (lockfileVersion 5.4 = pnpm 7, 6.0 = pnpm 8; flavor `pnpm-legacy`) | (same tarball) | root `package.json` `pnpm.overrides` **+** legacy lock surgery (overrides / root dep + specifiers / packages rekey to a bare `file:` key with recomputed integrity / in-package dep refs). **No `pnpm-workspace.yaml` is written** (pnpm ≤ 8 reads overrides only from package.json). The lock's SPECIFIER is machine-ABSOLUTE — pnpm ≤ 8 absolutizes `file:` overrides itself — surfaced as `vendor_pnpm_legacy_absolute_specifier`. Legacy WORKSPACE locks (`importers:`) refused | same-path `pnpm install --frozen-lockfile --offline`, cold store (byte-stable on pnpm 7.33.5 / 8.15.9). A checkout at a DIFFERENT path fails the frozen check (path-bound specifier) and must run `pnpm install --offline --no-frozen-lockfile` once (the flag matters on CI, where pnpm defaults frozen on), which installs the vendored tarball and re-resolves only the specifier line | -| npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` rebuilds on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. `bun.lockb` (binary) refused `vendor_bun_lockb_unsupported` with the `bun install --save-text-lockfile` (Bun ≥ 1.1.39) pointer. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet). Bun 1.1.39–1.3.9 re-save the local tuple WITHOUT its `sha512` on any later lock re-save (`bun add`, `bun install` after a manifest change); the digest-less 2-tuple is recognised as the same wiring — an in-sync re-run stays `already_vendored` and re-pins the digest on disk (no new wiring record) when the committed artifact still holds the bytes the lock was written from — otherwise, as for any stale tuple of ours, the line is re-pinned and the fresh entry carries the new fingerprint — `repair` rebuilds through it, and `vendor --revert` / `rollback` restore the registry line over it (a 2-tuple at ANOTHER uuid is still `vendor_lock_entry_drifted`) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | +| npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` rebuilds on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. Native binary support is described in the next row. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet). Bun 1.1.39–1.3.9 re-save the local tuple WITHOUT its `sha512` on any later lock re-save (`bun add`, `bun install` after a manifest change); the digest-less 2-tuple is recognised as the same wiring — an in-sync re-run stays `already_vendored` and re-pins the digest on disk (no new wiring record) when the committed artifact still holds the bytes the lock was written from — otherwise, as for any stale tuple of ours, the line is re-pinned and the fresh entry carries the new fingerprint — `repair` rebuilds through it, and `vendor --revert` / `rollback` restore the registry line over it (a 2-tuple at ANOTHER uuid is still `vendor_lock_entry_drifted`) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | +| npm / bun binary (`bun.lockb`, native binary format 1, 2 or 3) | (same tarball) | Rewrite matching binary package resolutions and integrity in place; preserve topology and unrelated metadata, update binary offsets and the package metadata hash. Text `bun.lock` takes precedence. `bun_lockb_package` wiring snapshots recover pristine registry metadata for repair and support per-package revert and hosted ↔ vendored migration. Binary discovery and rewrites require no installed Bun runtime. Malformed or unsupported content refuses `vendor_bun_lockb_invalid` before download or takeover. | Frozen installs with the original compatible Bun reader; see `docs/testing/bun-compatibility.md` for the release matrix and historical runtime integrity limits. | | cargo | crate dir `-/` (no `.cargo-checksum.json`) | `.cargo/config.toml` `[patch.crates-io]` path entry **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed) | `cargo build --locked --offline` on a fresh checkout. Requires cargo ≥ 1.56 (`[patch]` in config files). Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | | composer | package dir `/@/` | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: null}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). `composer update ` reverts it | @@ -586,10 +587,9 @@ to **six flavors**. Ecosystems with no vendor backend (jsr) refuse per-purl with `vendor_unsupported_ecosystem`. yarn-berry **PnP** -(`.pnp.*`) and bun's binary `bun.lockb` are refused with stable codes pointing at the native -alternative / a text-lockfile migration (`bun install --save-text-lockfile`, Bun ≥ 1.1.39 — one -detail text on the `vendor` router and on the `get`/`scan --mode vendored` pre-download -preflight); a lock-less tool marker (a `[tool.uv]`/`[tool.poetry]`/ +(`.pnp.*`) is refused with a stable code pointing at the native patch workflow. +Bun's binary `bun.lockb` is supported natively, including lockfile-only discovery, +vendoring, hosting, repair and migration between those modes. A lock-less tool marker (a `[tool.uv]`/`[tool.poetry]`/ `[tool.pdm]` table or a `Pipfile` without its lock) refuses `_no_lockfile` unless a `requirements.txt` fallback exists. PURLs of **compiled-out** ecosystems are invisible to `vendor` exactly as they are to `apply` (the binary cannot parse them). @@ -757,8 +757,8 @@ Restore the system but keep the local patch state for a later re-apply: manifest ### Hosted unwind coverage -* **Per-purl reverts** exist for **cargo and the npm family** (`redirect_revert_supported`): staged, fail-closed on drift, and honoring `dry_run` (every inverse and drift check resolves like a wet run; nothing flushes and the ledger is untouched). npm purls on projects with bun-lock edits DEFER to the whole-ledger replay (below) whenever it will run — the scope covers every record, and the replay stages the bun group together with the `bun.lockb` migration marker all-or-nothing. A SCOPED unwind (`rollback `, or `remove ` while other hosted records remain) takes the per-purl revert instead: it claims that purl's `redirect_bun_lock_package` edits by the recorded line's spec (`@` registry spec, or a hosted URL whose tarball leaf is `-.tgz`) and replays them like the yarn/pnpm text kinds (whole-line fragments, CRLF-exact); a sibling version's edit is neither claimed nor a refusal; an edit that mentions the package but is not a bun packages-entry line refuses with the unscoped-`rollback` remedy. Pinned by `tests/in_process_vendor_bun_takeover.rs` (`bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl` and the `remove` twin). -* **Whole-ledger reverse replay** (`revert_remaining_redirect_edits`, core `patch/redirect/replay.rs`) runs whenever the in-scope hosted record set equals the FULL ledger record set — however the scope was spelled (bare `rollback`, `rollback '**'`, an identifier set covering every record; `remove` reuses the same eligibility rule). It walks every remaining ledger edit in reverse write order through a **per-kind inverse table**, staged and committed **per ecosystem group, all-or-nothing**: one drifted, ambiguous (a fragment appearing more than once), or unhandled edit refuses the whole group byte-untouched while other groups proceed. This covers **gem, golang, pypi, composer, bun**, the yarn/pnpm text kinds (normally claimed by the per-purl npm revert first), and the **non-package rideshare edits** — the pnpm `trustLockfile` auto-config (a pristine created scaffold is deleted; a user-modified one keeps the file and loses only the `trustLockfile: true` line, warned as `redirect_pnpm_trust_scaffold_modified`) — plus a "last one out turns off the lights" pass: when the record map empties but non-package edits remain, they are replayed in the same persist, so the trust edit never strands. The **bun.lockb migration marker** (`redirect_bun_lockb_migrated`, action `removed`) restores the binary lock: when the edit's `original` carries the pre-migration bytes (standard base64, captured by the hosted run for locks up to 8 MiB) and no `bun.lockb` exists, the replay writes them back and warns `redirect_bun_lockb_restored` — the text `bun.lock` generated during the redirect is left in place (Bun ≥ 1.1.39 reads `bun.lock` when both exist; delete whichever lockfile you do not want). A `bun.lockb` already holding those bytes is a silent no-op; a DIFFERENT `bun.lockb` (the user re-locked with an old Bun) is never clobbered and warns `redirect_bun_lockb_unrestorable`, as does a marker recorded WITHOUT bytes (an oversize lock, or a ledger written by a CLI that predates the capture) when the file is absent — the detail names git history as the restore path; a present `bun.lockb` with no recorded bytes is left alone silently. An undecodable payload degrades like an absent one. The marker never blocks its group. +* **Per-purl reverts** exist for **cargo and the npm family** (`redirect_revert_supported`): staged, fail-closed on drift, and honoring `dry_run` (every inverse and drift check resolves like a wet run; nothing flushes and the ledger is untouched). npm purls on projects with bun-lock edits DEFER to the whole-ledger replay (below) whenever it will run — the scope covers every record, and the replay stages the bun group all-or-nothing. A SCOPED unwind (`rollback `, or `remove ` while other hosted records remain) takes the per-purl revert instead: it claims that purl's `redirect_bun_lock_package` edits by the recorded line's spec (`@` registry spec, or a hosted URL whose tarball leaf is `-.tgz`) and replays them like the yarn/pnpm text kinds (whole-line fragments, CRLF-exact); a sibling version's edit is neither claimed nor a refusal; an edit that mentions the package but is not a bun packages-entry line refuses with the unscoped-`rollback` remedy. Pinned by `tests/in_process_vendor_bun_takeover.rs` (`bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl` and the `remove` twin). Native binary `redirect_bun_lockb_package` snapshots follow the same scoped ownership rule and restore only the claimed package records; unrelated binary resolutions stay intact. +* **Whole-ledger reverse replay** (`revert_remaining_redirect_edits`, core `patch/redirect/replay.rs`) runs whenever the in-scope hosted record set equals the FULL ledger record set — however the scope was spelled (bare `rollback`, `rollback '**'`, an identifier set covering every record; `remove` reuses the same eligibility rule). It walks every remaining ledger edit in reverse write order through a **per-kind inverse table**, staged and committed **per ecosystem group, all-or-nothing**: one drifted, ambiguous (a fragment appearing more than once), or unhandled edit refuses the whole group byte-untouched while other groups proceed. This covers **gem, golang, pypi, composer, bun**, the yarn/pnpm text kinds (normally claimed by the per-purl npm revert first), and the **non-package rideshare edits** — the pnpm `trustLockfile` auto-config (a pristine created scaffold is deleted; a user-modified one keeps the file and loses only the `trustLockfile: true` line, warned as `redirect_pnpm_trust_scaffold_modified`) — plus a "last one out turns off the lights" pass: when the record map empties but non-package edits remain, they are replayed in the same persist, so the trust edit never strands. * **maven and nuget fail closed**: their structured-metadata kinds (`redirect_maven_repository` / `redirect_maven_dep_management` / `redirect_maven_config` / `redirect_maven_trusted_checksums`, `redirect_nuget_source` / `redirect_nuget_lock`) have no revert implementation, so any such edit refuses its whole group (the maven `` suffix rewrite alone IS invertible, but it rides the same all-or-nothing group). The refusal keeps their records + edits in the ledger and names the remedy: re-run `scan --mode hosted` to normalize, or restore the lockfiles from version control. Unknown future kinds refuse the same way (forward-compat). * **Scoped runs** (paths / identifiers / `--ecosystems`) that do NOT cover the full record set get per-purl reverts only; in-scope hosted purls of ecosystems without one fail closed — `rollback` reports them in `hosted.unsupported` (exit 1), `remove` as the top-level `hosted_revert_unsupported` error — with the remedy "run an unscoped `socket-patch rollback` to unwind ALL hosted redirects, or re-run `scan --mode hosted`". * **Ledger accounting**: exactly the replayed (or already-at-original) edits are dropped; a record is dropped only when every group its ecosystem writes ended clean, so refused groups keep both edits and records — the intermediate-but-coherent ledger a retry needs. The mutated ledger is persisted (delete-when-empty); a failed persist rides `hosted.failed` / `hosted_revert_failed`. @@ -769,7 +769,7 @@ Restore the system but keep the local patch state for a later re-apply: manifest | Key | Shape | Meaning | |---|---|---| -| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `redirect_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `redirect_bun_lockb_restored`, `redirect_bun_lockb_unrestorable`, `redirect_pnpm_trust_scaffold_modified`, plus vendored/hosted leg advisories. New codes are additive (MINOR) | +| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `redirect_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `redirect_pnpm_trust_scaffold_modified`, plus vendored/hosted leg advisories. New codes are additive (MINOR) | | `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. Previously this listed every vendor-owned skip | | `vendoredReverted` | `[purl]` | Ledger entries cleanly reverted this run (unwired + artifact deleted + entry dropped; previewed on dry-run) | | `vendoredPreserved` | `[purl]` | `--preserve-state`: unwired with artifact + ledger entry kept | @@ -1049,8 +1049,6 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `path_scope_excluded_supplements` | scan `warnings[]` | path-scoped scan (v5.0): lockfile-only / vendor-ledger supplement packages have no installed path and were excluded from the scoped scan; the detail carries the count. | | `vendor_state_unreadable` / `redirect_state_unreadable` | rollback `warnings[]`; remove top-level error | corrupt-ledger containment (v5.0). Rollback: an unreadable vendor ledger skips the vendored leg + manifest cleanup + GC; an unreadable redirect ledger skips the hosted leg (quarantine/restore remedy in the detail); either drives `partial_failure` exit 1 while the agent leg still restores files. Remove: `vendor_state_unreadable` is a hard top-level error before any mutation (an unreadable redirect ledger only warns — the identifier may match other stores). Also the Bun vendored preflight's refusal code: `get` / `scan --mode vendored`, `--detached` runs, `vendor`'s pre-takeover check and the `--dry-run` `would_refuse` preview report an unreadable `.socket/vendor/state.json` as itself (`errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code`), fail-closed — nothing is exempt — instead of a Bun lock code. | | `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (`manifest.removedEntries: []`) and the run exits `partial_failure` 1. | -| `redirect_bun_lockb_restored` | rollback/remove `warnings[]` | hosted replay: the ledger's `redirect_bun_lockb_migrated` edit carried the pre-migration bytes and `bun.lockb` was absent, so the binary lock was written back; the text `bun.lock` generated during the redirect is kept (Bun ≥ 1.1.39 reads `bun.lock` when both exist — delete whichever lockfile you do not want). Never blocks its group. | -| `redirect_bun_lockb_unrestorable` | rollback/remove `warnings[]` | hosted replay (v5.0): the bun.lockb→bun.lock migration marker cannot restore `bun.lockb` — it was recorded without bytes (a lock above 8 MiB, or a ledger written before the bytes were captured) and the file is absent, or a DIFFERENT `bun.lockb` has appeared since (left untouched). Restore from git history if the binary format is required. Not emitted when the file is present with no recorded bytes. Never blocks its group. | | `redirect_pnpm_trust_scaffold_modified` | rollback/remove `warnings[]` | hosted replay (v5.0): the redirect-created `pnpm-workspace.yaml` scaffold was modified since; the file was kept and only the `trustLockfile: true` line removed. | | `vendor_stale_artifact_removed` | `removed` | vendor / scan `--vendor`: re-vendor under a newer patch uuid removed the previous uuid's orphaned artifact dir. | | `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (jsr). | @@ -1060,10 +1058,11 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `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. | | `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). Previously invisible in `--json`. | | `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager will still install UNPATCHED bytes; names the wired winner + the ignored locks. | -| `vendor_yarn_berry_unsupported` / `vendor_bun_lockb_unsupported` | `failed` | vendor (npm): yarn-berry PnP / bun binary lockfile — pointer to `yarn patch` / `bun install --save-text-lockfile` (Bun ≥ 1.1.39; one detail text on the `vendor` router and on the `get`/`scan --mode vendored` pre-download preflight, where the bun code is a `failed` record with `errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code` with `status: "error"`). | -| `vendor_bun_workspace_unsupported` | `failed` | vendor / scan / get `--mode vendored` (bun): the text lock holds `workspace:` packages and its `lockfileVersion` is below 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the member; a committed version-2 lock is the proof every consumer runs Bun ≥ 1.4 (deliberate over-approximation: root-only declared packages would install on version 1 too). Detail names the version integer and a version-specific remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing version) — then, for a version-1 lock, "or use `--mode hosted`, which accepts version-1 workspace locks"; for a version-0 lock, "or delete `bun.lock`, re-lock with Bun ≥ 1.2 (which writes lockfileVersion 1) and use `--mode hosted`" (hosted refuses version-0 workspace locks, so a bare hosted pointer would send the user into a second refusal). Refused before any write — in the pre-download preflight on `get`/`scan` (see `vendor_bun_lockb_unsupported` for the placements); in the shared preflight that `vendor` and the vendor step run BEFORE a hosted → vendored takeover's revert (a hosted-redirected purl stays hosted-wired, ledger and lock untouched; `vendor --dry-run` previews the same `failed` code); and in the engine when the run would write a NEW local tuple. Exempt: purls the vendor ledger wires at the selected uuid, purls whose every `bun.lock` instance is already a `.socket/vendor/npm/` tuple (any uuid), in-sync re-runs and `repair` rebuilds. | -| `vendor_lockfile_missing` / `vendor_lockfile_version_unsupported` (bun preflight placement) | `failed` | scan / get `--mode vendored` (bun): the pre-download preflight found `bun.lock` unreadable / at a `lockfileVersion` other than 0, 1 or 2 (a newer version: update socket-patch; no integer: re-lock with Bun ≥ 1.2 — the same text as hosted's `redirect_bun_lock_unsupported`) or outside bun's single-line `packages` grammar. Same placements as `vendor_bun_lockb_unsupported`; nothing fetched, no patch record. An unreadable `.socket/vendor/state.json` met by the same preflight is `vendor_state_unreadable` (see that row), never one of these. | -| `bun_lockb_unsupported` | scan `warnings[]` (run-level) | scan (every mode): `bun.lockb` is present with no `bun.lock` beside it, so the lockfile inventory cannot read the project's lock — a fresh clone used to report a clean `scannedPackages: 0` success in every mode. The detail names `bun install --save-text-lockfile` (Bun ≥ 1.1.39); when a recognised sibling lock (`package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `pnpm-lock.yaml`) sits beside it, the detail instead says the stale `bun.lockb` shadows `` in lockfile discovery — delete it if ``'s installer is in use, or run `bun install --save-text-lockfile` if bun is (the sibling is still not inventoried: fail-closed). Also a stderr `Warning (bun_lockb_unsupported): …` line. Exit code and `status` unchanged (the PnP-refusal posture). Kept in EVERY mode, hosted included: it states a fact about this run's discovery (the binary lock was never read, so its lockfile-only packages are invisible), and nothing at that point can know whether the hosted driver will speak about the file — it does so only when an npm override is granted (`redirect_bun_lockb_*` on `redirect.warnings[]`, or the `redirect_bun_lockb_migrated` edit), so on the run that migrates the two codes ride side by side (nothing is deduplicated) and on the many runs that grant nothing the warning is the only voice — never a silent no-op. (An earlier build dropped it on every non-empty hosted run, so a polyglot project or an installed-but-unpatched npm tree printed a clean hosted success.) | +| `vendor_yarn_berry_unsupported` | `failed` | vendor (npm): yarn-berry Plug'n'Play layout; use its native `yarn patch` workflow. | +| `vendor_bun_lockb_invalid` | `failed` | vendor / scan / get `--mode vendored`: the binary lock is malformed, unreadable, unsupported or cannot be rewritten safely. The detail names the parser, hash or filesystem error. Refused before patch downloads and before hosted takeover; `patches[]` / `download.patches[]` carry `errorCode` and `error`, while `get ` also carries top-level `error.code`. Dry-run predicts the same refusal. | +| `vendor_bun_workspace_unsupported` | `failed` | vendor / scan / get `--mode vendored` (bun): the text lock holds `workspace:` packages and its `lockfileVersion` is below 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the member; a committed version-2 lock is the proof every consumer runs Bun ≥ 1.4 (deliberate over-approximation: root-only declared packages would install on version 1 too). Detail names the version integer and a version-specific remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing version) — then, for a version-1 lock, "or use `--mode hosted`, which accepts version-1 workspace locks"; for a version-0 lock, "or delete `bun.lock`, re-lock with Bun ≥ 1.2 (which writes lockfileVersion 1) and use `--mode hosted`" (hosted refuses version-0 workspace locks, so a bare hosted pointer would send the user into a second refusal). Refused before any write — in the pre-download preflight on `get`/`scan` (see `vendor_bun_lockb_invalid` for the placements); in the shared preflight that `vendor` and the vendor step run BEFORE a hosted → vendored takeover's revert (a hosted-redirected purl stays hosted-wired, ledger and lock untouched; `vendor --dry-run` previews the same `failed` code); and in the engine when the run would write a NEW local tuple. Exempt: purls the vendor ledger wires at the selected uuid, purls whose every `bun.lock` instance is already a `.socket/vendor/npm/` tuple (any uuid), in-sync re-runs and `repair` rebuilds. | +| `vendor_lockfile_missing` / `vendor_lockfile_version_unsupported` (bun preflight placement) | `failed` | scan / get `--mode vendored` (bun): the pre-download preflight found `bun.lock` unreadable / at a `lockfileVersion` other than 0, 1 or 2 (a newer version: update socket-patch; no integer: re-lock with Bun ≥ 1.2 — the same text as hosted's `redirect_bun_lock_unsupported`) or outside bun's single-line `packages` grammar. Same placements as `vendor_bun_lockb_invalid`; nothing fetched, no patch record. An unreadable `.socket/vendor/state.json` met by the same preflight is `vendor_state_unreadable` (see that row), never one of these. | +| `bun_lockb_invalid` | scan `warnings[]` (run-level) | scan (every mode): the native binary inventory could not parse or read `bun.lockb`; detail names the format or filesystem error. Also printed as `Warning (bun_lockb_invalid): …` on stderr. Exit and status remain unchanged. The warning is retained on empty and non-empty scans; valid binary locks are inventoried normally without a runtime or install. | | `would_refuse` | dry-run preview action (`vendor.patches[]`) | scan `--mode vendored --dry-run` / get `--mode vendored --dry-run`: the wet run's Bun preflight would refuse this npm purl; the record carries `errorCode` (one of the four Bun lock codes above, or `vendor_state_unreadable` for an unreadable vendor ledger) + `error`. Exit 0 / `status: "success"`, nothing written. | | `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted-redirected purl (cargo and the npm family, bun included): dry run — the per-purl hosted revert was PROBED and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the hosted lockfile edits were reverted to their pre-redirect registry values and the redirect-ledger record dropped before vendoring (mode takeover). Fires on the run that takes over, not on re-runs. | | `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the per-purl hosted revert refused (drifted lock, missing original fragment, an undecidable ledger edit) — nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`; the detail names the remedy (for bun: an unscoped `socket-patch rollback`). | @@ -1099,9 +1098,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_pdm_refused` / `redirect_pdm_legacy_sync_required` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pdm): the `pdm.lock` rewrite was refused — an unsupported `[metadata] lock_version` (the identity-losing `3.1` / `4.0`–`4.2` formats or an untested future format), an unsupported `strategy`, a package listed at several versions (fork) or absent, a user-authored `url`/`path`/VCS/`editable` source, hash-less or malformed `files`, or a wheel whose filename does not match the locked package (`redirect_pdm_refused`); or the lock was written in format `2` (PDM 0.12–1.4), whose upstream freshness bug lets `pdm install` regenerate the lock — use `pdm sync` (`redirect_pdm_legacy_sync_required`). A refused uuid is withheld from every other PyPI rewriter when `pdm.lock` is the install driver, and its patch is not confirmed. Exit code and `status` unchanged (hosted-refusal posture). | | `redirect_bun_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the text lock's `lockfileVersion` is not 0, 1 or 2 (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2 — the shared gate's text, identical to vendored's `vendor_lockfile_version_unsupported`), or its `packages` section is not bun's single-line grammar. Nothing rewritten; exit 0 (hosted-refusal posture). | | `redirect_bun_workspace_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): a lockfileVersion-0 lock (Bun 1.1.39–1.1.45 `--save-text-lockfile`) holds `workspace:` packages; frozen installs of that grammar cannot keep the hosted tuple. Detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen installs; delete bun.lock and re-run `bun install` with Bun >= 1.2 (which writes lockfileVersion 1, accepted by hosted mode) — a plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root -> member); otherwise it keeps version 0 or fails to resolve" (measured: Bun 1.2.0 keeps 0, 1.2.23–1.4.2 exit 1 "failed to resolve" on a root that does not depend on its members). Version-1/2 workspace locks are rewritten. Exit 0. | -| `redirect_bun_lockb_would_migrate` / `redirect_bun_lockb_manual_migration` / `redirect_bun_lockb_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun; `bun.lockb` with no `bun.lock` and an npm override granted): `--dry-run` would run the migration, nothing spawned / the resolved `bun` exited 0 but wrote no text lock — Bun 1.1.39–1.1.42 under `--frozen-lockfile`, or Bun ≤ 1.1.38 with no text lockfile at all — run `bun install --save-text-lockfile` (Bun ≥ 1.1.39) yourself and re-run / `bun` is missing on an absolute `PATH` entry, could not be spawned, exited non-zero (detail carries bun's output tail) or the surviving `bun.lockb` could not be removed (the text lock is dropped again) — or `bun.lockb` is not a regular file (a FIFO, socket or directory squatting the name; detail "bun.lockb is not a regular file; refusing to migrate it"), refused BEFORE any spawn since bun would block on it too. `bun.lockb` is never parsed; exit 0. | -| `redirect_bun_lockb_sibling_lock` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun; `bun.lockb` with no `bun.lock`, an npm override granted, AND a recognised sibling lock — `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` or `pnpm-lock.yaml` — present): the stale binary lock is NOT migrated (the recipe would have converted an npm / yarn / pnpm project into a `bun.lock` project) and `bun` is not spawned; detail "bun.lockb was left alone because is also present; the redirect follows — delete the stale bun.lockb if it is debris, or remove and re-run if bun is the installer". The npm-family rewrite proceeds on the sibling lock as before; `--dry-run` reports this code in place of `redirect_bun_lockb_would_migrate`. Exit 0. | -| `redirect_bun_lockb_migration_reverted` / `redirect_bun_lockb_migrated_without_redirect` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the migration succeeded but no redirect landed in the new `bun.lock` (the rewriter's own warning says why) — the pre-migration `bun.lockb` was restored, the text lock removed and no ledger record kept / the restore itself failed, so the migration and its `removed` ledger record stand (git history is the restore path). Exit 0. | +| `redirect_bun_lockb_invalid` | `redirect.warnings[]` (warning) | scan/get `--mode hosted`: the native binary lock is malformed, unreadable, unsupported or cannot be rewritten safely. No installer is spawned and no binary or sibling npm lock edit or takeover occurs; dry-run reports the same format error. Exit 0, `redirected: 0`. | | `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | | `vendor_prebuilt_stub_invalid` | `failed` / `skipped` (warning) | vendor (gem, `--vendor-source`): the served stub gemspec fails the rubygems `summary`/`authors` bar, so bundler would refuse the vendored path source at install time. `service`: refusal naming the missing attributes; `auto`: loud warning + local-build fallback — or, when the gem is also not installed locally (no stub to derive), a refusal naming the served defect and the install-the-gem remedy. | | `gem_spec_invalid` | `failed` | vendor (gem): the LOCAL `specifications/` stub gemspec fails the same rubygems `summary`/`authors` bar (a corrupted or hand-edited gem home); the refusal names the file — reinstall the gem (`gem pristine ` / fresh `bundle install`). | @@ -1203,7 +1200,7 @@ the classic "already in manifest" skip. Additive: a `failed` record may ALSO carry `errorCode` beside `error` — today exactly the vendored-mode Bun preflight refusals -(`vendor_bun_lockb_unsupported`, `vendor_lockfile_missing`, +(`vendor_bun_lockb_invalid`, `vendor_lockfile_missing`, `vendor_lockfile_version_unsupported`, `vendor_bun_workspace_unsupported`, and `vendor_state_unreadable` when the preflight cannot read `.socket/vendor/state.json`) diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 2a3cc2dc..9075a09d 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -28,10 +28,6 @@ uuid = { workspace = true } regex = { workspace = true } glob = { workspace = true } tempfile = { workspace = true } -# The hosted redirect ledger carries a migrated bun.lockb's pre-migration -# bytes as standard base64 (`commands/scan/hosted.rs`); the core replay -# decodes them with the same crate. -base64 = { workspace = true } [target.'cfg(unix)'.dependencies] # main.rs restores the default SIGPIPE disposition so piped invocations diff --git a/crates/socket-patch-cli/src/commands/bun_preflight.rs b/crates/socket-patch-cli/src/commands/bun_preflight.rs index b8297a05..cba818aa 100644 --- a/crates/socket-patch-cli/src/commands/bun_preflight.rs +++ b/crates/socket-patch-cli/src/commands/bun_preflight.rs @@ -7,7 +7,7 @@ //! //! One read-only [`preflight_vendor`] per run, evaluated before any //! `/patches/view/` fetch and before any write, so an incompatible Bun -//! project (binary `bun.lockb` without a text lock, an unreadable lock, an +//! project (malformed binary `bun.lockb`, an unreadable lock, an //! unsupported `lockfileVersion`, a pre-version-2 `workspace:` lock) never //! has a patch downloaded on its behalf — let alone recorded in the //! manifest, or its live hosted redirect stripped — and every entry point @@ -50,7 +50,7 @@ pub(crate) type LedgerLoad<'a> = Result<&'a HashMap, &'a st /// /// [`wired_instances_all_ours`]: socket_patch_core::vendor::bun_lock::wired_instances_all_ours pub(crate) struct BunVendorRefusal { - /// The stable vendor error code (`vendor_bun_lockb_unsupported`, + /// The stable vendor error code (`vendor_bun_lockb_invalid`, /// `vendor_lockfile_missing`, `vendor_lockfile_version_unsupported`, /// `vendor_bun_workspace_unsupported`) — the same string the vendor /// engine would have emitted as a `failed` event — or @@ -283,15 +283,15 @@ mod tests { let npm = vec![sel(UUID, PURL)]; let refusal = bun_vendor_preflight(tmp.path(), &npm) .await - .expect("lockb-only project is refused"); - assert_eq!(refusal.code, "vendor_bun_lockb_unsupported"); + .expect("malformed lockb project is refused"); + assert_eq!(refusal.code, "vendor_bun_lockb_invalid"); assert!(refusal.applies_to(PURL)); assert!(!refusal.applies_to("pkg:pypi/only@1.0.0")); - // A ledger at this UUID cannot make a binary lock vendorable. + // A ledger at this UUID cannot make a malformed binary lock vendorable. seed_bun_vendor_entry(tmp.path(), PURL, UUID); let refusal = bun_vendor_preflight(tmp.path(), &npm).await.unwrap(); - assert_eq!(refusal.code, "vendor_bun_lockb_unsupported"); + assert_eq!(refusal.code, "vendor_bun_lockb_invalid"); assert!(refusal.applies_to(PURL), "the live lock must be compatible"); // …but a corrupt ledger exempts nothing and names itself. diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index b08d3b7c..219675e0 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -5198,7 +5198,7 @@ mod tests { #[tokio::test] #[serial_test::serial] - async fn download_patch_records_bun_lockb_refuses_before_fetch() { + async fn download_patch_records_malformed_bun_lockb_refuses_before_fetch() { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -5234,7 +5234,7 @@ mod tests { assert_eq!(json["failed"], 1, "json={json}"); assert_eq!(json["patches"][0]["action"], "failed", "json={json}"); assert_eq!( - json["patches"][0]["errorCode"], "vendor_bun_lockb_unsupported", + json["patches"][0]["errorCode"], "vendor_bun_lockb_invalid", "json={json}" ); assert!( diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 0582a0f9..f8938a68 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -126,11 +126,27 @@ const WIRING_FILES: &[&str] = &[ /// Scan the wiring-bearing files for vendored-artifact references, /// returning deduped `(ecosystem, uuid, artifact relpath)` triples. Pure -/// text scan + the canonical path parser — the same recovery rule the CLI -/// contract documents for external tools. +/// 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()) @@ -273,6 +289,13 @@ async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) -> 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`. @@ -529,7 +552,53 @@ pub(crate) async fn repair_vendored_artifacts( ); continue; } - match check_vendored_artifact(&common.cwd, &entry, &record).await { + 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, quiet, purl, "vendor_artifact_unrepairable", detail); + continue; + } + } + if workspace_copy_issue(&health) { + continue; + } + } + match health { ArtifactHealth::Healthy => { // Dir-shaped artifacts from pre-inventory vendors: the // health check above could only verify the PATCHED members @@ -723,7 +792,7 @@ pub(crate) async fn repair_vendored_artifacts( } } match check_vendored_artifact(&common.cwd, &entry, &record).await { - ArtifactHealth::Healthy => { + 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) @@ -771,6 +840,9 @@ pub(crate) async fn repair_vendored_artifacts( "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. @@ -789,6 +861,12 @@ pub(crate) async fn repair_vendored_artifacts( // 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, quiet, &purl, "vendor_artifact_unrepairable", detail); + continue; + } let save_failed = persist_vendor_entry( common, env, &mut state, &purl, entry, detached, &record, ) @@ -827,6 +905,12 @@ pub(crate) async fn repair_vendored_artifacts( soft: true, }); } + ArtifactHealth::Unverifiable { reason } + if reason == "vendor_workspace_artifact_invalid" => + { + fail(env, quiet, &purl, "vendor_artifact_unrepairable", + "workspace tarball paths cannot be validated; fix the binary lock or symbolic links before repairing".into()); + } _ => { candidates.push(Candidate { purl, @@ -1151,20 +1235,33 @@ pub(crate) async fn repair_vendored_artifacts( // 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)>> = + let wiring_snapshot: Option = if must_verify.contains_key(&c.purl) { - let mut snap = Vec::new(); + 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, quiet, &c.purl, "vendor_artifact_unrepairable", detail); + continue; + } + }; for name in [ "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", + "bun.lockb", "package.json", ] { let p = common.cwd.join(name); if let Ok(bytes) = tokio::fs::read(&p).await { - snap.push((p, bytes)); + snap.push((p, Some(bytes))); } } Some(snap) @@ -1254,7 +1351,11 @@ pub(crate) async fn repair_vendored_artifacts( // integrity to the rejected rebuild's. if let Some(snap) = &wiring_snapshot { for (path, bytes) in snap { - let _ = tokio::fs::write(path, bytes).await; + if let Some(bytes) = bytes { + let _ = tokio::fs::write(path, bytes).await; + } else { + let _ = tokio::fs::remove_file(path).await; + } } } fail( @@ -1415,6 +1516,40 @@ async fn fill_artifact_fingerprint(project_root: &Path, entry: &mut VendorEntry) } } +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 @@ -1470,6 +1605,88 @@ fn npm_coords(base_purl: &str) -> Option<(String, String)> { mod tests { use super::*; + /// 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 `