diff --git a/.gitattributes b/.gitattributes index 6e2026463..585890399 100644 --- a/.gitattributes +++ b/.gitattributes @@ -47,3 +47,11 @@ crates/socket-patch-core/tests/fixtures/sbt/** -text # (sbt-compatibility.yml); a CRLF checkout breaks them ($'\r'). scripts/sbt-warm-seed.sh text eol=lf scripts/sbt-compat-matrix.sh text eol=lf + +# Real `bun install --save-text-lockfile` output: the migrated-lock revert +# tests compare restored bun.lock bytes against it exactly (#784). +crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/*.lock -text + +# Real Bun locks with a version-less own-source copy (#497): the VEX tests +# rewire the nested registry entry of the captured bytes in place. +crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6265beb0a..8a82f719d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1127,8 +1127,13 @@ jobs: - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.0.36', test_filter: --include-ignored} - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.1.45', test_filter: --include-ignored} # Bun >= 1.4 migrating a hosted workspace bun.lockb to bun.lock - # (#803): the only binary-lock test whose reader must be 1.4+. - - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.4.2', test_filter: --include-ignored workspace_text_migration_heals_on_rerun} + # (#803; its reader must be 1.4+) and a vendored one, then + # reverting it (#784; skipped by the < 1.2 readers above). Both + # 1.4.2 and 1.3.14 also run the isolated-linker vendored re-run + # after a late dependent (#861); 1.3 re-hoists a frozen binary lock + # and refuses one whose trees changed. + - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.4.2', test_filter: --include-ignored text_migration workspace_late_dependent} + - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.3.14', test_filter: --include-ignored workspace_late_dependent} # Real-vlt capstones (DESIGN §8.4): wiremock patch service and a local # npm registry fed from npmjs, driven by the pinned vlt release # (`node vlt.js`, installed below from a sha512-checked `npm pack`). diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 6959bc5ee..ab9861c06 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -126,7 +126,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. `rollback` / `remove` restore each hosted node's slots [2] and [3] from the version document of the registry the node resolves against (slot [3] is its `dist.tarball`, as vlt writes it; `upstream_registry_fallback` when that registry can't be read), following the lock's own slot-[3] convention (see "Hosted unwind coverage"). Tested releases: `docs/testing/vlt-compatibility.md`. -**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). **A takeover the vendored backend does not carry through keeps the hosted pin (#853, #944)**: the wet run holds the restore in its group commit, and when the backend then refuses the purl — whatever the code: a pnpm `catalog:` dependency (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` (`vendor_lockfile_crlf_unsupported`), a conflicting `pnpm-workspace.yaml` override — a range, another version or a `>` selector (`vendor_override_conflict`), a uv inline `[tool.uv] sources` table, a prebuilt download that fails, … — or its apply fails with nothing recorded, the restore is rolled back before anything reaches disk: the purl is reported `failed ` with the backend's own code and detail, neither `vendor_takeover_reverted_redirect` nor the restore's advisories are recorded for it, and the hosted wiring stays byte-for-byte (exit 1 / `partial_failure`), so the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed ` by running the backend's dry run over the restored project staged in memory (nothing written). A restore that writes a file outside the group commit's captured set (`.socket/gradle/hosted-index.tsv`) is not rolled back. The `scan` / `get --mode vendored --dry-run` preview, which stages no restore (it never touches the registry), predicts the pnpm (lockfileVersion 9) part of this (#853): a hosted pin the pnpm vendored backend refuses on lock or manifest text (a `catalog:` dependency, a CRLF `pnpm-lock.yaml` / `pnpm-workspace.yaml`, a conflicting override, …) is listed `would_refuse` with the wet run's `errorCode` and the backend's own `error` detail, evaluated on the still-hosted project (the pnpm restore only rewrites the entry's `resolution:`, which none of those gates reads); a hosted pin of any other lock flavor or ecosystem — a legacy pnpm 7/8 lock (`lockfileVersion` 5.4 / 6.0) included, whose CRLF, override-conflict and entry refusals are not lock-text gated — still previews `would_vendor` even when the wet takeover refuses it (the wet refusal keeps the hosted pin), except for the gem preflight below. **Gem preflight before the takeover**: `scan` / `get --mode vendored` ask the gem vendored backend's own refusals BEFORE the upstream restore — the manifest gate (`gemfile_not_loaded`: a `gems.rb` twin or a `BUNDLE_GEMFILE`-configured manifest) and the Gemfile declaration gate evaluated on the Gemfile and Gemfile.lock text the restore would leave (`gemfile_declaration_not_editable`: a declaration inside a `group` / `platforms` / conditional block, a parenthesized or duplicate declaration, …) — so a hosted gem vendored mode cannot wire is reported `failed ` with the hosted `Gemfile` / `Gemfile.lock` byte-untouched (exit 1 / `partial_failure`), and their `--dry-run` preview reports that gem as `would_refuse` with the same `errorCode` (exit 0, like the Bun / vlt `would_refuse` rows), never `would_vendor`. Pinned against real Bundler by `tests/e2e_redirect_gem_build.rs`. `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused 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 upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **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 upstream restore, so a hosted 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 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 npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. +**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). **A takeover the vendored backend does not carry through keeps the hosted pin (#853, #944)**: the wet run holds the restore in its group commit, and when the backend then refuses the purl — whatever the code: a pnpm `catalog:` dependency (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` (`vendor_lockfile_crlf_unsupported`), a conflicting `pnpm-workspace.yaml` override — a range, another version or a `>` selector (`vendor_override_conflict`), a uv inline `[tool.uv] sources` table, a prebuilt download that fails, … — or its apply fails with nothing recorded, the restore is rolled back before anything reaches disk: the purl is reported `failed ` with the backend's own code and detail, neither `vendor_takeover_reverted_redirect` nor the restore's advisories are recorded for it, and the hosted wiring stays byte-for-byte (exit 1 / `partial_failure`), so the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed ` by running the backend's dry run over the restored project staged in memory (nothing written). A restore that writes a file outside the group commit's captured set (`.socket/gradle/hosted-index.tsv`) is not rolled back. The `scan` / `get --mode vendored --dry-run` preview, which stages no restore (it never touches the registry), predicts the pnpm (lockfileVersion 9) part of this (#853): a hosted pin the pnpm vendored backend refuses on lock or manifest text (a `catalog:` dependency, a CRLF `pnpm-lock.yaml` / `pnpm-workspace.yaml`, a conflicting override, …) is listed `would_refuse` with the wet run's `errorCode` and the backend's own `error` detail, evaluated on the still-hosted project (the pnpm restore only rewrites the entry's `resolution:`, which none of those gates reads); a hosted pin of any other lock flavor or ecosystem — a legacy pnpm 7/8 lock (`lockfileVersion` 5.4 / 6.0) included, whose CRLF, override-conflict and entry refusals are not lock-text gated — still previews `would_vendor` even when the wet takeover refuses it (the wet refusal keeps the hosted pin), except for the gem preflight below. **Gem preflight before the takeover**: `scan` / `get --mode vendored` ask the gem vendored backend's own refusals BEFORE the upstream restore — the manifest gate (`gemfile_not_loaded`: a `gems.rb` twin or a `BUNDLE_GEMFILE`-configured manifest) and the Gemfile declaration gate evaluated on the Gemfile and Gemfile.lock text the restore would leave (`gemfile_declaration_not_editable`: a declaration inside a `group` / `platforms` / conditional block, a parenthesized or duplicate declaration, …) — so a hosted gem vendored mode cannot wire is reported `failed ` with the hosted `Gemfile` / `Gemfile.lock` byte-untouched (exit 1 / `partial_failure`), and their `--dry-run` preview reports that gem as `would_refuse` with the same `errorCode` (exit 0, like the Bun / vlt `would_refuse` rows), never `would_vendor`. Pinned against real Bundler by `tests/e2e_redirect_gem_build.rs`. `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused 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 upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`) (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **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 upstream restore, so a hosted 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 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 npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. ### Scan modes (v5.0) @@ -142,7 +142,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning: ` (stderr, muted by `--silent`); never a status or exit change. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob and diff-archive files, and every legacy package archive, from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed) and `cleanup_failed` (an orphan sweep failed mid-way). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob and diff-archive files, and every legacy package archive, from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed), `cleanup_failed` (an orphan sweep failed mid-way), and the reinstall advisories of the vendored reverts (`vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`; a revert's other warnings, such as `vendor_lock_entry_removed` or a drift keep's, are not repeated here). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. @@ -173,7 +173,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported in `redirect.skipped[]` as `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). **Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`). @@ -374,7 +374,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma | npm | `package-lock.json` and `npm-shrinkwrap.json` (both when both exist) | `resolved` on the patch host (`packages` in v2/v3; `dependencies` only in v1; `link` / `inBundle` / `bundled` entries skipped, and so is any entry npm installs from a git, URL or `file:` spec or from a dependency's own `npm-shrinkwrap.json` (beneath a `hasShrinkwrap: true` package, which npm 7–11 install from that shrinkwrap; `redirect_npm_shrinkwrapped_instance_skipped` / `vendor_shrinkwrapped_instance_skipped`), together with every ref for the same `name@version`) | `resolved: file:.socket/vendor/npm//-.tgz` | `integrity`, required | | pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required | | yarn | `yarn.lock` (classic and berry) | classic `resolved`; berry entry keyed and resolved `@` + root `package.json` `resolutions` `"@npm:": ""` (older releases: `resolution: …::__archiveUrl=`) | classic `resolved "file:./.socket/vendor/npm/…#"`; berry `file:` entry **plus** a root `package.json` `resolutions` mapping onto the same artifact (without it the entry is orphaned: diagnosed, no ref) | classic `integrity` / `#sha1`, berry `checksum`, required | -| bun | `bun.lock`; `bun.lockb` only when there is no `bun.lock` (bun reads exactly one) | URL tuple / binary remote-tarball resolution; version from the URL leaf | `.socket/vendor/npm//-.tgz` tuple / local-tarball resolution | `sha512-…`, required. A 2-tuple that Bun < 1.3.10 re-saved without its digest is still a reference, but it attests only from an installed tree. | +| bun | `bun.lock`; `bun.lockb` only when there is no `bun.lock` (bun reads exactly one) | URL tuple / binary remote-tarball resolution; version from the URL leaf. A user URL or `file:` tarball tuple / record whose `-.tgz` leaf names the same `name@version` is installed from its own spec, so every ref for that `name@version` is dropped | `.socket/vendor/npm//-.tgz` tuple / local-tarball resolution | `sha512-…`, required. A 2-tuple that Bun < 1.3.10 re-saved without its digest is still a reference, but it attests only from an installed tree. | | vlt | `vlt-lock.json` (lockfileVersion absent, `0` or `1`; a BOM-prefixed, unparseable, non-object or other-version lock is not read: diagnosed, no ref). `vlt.json` (read only for its `modifiers`) and `node_modules/.vlt-lock.json` are never wiring. | a registry node (any segment) whose slot [3] is a `/patch/npm/…` URL on the patch host with the leaf `-.tgz` of its DepID's `name@version`, whose embedded `/` path (when present) is that `name@version`, and slot [1] == name; version from the DepID | a `file` node `.socket/vendor/npm//-/node_modules/` (or a user-installed `-.tgz`) with slot [1] == name; version from the path. A same-`name@version` registry node, or a diagnosed Socket-shaped one, beside it is diagnosed, no ref. | slot [2] `sha512-…`, required (a hosted node without one is no reference). A same-`name@version` node on another registry, or a diagnosed Socket-shaped one, keeps the reference but withholds the lockfile basis: only an installed tree whose every store copy verifies attests. So does a lock some vlt release discards, the conditions of `redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored` and `redirect_vlt_scalar_registry_ignored` (which in-run `--vex` withholds too): every hosted reference in it keeps no pin, and one `patched_ref_unattributable` names them. | | cargo | `Cargo.lock`, `Cargo.toml`, `.cargo/config` (else `.cargo/config.toml`) | `Cargo.lock` `source = "sparse+…//index/"`, confirmed by `Cargo.toml`: a crate the root manifest declares must pin `registry = "socket-patch-"`. A reverted pin is diagnosed, no ref. | `[patch.] = { path = ".socket/vendor/cargo//-" }` — primarily the root `Cargo.toml` (v5 `vendor`; key-agnostic: `` is `package` when renamed, else the key, so `-socket-` keys count), also the project config (pre-v5 wiring), live only while the lock holds a sourceless entry for it that is not in `[[patch.unused]]`; a manifest entry cargo ignores — the project config redefines its key with another path, or a `[patch."https://github.com/rust-lang/crates.io-index"]` table replaces `[patch.crates-io]` — is diagnosed (`patched_ref_invalid`), no ref | `checksum` (v1: `[metadata]`), required | | golang | `go.mod`, `go.work`, `go.sum`, `go.work.sum` | `replace M v => patch.socket.dev/gopatch/ ` | `replace M v => ./.socket/vendor/golang//M@v` | both go.sum lines, required. A replace that `require` no longer selects (`require M v'`) is inert: diagnosed, no ref. | @@ -741,7 +741,7 @@ to **six flavors**. | npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) **+** the same override in `pnpm-workspace.yaml` (pnpm >= 10.5 reads it there; created with a root-only `packages:` scaffold when absent). A project with no `pnpm-workspace.yaml` pinned to pnpm 9.0–10.4 (every pin, read as for the hosted trust config) gets no file: those read package.json, and a root-only workspace makes `pnpm add` fail there (#734); a later vendor on pnpm >= 10.5 adds it. Residual: an unpinned project with no install record still gets the scaffold, so on pnpm 9.0–10.4 it needs `pnpm add -w ` or a `packageManager` pin | `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` redownloads 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` redownloads 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. | +| 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. A second record of the same `name@version` (Bun writes one for a dependent added after vendoring) is folded into the tarball record — its dependents re-pointed, the record and its own dependency edges dropped and the hoisting trees re-derived with Bun's hoister — so no two records share a tarball resolution, which Bun's isolated linker fails to install (`EEXIST`); where that is not exact (the records' dependencies resolve to different packages, or the lock's hoisting is not one the codec reproduces) both are rewired with `vendor_bun_lockb_duplicate_records`. 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. | | npm / vlt (`vlt-lock.json`, lockfileVersion 0 or 1 — A0 locks without a version and every other version refuse `vendor_lockfile_version_unsupported`; flavor `vlt`) | patched package **directory** `.socket/vendor/npm//[@scope/]-/node_modules//` (the extra `node_modules/` level lets a package `require()` its own name), its `package.json` without `devDependencies`, plus `/.gitignore` (re-includes the payload against the project's ignores, ignores vlt's links inside it) and `/.gitattributes` (`-text`) | direct dependencies of the root or a workspace member only: the lock node becomes a `file` node for the directory, its importer edges and outgoing edges are re-keyed, and each importer's `package.json` spec becomes `file:`; every moved entry lands where vlt's serializer puts it. A node whose only extra is one peer context (`ṗ:N`, `peer.N`, `peer.<16 hex>`: from vlt 1.0.8 a root dependency with resolved peers, from rc.15 a workspace member's) becomes a `file` node without the extra, as vlt writes `file:` dependencies, keeping its peer edges; revert restores the extra-bearing DepID. Refused before any write: transitive targets (`vendor_vlt_transitive_unsupported`), two or more instances of one `name@version` or a modifier extra, importer `peer` edges, foreign registries, a git, remote-tarball or local-directory node of the same package name (vlt records no version for it), a package `vlt build` would build in place (`vendor_vlt_build_scripts_unsupported`), a name declared in several dependency fields (`vendor_lock_entry_unsupported`), a spec that disagrees with the lock (`vendor_vlt_lock_out_of_sync`), a payload git would ignore (`vendor_artifact_gitignored`), a purl vendored under another flavor (`vendor_flavor_changed`); era-A locks warn `vendor_vlt_legacy_lockfile`; an optional dependency (or any dependency node_modules still links to its installed upstream copy) gets `vendor_vlt_reinstall_required` | fresh checkout, `vlt ci` with cold caches: the patched bytes load and `vlt-lock.json` stays byte-identical, also through a warm and a cold `vlt install --frozen-lockfile` (checked on 1.2.0, 1.0.10, 1.0.4, 1.0.0-rc.32 and 1.0.0-rc.14, and on every release by `docs/testing/vlt-compatibility.md`); no-op installs, `vlt install `, `uninstall` and `vlt update` keep the direct dependency vendored. `vendor --revert` restores the registry node, edges and specs, keeping what vlt re-laid since, and refuses on drift | | cargo | crate dir `-/` (no `.cargo-checksum.json`) | (v5.0) `[patch.crates-io]` path entry in the **workspace-root `Cargo.toml`** (the manifest beside the `Cargo.lock` it detaches — never `.cargo/config*`) **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed and its `version` set to the copy's TAGGED version `+socket.` — `+.socket.` when the version already has build metadata — with every lock reference that spells the old version rewritten, formats v1–v4; the copy's own `Cargo.toml` version carries the same tag, so the patched crate sees it in `CARGO_PKG_VERSION`; revert restores the lock byte for byte). Key: always the Socket-owned `-socket-` with `package = ""` (the full uuid hex when that key is taken), never the bare crate name — cargo lets a config-file `[patch]` item (project, ancestor directory or `$CARGO_HOME`) replace the manifest item with the same key whatever its version, so keys any of those configs use are avoided and a re-run moves an entry off a now-shadowed key; two versions of one crate are wired side by side. Pre-v5 wiring in `.cargo/config.toml` / `.cargo/config` is moved into `Cargo.toml` by a re-run (`vendor`, `scan`/`get --mode vendored`) or `repair` (`cargo_wiring_migrated` note; the ledger's `cargo_patch_entry` record then names `Cargo.toml`); a detached lock entry left unwired by the pre-v5 multi-version overwrite is re-wired the same way (`cargo_wiring_restored`); every revert removes both spellings | `cargo build --locked --offline` on a fresh checkout — single-version manifest `[patch]` also builds with no network on cargo older than 1.56 (the old config-file wiring's floor); two vendored versions of ONE crate need cargo 1.45 or newer (`--offline` from an empty CARGO_HOME is enough there); older cargo fails closed whatever the index state, and a project that does not pin cargo ≥ 1.45 (`rust-version` or toolchain file) gets the `cargo_multi_version_old_cargo` warning. 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`) | @@ -957,7 +957,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem * **Scope.** The hosted pins are what lockfile discovery finds — `(purl, patch uuid, files wiring it)`, recognized only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A scoped rollback (paths / identifiers / `--ecosystems`) restores exactly the pins in scope; each pin restores or refuses on its own (there is no whole-ledger replay, and a pre-v5 ledger's edits are never replayed). A pin discovery cannot see is out of reach: a lockless cargo `registry = "socket-patch-"` pin, a nuget exact-id mapping with no `packages.lock.json`, a gem wired only in the `Gemfile` (pre-bundler-2.6 mixed state) — restore those files from version control. * **What a restore does.** Every file wiring the pin is rewritten back to the DEFAULT UPSTREAM registry entry for `name@version`, re-resolving whatever the entry pins (tarball URL, integrity, checksum, hashes) from the public registry; only the hosted entries change and every other byte stays the file's own. A pin is **all-or-nothing**: refused in one of its files, it is restored in none of them, so no pin is left half hosted. Nothing reaches disk until every pin has resolved, and `--dry-run` resolves exactly like a wet run — registry lookups included — and skips only the write. Per format: - * **npm family** — `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / `shrinkwrap.yaml`, `bun.lock`: resolution + integrity (+ shasum where recorded) from the npm registry's version document (`SOCKET_NPM_REGISTRY`); a yarn berry lock whose `.yarnrc.yml` names another `npmRegistryServer` reads that registry's document instead, so a mirror's off-path `dist.tarball` keeps its `::__archiveUrl=` binding, and a pnpm lock whose sibling settings name a registry reads that registry's document — `.npmrc` `registry` (or, for a scoped name, `@scope:registry`); on pnpm 10 a pnpm-workspace.yaml `registries` map instead when present; on pnpm 11+ (or an unknown major) pnpm-workspace.yaml `registries."@scope"` / `registry` / `registries.default` too, ahead of the matching `.npmrc` key — so a mirror's `tarball:` comes back as pnpm recorded it and a URL conventional under that registry stays derived (falling back to the default registry, with `upstream_registry_fallback`, when the mirror can't be read; a value holding an unexpanded `${VAR}` is read as unset). Whether a restored pnpm entry gets its `tarball:` back follows `lockfileIncludeTarballUrl` as the pnpm that wrote the lock read it (#902), from the strongest evidence available: (1) the lock's own unpinned registry resolutions (a bare one proves it off; a URL pnpm could have derived proves it on; pnpm 11+'s env lockfile document does not count); else (2) the settings file the installed pnpm major reads (`node_modules/.modules.yaml` `packageManager`, else package.json `packageManager`; a pre-9 lock or shrinkwrap means pnpm <= 8): `.npmrc` `lockfile-include-tarball-url` on pnpm <= 9, pnpm-workspace.yaml `lockfileIncludeTarballUrl` on pnpm >= 11 (also assumed for a lock carrying an env lockfile document), the workspace file then `.npmrc` on pnpm 10; else (3) pnpm 10's reading. A Rush lock (`common/config/rush/pnpm-lock.yaml` or a subspace lock, with `rush.json` at the Rush root) takes its pnpm major from rush.json `pnpmVersion` instead of tier 2's install record and package.json pin. A 9.0 lock may come from pnpm 9, 10 or 11+, so when tier 3's reading differs from pnpm 9's (`.npmrc` only) or pnpm >= 11's (pnpm-workspace.yaml only) — e.g. `.npmrc` on with the workspace file silent, or the workspace file setting it with `.npmrc` silent or disagreeing — the restore follows pnpm 10 but warns `upstream_pnpm_tarball_setting_guessed` (once per lock, naming the entries); a URL pnpm records anyway (not derivable from the registry) never warns. Side settings: a project `.npmrc` that is exactly `allow-remote=all\n` is deleted once no root npm lock entry is hosted, otherwise a remaining top-level `allow-remote=all` warns `npm_allow_remote_left`; a `pnpm-workspace.yaml` that is exactly the scaffold hosted mode creates is deleted once `pnpm-lock.yaml` is no longer hosted, otherwise a remaining `trustLockfile: true` warns `pnpm_trust_lockfile_left`. **`bun.lockb` (binary)**: `rollback` and `remove` refuse it (the checkout remedy). The hosted → vendored takeover and the eject DO restore it, since the vendor ledger then records the rebuilt record as its pre-vendor original: the native codec turns each hosted remote-tarball record back into Bun's npm registry record for `name@version` (the registry's `dist.tarball` + `dist.integrity`, the package metadata hash re-derived, the hosted URL string dropped from the string pool). The hosted rewrite keeps the registry record's inactive bytes (padding, semver) in the tarball record, so a lock it wrote comes back byte for byte — early writers' uninitialized padding included; a record without them (an older socket-patch or a Bun re-save) is rebuilt the way Bun writes one, and refused for a prerelease/build version. A lock the hosted rewrite had to normalize is marked in the root package's resolution value bytes (which no Bun reader reads): a binary format 1 lock it promoted to format 2 is demoted back to its exact format-1 bytes (verified by promoting it again, otherwise refused), and a lock whose workspace dependency behaviors it normalized is refused with the `git checkout -- bun.lockb` remedy. + * **npm family** — `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / `shrinkwrap.yaml`, `bun.lock`: resolution + integrity (+ shasum where recorded) from the npm registry's version document (`SOCKET_NPM_REGISTRY`); a yarn berry lock whose `.yarnrc.yml` names another `npmRegistryServer` reads that registry's document instead, so a mirror's off-path `dist.tarball` keeps its `::__archiveUrl=` binding, and a pnpm lock whose sibling settings name a registry reads that registry's document — `.npmrc` `registry` (or, for a scoped name, `@scope:registry`); on pnpm 10 a pnpm-workspace.yaml `registries` map instead when present; on pnpm 11+ (or an unknown major) pnpm-workspace.yaml `registries."@scope"` / `registry` / `registries.default` too, ahead of the matching `.npmrc` key — so a mirror's `tarball:` comes back as pnpm recorded it and a URL conventional under that registry stays derived (falling back to the default registry, with `upstream_registry_fallback`, when the mirror can't be read; a value holding an unexpanded `${VAR}` is read as unset). Whether a restored pnpm entry gets its `tarball:` back follows `lockfileIncludeTarballUrl` as the pnpm that wrote the lock read it (#902), from the strongest evidence available: (1) the lock's own unpinned registry resolutions (a bare one proves it off; a URL pnpm could have derived proves it on; pnpm 11+'s env lockfile document does not count); else (2) the settings file the installed pnpm major reads (`node_modules/.modules.yaml` `packageManager`, else package.json `packageManager`; a pre-9 lock or shrinkwrap means pnpm <= 8): `.npmrc` `lockfile-include-tarball-url` on pnpm <= 9, pnpm-workspace.yaml `lockfileIncludeTarballUrl` on pnpm >= 11 (also assumed for a lock carrying an env lockfile document), the workspace file then `.npmrc` on pnpm 10; else (3) pnpm 10's reading. A Rush lock (`common/config/rush/pnpm-lock.yaml` or a subspace lock, with `rush.json` at the Rush root) takes its pnpm major from rush.json `pnpmVersion` instead of tier 2's install record and package.json pin. A 9.0 lock may come from pnpm 9, 10 or 11+, so when tier 3's reading differs from pnpm 9's (`.npmrc` only) or pnpm >= 11's (pnpm-workspace.yaml only) — e.g. `.npmrc` on with the workspace file silent, or the workspace file setting it with `.npmrc` silent or disagreeing — the restore follows pnpm 10 but warns `upstream_pnpm_tarball_setting_guessed` (once per lock, naming the entries); a URL pnpm records anyway (not derivable from the registry) never warns. A `bun.lock` 4-tuple's registry slot is rebuilt the way Bun writes it (#992): `""` for a package from registry.npmjs.org, otherwise the full tarball URL — Bun 1.1.39–1.3.6 read `""` as npmjs whatever the project configures. The registry is the one Bun resolves the package against: a scope's `.npmrc` `@scope:registry` or `bunfig.toml` `[install.scopes]` entry, else `BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY`, the `.npmrc` `registry`, then `bunfig.toml` `[install] registry`; its version document's `dist.tarball` fills the slot, and when it can't be read (`upstream_registry_fallback`) the default registry's conventional URL is re-based on it. The `bun.lockb` takeover restore records the same URL. Side settings: a project `.npmrc` that is exactly `allow-remote=all\n` is deleted once no root npm lock entry is hosted, otherwise a remaining top-level `allow-remote=all` warns `npm_allow_remote_left`; a `pnpm-workspace.yaml` that is exactly the scaffold hosted mode creates is deleted once `pnpm-lock.yaml` is no longer hosted, otherwise a remaining `trustLockfile: true` warns `pnpm_trust_lockfile_left`. **`bun.lockb` (binary)**: `rollback` and `remove` refuse it (the checkout remedy). The hosted → vendored takeover and the eject DO restore it, since the vendor ledger then records the rebuilt record as its pre-vendor original: the native codec turns each hosted remote-tarball record back into Bun's npm registry record for `name@version` (the registry's `dist.tarball` + `dist.integrity`, the package metadata hash re-derived, the hosted URL string dropped from the string pool). The hosted rewrite keeps the registry record's inactive bytes (padding, semver) in the tarball record, so a lock it wrote comes back byte for byte — early writers' uninitialized padding included; a record without them (an older socket-patch or a Bun re-save) is rebuilt the way Bun writes one, and refused for a prerelease/build version. A lock the hosted rewrite had to normalize is marked in the root package's resolution value bytes (which no Bun reader reads): a binary format 1 lock it promoted to format 2 is demoted back to its exact format-1 bytes (verified by promoting it again, otherwise refused), and a lock whose workspace dependency behaviors it normalized is refused with the `git checkout -- bun.lockb` remedy. * **vlt** — `vlt-lock.json`: slot [2] from the registry's `dist.integrity`, slot [3] per the lock's own convention (see the vlt hosted-mode contract); every hosted instance of the pin together. * **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`); every `Cargo.toml` declaration loses its `registry = "socket-patch-"` pin (the shorthand the rewriter produced collapses back); every `[registries.socket-patch-]` block no manifest or lock still references leaves the project cargo config — including a superseded patch generation's block an earlier re-pin left behind (#864). A declaration it cannot unpin refuses. * **golang** — the hosted `replace` and the socket module's go.sum lines go; the upstream module's two go.sum lines come back, hashed from the module proxy (`SOCKET_GOPROXY`, else `GOPROXY` / `GONOPROXY` / `GOPRIVATE` as go reads them) and cross-checked against the checksum database (`SOCKET_GOSUMDB_URL`, else `sum.golang.org` unless `GOSUMDB=off` / `GONOSUMDB` / `GOPRIVATE` say go would not ask it). A `replace` the user had before the hosted run is not recorded anywhere, so the restore lands on the plain upstream module. @@ -967,7 +967,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem * **maven** — `pom.xml` (the `-socket.` version suffix, the added `` / `` entry) and the `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` lines hosted mode writes: **no network**, so it restores under `--offline` too. `.mvn` files holding anything else keep the resolver lines (`maven_trusted_checksums_left`). * **nuget** — `nuget.config` loses the `socket-patch-` source and its exact-id mapping; every `packages.lock.json` entry of the id gets nuget.org's `contentHash` back (`SOCKET_NUGET_URL`). Refused when the restored config would not resolve the id from nuget.org alone. A config hosted mode created from scratch is kept (`nuget_default_config_left`). * Any other file wiring a pin refuses it (`socket-patch cannot re-derive the upstream entry in `). -* **Refusals.** `--offline` refuses every pin whose restore needs a registry lookup (all but maven), as does a registry that does not answer or no longer describes the entry. A refused pin writes nothing; its message is `cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` — human `Error: Cannot restore …` on stderr (even under `--silent`), JSON `hosted.failed[{purl, error}]`, and `partial_failure` exit 1 (`remove`: the `hosted_revert_failed` error). A write failure after every pin resolved is one `hosted.failed` entry with the pseudo-purl `files`. +* **Refusals.** `--offline` refuses every pin whose restore needs a registry lookup (all but maven), as does a registry that does not answer or no longer describes the entry. A refused pin writes nothing; its message is `cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` — for a `bun.lock` / `bun.lockb` followed by `, then run `bun install --force` (a plain `bun install` keeps the patched copy)`, since Bun's hoisted linker does not re-extract a package whose entry returns to the registry copy of the same `name@version` (#764) — human `Error: Cannot restore …` on stderr (even under `--silent`), JSON `hosted.failed[{purl, error}]`, and `partial_failure` exit 1 (`remove`: the `hosted_revert_failed` error). A write failure after every pin resolved is one `hosted.failed` entry with the pseudo-purl `files`. * **Output.** Human `Restored to its upstream registry entry` / `Would restore to its upstream registry entry` (`--dry-run`). vlt: the stale installed copies of restored nodes are removed afterwards, as before (`--no-vlt-install-cleanup` keeps them). ### JSON envelope (legacy shape + additive always-present keys) @@ -1267,7 +1267,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_revert_kept` | `skipped` + top-level error | remove (v5.0): the vendored revert drift-kept (`kept_artifact`), so the ledger entry AND the manifest entry were both kept. ANY drift-keep makes the run a `partialFailure` (exit 1) — part of the requested removal did not happen; when EVERY matching entry drift-kept, the top-level error carries this code (`summary.removed` stays 0; the identifier DID match, so never `not_found`). Remedy: re-run `scan --mode vendored` to normalize, then remove. Rollback's counterpart is the `vendoredKept: []` envelope array (also exit 1). | | `hosted_reverted` | `removed` | remove (v5.0): a hosted lockfile pin was restored to its upstream registry entry as part of removing the patch (`verified` on dry-run). Beside a manifest entry it bypasses `summary.removed` like `vendor_reverted`. | | `hosted_revert_failed` | top-level error | remove (v5.0): a matched hosted pin could not be restored to its upstream registry entry (`--offline`, a registry that does not answer, `bun.lockb`, a lock shape the restore refuses — see "Hosted unwind coverage"), or writing the restored files failed; the message names the `git checkout -- ` remedy. The manifest was not modified, exit 1. Rollback's counterpart is a `hosted.failed[]` entry (also `partial_failure` exit 1). v4's `hosted_revert_unsupported` is no longer emitted (every ecosystem has a restore). | -| `reinstall_required` | rollback `warnings[]` | rollback (v5.0): vendored/hosted wiring was unwound, but installed trees keep their patched bytes until the next package-manager install — the stale-install advisory. | +| `reinstall_required` | rollback `warnings[]` | rollback (v5.0): vendored/hosted wiring was unwound, but installed trees keep their patched bytes until the next package-manager install — the stale-install advisory. When the run also emits a Bun advisory (`vendor_bun_reinstall_required` / `redirect_bun_reinstall_required`) the detail and the human note add " (Bun: a plain `bun install` keeps them; run `bun install --force`)". | | `hosted_state_not_preservable` | rollback / remove `warnings[]` | rollback `--preserve-state` (v5.0): hosted pins were restored to upstream anyway — the lockfile pins are hosted mode's only record, so there is no local state to preserve; re-run `scan --mode hosted` to re-wire. (`remove --preserve-state` reports the same code in its `warnings[]` — manifest-backed and hosted-only alike — and prints it as a `Note:` on stderr in human mode.) | | `out_of_scope_copies_restored` | rollback `warnings[]` | path-scoped rollback (v5.0): a selected patch had installed copies outside the given patterns; ALL copies were restored (patches are per-package). Informational — never flips the exit. | | `vendor_ledger_entry_unwired` | scan `warnings[]` | a vendored entry's dependency left the lockfile (upgraded or removed), so the ledger supplement skipped it; the detail names the purls and points at `scan --prune`, which reverts them (no warning on a pruning non-hosted run). An entry that prune drift-keeps (its lock entries were re-resolved since vendoring, e.g. an npm uninstall re-locked it away) is reported on the prune's `GC: kept` line and keeps being warned about. | @@ -1320,7 +1320,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `cargo_wiring_restored` | `skipped` (advisory note) | repair (v5.0): a vendored crate's Cargo.lock entry was detached with no Socket-owned `[patch]` pointing at its committed copy (a pre-v5 release overwrote its crate-named config key when a second version was vendored); the manifest entry is written back and the ledger updated (dry run: "would restore"). A `vendor` re-run heals the same state as a plain re-vendor. | | `cargo_manifest_unreadable` / `cargo_manifest_unparseable` / `cargo_manifest_not_workspace_root` / `cargo_manifest_patch_source_alias` | `failed` | vendor / scan / get `--mode vendored` (cargo, v5.0): the workspace-root `Cargo.toml` cannot carry the vendored `[patch.crates-io]` entry (or cargo would ignore it there) — see the cargo caveat under "Vendored mode". Refused before any write. | | `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) 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 pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). | -| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | +| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`); nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | | `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | | `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; a directory whose only locks are `package-lock.json` / `npm-shrinkwrap.json` is refused the same way when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json`, because npm never reads a lock inside a workspace member (#1094; vendored refuses it with `vendor_lockfile_missing`)) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | | `redirect_pnpm_settings_elsewhere` | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member (listed by the `packages:` globs of the nearest ancestor `pnpm-workspace.yaml`) with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from that ancestor file, which pnpm reads alone (a member's own file is ignored). A directory those globs do not list (no `packages:`, an empty list, a non-matching or `!`-excluded path) is a standalone project on pnpm 11.28+/12 that reads only its own file: it is pinned and gets its own `pnpm-workspace.yaml` like any single project. A root file that does not parse, or whose patterns use braces, classes or extglobs, counts as listing the project. When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. In memory, a member whose lock is demoted into its workspace root (#492) is never refused; one whose lock is not (the workspace root's files do not confirm it pins or ignores that lock, or socket.yml leaves the root out) is refused with this code as its project error, nothing written for it, whenever its lock is v9, the trust auto-config is on and that file may list it (listed, unreadable, or not readable as globs), whatever it says about `trustLockfile`. | @@ -1345,6 +1345,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_vlt_build_scripts_unsupported` | `failed` | vendor (vlt): the package declares a `preinstall`, `install`, `postinstall` or `prepare` script, or ships a `binding.gyp`. vlt builds a registry copy in the untracked store, but a vendored `file:` dependency in place, so `vlt build` would rewrite the committed artifact (a platform binary over a JS shim, say) and every later vendor, repair and `vex` would treat it as tampered. Remedy: `--mode hosted`. Refused before any write. | | `vendor_vlt_legacy_lockfile` | `skipped` (warning) | vendor (vlt): an era-A lock (vlt 0.0.0-19 … 1.0.0-rc.8): a `··` default-registry id, or default-registry ids that are URL segments equal to a scalar `options.registry` with no `·npm·` id (era B writes `·npm·` whatever the scalar). vlt 0.0.0-31 … 1.0.0-rc.5 install the vendored lock but fail to reinstall the vendored `file:` dependency if `vlt-lock.json` is deleted and re-created (the other era-A releases reinstall it; the lock does not say which release reads it). The package is still vendored; remedy: upgrade vlt. | | `vendor_vlt_reinstall_required` | `skipped` (advisory; human: `Warning: …`) | vendor / scan / get `--mode vendored` (vlt), wet and dry runs, and in-sync reruns: (a) the run rewires an optional dependency, or an importer's `node_modules/` of an optional dependency still resolves into `node_modules/.vlt/`: from vlt 0.0.0-30 a plain `vlt install` (1.2.0: also `--force`) keeps that installed upstream copy linked; the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the vendored copy, and that vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies (upgrade to 1.0.5 or later first); (b) otherwise, an importer's link of the dependency still resolves into `node_modules/.vlt/`: the detail names the links (`node_modules/`, `/node_modules/`) and says `vlt install` (or `vlt ci`) links the vendored copy — on a warm tree after a plain `vlt install` that is true of every vendored direct dependency; (c) an importer's link resolves into the vendored dir of the patch this run replaces (a new patch uuid), which the run removes: the detail names the links and says `vlt install` (or `vlt ci`) links the new vendored copy; (d) a redownload of the payload (vendor, or `repair` after a corrupt or missing payload) could not keep vlt's links to the package's own dependencies (its old `node_modules/` held more than links): the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`), since a plain `vlt install` does not re-link them. `repair` moves those links back into the downloaded payload when they are only links. The package is vendored either way; a run whose patch fails to apply emits neither. A wet `vendor --revert` (and the revert a vendored → hosted takeover runs, whose advisory joins `redirect.warnings[]`): (a) the revert moves an `optionalDependencies` spec back from the `file:` dir, or an optional importer's `node_modules/` still resolves into the vendored uuid dir: from vlt 0.0.0-30 a plain `vlt install` keeps that link (dangling once the dir is removed), so the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the restored copy, with the same vlt 1.0.5 note; (b) otherwise, an importer's link still resolves into the vendored uuid dir: the detail names the links and says `vlt install` (or `vlt ci`) links the restored copy. A dry-run revert emits neither. | +| `vendor_bun_reinstall_required` | `skipped` (advisory; human: `Warning: …`); rollback/remove `warnings[]`; `scan --prune` `gc.warnings[]` (human: `GC: …`) | a wet Bun revert (`vendor --revert`, rollback / remove of a vendored entry, `--preserve-state` included) that restored the lock entry while `node_modules/` is a real directory, or the tree has no `node_modules/.bun/` (a hoisted install): Bun's hoisted linker does not re-extract a package whose lock entry moves from the vendored tarball back to the registry record of the same `name@version`, so a plain `bun install` (also `--frozen-lockfile`) reports no changes and keeps the vendored bytes (measured on 1.1.45 … 1.4.2). The detail names `name@version` and says to run `bun install --force` (or delete `node_modules` and run `bun install`); the human revert hint names `bun install --force` too. An isolated install (a link into `node_modules/.bun/`) relinks and a project without `node_modules/` has nothing installed: neither warns, and neither does a dry run, a drift-kept revert, or a revert that restored nothing (`vendor_lockfile_missing`, or `vendor_lock_entry_removed` after `bun remove`, whose copy a plain `bun install` prunes). | | `vendor_flavor_changed` | `failed` | vendor (npm): the purl's vendor ledger entry was written for another lockfile `flavor` than the one the router now detects (for example `npm` → `vlt` after switching package managers). Remedy: `socket-patch vendor --revert` it first, then re-vendor. Refused before any write. | | `vendor_artifact_gitignored` | `failed` | vendor (vlt and the npm-family tarball flavors: npm, pnpm, bun, yarn classic, yarn berry): inside a git work tree, `git check-ignore --no-index` reports the new artifact's uuid directory as ignored by a rule its own `.gitignore` cannot override (such as a root `.socket/` or `vendor/` rule; the detail names the rule). Remedy: drop that rule for `.socket/vendor/`. Refused before any write. A file rule such as `*.tgz` is overridden by the `/.gitignore` vendoring writes; if the written artifact still reads as ignored, the run refuses and removes the uuid dir it created. | | `vendor_artifact_gitignore_unchecked` | warning | vendor (vlt and the npm-family tarball flavors): git is installed but could not answer the ignore check for the written vendored directory (it failed to start, ran past 30 s, or `rev-parse` / `check-ignore` exited with an error); the package is vendored and the detail names what failed. Remedy: make sure no ignore rule covers `.socket/` before committing. Git absent, or a project outside any work tree, raises nothing. | @@ -1384,8 +1385,10 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `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_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. | +| `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 whose leaf names no version or another version; a same-version user tarball reports `redirect_bun_non_registry_entry_skipped` instead) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | | `redirect_bun_patched_dependency_skipped` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun, `bun.lock` and `bun.lockb`): the project patches the granted `name@version` itself with `bun patch` (a `patchedDependencies` key for `name@version`, or the bare name, in the root `package.json` or mirrored in `bun.lock`). Bun applies that patch only to the registry resolution, so the entry is left on its registry tuple instead of silently losing the user's patch (#367). Per-dep; the detail names the key and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); the in-run VEX never assumes the uuid applied. Vendored mode refuses the same package `vendor_lock_entry_unsupported` before any write or download. Exit 0. | +| `redirect_bun_default_trust_lost` / `vendor_bun_default_trust_lost` | `redirect.warnings[]` / vendor `warnings[]` (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the rewired package is on Bun's built-in default trusted list (better-sqlite3, esbuild, sharp, …) and the project declares no `trustedDependencies` (root `package.json`, or the copy mirrored in `bun.lock`). Bun 1.3.5 and later apply that list only to packages resolved from the npm registry, so on a hosted URL or a local tarball the package's install scripts are skipped with exit 0 (#371). The package is still rewired; the detail tells the user to add it to `trustedDependencies` (which replaces Bun's default list, so other default-trusted dependencies whose scripts matter must be listed too). Repeated on every run while the pin stays and no list is declared. Exit 0. | +| `redirect_bun_non_registry_entry_skipped` / `vendor_non_registry_entry_skipped` | `redirect.warnings[]` / vendor warnings (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the lock also installs the granted `name@version` from a user URL or `file:` tarball (its `-.tgz` leaf names that version). Bun installs it from that spec, so the copy stays unpatched beside any rewired registry copy; it is left untouched, the in-run VEX does not assume the patch, and `vex` attests nothing for that `name@version` (#497). With no registry copy left, vendoring refuses with `vendor_lock_entry_not_rewritable`. A same-name URL / `file:` tarball whose leaf names no version, or a git, folder or `link:` copy, has no version in the lock: these codes do not fire for it, but `vex` attests no ref of that package from that lock (`patched_ref_unattributable`), since the copy may be the wired version. | | `redirect_npm_patched_dependency_skipped` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (`package-lock.json` / `npm-shrinkwrap.json`): the project patches the granted `name@version` itself with npm ≥ 12.1's native `npm patch` (a root `package.json` `patchedDependencies` key for `name@version` or the bare name, or a `packages` entry carrying npm's `patched` record, which npm writes in a lockfileVersion 4 lock). npm applies the user's diff on top of whatever tarball the lock names and fails the install `EPATCHFAILED` when it no longer applies, so a hosted pin would break every later `npm ci` / `npm install` (or install bytes VEX can never attest); the dep is left on its registry entry in every present npm lock instead (#711). Per-dep; the detail names the key or lock entry and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); when an earlier hosted run already pinned the entry, the detail says so and names `socket-patch rollback ` to restore the registry entry instead of claiming it is unchanged; the in-run VEX never assumes the uuid applied, and no sibling lock confirms it. Vendored mode refuses the lockfileVersion 4 lock `vendor_lockfile_version_unsupported`, its detail naming `npm patch` / `patchedDependencies`. Exit 0. | | `redirect_vlt_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` has a `lockfileVersion` other than absent, `0` or `1` (decided on the raw JSON token), is not a JSON object, starts with a UTF-8 BOM, or its `nodes` section is not vlt's one-node-per-line layout. Nothing rewritten; also refuses a vendored → hosted takeover of a `flavor: "vlt"` entry before its revert (`redirect.skipped[].reason`). Exit 0. | | `redirect_takeover_kept_vendored` | `redirect.warnings[]` (warning) | scan/get `--mode hosted`: a vendored → hosted takeover the hosted rewrite would not pin was retracted (see **Staged takeover**): the package keeps its vendored wiring, ledger entry and artifact byte-identical and stays patched. The detail names the cause code, which is also the purl's `redirect.skipped[].reason`. Exit 0. Replaces v5.0-pre `redirect_takeover_unpatched`, which reported a package left unpatched in both modes and is no longer emitted. | @@ -1401,6 +1404,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_npm_shrinkwrap_only` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (npm, #899): the root `npm-shrinkwrap.json` is the only npm lock and carries a hosted redirect (this run's or an earlier one's — every run repeats it until the project gains a `package-lock.json`). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; npm <= 11 installs the patch. Rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. | | `redirect_vlt_artifact_unverifiable` | `redirect.warnings[]` (warning), `redirect.skipped[].reason` | scan/get `--mode hosted` (vlt): before any takeover or rewrite (dry runs included), each granted artifact with a default-registry instance in `vlt-lock.json` (or, for a purl a `flavor: "vlt"` vendored entry claims, its vendored node, probed before the takeover reverts it) is fetched once as vlt fetches it (`accept-encoding: gzip;q=1.0, identity;q=0.5`, no `Authorization`, up to 10 redirects) and must return 200 with no content encoding (or `identity`) and the granted sha512. On failure (`content-encoding `, `sha512 mismatch`, `http `, `fetch error `, `offline`) the dep is withheld from every rewriter when vlt drives or it is vlt-vendored (which also keeps it vendored), and from the vlt rewrite only otherwise (detail "…; vlt-lock.json was not changed for {purl}"; only the sibling lock this run rewrote can confirm it). A lock already pinned by an earlier run is left pinned, and neither confirmed nor attested. Projects without `vlt-lock.json` make no such request. The detail quotes the artifact URL (and any fetch error that echoes it) with its grant-token path level, the one just before the patch uuid, spelled ``; host, uuid and leaf stay. The in-memory hosted engine (`hosted-bundle`, the Node addon) has no network for this fetch, so it judges every in-scope artifact as `--offline` does (withheld, never pinned; the vendored takeover it refuses anyway). Exit 0. | | `redirect_vlt_reinstall_required` | `redirect.warnings[]` (advisory); rollback/remove `warnings[]` (+ human stderr) | vlt: `vlt-lock.json` pins (or, after rollback/remove, no longer pins) Socket-patched packages, and vlt never refreshes an installed copy. The heal removes `node_modules/.vlt-lock.json` and each stale `node_modules/.vlt/` of a Socket-owned node (never a link's target, never outside the project, never a copy it cannot judge) unless `--no-vlt-install-cleanup` or `--dry-run`. It never removes an optional node's copy (lock flags 1 or 3, or flags it cannot read): `vlt install` does not put a removed optional dependency back (its link dangles) unless the same install also reinstalls a non-optional node, so such a copy is left stale and the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`); vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies, so there both commands remove the installed copy and the detail says to upgrade vlt to 1.0.5 or later first. The detail says whether copies were removed, left stale by a skipped cleanup, could not be checked, or none were stale, and adds how many optional copies were kept whenever there are any. The kept optional copies are named by what they are: `unpatched copies of optional dependencies` after `scan`/`get`, `patched copies of optional dependencies` after `rollback`/`remove`, and `installed copies of the vendored optional dependencies` after a hosted → vendored takeover (the copy the hosted pin left installed, which may still be the registry bytes). Stale or unchecked copies are not attested by the run's `--vex`, nor is a confirmed vlt pin the heal did not check (a URL on a host other than patch.socket.dev and the configured `--patch-server-url`/`--api-url`). A hidden lock that cannot be removed keeps every store entry. Invalidation failures only warn. | +| `redirect_bun_reinstall_required` | rollback/remove `warnings[]` (+ human stderr), `vendor --revert` `warnings[]` | a wet hosted unwind that restored `bun.lock` / `bun.lockb` pins to the registry record while their installed copy may be kept (the `vendor_bun_reinstall_required` rule: Bun's hoisted linker keeps it through a plain `bun install`). One run-level warning naming every such `name@version` and `bun install --force` (or deleting `node_modules`). `vendor --revert` leaves it out when the vendored revert already advised `vendor_bun_reinstall_required` for every package it re-hosted. | | `vendor_prebuilt_stub_invalid` | `failed` | RubyGems: the server stub lacks required attributes or is otherwise invalid; no local stub fallback is permitted. | | `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index a85dda813..22ed44888 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -2,7 +2,9 @@ use clap::Args; use socket_patch_core::api::blob_fetcher::get_missing_blobs; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; -use socket_patch_core::crawlers::{detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler}; +use socket_patch_core::crawlers::{ + bun_uses_global_store, detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler, +}; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{ @@ -1349,9 +1351,21 @@ pub(crate) async fn run_locked( } NpmPkgManager::Bun => { if !args.common.json && !args.common.silent { - eprintln!( - "Note: bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched." - ); + if bun_uses_global_store(&args.common.cwd) { + // #635: the installed package dirs ARE the shared + // store (/links/...), so copy-on-write cannot + // isolate them; core refuses each such package. + eprintln!( + "Note: bun global store detected (install.globalStore). Packages linked \ + from the shared Bun cache are used by other projects and will not be \ + patched; set `globalStore = false` under `[install]` in bunfig.toml \ + (and unset BUN_INSTALL_GLOBAL_STORE), then reinstall." + ); + } else { + eprintln!( + "Note: bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched." + ); + } } // Same shape as pnpm: bun hard-links from its global // install cache by default. The rename-over write handles the diff --git a/crates/socket-patch-cli/src/commands/hosted_unwind.rs b/crates/socket-patch-cli/src/commands/hosted_unwind.rs index cf550e11f..3eaffc461 100644 --- a/crates/socket-patch-cli/src/commands/hosted_unwind.rs +++ b/crates/socket-patch-cli/src/commands/hosted_unwind.rs @@ -110,6 +110,25 @@ pub(crate) async fn run_hosted_leg(common: &GlobalArgs, pins: &[HostedPin]) -> H ); out.edited_files .extend(outcome.reverted_files.iter().cloned()); + // Bun's hoisted linker keeps the patched copies of the pins it no + // longer pins (#764): say so, with the install that does reinstall. + // A dry run says it too, so the preview's reinstall note names + // `bun install --force` like the real run's. + if outcome.flush_error.is_none() { + use socket_patch_core::constants::npm_family::{BUN_LOCK, BUN_LOCKB}; + use socket_patch_core::vendor::bun_lock; + let bun_purls = outcome + .restored() + .filter(|pin| pin.files.iter().any(|f| f == BUN_LOCK || f == BUN_LOCKB)); + let stale = + bun_lock::stale_hoisted_copies(&common.cwd, bun_purls.map(|p| p.purl.as_str())).await; + if !stale.is_empty() { + out.warnings.push(( + "redirect_bun_reinstall_required".to_string(), + bun_lock::reinstall_advisory(&stale), + )); + } + } let unwound: Vec<_> = vlt_targets .into_iter() .filter(|t| out.reverted.iter().any(|p| p == &t.purl)) diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index dbaa051a2..bce17b980 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -331,9 +331,24 @@ fn format_gc_freed(bytes: u64, dry_run: bool) -> String { ) } +/// Appended to the generic stale-install advisory when a Bun advisory +/// fired in the same run: Bun's hoisted linker keeps the patched copy +/// through a plain `bun install` (#764), so "the next package-manager +/// install" alone would contradict it. +const BUN_REINSTALL_QUALIFIER: &str = + " (Bun: a plain `bun install` keeps them; run `bun install --force`)"; + +/// True when the run's leg warnings carry a Bun reinstall advisory. +fn bun_reinstall_advised<'a>(mut codes: impl Iterator) -> bool { + codes.any(|c| { + c == socket_patch_core::vendor::bun_lock::REINSTALL_REQUIRED + || c == "redirect_bun_reinstall_required" + }) +} + /// The reinstall note for packages whose wiring was undone but whose /// installed tree still holds patched bytes. -fn format_reinstall_note(still_patched: usize, dry_run: bool) -> String { +fn format_reinstall_note(still_patched: usize, dry_run: bool, bun: bool) -> String { let keep = match (still_patched == 1, dry_run) { (true, false) => "keeps its", (true, true) => "would keep its", @@ -342,8 +357,9 @@ fn format_reinstall_note(still_patched: usize, dry_run: bool) -> String { }; format!( "Note: {} {keep} patched bytes in installed trees until the next \ - package-manager install.", - plural(still_patched, "unwired package", "unwired packages") + package-manager install{}.", + plural(still_patched, "unwired package", "unwired packages"), + if bun { BUN_REINSTALL_QUALIFIER } else { "" } ) } @@ -1492,12 +1508,25 @@ pub async fn run(args: RollbackArgs) -> i32 { let unwired_any = !vendored_leg.reverted.is_empty() || !vendored_leg.preserved.is_empty() || !hosted_leg.reverted.is_empty(); + let bun_advised = bun_reinstall_advised( + vendored_leg + .warnings + .iter() + .chain(hosted_leg.warnings.iter()) + .map(|(code, _)| code.as_str()), + ); if unwired_any { run_warnings.push(( "reinstall_required".into(), - "unwired packages keep their patched bytes in installed trees until \ - the next package-manager install" - .into(), + format!( + "unwired packages keep their patched bytes in installed trees until \ + the next package-manager install{}", + if bun_advised { + BUN_REINSTALL_QUALIFIER + } else { + "" + } + ), )); } if args.preserve_state && !hosted_leg.reverted.is_empty() { @@ -1782,7 +1811,7 @@ pub async fn run(args: RollbackArgs) -> i32 { if still_patched > 0 { println!( "\n{}", - format_reinstall_note(still_patched, args.common.dry_run) + format_reinstall_note(still_patched, args.common.dry_run, bun_advised) ); } } @@ -5249,14 +5278,35 @@ mod tests { #[test] fn reinstall_note_tense_and_number() { assert_eq!( - format_reinstall_note(1, false), + format_reinstall_note(1, false, false), "Note: 1 unwired package keeps its patched bytes in installed trees until the \ next package-manager install." ); assert_eq!( - format_reinstall_note(2, true), + format_reinstall_note(2, true, false), "Note: 2 unwired packages would keep their patched bytes in installed trees \ until the next package-manager install." ); } + + /// #764: next to a Bun advisory the generic note must not imply that + /// any install refreshes the copy. + #[test] + fn reinstall_note_defers_to_the_bun_advisory() { + assert_eq!( + format_reinstall_note(1, false, true), + "Note: 1 unwired package keeps its patched bytes in installed trees until the \ + next package-manager install (Bun: a plain `bun install` keeps them; run \ + `bun install --force`)." + ); + assert!(bun_reinstall_advised( + ["cleanup_failed", "vendor_bun_reinstall_required"].into_iter() + )); + assert!(bun_reinstall_advised( + ["redirect_bun_reinstall_required"].into_iter() + )); + assert!(!bun_reinstall_advised( + ["redirect_vlt_reinstall_required"].into_iter() + )); + } } diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index 2851995ee..38c102440 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -53,8 +53,10 @@ pub(super) struct GcSummary { /// finish (`cleanup_failed`: the pass aborted, or left orphans it could /// not unlink — the removed counts above are what it did reclaim). The /// mutations already happened on disk, so the stale record is reported, - /// not the pass failed. Serialized as additive `warnings[]` on the - /// apply shape only. + /// not the pass failed. Also the vendored reverts' reinstall advisories + /// (`vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`) + /// and no other revert warning. Serialized as additive + /// `warnings[]` on the apply shape only. warnings: Vec<(&'static str, String)>, } @@ -87,6 +89,7 @@ impl GcSummary { self.vendored_failed = v.failed; self.vendored_failed.sort(); self.warnings.extend(v.write_failures); + self.warnings.extend(v.advisories); self.vendor_orphan_dirs = v.orphan_dirs; } @@ -1496,6 +1499,14 @@ mod tests { serde_json::json!([PURL]), "scan --prune --json must carry the keep" ); + // The revert's own drift warnings (`vendor_lock_entry_drifted`, + // `vendor_artifact_kept`) are already said by the keep above; they + // must not also land in `gc.warnings[]`. + assert!( + gc.to_apply_json().get("warnings").is_none(), + "a drift keep adds no gc warning: {}", + gc.to_apply_json() + ); // Nothing reclaimed: manifest record, blob, ledger entry, and // artifacts all survive (the drift-keep contract). assert_eq!(gc.blobs.blobs_removed, 0, "kept entry's blob is not swept"); diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 89368498c..754d9ed4b 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1684,8 +1684,9 @@ pub(crate) async fn run_redirect_selected( confirmed.is_empty(), // Only the lockfile rewriters' own warnings explain a // missing lock entry; unrelated guidance (pnpm trust, VEX, - // stale installs) is not what the hint points at. - rewrite.warnings.len(), + // stale installs, Bun default trust) is not what the hint + // points at. + lock_entry_warning_count(&rewrite.warnings), ) { eprintln!("{line}"); } @@ -2053,6 +2054,20 @@ fn describe_skip_reason(reason: &str) -> String { } } +/// How many of the lockfile rewriters' warnings can explain why a granted +/// package has no lock entry: the count `format_unredirected` turns into its +/// "(see the warning below)" hint. `redirect_bun_default_trust_lost` rides in +/// the same vector but is about a pin that *was* written (Bun's default trust +/// lost on the hosted URL, #371), so it never explains a missing entry. +fn lock_entry_warning_count( + warnings: &[socket_patch_core::patch::redirect::RewriteWarning], +) -> usize { + warnings + .iter() + .filter(|w| w.code != "redirect_bun_default_trust_lost") + .count() +} + /// The per-package "not redirected" lines, `skipped` (with a reason code) /// first, then `unconfirmed` (granted, but nothing in the project's files /// pins it). When nothing at all was redirected they sit under a @@ -2298,8 +2313,8 @@ mod tests { use super::{ describe_skip_reason, format_error_line, format_next_steps, format_redirect_summary, format_takeover_line, format_unredirected, format_warning, join_names, - pnpm_lock_may_need_store_flag, pnpm_trust_rerun_reminder, split_sentences, wrap_tokens, - wrap_words, TAKEOVER_INFO_CODES, + lock_entry_warning_count, pnpm_lock_may_need_store_flag, pnpm_trust_rerun_reminder, + split_sentences, wrap_tokens, wrap_words, TAKEOVER_INFO_CODES, }; use super::{wheel_metadata_concurrency, WHEEL_METADATA_CONCURRENCY}; @@ -4053,6 +4068,30 @@ mod tests { } } + #[test] + fn lock_entry_warning_count_skips_bun_default_trust() { + use socket_patch_core::patch::redirect::RewriteWarning; + let w = |code: &str| RewriteWarning { + code: code.into(), + detail: String::new(), + }; + assert_eq!(lock_entry_warning_count(&[]), 0); + // A trust warning alone must not make an unconfirmed package's + // line point at it (#371 review). + assert_eq!( + lock_entry_warning_count(&[w("redirect_bun_default_trust_lost")]), + 0 + ); + assert_eq!( + lock_entry_warning_count(&[ + w("redirect_bun_default_trust_lost"), + w("redirect_lock_unparseable"), + w("redirect_bun_default_trust_lost"), + ]), + 1 + ); + } + #[test] fn unredirected_lines_empty_partial_and_nothing_redirected() { assert!(format_unredirected(&[], &[], true, 1).is_empty()); diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 49b844df2..29bcad9cd 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -3996,6 +3996,16 @@ fn flavor_install_command(flavor: &str) -> Option<&'static str> { } } +/// The install that resyncs an installed tree after a revert: Bun's +/// hoisted linker keeps the vendored copy through a plain `bun install` +/// (#764), so Bun's needs `--force`. +fn flavor_revert_install_command(flavor: &str) -> Option<&'static str> { + match flavor { + "bun" => Some("bun install --force"), + other => flavor_install_command(other), + } +} + /// Drop installed npm copies that resolve into `.socket/vendor/` (or no /// longer resolve at all): vlt links a vendored `file:` dependency straight /// to its committed dir, which is this tool's own artifact and never a @@ -4263,7 +4273,25 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { .with_error("hosted_restore_failed", why.clone()), ); } + // The vendored revert above already advised a Bun reinstall + // per package (#764); the hosted unwind's run-level twin for + // the same packages would only repeat it. + let bun_advised: HashSet = env + .events + .iter() + .filter(|e| { + e.error_code.as_deref() + == Some(socket_patch_core::vendor::bun_lock::REINSTALL_REQUIRED) + }) + .filter_map(|e| e.purl.as_deref().map(PurlKey::new)) + .collect(); + let bun_repeat = rehosted + .iter() + .all(|pin| bun_advised.contains(&PurlKey::new(&pin.purl))); for (code, detail) in &leg.warnings { + if bun_repeat && code == "redirect_bun_reinstall_required" { + continue; + } env.warnings.push(RunWarning { code: code.clone(), detail: detail.clone(), @@ -4328,7 +4356,7 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { if summary.reverted > 0 && !common.dry_run { let mut installs: Vec<&str> = reverted_flavors .iter() - .filter_map(|f| flavor_install_command(f)) + .filter_map(|f| flavor_revert_install_command(f)) .collect(); installs.sort_unstable(); installs.dedup(); @@ -4373,6 +4401,40 @@ pub(crate) struct VendorGcSummary { /// "manifest_write_failed", )`. The reverts themselves already /// happened on disk; the stale record is what the caller must report. pub write_failures: Vec<(&'static str, String)>, + /// The wet reverts' reinstall advisories (`code`, `detail`): Bun's + /// `vendor_bun_reinstall_required` (#764) and vlt's + /// `vendor_vlt_reinstall_required`. Every other reverting command + /// surfaces these, and the GC must not drop them. + pub advisories: Vec<(&'static str, String)>, +} + +/// The revert warnings `scan --prune` forwards into `gc.warnings[]`: only +/// the "the installed tree still holds the vendored copy" advisories. The +/// revert's other warnings are routine for a prune and stay out: +/// `vendor_lock_entry_removed` is the normal leg-(b) case (the dependency +/// was uninstalled), and a drift keep is already reported through +/// `keptVendoredEntries` and its own `GC: kept …` line. +const GC_FORWARDED_ADVISORIES: &[&str] = &[ + socket_patch_core::vendor::bun_lock::REINSTALL_REQUIRED, + socket_patch_core::vendor::vlt_lock::REINSTALL_REQUIRED, +]; + +impl VendorGcSummary { + /// Keep a revert's reinstall advisories. Only a revert that actually + /// restored the lock (succeeded, not drift-kept) can leave a stale + /// installed copy behind. + fn take_advisories(&mut self, outcome: &RevertOutcome) { + if !outcome.success || outcome.kept_artifact { + return; + } + self.advisories.extend( + outcome + .warnings + .iter() + .filter(|w| GC_FORWARDED_ADVISORIES.contains(&w.code)) + .map(|w| (w.code, w.detail.clone())), + ); + } } /// The manifest keys an unused vendored `entry`, stored under ledger key /// `purl`, owns: every key with the same [`PurlKey`] as the ledger key OR @@ -4459,6 +4521,7 @@ pub(crate) async fn run_vendor_gc( } let entry = state.entries.get(&purl).cloned().expect("listed above"); let outcome = dispatch_revert_one(&entry, &common.cwd, false).await; + out.take_advisories(&outcome); if !outcome.success { out.failed.push(purl); } else if outcome.kept_artifact { @@ -4506,6 +4569,7 @@ pub(crate) async fn run_vendor_gc( continue; } let outcome = dispatch_revert_one(&entry, &common.cwd, false).await; + out.take_advisories(&outcome); if !outcome.success { out.failed.push(purl); continue; @@ -7118,6 +7182,16 @@ mod ui_format_tests { ); } + #[test] + fn revert_install_hint_forces_bun() { + assert_eq!( + flavor_revert_install_command("bun"), + Some("bun install --force") + ); + assert_eq!(flavor_revert_install_command("pnpm"), Some("pnpm install")); + assert_eq!(flavor_revert_install_command("cargo"), None); + } + #[test] fn revert_install_hint_names_the_command() { assert_eq!( diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index 5d6b9768a..e710c139d 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -42,7 +42,16 @@ struct Candidate { 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() { + // The binary lock is read unless a readable text lock shadows it: a + // `bun.lock` that drives Bun but cannot be read here (EACCES, ELOOP) + // hides its own references, so the binary lock's are kept rather than + // letting the orphan sweep delete a dir it still names. + let text_lock_read = socket_patch_core::vendor::lock_inventory::bun_text_lock_drives( + &socket_patch_core::vendor::lock_inventory::ProjectView::Disk(project_root), + ) && read_regular_to_string(&project_root.join("bun.lock")) + .await + .is_ok(); + if !text_lock_read { if let Ok(paths) = socket_patch_core::vendor::bun_lock::binary_vendor_paths(project_root).await { @@ -860,6 +869,18 @@ mod tests { tokio::fs::remove_file(root.path().join("bun.lock")) .await .unwrap(); + + // A text lock Bun stops at but this scan cannot read (here a + // symlink loop, ELOOP) hides its own references, so the binary + // lock's are still kept from the orphan sweep. + #[cfg(unix)] + { + std::os::unix::fs::symlink("bun.lock", root.path().join("bun.lock")).unwrap(); + assert_eq!(scan_vendor_references(root.path()).await.len(), 1); + tokio::fs::remove_file(root.path().join("bun.lock")) + .await + .unwrap(); + } tokio::fs::write(root.path().join("bun.lockb"), b"malformed") .await .unwrap(); diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index 2c0ac9605..8e14a6502 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -115,6 +115,15 @@ pub(crate) async fn hosted_consumed_copies( }, ); } + // An orphaned Bun store entry is no consumed copy (#599); the + // installed lookup dropped its own, this drops the ones the alias + // and identity fallbacks' variant expansion added. + socket_patch_core::crawlers::npm_crawler::retain_live_store_copies( + out.iter_mut() + .filter(|(purl, _)| npm.contains(purl)) + .map(|(_, copies)| &mut copies.paths), + ) + .await; } // Distinct-store ecosystems: only the hosted artifact's own store entry. diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 7e2d9ae7f..76796c000 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -8,7 +8,9 @@ use std::path::PathBuf; use crate::args::GlobalArgs; -use socket_patch_core::crawlers::npm_crawler::with_store_peer_variant_copies; +use socket_patch_core::crawlers::npm_crawler::{ + retain_live_store_copies, with_store_peer_variant_copies, +}; use socket_patch_core::crawlers::walk_pool; use socket_patch_core::crawlers::CargoCrawler; use socket_patch_core::crawlers::ComposerCrawler; @@ -695,6 +697,17 @@ pub async fn find_manifest_package_copies_reusing( *paths = with_store_peer_variant_copies(std::mem::take(paths)).await; } } + // A Bun store entry nothing links any more (Bun never prunes `.bun`) + // is no copy the install loads, so the check skips it (#599). Rollback + // and remove, which resolve without this, still restore it. + retain_live_store_copies( + copies + .iter_mut() + .filter(|(purl, _)| purl.starts_with("pkg:npm/")) + .map(|(_, paths)| paths), + ) + .await; + copies.retain(|_, paths| !paths.is_empty()); // Verification also READS a `.bundle/config` bundle path the crawler // refused as a write root (it resolves outside the project): bundler // installs into and loads from it, so a copy there must verify too — diff --git a/crates/socket-patch-cli/tests/apply/bun_global_store.rs b/crates/socket-patch-cli/tests/apply/bun_global_store.rs new file mode 100644 index 000000000..2f1ffa26e --- /dev/null +++ b/crates/socket-patch-cli/tests/apply/bun_global_store.rs @@ -0,0 +1,227 @@ +//! Agent-mode `apply` / `rollback` on a project installed with Bun's global +//! store (`[install] globalStore = true`, Bun >= 1.3.14, #635). +//! +//! Each `node_modules/.bun/` is then a link into +//! `/links/-`, and every project on the machine links +//! to the same dir. Writing through it patched (and a rollback unpatched) +//! every other project using the store, with `success`. And because the +//! `.bun` walk kept only real dirs, a transitive dependency was reported +//! `package_not_installed` and left unpatched. + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use crate::common; + +use common::{git_sha256, parse_json_envelope, run_with_env}; + +const BEFORE: &[u8] = b"module.exports = 'pristine';\n"; +const AFTER: &[u8] = b"module.exports = 'patched';\n"; + +fn link_dir(target: &Path, link: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + let link: PathBuf = link.components().collect(); + let target: PathBuf = target.components().collect(); + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&link) + .arg(&target) + .status() + .unwrap(); + assert!(status.success(), "mklink /J failed"); + } +} + +/// The layout Bun 1.3.14 writes with `linker = "isolated"` and +/// `globalStore = true`: `proj/node_modules/{left-pad,is-odd}` link to +/// `.bun//node_modules/`, each `.bun/` links to +/// `/links/-`, and is-odd's entry links `is-number` to +/// its sibling entry. Returns the project dir and the store's `left-pad` +/// and `is-number` dirs. +fn stage(tmp: &Path, bytes: &[u8]) -> (PathBuf, PathBuf, PathBuf) { + let links = tmp.join("bun-cache").join("links"); + let proj = tmp.join("proj"); + let nm = proj.join("node_modules"); + let bun = nm.join(".bun"); + std::fs::create_dir_all(&bun).unwrap(); + let entry = |name: &str, version: &str, hash: &str| { + let shared = links.join(format!("{name}@{version}-{hash}")); + let pkg = shared.join("node_modules").join(name); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{name}", "version": "{version}" }}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), bytes).unwrap(); + link_dir(&shared, &bun.join(format!("{name}@{version}"))); + pkg + }; + let left_pad = entry("left-pad", "1.3.0", "6a490709ba3c5c8f"); + let odd = entry("is-odd", "3.0.1", "630ebdaa4b425d00"); + let number = entry("is-number", "6.0.0", "fe514fa0667977a7"); + link_dir(&number, &odd.parent().unwrap().join("is-number")); + for name in ["left-pad", "is-odd"] { + let version = if name == "left-pad" { "1.3.0" } else { "3.0.1" }; + link_dir( + &bun.join(format!("{name}@{version}")) + .join("node_modules") + .join(name), + &nm.join(name), + ); + } + std::fs::write( + proj.join("package.json"), + r#"{ "name": "p", "version": "1.0.0", "dependencies": { "left-pad": "1.3.0", "is-odd": "3.0.1" } }"#, + ) + .unwrap(); + std::fs::write( + proj.join("bunfig.toml"), + "[install]\nlinker = \"isolated\"\nglobalStore = true\n", + ) + .unwrap(); + (proj, left_pad, number) +} + +fn write_manifest(root: &Path, purls: &[&str]) { + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + for bytes in [BEFORE, AFTER] { + std::fs::write(socket.join("blobs").join(git_sha256(bytes)), bytes).unwrap(); + } + let patches: serde_json::Map = purls + .iter() + .enumerate() + .map(|(i, purl)| { + let patch = json!({ + "uuid": format!("63563563-0000-4000-8000-00000000000{i}"), + "exportedAt": "2024-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": git_sha256(BEFORE), + "afterHash": git_sha256(AFTER), + }}, + "vulnerabilities": {}, + "description": "bun global store fixture", + "license": "MIT", + "tier": "free" + }); + (purl.to_string(), patch) + }) + .collect(); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_vec_pretty(&json!({ "patches": patches })).unwrap(), + ) + .unwrap(); +} + +/// `purl`'s entry in an apply envelope's `events` or a rollback +/// envelope's `results`. +fn event<'a>(v: &'a Value, purl: &str) -> &'a Value { + v["events"] + .as_array() + .or_else(|| v["results"].as_array()) + .expect("events or results array") + .iter() + .find(|e| e["purl"] == purl) + .unwrap_or_else(|| panic!("no event for {purl}: {v}")) +} + +fn run(proj: &Path, command: &str) -> Value { + let (code, stdout, stderr) = run_with_env( + proj, + &[command, "--offline", "--json"], + &[("SOCKET_TELEMETRY_DISABLED", "1")], + ); + let v = parse_json_envelope(&stdout); + assert_ne!( + code, 0, + "a shared-store refusal fails the {command}; {v}\n{stderr}" + ); + v +} + +fn assert_refused(v: &Value, purl: &str) { + let ev = event(v, purl); + assert_ne!(ev["errorCode"], "package_not_installed", "{ev}"); + let text = ev.to_string(); + assert!( + text.contains("shared by other projects") && text.contains("globalStore = false"), + "the refusal names the shared store and its remedy; {ev}" + ); +} + +/// #635: the direct `left-pad` and the transitive `is-number` (reachable +/// only through `.bun`) are both refused as shared, and the store keeps +/// its bytes for the other projects linked to it. +#[test] +fn apply_refuses_bun_global_store_packages() { + let tmp = tempfile::tempdir().unwrap(); + let (proj, left_pad, number) = stage(tmp.path(), BEFORE); + write_manifest( + &proj, + &["pkg:npm/left-pad@1.3.0", "pkg:npm/is-number@6.0.0"], + ); + + let v = run(&proj, "apply"); + assert_eq!(v["status"], "partialFailure", "{v}"); + assert_refused(&v, "pkg:npm/left-pad@1.3.0"); + assert_refused(&v, "pkg:npm/is-number@6.0.0"); + assert_eq!(std::fs::read(left_pad.join("index.js")).unwrap(), BEFORE); + assert_eq!(std::fs::read(number.join("index.js")).unwrap(), BEFORE); +} + +/// #635: a rollback in one project must not restore the original bytes +/// into the shared store, which would unpatch every other project +/// relying on them. +#[test] +fn rollback_refuses_bun_global_store_packages() { + let tmp = tempfile::tempdir().unwrap(); + let (proj, left_pad, number) = stage(tmp.path(), AFTER); + write_manifest( + &proj, + &["pkg:npm/left-pad@1.3.0", "pkg:npm/is-number@6.0.0"], + ); + + let v = run(&proj, "rollback"); + assert_eq!(v["status"], "partial_failure", "{v}"); + assert_refused(&v, "pkg:npm/left-pad@1.3.0"); + assert_refused(&v, "pkg:npm/is-number@6.0.0"); + assert_eq!(std::fs::read(left_pad.join("index.js")).unwrap(), AFTER); + assert_eq!(std::fs::read(number.join("index.js")).unwrap(), AFTER); +} + +/// #635: the human-mode Bun note must not promise that copy-on-write +/// keeps the install cache untouched when the installed packages ARE the +/// cache's shared `links` dirs; it says they are refused and how to fix. +#[test] +fn apply_note_names_the_bun_global_store() { + let tmp = tempfile::tempdir().unwrap(); + let (proj, left_pad, _) = stage(tmp.path(), BEFORE); + std::fs::write(proj.join("bun.lock"), "{}\n").unwrap(); + write_manifest(&proj, &["pkg:npm/left-pad@1.3.0"]); + + let (code, _stdout, stderr) = run_with_env( + &proj, + &["apply", "--offline"], + &[("SOCKET_TELEMETRY_DISABLED", "1")], + ); + assert_ne!( + code, 0, + "the shared-store refusal fails the apply; {stderr}" + ); + assert!( + stderr.contains("Note: bun global store detected") + && stderr.contains("globalStore = false"), + "{stderr}" + ); + assert!( + !stderr.contains("will keep ~/.bun/install/cache/ untouched"), + "{stderr}" + ); + assert_eq!(std::fs::read(left_pad.join("index.js")).unwrap(), BEFORE); +} diff --git a/crates/socket-patch-cli/tests/apply/main.rs b/crates/socket-patch-cli/tests/apply/main.rs index afd82271f..3de89ed38 100644 --- a/crates/socket-patch-cli/tests/apply/main.rs +++ b/crates/socket-patch-cli/tests/apply/main.rs @@ -11,6 +11,7 @@ mod vlt_vendored; mod apply_invariants; mod apply_network; +mod bun_global_store; mod check_verifies_installed_tree; mod cli_gem_variant_mismatch_policy; mod covgap_commands_apply; diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index c9e482711..d6f1585d0 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -2090,6 +2090,103 @@ fn bun_lock_pin_restores_to_the_registry_tuple() { assert!(!tmp.path().join(".socket").exists(), "no .socket/ residue"); } +/// #764: Bun's hoisted linker keeps an installed copy whose lock entry +/// returns to the registry record (a plain `bun install` reports "no +/// changes"), so a rollback over a hoisted `node_modules/left-pad` warns +/// `redirect_bun_reinstall_required` and names `bun install --force`, in +/// the JSON `warnings[]` and on stderr. An isolated install (the copy a +/// link into `node_modules/.bun/`) relinks, so it stays silent. +#[test] +fn bun_lock_rollback_warns_that_a_hoisted_copy_is_kept() { + let registry = NpmRegistry::start(&[("left-pad", "1.2.3")]); + let project = |installed: bool| { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join("bun.lock"), + bun_lock(&bun_redirected_line()), + ) + .unwrap(); + if installed { + let pkg = tmp.path().join("node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write(pkg.join("index.js"), "// PATCHED\n").unwrap(); + } + tmp + }; + let has_code = |v: &serde_json::Value| { + v["warnings"].as_array().is_some_and(|ws| { + ws.iter().any(|w| { + w["code"] == "redirect_bun_reinstall_required" + && w["detail"].as_str().is_some_and(|d| { + d.contains("left-pad@1.2.3") && d.contains("`bun install --force`") + }) + }) + }) + }; + + let hoisted = project(true); + let (code, stdout, stderr) = run_hosted( + hoisted.path(), + &["rollback", "--json", "--yes"], + Some(®istry), + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + has_code(&parse_envelope(&stdout, &stderr)), + "stdout=\n{stdout}" + ); + + let hoisted = project(true); + let (code, stdout, stderr) = + run_hosted(hoisted.path(), &["rollback", "--yes"], Some(®istry)); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + stderr.contains("`bun install --force`"), + "the human run names the forcing install; stderr=\n{stderr}" + ); + + // The preview warns the same way: its run-level `reinstall_required` + // note would otherwise promise that the next plain install refreshes + // the tree, which is the #764 failure. + let hoisted = project(true); + let (code, stdout, stderr) = run_hosted( + hoisted.path(), + &["rollback", "--dry-run", "--json", "--yes"], + Some(®istry), + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let env = parse_envelope(&stdout, &stderr); + assert!(has_code(&env), "stdout=\n{stdout}"); + assert!( + env["warnings"] + .as_array() + .is_some_and(|ws| ws.iter().any(|w| { + w["code"] == "reinstall_required" + && w["detail"] + .as_str() + .is_some_and(|d| d.contains("`bun install --force`")) + })), + "the preview's reinstall note names the forcing install; stdout=\n{stdout}" + ); + assert_eq!( + std::fs::read_to_string(hoisted.path().join("bun.lock")).unwrap(), + bun_lock(&bun_redirected_line()), + "a dry run writes nothing" + ); + + let fresh = project(false); + let (code, stdout, stderr) = run_hosted( + fresh.path(), + &["rollback", "--json", "--yes"], + Some(®istry), + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + !has_code(&parse_envelope(&stdout, &stderr)), + "nothing installed, nothing kept; stdout=\n{stdout}" + ); +} + /// Dry-run twin of `bun_lock_pin_restores_to_the_registry_tuple`: "Would /// restore …", bun.lock byte-identical afterwards. #[test] @@ -3406,3 +3503,76 @@ fn vlt_hosted_rollback_dry_run_keeps_the_store_and_wet_human_run_heals() { assert!(!store.exists()); assert!(!root.join("node_modules/.vlt-lock.json").exists()); } + +/// #599: Bun never prunes `node_modules/.bun`. A patched `is-number@6.0.0` +/// entry the project has since moved off (an in-place `bun install` of +/// 7.0.0 re-linked the importer and hoist dir to the new entry) is an +/// orphan nothing loads now, but a later install that resolves back to +/// 6.0.0 re-links it as it is ("no changes"). Rollback must still restore +/// it, or the rolled-back patch silently returns: the orphan filter that +/// keeps `vex` from judging the install by such an entry is for checks of +/// the live install only. +#[cfg(unix)] +#[test] +fn bun_orphaned_store_entry_is_still_rolled_back() { + let before: &[u8] = b"module.exports = 'original'\n"; + let after: &[u8] = b"module.exports = 'original' // PATCHED-599\n"; + let (before_hash, after_hash) = (git_sha256(before), git_sha256(after)); + let purl = "pkg:npm/is-number@6.0.0"; + + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + r#"{ "name": "app", "version": "0.0.0", "dependencies": { "is-number": "7.0.0" } }"#, + ) + .expect("write root package.json"); + let orphan = install_npm_pkg( + root, + "node_modules/.bun/is-number@6.0.0/node_modules", + "is-number", + "6.0.0", + after, + ); + install_npm_pkg( + root, + "node_modules/.bun/is-number@7.0.0/node_modules", + "is-number", + "7.0.0", + b"module.exports = 7\n", + ); + std::fs::create_dir_all(root.join("node_modules/.bun/node_modules")).expect("hoist dir"); + std::os::unix::fs::symlink( + "../is-number@7.0.0/node_modules/is-number", + root.join("node_modules/.bun/node_modules/is-number"), + ) + .expect("hoist link"); + std::os::unix::fs::symlink( + ".bun/is-number@7.0.0/node_modules/is-number", + root.join("node_modules/is-number"), + ) + .expect("importer link"); + let socket = write_socket_manifest( + root, + &[manifest_entry( + purl, + "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + &before_hash, + &after_hash, + )], + ); + stage_blob(&socket, &before_hash, before); + stage_blob(&socket, &after_hash, after); + + let (code, stdout, stderr) = run(root, &["rollback", "--offline", "--yes"]); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + !stderr.contains("no matching installed package"), + "the orphaned entry must be found; stderr=\n{stderr}" + ); + assert_eq!( + std::fs::read(orphan.join("index.js")).expect("read orphan index.js"), + before, + "the orphaned entry keeps its patched bytes; stdout=\n{stdout}\nstderr=\n{stderr}" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs index 3e6d41a25..f07a3031c 100644 --- a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs @@ -214,14 +214,23 @@ fn copy_tree(source: &Path, target: &Path) { } fn find_installed_target(root: &Path) -> Option { - fn visit(dir: &Path, seen: &mut std::collections::HashSet) -> Option { + find_installed(root, ("minimist", "1.2.2")) +} + +/// The first installed copy of `(name, version)` under `root`. +fn find_installed(root: &Path, (name, version): (&str, &str)) -> Option { + fn visit( + dir: &Path, + target: (&str, &str), + seen: &mut std::collections::HashSet, + ) -> Option { let canonical = dir.canonicalize().ok()?; if !seen.insert(canonical) { return None; } if let Ok(bytes) = std::fs::read(dir.join("package.json")) { if let Ok(package) = serde_json::from_slice::(&bytes) { - if package["name"] == "minimist" && package["version"] == "1.2.2" { + if package["name"] == target.0 && package["version"] == target.1 { return Some(dir.to_path_buf()); } } @@ -229,14 +238,14 @@ fn find_installed_target(root: &Path) -> Option { for entry in std::fs::read_dir(dir).ok()?.flatten() { let path = entry.path(); if path.is_dir() { - if let Some(found) = visit(&path, seen) { + if let Some(found) = visit(&path, target, seen) { return Some(found); } } } None } - visit(root, &mut std::collections::HashSet::new()) + visit(root, (name, version), &mut std::collections::HashSet::new()) } fn installed_target(root: &Path) -> PathBuf { @@ -250,6 +259,9 @@ fn installed_target(root: &Path) -> PathBuf { struct Fixture { temp: tempfile::TempDir, + /// The patched package: minimist@1.2.2, or (`*-deps` shapes) + /// mkdirp@0.5.6, which has a dependency of its own. + target: (&'static str, &'static str), project: PathBuf, reader: PathBuf, legacy_reader: Option, @@ -322,6 +334,13 @@ impl Fixture { "alias" => json!({"alias":"npm:minimist@1.2.2", "is-number":"7.0.0"}), "transitive" => json!({"mkdirp":"0.5.3", "is-number":"7.0.0"}), "workspace" => json!({"consumer":"workspace:*", "is-number":"7.0.0"}), + // REGRESSION (#861): ws@8 has two optional peers nothing + // installs (bufferutil, utf-8-validate), so the lock holds + // unresolved edges, as most real locks do; the fold must still + // re-hoist it. + "workspace-adder" | "workspace-deps" | "workspace-deps-adder" => { + json!({"consumer":"workspace:*", "is-number":"7.0.0", "ws":"8.18.0"}) + } "workspace-nested" => { json!({"consumer":"workspace:*", "minimist":"1.2.8", "is-number":"7.0.0"}) } @@ -342,12 +361,27 @@ impl Fixture { package["scripts"] = json!({"preinstall":"echo root-pre", "postinstall":"echo root-post"}); } + let target = if shape.ends_with("-deps") || shape.ends_with("-deps-adder") { + ("mkdirp", "0.5.6") + } else { + ("minimist", "1.2.2") + }; if shape.starts_with("workspace") || shape == "extensions" { package["workspaces"] = json!(["packages/*"]); std::fs::create_dir_all(project.join("packages/consumer")).unwrap(); std::fs::write( project.join("packages/consumer/package.json"), - br#"{"name":"consumer","version":"1.0.0","dependencies":{"minimist":"1.2.2"}}"#, + serde_json::to_vec(&json!({"name":"consumer","version":"1.0.0", + "dependencies":{target.0:target.1}})) + .unwrap(), + ) + .unwrap(); + } + if shape.ends_with("-adder") { + std::fs::create_dir_all(project.join("packages/adder")).unwrap(); + std::fs::write( + project.join("packages/adder/package.json"), + br#"{"name":"adder","version":"1.0.0","dependencies":{"is-number":"7.0.0"}}"#, ) .unwrap(); } @@ -396,7 +430,9 @@ impl Fixture { "writer must not produce text" ); let original_lock = std::fs::read(project.join("bun.lockb")).unwrap(); - let original = std::fs::read(installed_target(&project).join("index.js")).unwrap(); + let installed = find_installed(&project, target) + .unwrap_or_else(|| panic!("installed {target:?} not found")); + let original = std::fs::read(installed.join("index.js")).unwrap(); let patched = [MARKER, original.as_slice()].concat(); let bystander = std::fs::read(project.join("node_modules/is-number/index.js")).unwrap(); eprintln!( @@ -406,6 +442,7 @@ impl Fixture { ); Some(Self { temp, + target, project, reader, legacy_reader: std::env::var_os("SOCKET_PATCH_BUN_LOCKB_LEGACY_READER") @@ -418,6 +455,15 @@ impl Fixture { }) } + fn purl(&self) -> String { + format!("pkg:npm/{}@{}", self.target.0, self.target.1) + } + + /// The patch's tarball file name. + fn tgz(&self) -> String { + format!("{}-{}.tgz", self.target.0, self.target.1) + } + fn lock(&self) -> Vec { assert!( !self.project.join("bun.lock").exists(), @@ -430,7 +476,7 @@ impl Fixture { let socket = self.project.join(".socket"); std::fs::create_dir_all(socket.join("blobs")).unwrap(); let after = compute_git_sha256_from_bytes(&self.patched); - let manifest = json!({"patches":{PURL:{"uuid":UUID, + let manifest = json!({"patches":{self.purl():{"uuid":UUID, "exportedAt":"2026-01-01T00:00:00Z", "files":{"package/index.js":{ "beforeHash":compute_git_sha256_from_bytes(&self.original), "afterHash":after}}, "vulnerabilities":{GHSA:{"cves":[CVE],"summary":"binary lock vuln","severity":"high","description":"d"}}, @@ -495,7 +541,7 @@ impl Fixture { .output() .unwrap(); let output = require_success(output, label); - let target = find_installed_target(&checkout).unwrap_or_else(|| { + let target = find_installed(&checkout, self.target).unwrap_or_else(|| { panic!( "{label}: installed target absent under {}\nstdout: {}\nstderr: {}", checkout.display(), @@ -680,41 +726,54 @@ fn file_mode(_p: &Path, name: &str) -> u32 { } async fn mock_api(server: &MockServer, fixture: &Fixture, _target: &str) { - let tgz = make_tgz_from_installed(&installed_target(&fixture.project), &fixture.patched); - prebuilt_common::mount_download(server, PURL, UUID, "minimist-1.2.2.tgz", &tgz).await; + mock_api_patch(server, fixture, UUID, &fixture.patched).await; +} + +/// [`mock_api`] serving patch `uuid`, which writes `patched` as the fixture +/// target's `index.js` (a superseding patch is a second server with a new +/// uuid). +async fn mock_api_patch(server: &MockServer, fixture: &Fixture, uuid: &str, patched: &[u8]) { + let (name, version) = fixture.target; + let (purl, file) = (fixture.purl(), fixture.tgz()); + let installed = find_installed(&fixture.project, fixture.target).unwrap(); + let tgz = make_tgz_from_installed(&installed, patched); + prebuilt_common::mount_download(server, &purl, uuid, &file, &tgz).await; std::fs::write(fixture.temp.path().join("hosted.tgz"), &tgz).unwrap(); - let url = format!("{}/patch/npm/minimist/1.2.2/33333333-3333-4333-8333-333333333333/{UUID}/minimist-1.2.2.tgz", server.uri()); + let url = format!( + "{}/patch/npm/{name}/{version}/33333333-3333-4333-8333-333333333333/{uuid}/{file}", + server.uri() + ); let sri = format!( "sha512-{}", base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&tgz)) ); Mock::given(method("POST")).and(path(format!("/v0/orgs/{ORG}/patches/batch"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"packages":[{ - "purl":PURL,"patches":[{"uuid":UUID,"purl":PURL,"tier":"free","cveIds":[],"ghsaIds":[],"severity":"high","title":"binary lock patch"}]}],"canAccessPaidPatches":false}))) + "purl":purl,"patches":[{"uuid":uuid,"purl":purl,"tier":"free","cveIds":[],"ghsaIds":[],"severity":"high","title":"binary lock patch"}]}],"canAccessPaidPatches":false}))) .mount(server).await; - Mock::given(method("GET")).and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.*minimist.*$"))) + Mock::given(method("GET")).and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.*{name}.*$"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"patches":[{ - "uuid":UUID,"purl":PURL,"publishedAt":"2026-01-01T00:00:00Z","description":"binary lock patch","license":"MIT","tier":"free","vulnerabilities":{}}],"canAccessPaidPatches":false}))) + "uuid":uuid,"purl":purl,"publishedAt":"2026-01-01T00:00:00Z","description":"binary lock patch","license":"MIT","tier":"free","vulnerabilities":{}}],"canAccessPaidPatches":false}))) .mount(server).await; Mock::given(method("POST")).and(path(format!("/v0/orgs/{ORG}/patches/package"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"results":{UUID:{ - "status":"granted","url":url,"purl":PURL,"artifacts":[{"kind":"tarball","url":url,"integrity":{"sha512":sri}}],"registryOverride":null}}}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"results":{uuid:{ + "status":"granted","url":url,"purl":purl,"artifacts":[{"kind":"tarball","url":url,"integrity":{"sha512":sri}}],"registryOverride":null}}}))) .mount(server).await; Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) .respond_with( - ResponseTemplate::new(200).set_body_json(json!({"uuid":UUID,"purl":PURL, + ResponseTemplate::new(200).set_body_json(json!({"uuid":uuid,"purl":purl, "publishedAt":"2026-01-01T00:00:00Z","files":{"package/index.js":{ "beforeHash":compute_git_sha256_from_bytes(&fixture.original), - "afterHash":compute_git_sha256_from_bytes(&fixture.patched), - "blobContent":base64::engine::general_purpose::STANDARD.encode(&fixture.patched)}}, + "afterHash":compute_git_sha256_from_bytes(patched), + "blobContent":base64::engine::general_purpose::STANDARD.encode(patched)}}, "vulnerabilities":{GHSA:{"cves":[CVE],"summary":"binary lock vuln","severity":"high","description":"d"}}, "description":"binary lock patch","license":"MIT","tier":"free"})), ) .mount(server) .await; Mock::given(method("GET")) - .and(path_regex("^/patch/npm/minimist/.*$")) + .and(path_regex(format!("^/patch/npm/{name}/.*$"))) .respond_with(ResponseTemplate::new(200).set_body_raw(tgz, "application/octet-stream")) .mount(server) .await; @@ -1119,6 +1178,308 @@ async fn native_binary_alias_and_transitive() { } } +/// #784: after Bun migrates a vendored `bun.lockb` to `bun.lock` +/// (`bun install --save-text-lockfile`), `vendor --revert` and `rollback` +/// must restore the registry tuple Bun writes when it migrates the pristine +/// binary lock, instead of failing on the missing `bun.lockb`, and a hosted +/// takeover must replace the vendored wiring; a fresh frozen install of the +/// result gets the original (or, hosted, the patched) bytes. Needs a Bun >= 1.2 +/// reader (the text lock releases the vendored text path parses). Not named +/// `native_binary_*`, like the #803 test: it runs on the 1.4.2 CI leg. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn vendored_text_migration_reverts_to_registry() { + for unwind in [ + &["vendor", "--revert"][..], + &["rollback", "--yes"][..], + &["scan"][..], + ] { + let Some(fixture) = Fixture::new("direct") else { + return; + }; + let Some((pristine, server)) = vendor_then_migrate(&fixture).await else { + return; + }; + let project = &fixture.project; + + let result = if unwind == ["scan"] { + // The hosted takeover reverts the vendored wiring first. + let hosted = scan(project, &server, "hosted", &[]); + assert_eq!(hosted["redirect"]["redirected"], 1, "{hosted}"); + hosted + } else { + cli(project, unwind) + }; + assert_eq!(result["status"], "success", "{unwind:?}: {result}"); + assert!(!project.join(".socket/vendor").exists(), "{unwind:?}"); + let expected = if unwind == ["scan"] { + let lock = std::fs::read_to_string(project.join("bun.lock")).unwrap(); + assert!(lock.contains("/patch/npm/minimist/"), "{lock}"); + &fixture.patched + } else { + assert_eq!( + std::fs::read_to_string(project.join("bun.lock")).unwrap(), + pristine, + "{unwind:?} restores the registry tuple" + ); + &fixture.original + }; + + assert_eq!( + frozen_text_install(&fixture, "reverted"), + *expected, + "{unwind:?}: a fresh frozen install" + ); + } +} + +/// The Bun >= 1.2 setup of the #784 tests: vendor the fixture's binary lock, +/// then let Bun migrate it to `bun.lock` (`bun install +/// --save-text-lockfile`). Returns what Bun writes when it migrates the +/// pristine binary lock, and the mock server that vendored it; `None` (a +/// skip) under a reader older than 1.2. +async fn vendor_then_migrate(fixture: &Fixture) -> Option<(String, MockServer)> { + let raw = String::from_utf8_lossy( + &command(&fixture.reader, &fixture.project) + .arg("--version") + .output() + .unwrap() + .stdout, + ) + .trim() + .to_string(); + let major_minor: Vec = raw + .split('.') + .take(2) + .filter_map(|p| p.parse().ok()) + .collect(); + if major_minor.as_slice() < [1, 2].as_slice() { + eprintln!("SKIP vendored text migration: Bun {raw} < 1.2"); + return None; + } + let pristine_dir = fixture.temp.path().join("pristine-migration"); + std::fs::create_dir_all(&pristine_dir).unwrap(); + for file in ["package.json", "bun.lockb", "bunfig.toml"] { + std::fs::copy(fixture.project.join(file), pristine_dir.join(file)).unwrap(); + } + let pristine = migrate_to_text_lock(fixture, &pristine_dir, "pristine"); + + let server = MockServer::start().await; + mock_api(&server, fixture, "minimist").await; + let vendored = scan(&fixture.project, &server, "vendored", &[]); + assert_eq!(vendored["vendor"]["summary"]["applied"], 1, "{vendored}"); + let migrated = migrate_to_text_lock(fixture, &fixture.project, "vendored"); + assert!( + migrated.contains(&format!("minimist@.socket/vendor/npm/{UUID}/")), + "the migration carries the vendored tuple:\n{migrated}" + ); + Some((pristine, server)) +} + +/// `bun install --save-text-lockfile` in `dir`: Bun deletes `bun.lockb` and +/// writes `bun.lock`, which is returned. +fn migrate_to_text_lock(fixture: &Fixture, dir: &Path, label: &str) -> String { + std::fs::remove_file(dir.join("bunfig.toml")).unwrap(); + let _ = std::fs::remove_dir_all(dir.join("node_modules")); + let output = command(&fixture.reader, dir) + .args(["install", "--save-text-lockfile", "--ignore-scripts"]) + .env( + "BUN_INSTALL_CACHE_DIR", + fixture.temp.path().join(format!("{label}-cache")), + ) + .env( + "BUN_INSTALL", + fixture.temp.path().join(format!("{label}-home")), + ) + .output() + .unwrap(); + require_success(output, &format!("{label}: bun.lockb -> bun.lock migration")); + assert!(!dir.join("bun.lockb").exists(), "{label}"); + std::fs::read_to_string(dir.join("bun.lock")).unwrap() +} + +/// An empty-cache `bun install --frozen-lockfile` of a fresh checkout of +/// the project's `package.json`, `bun.lock` and `.socket`: the installed +/// minimist `index.js`. +fn frozen_text_install(fixture: &Fixture, label: &str) -> Vec { + let checkout = fixture.temp.path().join(format!("{label}-checkout")); + std::fs::create_dir_all(&checkout).unwrap(); + for file in ["package.json", "bun.lock"] { + std::fs::copy(fixture.project.join(file), checkout.join(file)).unwrap(); + } + if fixture.project.join(".socket").exists() { + copy_tree(&fixture.project.join(".socket"), &checkout.join(".socket")); + } + let output = command(&fixture.reader, &checkout) + .args(["install", "--frozen-lockfile", "--ignore-scripts"]) + .env( + "BUN_INSTALL_CACHE_DIR", + fixture.temp.path().join(format!("{label}-cache")), + ) + .env( + "BUN_INSTALL", + fixture.temp.path().join(format!("{label}-home")), + ) + .output() + .unwrap(); + require_success(output, &format!("{label}: frozen install of bun.lock")); + assert!(!checkout.join("bun.lockb").exists(), "{label}"); + std::fs::read(installed_target(&checkout).join("index.js")).unwrap() +} + +/// #784, after Bun migrated a vendored `bun.lockb` to `bun.lock`: +/// +/// - `repair` and a vendored re-run keep the project vendored (a frozen +/// install still gets the patched bytes), and a re-run whose committed +/// artifact is gone (a same-uuid re-pin) rebuilds it; +/// - a superseding patch (new uuid) re-vendored on the migrated lock pins +/// the new artifact; +/// +/// and either way `vendor --revert` then restores the registry tuple Bun +/// writes for the pristine binary lock, byte for byte, with no drift left +/// behind, so a fresh frozen install gets the original bytes. Named +/// `*text_migration*` to run on the 1.4.2 CI leg. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn vendored_text_migration_rerun_and_supersede_revert() { + const UUID2: &str = "c0ffee00-4da6-45f9-bba8-b888e0ffd58c"; + for case in ["rerun", "supersede"] { + let Some(fixture) = Fixture::new("direct") else { + return; + }; + let Some((pristine, server)) = vendor_then_migrate(&fixture).await else { + return; + }; + let project = &fixture.project; + let migrated = std::fs::read_to_string(project.join("bun.lock")).unwrap(); + let artifact = project.join(format!(".socket/vendor/npm/{UUID}")); + let expected_patched = if case == "rerun" { + let uri = server.uri(); + let repair_args = ["repair", "--patch-server-url", &uri]; + let repair = cli(project, &repair_args); + assert_eq!(repair["status"], "success", "{repair}"); + let rerun = scan(project, &server, "vendored", &[]); + assert_eq!(rerun["status"], "success", "{rerun}"); + // Bun 1.2 migrates the vendored tuple without its digest, which + // the re-run heals in place; a newer Bun's lock is kept as is. + let digestless = migrated + .lines() + .any(|l| l.contains("\"minimist\": [") && !l.contains("sha512-")); + let lock = std::fs::read_to_string(project.join("bun.lock")).unwrap(); + assert!( + lock == migrated + || (digestless + && lock.contains(&format!("minimist@.socket/vendor/npm/{UUID}/"))), + "a vendored re-run keeps Bun's migrated lock:\n{migrated}\n{lock}" + ); + let again = scan(project, &server, "vendored", &[]); + assert_eq!(again["status"], "success", "{again}"); + assert_eq!( + std::fs::read_to_string(project.join("bun.lock")).unwrap(), + lock, + "a second re-run changes nothing" + ); + assert_eq!( + frozen_text_install(&fixture, "rerun"), + fixture.patched, + "the re-run keeps the project vendored" + ); + // A fresh clone that lost the artifact: repair rebuilds it. + std::fs::remove_dir_all(&artifact).unwrap(); + let repair = cli(project, &repair_args); + assert_eq!(repair["status"], "success", "{repair}"); + assert!(artifact.is_dir(), "repair rebuilds the artifact: {repair}"); + assert_eq!( + std::fs::read_to_string(project.join("bun.lock")).unwrap(), + lock, + "repair leaves the migrated lock alone" + ); + // A tuple whose digest no longer matches the artifact: the + // re-run re-pins the same uuid as a `bun.lock` record, next to + // the `bun.lockb` records the entry carries forward. + let line = lock + .lines() + .find(|l| l.contains("\"minimist\": [")) + .unwrap(); + let digest = &line[line.find("\"sha512-").unwrap()..line.rfind('"').unwrap() + 1]; + std::fs::write( + project.join("bun.lock"), + lock.replace(line, &line.replace(digest, "\"sha512-AAAA\"")), + ) + .unwrap(); + let repin = scan(project, &server, "vendored", &[]); + assert_eq!(repin["status"], "success", "{repin}"); + assert_eq!( + std::fs::read_to_string(project.join("bun.lock")).unwrap(), + lock, + "the re-pin restores the artifact's digest" + ); + let state = std::fs::read_to_string(project.join(".socket/vendor/state.json")).unwrap(); + assert!( + state.contains("\"bun_lock_package\"") && state.contains("\"bun_lockb_package\""), + "the re-pin leaves a mixed entry: {state}" + ); + fixture.patched.clone() + } else { + let patched2 = [ + b"/* SOCKET SUPERSEDING PATCH */\n".as_slice(), + &fixture.original, + ] + .concat(); + let server2 = MockServer::start().await; + mock_api_patch(&server2, &fixture, UUID2, &patched2).await; + let supersede = scan(project, &server2, "vendored", &[]); + assert_eq!(supersede["status"], "success", "{supersede}"); + let lock = std::fs::read_to_string(project.join("bun.lock")).unwrap(); + assert!( + lock.contains(&format!("minimist@.socket/vendor/npm/{UUID2}/")) + && !lock.contains(UUID), + "the superseding patch re-pins the migrated lock:\n{lock}" + ); + patched2 + }; + let state: Value = serde_json::from_slice( + &std::fs::read(project.join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + let wiring = state["entries"][PURL]["wiring"].as_array().unwrap(); + assert!( + wiring + .iter() + .filter(|w| w["file"] == "bun.lock") + .all(|w| w["original"].is_string()), + "{case}: every bun.lock record keeps its pre-vendor original: {state}" + ); + assert_eq!( + frozen_text_install(&fixture, &format!("{case}-vendored")), + expected_patched, + "{case}: a fresh frozen install of the re-vendored lock" + ); + + let revert = cli(project, &["vendor", "--revert"]); + assert_eq!(revert["status"], "success", "{case}: {revert}"); + let revert_text = revert.to_string(); + for code in [ + "vendor_lock_entry_drifted", + "vendor_artifact_kept", + "vendor_revert_kept", + ] { + assert!(!revert_text.contains(code), "{case}: {revert}"); + } + assert_eq!( + std::fs::read_to_string(project.join("bun.lock")).unwrap(), + pristine, + "{case}: revert restores the registry tuple" + ); + assert!(!project.join(".socket/vendor").exists(), "{case}"); + assert_eq!( + frozen_text_install(&fixture, &format!("{case}-reverted")), + fixture.original, + "{case}: a fresh frozen install of the reverted lock" + ); + } +} + /// #803: Bun 1.4 migrates a hosted workspace `bun.lockb` to `bun.lock` /// with the member path the binary normalization wrote as the root's /// `consumer` literal, so a frozen install of the migrated lock fails and @@ -1224,6 +1585,191 @@ async fn workspace_text_migration_heals_on_rerun() { ); } +/// The `(name, resolution)` package lines of Bun's own yarn-style dump of +/// the project's `bun.lockb`. +fn dumped_records(fixture: &Fixture) -> Vec { + let output = command(&fixture.reader, &fixture.project) + .arg("bun.lockb") + .output() + .unwrap(); + let dump = + String::from_utf8_lossy(&require_success(output, "bun bun.lockb").stdout).into_owned(); + dump.lines() + .filter(|line| line.trim_start().starts_with("resolved ")) + .map(str::trim) + .map(str::to_string) + .collect() +} + +/// #861: after a vendored workspace `bun.lockb`, a new dependent of the +/// patched package (a member added later, or `bun add` in an existing +/// member) makes Bun write a second, nested REGISTRY record of the same +/// `name@version`, since the hoisted one is a local tarball now. The +/// vendored re-run must not rewire that record to the same tarball: two +/// records with one tarball resolution share one isolated store directory, +/// and frozen installs fail intermittently with `EEXIST`. It folds the +/// record into the existing tarball record instead, so every fresh frozen +/// install links the patched bytes into the new dependent too. The member +/// names hoist the late dependent after (`late`) and before (`adder`) the +/// vendored one. Isolated linker (Bun >= 1.3); not named `native_binary_*` +/// (the backtest matrix runs exactly those). +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn workspace_late_dependent_rerun_shares_the_tarball_record() { + late_dependent_matrix([("workspace", "late"), ("workspace-adder", "adder")]).await; +} + +/// #861, a patched package WITH dependencies of its own (mkdirp@0.5.6 -> +/// minimist): the late registry record resolves its dependencies to the +/// same packages as the tarball record, so it folds the same way — its +/// edges leave the lock with it and the trees are re-hoisted as Bun +/// hoists them — instead of being rewired to the same tarball (`EEXIST`). +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn workspace_late_dependent_with_dependencies_rerun_shares_the_tarball_record() { + late_dependent_matrix([ + ("workspace-deps", "late"), + ("workspace-deps-adder", "adder"), + ]) + .await; +} + +async fn late_dependent_matrix(cases: [(&str, &str); 2]) { + for (shape, member) in cases { + let Some(fixture) = Fixture::new(shape) else { + return; + }; + let raw = String::from_utf8_lossy( + &command(&fixture.reader, &fixture.project) + .arg("--version") + .output() + .unwrap() + .stdout, + ) + .trim() + .to_string(); + let major_minor: Vec = raw + .split('.') + .take(2) + .filter_map(|p| p.parse().ok()) + .collect(); + if major_minor.as_slice() < [1, 3].as_slice() { + eprintln!("SKIP late workspace dependent: Bun {raw} has no isolated linker"); + return; + } + late_dependent_rerun(&fixture, member).await; + } +} + +async fn late_dependent_rerun(fixture: &Fixture, member: &str) { + let project = &fixture.project; + let (name, version) = fixture.target; + let file = fixture.tgz(); + std::fs::write( + project.join("bunfig.toml"), + "[install]\nsaveTextLockfile = false\nlinker = \"isolated\"\n", + ) + .unwrap(); + let server = MockServer::start().await; + mock_api(&server, fixture, name).await; + fixture.stage(); + let first = cli(project, &["vendor", "--offline"]); + assert_eq!( + first["summary"]["applied"], 1, + "{member}: first vendor: {first}" + ); + + let dir = project.join("packages").join(member); + let mut add = if dir.exists() { + let mut add = command(&fixture.reader, &dir); + add.args(["add", &format!("{name}@{version}"), "--ignore-scripts"]); + add + } else { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("package.json"), + format!( + r#"{{"name":"{member}","version":"1.0.0","dependencies":{{"{name}":"{version}"}}}}"# + ), + ) + .unwrap(); + let mut install = command(&fixture.reader, project); + install.args(["install", "--ignore-scripts"]); + install + }; + let output = add + .env( + "BUN_INSTALL_CACHE_DIR", + fixture.temp.path().join("late-cache"), + ) + .env("BUN_INSTALL", fixture.temp.path().join("late-home")) + .output() + .unwrap(); + require_success(output, &format!("{member}: the late dependent")); + let before = dumped_records(fixture); + assert!( + before + .iter() + .any(|line| line.contains(&file) && !line.contains(".socket/vendor/npm/")), + "{member}: Bun writes a nested registry record: {before:?}" + ); + + let rerun = cli(project, &["vendor", "--offline"]); + assert_eq!( + rerun["summary"]["applied"], 1, + "{member}: vendored re-run: {rerun}" + ); + assert!( + !rerun + .to_string() + .contains("vendor_bun_lockb_duplicate_records"), + "{member}: the records fold, no fallback: {rerun}" + ); + let after = dumped_records(fixture); + assert_eq!( + after + .iter() + .filter(|line| line.contains(&file)) + .collect::>(), + [&format!("resolved \".socket/vendor/npm/{UUID}/{file}\"")], + "{member}: one record, the tarball: {after:?}" + ); + // EEXIST was intermittent (about half the cold frozen installs). + for attempt in 0..6 { + let checkout = fixture.frozen_install( + &format!("{member}-{attempt}"), + &fixture.patched, + &fixture.bystander, + false, + ); + assert_eq!( + std::fs::read( + checkout + .join("packages") + .join(member) + .join("node_modules") + .join(name) + .join("index.js") + ) + .unwrap(), + fixture.patched, + "{member} attempt {attempt}: the late dependent links the patched bytes" + ); + } + let again = cli(project, &["vendor", "--offline"]); + assert_eq!( + again["summary"]["skipped"], 1, + "{member}: idempotent: {again}" + ); + cli(project, &["vendor", "--revert"]); + fixture.frozen_install( + &format!("{member}-reverted"), + &fixture.original, + &fixture.bystander, + false, + ); +} + fn production_scoped_rollback() { const SECOND_PURL: &str = "pkg:npm/is-number@7.0.0"; for first in [PURL, SECOND_PURL] { diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs index ff9291795..7153bee5d 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs @@ -202,7 +202,12 @@ fn text_tuple(wiring: &Wiring) -> String { /// `bun.lockb` for `release`, rewired by the production binary rewriter. fn binary_lock(release: &str, wiring: &Wiring) -> Vec { - let fixture = binary_fixture(release); + rewire_binary(binary_fixture(release), wiring) +} + +/// `fixture` with its registry minimist record rewired per `wiring` by the +/// production binary rewriter. +fn rewire_binary(fixture: Vec, wiring: &Wiring) -> Vec { let (artifact_url, sri, uuid) = match wiring { Wiring::Registry => return fixture, Wiring::Hosted { url, sri } => ( @@ -725,6 +730,91 @@ fn b_api_without_the_record_is_record_unavailable() { } } +/// REGRESSION (#497 follow-up): real Bun locks (1.4.2 / 1.2.23 / 1.1.45 +/// text, 1.1.45 binary) where the root depends on minimist by +/// `file:./pkg.tgz`, a codeload tarball URL or `github:…#v1.2.2`, and a +/// folder dependency pulls minimist@1.2.2 from the registry. Bun records +/// no version for the root copy and installs it from its own spec, so +/// wiring the nested registry copy is never attested: hosted under +/// `--no-verify`, vendored by default (both lockfile-only), and the run +/// says why. The same text lock without the root copy attests. +#[test] +fn unversioned_own_source_copy_withholds_the_nested_wiring() { + let api = Api::start(); + api.serve_view(UUID, view(UUID, PURL)); + for dir in [ + "text-0/file", + "text-1/file", + "text-2/file", + "text-2/url", + "text-2/git", + "lockb/file", + "lockb/url", + "lockb/git", + ] { + let fixture = fixture_dir(&format!("bun-unversioned-copy/{dir}")); + for mode in modes() { + let what = format!("{dir} {mode}"); + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + std::fs::copy(fixture.join("package.json"), cwd.join("package.json")).unwrap(); + let wiring = match mode { + "hosted" => hosted(UUID), + _ => commit_artifact(cwd, UUID, PATCHED), + }; + let extra: &[&str] = if mode == "hosted" { + &["--no-verify"] + } else { + &[] + }; + if dir.starts_with("lockb/") { + let bytes = std::fs::read(fixture.join("bun.lockb")).unwrap(); + std::fs::write(cwd.join("bun.lockb"), rewire_binary(bytes, &wiring)).unwrap(); + } else { + let text = std::fs::read_to_string(fixture.join("bun.lock")).unwrap(); + let nested = format!("\"dep/{NAME}\": "); + let lock: String = text + .lines() + .map( + |line| match line.trim_start().strip_prefix(nested.as_str()) { + Some(_) => format!(" {nested}{},\n", text_tuple(&wiring)), + None => format!("{line}\n"), + }, + ) + .collect(); + std::fs::write(cwd.join("bun.lock"), &lock).unwrap(); + + // Control: without the root copy the wiring attests. + let root = format!("\"{NAME}\": "); + let alone: String = lock + .lines() + .filter(|line| !line.trim_start().starts_with(root.as_str())) + .map(|line| format!("{line}\n")) + .collect(); + let control = tempfile::tempdir().unwrap(); + let ccwd = control.path(); + std::fs::copy(cwd.join("package.json"), ccwd.join("package.json")).unwrap(); + std::fs::write(ccwd.join("bun.lock"), alone).unwrap(); + if mode == "vendored" { + commit_artifact(ccwd, UUID, PATCHED); + } + let (code, env) = vex_online(ccwd, &api, extra); + assert_attested(ccwd, code, &env, UUID, mode, &format!("{what} control")); + } + // The withdrawn wiring was the only patch reference. + let (code, env) = vex_online(cwd, &api, extra); + assert_nothing_found(code, &env, &what); + assert!(read_doc(&cwd.join("out.vex.json")).is_none(), "{what}"); + let text = env.to_string(); + assert!( + text.contains("patched_ref_unattributable") + && text.contains("whose version the lock does not record"), + "{what}: the withheld wiring must be explained: {env}" + ); + } + } +} + // ────────────────────────────────────────────────────────────────────── // c) + d) + embedded: the state written by the REAL CLI. // ────────────────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs index dc88b49ff..c22287262 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs @@ -2225,6 +2225,190 @@ fn bun_hosted_ref_is_judged_by_the_bun_store_copy() { } } +/// #635: with Bun's global store (`[install] globalStore = true`, Bun >= +/// 1.3.14) the `.bun` entry is a link into `/links/-`, +/// a dir shared across projects. That linked store copy is still what this +/// project loads, so a pristine one is `not_applied` while `bun.lock` pins +/// the hosted tarball. Skipping the link read as "nothing installed" and +/// attested the pinned lock. +#[cfg(unix)] +#[test] +fn bun_hosted_ref_is_judged_by_a_global_store_copy() { + let (pristine, patched) = ( + &b"module.exports = 'pristine'\n"[..], + &b"module.exports = 'patched'\n"[..], + ); + let purl = "pkg:npm/left-pad@1.3.0"; + let url = hosted_npm_url("left-pad", "1.3.0", UUID); + let tmp = tempfile::tempdir().unwrap(); + let cwd = &tmp.path().join("app"); + put( + cwd, + "package.json", + br#"{ "name": "app", "version": "1.0.0", "dependencies": { "dep": "1.0.0" } }"#, + ); + put( + cwd, + "bunfig.toml", + b"[install]\nlinker = \"isolated\"\nglobalStore = true\n", + ); + put( + cwd, + "bun.lock", + format!( + "{{\n \"lockfileVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \ + \"name\": \"app\",\n \"dependencies\": {{\n \"dep\": \"1.0.0\",\n \ + }},\n }},\n }},\n \"packages\": {{\n \ + \"dep\": [\"dep@1.0.0\", \"\", {{ \"dependencies\": {{ \"left-pad\": \"1.3.0\" }} }}, \ + \"sha512-{dep}==\"],\n\n \ + \"left-pad\": [\"left-pad@{url}\", {{}}, \"{SRI}\"],\n }}\n}}\n", + dep = "D".repeat(86), + ) + .as_bytes(), + ); + let shared = tmp + .path() + .join("bun-cache/links/left-pad@1.3.0-6a490709ba3c5c8f"); + put( + &shared, + "node_modules/left-pad/package.json", + br#"{ "name": "left-pad", "version": "1.3.0" }"#, + ); + put(&shared, "node_modules/left-pad/index.js", pristine); + std::fs::create_dir_all(cwd.join("node_modules/.bun")).unwrap(); + std::os::unix::fs::symlink(&shared, cwd.join("node_modules/.bun/left-pad@1.3.0")).unwrap(); + let (_rt, server) = serve_patch_views(vec![( + UUID.to_string(), + one_file_view(UUID, purl, "package/index.js", pristine, patched), + )]); + let args = ["--proxy-url", &server.uri()]; + + let (code, env) = vex_json(cwd, &args); + assert_eq!(code, Some(1), "a pristine global store copy: {env}"); + assert_eq!(skipped_reason(&env, purl), "not_applied", "{env}"); + + put(&shared, "node_modules/left-pad/index.js", patched); + let (code, env) = vex_json(cwd, &args); + assert_attested(cwd, code, &env, UUID, "the global store copy verifies"); +} + +/// #599: Bun never prunes `.bun`. After the usual in-place `bun install` +/// of a hosted-rewired lock, the pre-scan `left-pad@1.3.0` registry entry +/// stays on disk, pristine, but the importer's dependency entry and the +/// `.bun/node_modules` hoist link now point at the hosted tarball's entry +/// (real Bun 1.3.14 / 1.4.2 layout). Nothing can load the orphan, so it is +/// no installed copy: the patched live copy attests, where vex used to +/// refuse the patch as `not_applied` until `rm -rf node_modules`. +/// +/// With Bun's global store (#635, `globalStore = true`) every `.bun` entry +/// is instead an absolute link into `/links/-`, and +/// the dependency entry's cache dir links left-pad at its sibling cache +/// dir, never back into `.bun`; Bun leaves the orphaned link behind just +/// the same, and it is no copy either. +#[cfg(unix)] +#[test] +fn bun_hosted_ref_ignores_orphaned_registry_store_entry() { + let (pristine, patched) = ( + &b"module.exports = 'pristine'\n"[..], + &b"module.exports = 'patched'\n"[..], + ); + let purl = "pkg:npm/left-pad@1.3.0"; + let url = hosted_npm_url("left-pad", "1.3.0", UUID); + for global in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = &tmp.path().join("app"); + put( + cwd, + "package.json", + br#"{ "name": "app", "version": "1.0.0", "dependencies": { "dep": "1.0.0" } }"#, + ); + put( + cwd, + "bun.lock", + format!( + "{{\n \"lockfileVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \ + \"name\": \"app\",\n \"dependencies\": {{\n \"dep\": \"1.0.0\",\n \ + }},\n }},\n }},\n \"packages\": {{\n \ + \"dep\": [\"dep@1.0.0\", \"\", {{ \"dependencies\": {{ \"left-pad\": \"1.3.0\" }} }}, \ + \"sha512-{dep}==\"],\n\n \ + \"left-pad\": [\"left-pad@{url}\", {{}}, \"{SRI}\"],\n }}\n}}\n", + dep = "D".repeat(86), + ) + .as_bytes(), + ); + let live_entry = format!("left-pad@{}", url.replace([':', '/'], "+")); + // Where each `.bun` entry's files live: the store itself, or a + // global store cache dir the entry links to. + let entry_dir = |entry: &str, hash: &str| { + if global { + let shared = tmp.path().join(format!("bun-cache/links/{entry}-{hash}")); + std::fs::create_dir_all(&shared).unwrap(); + std::fs::create_dir_all(cwd.join("node_modules/.bun")).unwrap(); + std::os::unix::fs::symlink(&shared, cwd.join("node_modules/.bun").join(entry)) + .unwrap(); + shared + } else { + cwd.join("node_modules/.bun").join(entry) + } + }; + for (entry, hash, bytes) in [ + ("left-pad@1.3.0", "6a490709ba3c5c8f", pristine), + (live_entry.as_str(), "70aa8f1dde99846b", patched), + ] { + let dir = entry_dir(entry, hash); + put( + &dir, + "node_modules/left-pad/package.json", + br#"{ "name": "left-pad", "version": "1.3.0" }"#, + ); + put(&dir, "node_modules/left-pad/index.js", bytes); + } + let dep = entry_dir("dep@1.0.0", "59cbc6610791e74a"); + put( + &dep, + "node_modules/dep/package.json", + br#"{ "name": "dep", "version": "1.0.0" }"#, + ); + let link = |target: String, at: &Path| { + std::fs::create_dir_all(at.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(target, at).unwrap(); + }; + link( + if global { + format!("../../{live_entry}-70aa8f1dde99846b/node_modules/left-pad") + } else { + format!("../../{live_entry}/node_modules/left-pad") + }, + &dep.join("node_modules/left-pad"), + ); + link( + format!("../{live_entry}/node_modules/left-pad"), + &cwd.join("node_modules/.bun/node_modules/left-pad"), + ); + link( + "../dep@1.0.0/node_modules/dep".to_string(), + &cwd.join("node_modules/.bun/node_modules/dep"), + ); + link( + ".bun/dep@1.0.0/node_modules/dep".to_string(), + &cwd.join("node_modules/dep"), + ); + let (_rt, server) = serve_patch_views(vec![( + UUID.to_string(), + one_file_view(UUID, purl, "package/index.js", pristine, patched), + )]); + + let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); + assert_attested( + cwd, + code, + &env, + UUID, + &format!("global store {global}: the orphaned registry entry is no copy"), + ); + } +} + /// The patch view for `name@version` (the [`left_pad_view`] shape). fn npm_view(name: &str, version: &str, after_hash: &str) -> Value { let mut view = left_pad_view(after_hash); diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index f67b4d4df..20b0b611c 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -1343,6 +1343,128 @@ fn vendored_live_tree_out_of_sync_warns_but_attests() { ); } +/// REGRESSION (#599, with #635): Bun never prunes `node_modules/.bun`. +/// After the in-place `bun install` that consumes a vendored tarball, the +/// pre-vendor `lodash@4.17.21` registry entry stays on disk, pristine, while +/// the importer and the `.bun/node_modules` hoist link now point at the +/// tarball's entry (`lodash@.socket+vendor+npm++lodash-4.17.21.tgz`, +/// the name real Bun 1.4.2 gives a `file:` tarball). Nothing can load the +/// orphan, so it is no installed copy: no `vendored_tree_out_of_sync` +/// warning, which re-running the install could never clear. Both the +/// project-local store and the global store (`globalStore = true`: every +/// `.bun` entry an absolute link into `/links/-`) are +/// covered, each with a control whose live copy is unpatched and must +/// still warn. +#[cfg(unix)] +#[test] +fn vendored_bun_orphaned_store_entry_is_not_out_of_sync() { + let purl = "pkg:npm/lodash@4.17.21"; + let uuid = "0a0a0a0a-1111-4111-8111-0a0a0a0a0a0a"; + let patched = b"patched npm bytes\n"; + let pristine = b"original unpatched bytes\n"; + for (global, live_patched) in [(false, true), (false, false), (true, true), (true, false)] { + let label = format!("global store {global}, live copy patched {live_patched}"); + let tmp = tempfile::tempdir().expect("create tempdir"); + let cwd = &tmp.path().join("app"); + std::fs::create_dir_all(cwd).unwrap(); + + let after_hash = compute_git_sha256_from_bytes(patched); + let rel = format!(".socket/vendor/npm/{uuid}/lodash-4.17.21.tgz"); + let sha256 = sha256_hex(&write_member_tgz( + &cwd.join(&rel), + "package/index.js", + patched, + )); + let record = make_record( + uuid, + "package/index.js", + &after_hash, + "GHSA-sync-aaaa", + &["CVE-2026-10"], + ); + let wiring = write_matrix_wiring(cwd, "npm", uuid, &rel); + let mut state = VendorState::new(); + state.entries.insert( + purl.to_string(), + detached_matrix_entry("npm", purl, uuid, &rel, sha256, record, wiring), + ); + std::fs::write( + cwd.join(".socket/vendor/state.json"), + serde_json::to_string_pretty(&state).expect("serialize vendor state"), + ) + .expect("write vendor state.json"); + + let store = cwd.join("node_modules/.bun"); + let live_entry = format!("lodash@.socket+vendor+npm+{uuid}+lodash-4.17.21.tgz"); + for (entry, hash, bytes) in [ + ("lodash@4.17.21", "6a490709ba3c5c8f", &pristine[..]), + ( + live_entry.as_str(), + "2fdd36c28041169b", + if live_patched { + &patched[..] + } else { + &pristine[..] + }, + ), + ] { + let dir = if global { + tmp.path().join(format!("bun-cache/links/{entry}-{hash}")) + } else { + store.join(entry) + }; + let pkg = dir.join("node_modules/lodash"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + r#"{"name":"lodash","version":"4.17.21"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), bytes).unwrap(); + if global { + std::fs::create_dir_all(&store).unwrap(); + std::os::unix::fs::symlink(&dir, store.join(entry)).unwrap(); + } + } + std::fs::create_dir_all(store.join("node_modules")).unwrap(); + std::os::unix::fs::symlink( + format!("../{live_entry}/node_modules/lodash"), + store.join("node_modules/lodash"), + ) + .unwrap(); + std::os::unix::fs::symlink( + format!(".bun/{live_entry}/node_modules/lodash"), + cwd.join("node_modules/lodash"), + ) + .unwrap(); + + let vex_path = cwd.join("out.vex.json"); + let out = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--json", + "--output", + vex_path.to_str().unwrap(), + "--product", + "pkg:npm/app@1.0.0", + ]) + .output() + .expect("invoke vex"); + let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); + assert!(out.status.success(), "{label}: {env}"); + assert_eq!(env["status"], "success", "{label}: {env}"); + let out_of_sync = env["warnings"].as_array().is_some_and(|ws| { + ws.iter().any(|w| { + w["code"] == "vendored_tree_out_of_sync" + && w["detail"].as_str().is_some_and(|d| d.contains(purl)) + }) + }); + assert_eq!(out_of_sync, !live_patched, "{label}: {env}"); + } +} + /// REGRESSION (#325): the lock rewires the hoisted `lodash@4.17.21` to the /// vendored tarball, but a parent package also BUNDLES `lodash@4.17.21` /// (`inBundle: true`). npm unpacks that copy from the parent's tarball, so diff --git a/crates/socket-patch-cli/tests/get/global_packages_e2e.rs b/crates/socket-patch-cli/tests/get/global_packages_e2e.rs index a86958fe8..c3bc89231 100644 --- a/crates/socket-patch-cli/tests/get/global_packages_e2e.rs +++ b/crates/socket-patch-cli/tests/get/global_packages_e2e.rs @@ -441,6 +441,57 @@ fn rollback_global_with_empty_path_handles_missing_npm() { assert_rollback_noop(&stdout); } +/// #443: when Bun is in use but its global dir can't be told (here +/// `BUN_INSTALL_GLOBAL_DIR` is relative, so it names a dir relative to +/// wherever `bun add -g` ran), a global run says so on stderr instead of +/// silently leaving Bun's globals out. `--json` keeps the warning (stdout +/// stays the envelope); `--silent` mutes it. +#[test] +fn apply_global_warns_when_bun_global_dir_is_undeterminable() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + write_manifest(tmp.path(), "pkg:npm/__bun_undetermined__@1.0.0"); + + let run = |extra: &[&str]| { + let mut args = vec!["apply", "--global", "--offline", "--json"]; + args.extend_from_slice(extra); + let out = cli(tmp.path()) + .args(&args) + .env("PATH", "/nonexistent-dir-for-test") + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("BUN_INSTALL_GLOBAL_DIR", "relative/bun-global") + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert_eq!( + out.status.code(), + Some(1), + "stdout={stdout}\nstderr={stderr}" + ); + assert_apply_not_installed(&stdout, "pkg:npm/__bun_undetermined__@1.0.0"); + stderr + }; + + let stderr = run(&[]); + assert!( + stderr.contains( + "Warning: could not determine Bun's global package directory \ + (BUN_INSTALL_GLOBAL_DIR is \"relative/bun-global\", not an absolute path)" + ), + "stderr={stderr}" + ); + assert_eq!( + stderr.matches("could not determine Bun's global").count(), + 1, + "said once per run; stderr={stderr}" + ); + let stderr = run(&["--silent"]); + assert!(!stderr.contains("Bun's global"), "stderr={stderr}"); +} + // --------------------------------------------------------------------------- // Stub-script PATH — controlled npm output exercises success + empty-output // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs index 0b7d2ebe5..44b96f661 100644 --- a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs +++ b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs @@ -1310,6 +1310,92 @@ async fn bun_isolated_linker_transitive_only_dep_apply_patches_store() { ); } +/// #635: with Bun's global store (`globalStore = true`, Bun >= 1.3.14) every +/// `node_modules/.bun/` links into `/links/-`, +/// which every project using that cache shares. Agent-mode apply in one +/// project must refuse the transitive `is-number` there (not skip it as not +/// installed, and not write through the link into the other project). +#[tokio::test] +#[serial] +async fn bun_global_store_transitive_dep_apply_is_refused() { + if !has("bun") { + println!("SKIP: bun not on PATH"); + return; + } + + let tmp = tempfile::tempdir().unwrap(); + let cache = tmp.path().join("bun-cache"); + for project in ["a", "b"] { + let dir = tmp.path().join(project); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("package.json"), + format!( + r#"{{ "name": "bun-gs-{project}", "version": "0.0.0", "dependencies": {{ "is-odd": "3.0.1" }} }}"# + ), + ) + .unwrap(); + std::fs::write( + dir.join("bunfig.toml"), + "[install]\nlinker = \"isolated\"\nglobalStore = true\n", + ) + .unwrap(); + let out = pm_command("bun", &["npm_config_", "BUN_"]) + .args(["install", "--no-progress"]) + .current_dir(&dir) + .env("BUN_INSTALL_CACHE_DIR", &cache) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .expect("bun install"); + if !out.status.success() { + println!( + "SKIP: bun install failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + return; + } + } + let project = tmp.path().join("a"); + let entry = project.join("node_modules/.bun/is-number@6.0.0"); + if !std::fs::symlink_metadata(&entry).is_ok_and(|m| m.file_type().is_symlink()) { + println!("SKIP: this bun has no global store (Bun < 1.3.14)"); + return; + } + let other = tmp + .path() + .join("b/node_modules/.bun/is-number@6.0.0/node_modules/is-number/index.js"); + assert_eq!( + std::fs::canonicalize(&other).unwrap(), + std::fs::canonicalize(entry.join("node_modules/is-number/index.js")).unwrap(), + "premise: both projects load is-number from the shared store" + ); + + let index = entry.join("node_modules/is-number/index.js"); + let original = std::fs::read(&index).expect("read is-number/index.js"); + let before_hash = git_sha256(&original); + let mut patched = original.clone(); + patched.extend_from_slice(b"\n// SOCKET-PATCH-BUN-GLOBAL-STORE-MARKER\n"); + let after_hash = git_sha256(&patched); + let socket = project.join(".socket"); + write_manifest( + &socket, + "pkg:npm/is-number@6.0.0", + &before_hash, + &after_hash, + ); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write(socket.join("blobs").join(&after_hash), &patched).unwrap(); + + let code = apply_run(default_apply(&project)).await; + assert_ne!(code, 0, "apply must refuse the shared store copy"); + assert_eq!( + std::fs::read(&other).unwrap(), + original, + "the other project's copy must stay untouched" + ); +} + /// #373: Deno's isolated `nodeModulesDir` keeps a transitive npm package /// only at `node_modules/.deno/@/node_modules/` /// (beside `.deno/.deno.lock` and the `.deno/node_modules` hoist dir). diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index d43043dba..9ba0cf737 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1666,6 +1666,80 @@ async fn scan_redirect_heals_digestless_bun_tuple_and_rollback_restores_the_regi } } +/// #992: Bun writes a package's full tarball URL into the `bun.lock` +/// registry slot whenever it is not under registry.npmjs.org, and Bun +/// 1.1.39–1.3.6 read an empty slot as npmjs whatever bunfig says. A hosted +/// rollback in a project whose `bunfig.toml` names a mirror must write the +/// mirror's URL back, not `""`: read from the mirror's own version document +/// when it answers, and rebuilt on the mirror from the default registry's +/// conventional URL (with `upstream_registry_fallback`) when it doesn't. +#[tokio::test] +#[serial] +async fn bun_rollback_keeps_the_bunfig_registry_tarball_url() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let integrity = "sha512-UPSTREAMupstream=="; + mock_npm_registry(&server, integrity, None).await; + // A mirror that serves its version document (conventional URLs). + let mirror_tarball = format!("{}/mirror/{NAME}/-/{NAME}-{VERSION}.tgz", server.uri()); + Mock::given(method("GET")) + .and(path(format!("/mirror/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": NAME, + "version": VERSION, + "dist": { "tarball": mirror_tarball, "integrity": integrity }, + }))) + .mount(&server) + .await; + + // `private` answers nothing: the restore falls back to the default + // registry's document and re-bases its conventional URL on the mirror. + for (mirror, readable) in [("mirror", true), ("private", false)] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), 1); + let lock_path = tmp.path().join("bun.lock"); + let slot = format!("{}/{mirror}/{NAME}/-/{NAME}-{VERSION}.tgz", server.uri()); + let pristine = std::fs::read_to_string(&lock_path) + .unwrap() + .replace("\"\", {}", &format!("\"{slot}\", {{}}")); + std::fs::write(&lock_path, &pristine).unwrap(); + std::fs::write( + tmp.path().join("bunfig.toml"), + format!("[install]\nregistry = \"{}/{mirror}/\"\n", server.uri()), + ) + .unwrap(); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 1, "{mirror}: {env:#}"); + assert!( + std::fs::read_to_string(&lock_path) + .unwrap() + .contains(HOSTED_URL), + "{mirror}: the lock is hosted" + ); + + let (code, env) = rollback_json(tmp.path(), &server); + assert_eq!(code, Some(0), "{mirror}: rollback: {env:#}"); + assert_eq!( + env["hosted"]["reverted"], + serde_json::json!([PURL]), + "{mirror}: {env:#}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + pristine, + "{mirror}: rollback writes the mirror's tarball URL back into the registry slot" + ); + assert_eq!( + env.to_string().contains("upstream_registry_fallback"), + !readable, + "{mirror}: {env:#}" + ); + } +} + // Native binary lockfiles are parsed and patched without invoking Bun. const INVALID_LOCKB_BYTES: &[u8] = b"\x00BUN-BINARY\xff\xfe\x00LOCK"; @@ -2015,6 +2089,15 @@ fn scrubbed_cli() -> std::process::Command { .env_remove("SOCKET_OFFLINE") .env_remove("SOCKET_ECOSYSTEMS") .env_remove("SOCKET_MANIFEST_PATH"); + // A registry exported by npm (`npm_config_registry`) or Bun would + // steer the Bun restores off the fixtures' registry. + for key in [ + "BUN_CONFIG_REGISTRY", + "NPM_CONFIG_REGISTRY", + "npm_config_registry", + ] { + cmd.env_remove(key); + } for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index d09fce8fa..96770f778 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -177,6 +177,15 @@ fn scrubbed_cli() -> std::process::Command { .env_remove("SOCKET_ECOSYSTEMS") .env_remove("SOCKET_MANIFEST_PATH") .env_remove("SOCKET_PRESERVE_STATE"); + // A registry exported by npm (`npm_config_registry`) or Bun would + // steer the Bun restores off the fixtures' registry. + for key in [ + "BUN_CONFIG_REGISTRY", + "NPM_CONFIG_REGISTRY", + "npm_config_registry", + ] { + cmd.env_remove(key); + } for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs index 67b6a9576..05851c204 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs @@ -35,6 +35,10 @@ //! `vendor_bun_workspace_unsupported` BEFORE the takeover restores //! anything, so the hosted wiring survives byte-for-byte; the v2 twin //! still takes over. +//! 6. A project with its own registries (bunfig `[install] registry` and +//! `[install.scopes]`, #992): `remove ` and the takeover + +//! `vendor --revert` chain keep each registry's tarball URL in the +//! slot (`in_process_vendor_bun_takeover/registry.rs`). //! //! Every child process gets the ambient `SOCKET_*` vars scrubbed and //! telemetry hard-disabled; each test runs in its own tempdir. @@ -53,6 +57,8 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; #[path = "vex_e2e_common/bun.rs"] mod bun_vex; +#[path = "in_process_vendor_bun_takeover/registry.rs"] +mod registry; #[path = "in_process_vendor_bun_takeover/vlt.rs"] mod vlt; #[path = "vlt_hosted_common/mod.rs"] @@ -354,6 +360,15 @@ fn run_cli(cwd: &Path, args: &[&str]) -> (i32, String, String) { cmd.env_remove(key); } } + // A registry exported by npm (`npm_config_registry`) or Bun would + // steer the Bun restores off the fixtures' registries (#992). + for key in [ + "BUN_CONFIG_REGISTRY", + "NPM_CONFIG_REGISTRY", + "npm_config_registry", + ] { + cmd.env_remove(key); + } cmd.env("SOCKET_TELEMETRY_DISABLED", "1") .env("SOCKET_NPM_REGISTRY", registry_uri()); let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); @@ -586,6 +601,9 @@ async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() { "bun.lock must be restored byte-identical to the pristine registry lock; got:\n{}", read(root, "bun.lock") ); + // #764: the hoisted node_modules/left-pad keeps the vendored bytes + // through a plain `bun install`; the envelope names the forcing one. + assert_bun_reinstall_event(&env); assert!( !root.join(".socket/vendor").exists(), ".socket/vendor must be fully pruned after the revert" @@ -1039,6 +1057,16 @@ fn bun_scoped_remove_of_one_of_two_hosted_records_unwinds_only_that_purl() { "no top-level error expected: {env:#}" ); assert_only_left_pad_unwound(root, &pristine); + // #764: the restored pin's hoisted copy is kept by a plain `bun + // install`; the advisory names it, and not the still-hosted sibling. + let detail = run_warning(&env, "redirect_bun_reinstall_required") + .unwrap_or_else(|| panic!("remove must advise a forced reinstall: {env:#}")); + assert!( + detail.contains("left-pad@1.3.0") + && !detail.contains("other@") + && detail.contains("`bun install --force`"), + "{detail}" + ); } // ───────────────────────────────────────────────────────────────────── @@ -1280,3 +1308,169 @@ fn bun_vendor_over_hosted_v2_workspace_lock_still_takes_over() { ); assert!(lock.starts_with("{\n \"lockfileVersion\": 2,\n"), "{lock}"); } + +// ───────────────────────────────────────────────────────────────────── +// 6. Bun keeps the vendored copy after an unwind (#764) +// ───────────────────────────────────────────────────────────────────── +// Bun's hoisted linker does not re-extract a package whose lock entry moves +// back to the registry record of the same name@version, so every command +// that unwinds a vendored bun.lock entry must name `bun install --force`. + +/// The `detail` of the run-level `warnings[]` entry with `code`. +fn run_warning<'a>(env: &'a Value, code: &str) -> Option<&'a str> { + env["warnings"] + .as_array()? + .iter() + .find(|w| w["code"] == code) + .and_then(|w| w["detail"].as_str()) +} + +/// The envelope carries the per-entry `vendor_bun_reinstall_required` +/// advisory for left-pad, naming the install that does reinstall. +fn assert_bun_reinstall_event(env: &Value) { + let event = find_event(env, "skipped", Some("vendor_bun_reinstall_required")); + assert_eq!(event["purl"], PURL, "{env:#}"); + let detail = event["reason"].as_str().unwrap_or_default(); + assert!( + detail.contains("left-pad@1.3.0") && detail.contains("`bun install --force`"), + "{env:#}" + ); +} + +/// A hoisted bun project with left-pad vendored over its (v5) hosted pin +/// by a plain `vendor` run; returns the pristine registry lock. +fn write_vendored_project(root: &Path) -> String { + let pristine = pristine_lock(); + write_bun_project(root, &pristine, &[(NAME, VERSION)]); + let hosted = pristine.replace( + LEFT_PAD_REGISTRY_LINE, + &hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512), + ); + assert_ne!(hosted, pristine, "the hosted splice must hit"); + std::fs::write(root.join("bun.lock"), &hosted).unwrap(); + seed_manifest_and_blob(root); + let (code, env) = vendor_cli(root, &[]); + assert_eq!(code, 0, "vendor must succeed: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + assert_pure_vendored(root); + pristine +} + +/// `remove ` of a vendored bun.lock entry — manifest-backed and +/// ledger-only alike — restores the registry line and carries the +/// advisory in its envelope. +#[test] +fn bun_remove_of_a_vendored_entry_advises_a_forced_reinstall() { + for ledger_only in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_vendored_project(root); + if ledger_only { + std::fs::remove_file(root.join(".socket/manifest.json")).unwrap(); + } + let (code, env) = run_json( + root, + &[ + "remove", + PURL, + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "ledger_only={ledger_only}: {env:#}"); + assert_eq!( + read(root, "bun.lock"), + pristine, + "ledger_only={ledger_only}" + ); + assert_bun_reinstall_event(&env); + } +} + +/// A pre-v5 ledger recorded the HOSTED line as left-pad's pre-vendor +/// original, so `vendor --revert` re-wires it to the patch server and then +/// restores its upstream registry entry. The vendored revert's per-entry +/// advisory already names left-pad; the hosted unwind's run-level twin for +/// the same package is dropped instead of repeating it. +#[test] +fn bun_pre_v5_revert_advises_a_forced_reinstall_once() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_vendored_project(root); + let state_path = root.join(".socket/vendor/state.json"); + let mut state: Value = serde_json::from_str(&read(root, ".socket/vendor/state.json")).unwrap(); + let wiring = state["entries"][PURL]["wiring"].as_array_mut().unwrap(); + let record = wiring + .iter_mut() + .find(|w| w["kind"] == "bun_lock_package") + .unwrap(); + record["original"] = json!(hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512)); + std::fs::write(&state_path, serde_json::to_vec_pretty(&state).unwrap()).unwrap(); + + let (code, env) = vendor_cli(root, &["--revert"]); + assert_eq!(code, 0, "revert must succeed: {env:#}"); + find_event(&env, "skipped", Some("vendor_revert_restored_upstream")); + assert_eq!( + read(root, "bun.lock"), + pristine, + "back to the registry line" + ); + assert_bun_reinstall_event(&env); + assert_eq!( + events(&env) + .iter() + .filter(|e| e["errorCode"] == "vendor_bun_reinstall_required") + .count(), + 1, + "{env:#}" + ); + assert!( + run_warning(&env, "redirect_bun_reinstall_required").is_none(), + "the run-level twin repeats the per-entry advisory: {env:#}" + ); +} + +/// `scan --prune` reverts a `vendor`-tracked entry whose patch left the +/// manifest (the GC's leg (a)). That revert restores left-pad's registry +/// line under the same hoisted copy, so the `gc` sub-object carries the +/// advisory too. +#[tokio::test(flavor = "multi_thread")] +async fn bun_scan_prune_revert_advises_a_forced_reinstall() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_vendored_project(root); + std::fs::write(root.join(".socket/manifest.json"), "{\"patches\": {}}\n").unwrap(); + + let (code, env) = scan_mode(root, &server.uri(), "vendored", &["--prune"]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!( + env["gc"]["revertedVendoredEntries"], + json!([PURL]), + "{env:#}" + ); + assert_eq!( + read(root, "bun.lock"), + pristine, + "back to the registry line" + ); + let advised = env["gc"]["warnings"].as_array().is_some_and(|ws| { + ws.iter().any(|w| { + w["code"] == "vendor_bun_reinstall_required" + && w["detail"].as_str().is_some_and(|d| { + d.contains("left-pad@1.3.0") && d.contains("`bun install --force`") + }) + }) + }); + assert!(advised, "{env:#}"); +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/registry.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/registry.rs new file mode 100644 index 000000000..3aaf3a7c2 --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/registry.rs @@ -0,0 +1,284 @@ +//! Bun hosted unwinds in a project with its own registries (#992), through +//! the built binary: `bunfig.toml` `[install] registry` for `left-pad` and +//! an `[install.scopes]` entry for `@corp/widget`. Bun writes the full +//! tarball URL into a `bun.lock` registry slot for any registry but npmjs, +//! and Bun 1.1.39–1.3.6 read an empty slot as npmjs whatever bunfig says, +//! so every unwind of a hosted pin — `remove `, and the hosted → +//! vendored takeover that `vendor --revert` later returns to — must give +//! back the URL Bun wrote, read from each package's own registry. +//! +//! Each registry's version document advertises an off-path (CDN-style) +//! tarball URL, so a restore that read the wrong registry, or fell back to +//! the default one and re-based its conventional URL, cannot land on the +//! pristine bytes by accident. The default registry (`SOCKET_NPM_REGISTRY`, +//! the shared mirror) does not know `@corp/widget` at all, and the scope's +//! registry is private: it answers 401 unless the restore sends the +//! `[install.scopes]` entry's token, as Bun does. + +use std::path::Path; + +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{header, method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use super::{ + assert_no_event_code, find_event, hosted_line, line_integrity, lock_line, patch_record, read, + run_json, vendor_cli, write_bun_project, HOSTED_URL, LEFT_PAD_REGISTRY_LINE, NAME, + PATCHED_INDEX, PATCHED_SHA512, PURL, UUID, VERSION, +}; + +const SCOPED_NAME: &str = "@corp/widget"; +const SCOPED_VERSION: &str = "2.0.0"; +const SCOPED_PURL: &str = "pkg:npm/@corp/widget@2.0.0"; +const SCOPED_UUID: &str = "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f"; +const SCOPED_HOSTED_URL: &str = "https://patch.socket.dev/patch/npm/@corp/widget/2.0.0/55555555-5555-4555-8555-555555555555/3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f/widget-2.0.0.tgz"; +const SCOPED_INTEGRITY: &str = "sha512-corpWIDGETcorp0123456789=="; +const SCOPED_PATCHED_SHA512: &str = "sha512-corpPATCHEDcorp0123456789=="; +/// The `@corp` scope registry's token, from `bunfig.toml`. +const SCOPE_TOKEN: &str = "corp-secret-token"; + +/// The project's registries, all on one wiremock: the bunfig default +/// registry (`/mirror/`) and the `@corp` scope's (`/corp/`). +struct Registries { + server: MockServer, +} + +impl Registries { + async fn start() -> Self { + let server = MockServer::start().await; + let registries = Self { server }; + let mirror_doc = json!({ + "name": NAME, + "version": VERSION, + "dist": { + "tarball": registries.mirror_tarball(), + "integrity": line_integrity(LEFT_PAD_REGISTRY_LINE), + } + }); + Mock::given(method("GET")) + .and(path(format!("/mirror/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(mirror_doc)) + .mount(®istries.server) + .await; + // Bun and npm ask for a scoped document as `@scope%2fname`. The + // private scope registry serves it only with the scope's token. + Mock::given(method("GET")) + .and(path_regex(r"^/corp/")) + .respond_with(ResponseTemplate::new(401)) + .with_priority(10) + .mount(®istries.server) + .await; + Mock::given(method("GET")) + .and(path_regex(r"^/corp/@corp(%2[fF]|/)widget/2\.0\.0$")) + .and(header( + "authorization", + format!("Bearer {SCOPE_TOKEN}").as_str(), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": SCOPED_NAME, + "version": SCOPED_VERSION, + "dist": { + "tarball": registries.scoped_tarball(), + "integrity": SCOPED_INTEGRITY, + } + }))) + .with_priority(1) + .mount(®istries.server) + .await; + registries + } + + fn mirror_tarball(&self) -> String { + format!( + "{}/mirror-cdn/files/{NAME}-{VERSION}.tgz", + self.server.uri() + ) + } + + fn scoped_tarball(&self) -> String { + format!("{}/corp-cdn/widget/{SCOPED_VERSION}.tgz", self.server.uri()) + } + + fn bunfig(&self) -> String { + let uri = self.server.uri(); + format!( + "[install]\nregistry = \"{uri}/mirror/\"\n\n\ + [install.scopes]\ncorp = {{ url = \"{uri}/corp/\", token = \"{SCOPE_TOKEN}\" }}\n" + ) + } + + /// The registry lines Bun writes for this project: each slot holds the + /// tarball URL of the registry the package resolved against. + fn left_pad_line(&self) -> String { + LEFT_PAD_REGISTRY_LINE.replace("\"\", {}", &format!("\"{}\", {{}}", self.mirror_tarball())) + } + + fn scoped_line(&self) -> String { + format!( + " \"{SCOPED_NAME}\": [\"{SCOPED_NAME}@{SCOPED_VERSION}\", \"{}\", {{}}, \"{SCOPED_INTEGRITY}\"],", + self.scoped_tarball() + ) + } + + /// The Bun-written registry lock (lockfileVersion 2) for both packages. + fn pristine_lock(&self) -> String { + format!( + "{{\n \"lockfileVersion\": 2,\n \"configVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \"name\": \"bun-takeover-fixture\",\n \"dependencies\": {{\n \"{SCOPED_NAME}\": \"{SCOPED_VERSION}\",\n \"left-pad\": \"1.3.0\",\n }},\n }},\n }},\n \"packages\": {{\n{}\n\n{}\n }}\n}}\n", + self.scoped_line(), + self.left_pad_line(), + ) + } +} + +/// A project with both packages pinned hosted, as `scan --mode hosted` +/// leaves it (the lock's URL 3-tuples, no ledger) beside its bunfig.toml; +/// returns the pristine registry lock. +fn write_hosted_project(root: &Path, registries: &Registries) -> String { + let pristine = registries.pristine_lock(); + write_bun_project( + root, + &pristine, + &[(NAME, VERSION), (SCOPED_NAME, SCOPED_VERSION)], + ); + std::fs::write(root.join("bunfig.toml"), registries.bunfig()).unwrap(); + let hosted = pristine + .replace( + ®istries.left_pad_line(), + &hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512), + ) + .replace( + ®istries.scoped_line(), + &hosted_line( + SCOPED_NAME, + SCOPED_NAME, + SCOPED_HOSTED_URL, + SCOPED_PATCHED_SHA512, + ), + ); + assert!( + !hosted.contains("/mirror-cdn/") && !hosted.contains("/corp-cdn/"), + "both lines are hosted:\n{hosted}" + ); + std::fs::write(root.join("bun.lock"), &hosted).unwrap(); + pristine +} + +/// `.socket/manifest.json` with both records + the after-hash blob, the +/// records `vendor` takes over. +fn seed_manifest(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let mut bytes = serde_json::to_vec_pretty(&json!({ "patches": { + PURL: patch_record(UUID), + SCOPED_PURL: patch_record(SCOPED_UUID), + }})) + .unwrap(); + bytes.push(b'\n'); + std::fs::write(socket.join("manifest.json"), &bytes).unwrap(); + std::fs::write( + socket + .join("blobs") + .join(compute_git_sha256_from_bytes(PATCHED_INDEX)), + PATCHED_INDEX, + ) + .unwrap(); +} + +fn assert_no_registry_fallback(env: &Value) { + assert!( + !env.to_string().contains("upstream_registry_fallback"), + "every registry was readable: {env:#}" + ); + assert!( + !env.to_string().contains(SCOPE_TOKEN), + "the scope's token is never reported: {env:#}" + ); +} + +/// `remove ` of each hosted pin in turn restores its line with the +/// tarball URL of the registry Bun resolved it against — the bunfig +/// default registry for `left-pad`, the `[install.scopes]` registry for +/// `@corp/widget` — landing on the pristine lock byte for byte. +#[tokio::test(flavor = "multi_thread")] +async fn bun_remove_restores_the_bunfig_and_scope_registry_tarball_urls() { + let registries = Registries::start().await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_hosted_project(root, ®istries); + let cwd = root.to_str().unwrap(); + + let (code, env) = run_json(root, &["remove", PURL, "--yes", "--json", "--cwd", cwd]); + assert_eq!(code, 0, "remove left-pad: {env:#}"); + assert!(env["error"].is_null(), "{env:#}"); + assert_no_registry_fallback(&env); + let lock = read(root, "bun.lock"); + assert_eq!( + lock_line(&lock, NAME), + lock_line(&pristine, NAME), + "left-pad's slot is the bunfig registry's tarball URL:\n{lock}" + ); + assert!( + lock_line(&lock, SCOPED_NAME).contains(SCOPED_HOSTED_URL), + "the scoped pin stays hosted:\n{lock}" + ); + + let (code, env) = run_json( + root, + &["remove", SCOPED_PURL, "--yes", "--json", "--cwd", cwd], + ); + assert_eq!(code, 0, "remove @corp/widget: {env:#}"); + assert!(env["error"].is_null(), "{env:#}"); + assert_no_registry_fallback(&env); + assert_eq!( + read(root, "bun.lock"), + pristine, + "the scope's tarball URL is restored and the lock is pristine" + ); +} + +/// The hosted → vendored takeover restores both registry lines (with their +/// registries' tarball URLs) before vendoring, records them as the vendor +/// ledger's originals, and `vendor --revert` returns the pristine lock. +#[tokio::test(flavor = "multi_thread")] +async fn bun_takeover_then_vendor_revert_keeps_the_bunfig_and_scope_registry_urls() { + let registries = Registries::start().await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_hosted_project(root, ®istries); + seed_manifest(root); + + let (code, env) = vendor_cli(root, &[]); + assert_eq!(code, 0, "vendor over the hosted pins: {env:#}"); + assert_eq!(env["summary"]["applied"], 2, "{env:#}"); + find_event(&env, "skipped", Some("vendor_takeover_reverted_redirect")); + assert_no_event_code(&env, "redirect_revert_failed"); + assert_no_registry_fallback(&env); + let lock = read(root, "bun.lock"); + assert!( + !lock.contains(HOSTED_URL) && !lock.contains(SCOPED_HOSTED_URL), + "no hosted pin is left:\n{lock}" + ); + + let state: Value = serde_json::from_str(&read(root, ".socket/vendor/state.json")).unwrap(); + for (purl, key) in [(PURL, NAME), (SCOPED_PURL, SCOPED_NAME)] { + let original = state["entries"][purl]["wiring"] + .as_array() + .and_then(|w| w.iter().find(|r| r["kind"] == "bun_lock_package")) + .map(|r| r["original"].clone()) + .unwrap_or_else(|| panic!("{purl}: bun_lock_package wiring: {state:#}")); + assert_eq!( + original, + json!(lock_line(&pristine, key)), + "{purl}: the ledger's original carries the registry's tarball URL: {state:#}" + ); + } + + let (code, env) = vendor_cli(root, &["--revert"]); + assert_eq!(code, 0, "vendor --revert: {env:#}"); + assert_eq!( + read(root, "bun.lock"), + pristine, + "the revert lands on the Bun-written registry lock" + ); +} diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index d6d7af5f1..8c0850eb0 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -1115,6 +1115,13 @@ async fn scan_prune_reverts_unused_vendored_entry() { serde_json::json!([]), "nothing resolves through the artifact, so nothing is kept: {v}" ); + // The revert warns `vendor_lock_entry_removed` (nothing to restore): + // routine for a prune of an uninstalled dependency, so it is not a + // gc warning. + assert!( + v["gc"].get("warnings").is_none(), + "a routine prune adds no gc warning: {v}" + ); // Ledger empty (an emptied state file is removed outright), artifact // gone. diff --git a/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs b/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs index 1e4911e29..27bad0a4f 100644 --- a/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs @@ -93,6 +93,15 @@ impl Project { cmd.env_remove(key); } } + // A registry exported by npm (`npm_config_registry`) or Bun would + // steer the takeover's restore off the fixtures' registry (#992). + for key in [ + "BUN_CONFIG_REGISTRY", + "NPM_CONFIG_REGISTRY", + "npm_config_registry", + ] { + cmd.env_remove(key); + } let uri = self.server.uri(); cmd.env("SOCKET_TELEMETRY_DISABLED", "1") .env("SOCKET_API_URL", &uri) @@ -302,6 +311,7 @@ async fn takeover_vendors_over_a_hosted_bun_lockb_and_reverts_exactly() { p.lock() == p.pristine, "{writer}: the revert restores the pre-hosted bun.lockb byte for byte" ); + assert_reinstall_advised(&env); } } @@ -317,6 +327,27 @@ async fn eject_vendors_a_hosted_bun_lockb_and_reverts_exactly() { let (code, env) = p.run_json(&["vendor", "--revert"]); assert_eq!(code, 0, "revert: {env:#}"); assert!(p.lock() == p.pristine, "exact pre-hosted bytes"); + assert_reinstall_advised(&env); +} + +/// #764: the revert put minimist's registry record back while the hoisted +/// `node_modules/minimist` still holds the vendored bytes, which a plain +/// `bun install` keeps: the envelope carries exactly one per-entry advisory +/// naming `bun install --force`. +fn assert_reinstall_advised(env: &Value) { + let advisories: Vec<&Value> = env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["errorCode"] == "vendor_bun_reinstall_required") + .collect(); + assert_eq!(advisories.len(), 1, "{env:#}"); + assert_eq!(advisories[0]["purl"], PURL, "{env:#}"); + let detail = advisories[0]["reason"].as_str().unwrap_or_default(); + assert!( + detail.contains("minimist@1.2.2") && detail.contains("`bun install --force`"), + "{env:#}" + ); } /// A hosted lock whose workspace dependency behaviors the rewrite had to @@ -399,6 +430,132 @@ async fn rollback_and_offline_vendor_refuse_with_the_checkout_remedy() { assert_eq!(p.lock(), hosted, "a refused rollback writes nothing"); } +/// #992: in a project whose `bunfig.toml` names a mirror, the takeover +/// rebuilds the binary record with the tarball URL the mirror's version +/// document advertises (Bun fetches from the URL the record holds), the +/// vendor ledger keeps it as the original, and `vendor --revert` writes it +/// back. A second hosted → vendored → revert round trip from that lock +/// lands on it byte for byte. +#[tokio::test] +async fn takeover_and_revert_keep_the_bunfig_registry_tarball_url() { + let p = hosted_project("1.1.38").await; + // An off-path (CDN-style) URL: a restore that fell back to the default + // registry and re-based its conventional URL cannot produce it. + let mirror_tarball = format!("{}/mirror-cdn/minimist-1.2.2.tgz", p.server.uri()); + Mock::given(method("GET")) + .and(path("/mirror/minimist/1.2.2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": "minimist", + "version": "1.2.2", + "dist": { "tarball": mirror_tarball, "integrity": UPSTREAM_INTEGRITY } + }))) + .mount(&p.server) + .await; + std::fs::write( + p.root().join("bunfig.toml"), + format!("[install]\nregistry = \"{}/mirror/\"\n", p.server.uri()), + ) + .unwrap(); + stage_record(p.root()); + + let mut reverted: Option> = None; + for round in 0..2 { + if let Some(lock) = &reverted { + // Pin the mirror lock hosted again, as `scan --mode hosted` does. + let dep: DepOverride = serde_json::from_value(json!({ + "ecosystem": "npm", + "name": "minimist", + "version": "1.2.2", + "token": GRANT, + "patchUuid": UUID, + "artifactUrl": p.hosted_url(), + "integrity": { "sha512": format!( + "sha512-{}", base64::engine::general_purpose::STANDARD.encode([42u8; 64])) } + })) + .unwrap(); + let mut rewrite = RewriteResult::default(); + rewrite_bun_binary(lock, &[dep], &mut rewrite); + assert!(rewrite.warnings.is_empty(), "{:?}", rewrite.warnings); + std::fs::write( + p.root().join("bun.lockb"), + &rewrite.binary_files["bun.lockb"], + ) + .unwrap(); + } + + let (code, env) = p.run_json(&["vendor"]); + assert_eq!( + code, 0, + "round {round}: vendor over the hosted pin: {env:#}" + ); + assert_eq!(env["summary"]["applied"], 1, "round {round}: {env:#}"); + let codes = codes(&env); + assert!( + codes + .iter() + .any(|c| c == "vendor_takeover_reverted_redirect"), + "round {round}: {env:#}" + ); + assert!( + !codes.iter().any(|c| c == "upstream_registry_fallback"), + "round {round}: the mirror was readable: {env:#}" + ); + let original = vendored_original(p.root()); + assert_eq!( + original["resolution"], mirror_tarball, + "round {round}: {original}" + ); + assert_eq!( + original["integrity"], UPSTREAM_INTEGRITY, + "round {round}: {original}" + ); + + let (code, env) = p.run_json(&["vendor", "--revert"]); + assert_eq!(code, 0, "round {round}: revert: {env:#}"); + let lock = p.lock(); + let text = String::from_utf8_lossy(&lock); + // The string pool keeps the replaced npmjs URL as dead bytes (the + // codec appends); the record references the mirror's. + assert!( + text.contains(&mirror_tarball), + "round {round}: the reverted record fetches from the mirror" + ); + assert!( + !text.contains(GRANT) && !text.contains(".socket/vendor"), + "round {round}: no hosted or vendored residue" + ); + if let Some(first) = &reverted { + assert!( + &lock == first, + "a mirror lock round-trips through hosted, vendored and revert byte for byte" + ); + } + reverted = Some(lock); + } + + // The reverted lock's own record (what a plain vendor snapshots from + // it) is the mirror's, not just a string left in its pool. + let reverted = reverted.unwrap(); + let (code, env) = p.run_json(&["vendor"]); + assert_eq!(code, 0, "vendor over the reverted lock: {env:#}"); + assert_eq!(vendored_original(p.root())["resolution"], mirror_tarball); + let (code, env) = p.run_json(&["vendor", "--revert"]); + assert_eq!(code, 0, "{env:#}"); + assert!(p.lock() == reverted, "the plain revert is exact"); +} + +/// The vendor ledger's recorded pre-vendor `bun_lockb_package` record. +fn vendored_original(root: &Path) -> Value { + let state: Value = + serde_json::from_slice(&std::fs::read(root.join(".socket/vendor/state.json")).unwrap()) + .unwrap(); + state["entries"][PURL]["wiring"] + .as_array() + .and_then(|w| w.iter().find(|r| r["kind"] == "bun_lockb_package")) + .map(|r| r["original"].clone()) + .unwrap_or_else(|| panic!("bun_lockb_package wiring: {state:#}")) +} + /// A Bun workspace's member-relative mirror of the vendored tarball goes /// missing while the canonical tarball still matches its ledger SHA-256: /// an offline re-vendor rewrites the mirror from the committed tarball diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs index d10399d62..2c92ec45c 100644 --- a/crates/socket-patch-core/src/crawlers/mod.rs +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -29,7 +29,7 @@ pub use composer_crawler::ComposerCrawler; pub use deno_crawler::DenoCrawler; pub use go_crawler::GoCrawler; pub use maven_crawler::MavenCrawler; -pub use npm_crawler::NpmCrawler; +pub use npm_crawler::{bun_uses_global_store, NpmCrawler}; pub use nuget_crawler::NuGetCrawler; pub use pkg_managers::{detect_npm_pkg_manager, NpmPkgManager, YarnPnpLoader}; pub use python_crawler::PythonCrawler; diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 9133ba9d1..7b796dcff 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -519,6 +519,25 @@ const PNPM_SHAPED_STORES: [(&str, StoreLayout); 3] = [ (".deno", StoreLayout::Deno), ]; +/// The entry dirs of a pnpm-shaped store: every real dir but the hidden +/// metadata and the `node_modules` hoist dir, and for Bun each link into +/// its global store (#635). +fn pnpm_shaped_store_candidates_sync(store_path: &Path, layout: StoreLayout) -> Vec { + list_dir_sync(store_path) + .entries + .into_iter() + .filter(|entry| { + !(entry.name_str.starts_with('.') || entry.name_str == "node_modules") + && entry.file_type.is_some_and(|ft| { + ft.is_dir() + || (ft.is_symlink() + && layout == StoreLayout::Bun + && is_bun_global_store_link_sync(store_path, &entry.name_str)) + }) + }) + .collect() +} + /// The pnpm-shaped store layout a `node_modules` child named `name` is. fn pnpm_shaped_store_layout(name: &str) -> Option { PNPM_SHAPED_STORES @@ -536,6 +555,713 @@ fn decode_bun_store_entry_name(entry_name: &str) -> Option<(String, String)> { decode_pnpm_store_entry_name(entry_name).filter(|(_, version)| !version.contains('+')) } +/// Whether the `.bun` entry `entry_name` of `store` is a link into Bun's +/// global store (`[install] globalStore`, Bun >= 1.3.14, #635): every +/// entry is then a link to `/links/-`, a dir +/// shared by every project on the machine. The entry is still this +/// project's installed copy (its transitive dependencies live nowhere +/// else), so it is walked: VEX must see its bytes, and agent apply and +/// rollback refuse it as shared (see [`crate::patch::shared_store`]) +/// instead of reporting it as not installed. Other links are skipped. +fn is_bun_global_store_link_sync(store: &Path, entry_name: &str) -> bool { + std::fs::canonicalize(store.join(entry_name)).is_ok_and(|real| { + real.is_dir() && crate::patch::shared_store::is_bun_global_store_entry(&real, entry_name) + }) +} + +/// Whether `project_root` was installed with Bun's global store (#635): +/// some `node_modules/.bun/` is a link into +/// `/links/-`. Read from the installed tree rather +/// than bunfig.toml or `BUN_INSTALL_GLOBAL_STORE`, which may not match +/// the layout the last install actually wrote. Stops at the first such +/// link; only links are resolved. +pub fn bun_uses_global_store(project_root: &Path) -> bool { + let store = project_root.join("node_modules").join(".bun"); + let Ok(entries) = std::fs::read_dir(&store) else { + return false; + }; + entries.flatten().any(|entry| { + entry.file_type().is_ok_and(|ft| ft.is_symlink()) + && entry + .file_name() + .to_str() + .is_some_and(|name| is_bun_global_store_link_sync(&store, name)) + }) +} + +/// The `.bun` store entries an install can still load (#599), with the +/// `node_modules` listings of the entries the walk read (by entry name, +/// so the scan does not list them a second time); `None` when the walk +/// cannot tell, and every entry must be kept. Bun never prunes its store: +/// an in-place `bun install` that re-resolves a package (a hosted tarball +/// rewire, a version bump) writes a new entry, re-links every dependent +/// to it and leaves the old `@` dir behind with nothing +/// pointing at it. Such an orphan is not an installed copy, and a +/// judgement of the live install (the scan, `vex`) must not count it. +/// Restoring operations still must (rollback, remove): a later install +/// that resolves back to that version re-links the orphan as it is, so +/// only those judgement callers use this. +/// +/// An entry is live when a link reaches it from the `node_modules` +/// holding the store, from Bun's hidden hoist dir `.bun/node_modules` +/// (every store package resolves through it), from a workspace member's +/// `node_modules`, or from a live entry's `node_modules`. The members are +/// the ones Bun itself installs (see +/// [`bun_workspace_member_node_modules_sync`]), read only when the cheaper +/// seeds leave some entry unreached; when they cannot be read, nothing +/// is dropped. A stale link Bun left behind still counts: the runtime +/// resolves through it. A store no link reaches at all gives no evidence +/// either way, so every entry is kept. +/// +/// A first, quick walk guesses link targets by name (see +/// [`BunStoreNames`]), which keeps a clean store's walk to the listings +/// the scan reads anyway. The guesses only ever over-reach, except that an +/// alias link can defeat one (`lp` linking `left-pad@…` while an `lp@…` +/// entry exists), so when the quick walk leaves any entry unreached, the +/// store is walked again reading every link before anything is dropped. +/// +/// With Bun's global store (#635) the entries are links into the shared +/// `/links`, whose entries link their dependencies to one another +/// there, never back into this `.bun`: the walk follows them through the +/// cache, mapping each cache entry back to the `.bun` link naming it (see +/// [`BunStoreDirs`]). Bun leaves an orphaned link behind exactly as it +/// leaves an orphaned dir, and judges liveness the same way. +fn live_bun_store_entries_sync( + store_path: &Path, + candidates: &[ListedEntry], +) -> Option { + if candidates.is_empty() { + return None; + } + let dirs = BunStoreDirs::new(store_path, candidates)?; + let importer = store_path.parent()?; + let root = match importer.parent() { + Some(root) if !root.as_os_str().is_empty() => root, + _ => Path::new("."), + }; + let names = BunStoreNames::new(candidates); + // The quick walk reads every entry's listing (the scan reads them + // anyway) in one parallel pass up front, instead of one pass per + // frontier: each pass costs a round of the walk pool waking and + // parking for little work (#578). + let mut quick_reads: Vec> = + par_map((0..candidates.len()).collect::>(), |index| { + let nm = dirs.entry_node_modules(&Reached::Entry(index)); + Some(read_bun_store_node_modules_sync(&nm, &dirs, Some(&names))) + }); + // Read at most once, shared by both walks. + let mut members: Option>> = None; + let mut walk = |unique: Option<&BunStoreNames>, + mut read: Option<&mut [Option]>| { + let mut live = LiveBunStore::new(candidates.len()); + let seeds = [store_path.join("node_modules"), importer.to_path_buf()]; + reach_bun_store_entries_sync(&seeds, &dirs, unique, &mut live, read.as_deref_mut())?; + if live.unreached() { + let members = members + .get_or_insert_with(|| bun_workspace_member_node_modules_sync(root)) + .as_deref()?; + reach_bun_store_entries_sync(members, &dirs, unique, &mut live, read)?; + } + live.any().then_some(live) + }; + let quick = walk(Some(&names), Some(&mut quick_reads))?; + if !quick.unreached() { + return Some(quick); + } + walk(None, None) +} + +/// What [`live_bun_store_entries_sync`] found, by index into the +/// candidates it was given: the scan walks thousands of entries, so they +/// are tracked by position instead of hashed and cloned by name (#578). +struct LiveBunStore { + /// Whether each candidate is live. + live: Vec, + /// How many of `live` are set. + count: usize, + /// Reached names that are no candidate (walked all the same). + other: HashSet, + /// The `node_modules` listing of each candidate the walk read. + listings: Vec>, +} + +impl LiveBunStore { + fn new(candidates: usize) -> Self { + Self { + live: vec![false; candidates], + count: 0, + other: HashSet::new(), + listings: std::iter::repeat_with(|| None).take(candidates).collect(), + } + } + + /// Mark `entry` live: whether it was not already. + fn insert(&mut self, entry: &Reached) -> bool { + match entry { + Reached::Entry(index) => { + let fresh = !std::mem::replace(&mut self.live[*index], true); + self.count += usize::from(fresh); + fresh + } + Reached::Other(name) => !self.other.contains(name) && self.other.insert(name.clone()), + } + } + + /// Some candidate is not live. + fn unreached(&self) -> bool { + self.count < self.live.len() + } + + /// Anything is live. + fn any(&self) -> bool { + self.count > 0 || !self.other.is_empty() + } + + /// The names of every live entry. + fn into_names(self, candidates: &[ListedEntry]) -> HashSet { + let mut names = self.other; + names.extend( + candidates + .iter() + .zip(self.live) + .filter(|(_, live)| *live) + .map(|(entry, _)| entry.name.clone()), + ); + names + } +} + +/// A `.bun` entry a link reaches: a candidate (by index) or some other +/// name in the store. +enum Reached { + Entry(usize), + Other(OsString), +} + +/// The `node_modules` dirs of the workspace members a Bun install at +/// `root` links: every member `bun.lock` lists under `workspaces`, plus +/// every dir the root `package.json` `workspaces` patterns match (the only +/// source for a binary `bun.lockb`). A pattern's `*` and `?` match any +/// name, dot-names included, and `!` exclusions are ignored: an extra +/// member can only keep an entry, never drop one. When the patterns cannot +/// be read (an unreadable or unparseable `package.json`, a `workspaces` +/// field of another shape, a `**` walk past its budget), a `bun.lock` +/// whose `workspaces` section parses is the member set on its own: Bun +/// rewrites it on every install, so it names every member the install +/// linked. Without one the member set cannot be known: `None`, and the +/// caller keeps every entry. +fn bun_workspace_member_node_modules_sync(root: &Path) -> Option> { + use crate::vendor::bun_lock_text::{is_plain_member_dir, workspace_member_dirs}; + + // `Some` once the lock's `workspaces` section parsed (it always lists + // the root, `""`). + let locked: Option> = + match crate::utils::fs::read_regular_to_string_sync(&root.join("bun.lock")) { + Ok(text) => { + let lines: Vec = text.lines().map(str::to_string).collect(); + let dirs = workspace_member_dirs(&lines); + (!dirs.is_empty()).then(|| { + dirs.into_iter() + .filter(|dir| !dir.is_empty() && is_plain_member_dir(dir)) + .map(|dir| root.join(dir)) + .collect() + }) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return None, + }; + let members = match bun_workspace_pattern_members_sync(root) { + Some(mut members) => { + members.extend(locked.into_iter().flatten()); + members + } + None => locked?, + }; + let mut seen = HashSet::new(); + Some( + members + .into_iter() + .map(|member| member.join("node_modules")) + .filter(|nm| seen.insert(nm.clone()) && is_dir_sync(nm)) + .collect(), + ) +} + +/// The dirs the root `package.json` `workspaces` patterns match (see +/// [`bun_workspace_member_node_modules_sync`]); `None` when they cannot be +/// read. No `package.json` has no members. +fn bun_workspace_pattern_members_sync(root: &Path) -> Option> { + let text = match crate::utils::fs::read_regular_to_string_sync(&root.join("package.json")) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Some(Vec::new()), + Err(_) => return None, + }; + let text = text.strip_prefix('\u{feff}').unwrap_or(&text); + let doc: serde_json::Value = serde_json::from_str(text).ok()?; + let patterns = match doc.get("workspaces") { + None | Some(serde_json::Value::Null) => Vec::new(), + Some(serde_json::Value::Array(list)) => list.clone(), + Some(serde_json::Value::Object(map)) => match map.get("packages") { + None => Vec::new(), + Some(packages) => packages.as_array()?.clone(), + }, + Some(_) => return None, + }; + let mut members = Vec::new(); + for pattern in &patterns { + let pattern = pattern.as_str()?; + if pattern.starts_with('!') { + continue; + } + members.extend(expand_workspace_pattern_sync(root, pattern)?); + } + Some(members) +} + +/// The dirs below `root` a `workspaces` glob matches (`/`-separated; `*`, +/// `?` and `[...]` classes within one component, `**` any number of them), never inside a +/// `node_modules` and never through a link for `**`. `None` when a `**` +/// walk passes [`WORKSPACE_GLOB_DIR_BUDGET`] dirs. +fn expand_workspace_pattern_sync(root: &Path, pattern: &str) -> Option> { + let segments: Vec<&str> = pattern + .trim() + .split(['/', '\\']) + .filter(|s| !s.is_empty() && *s != ".") + .collect(); + let mut dirs = vec![root.to_path_buf()]; + let mut budget = WORKSPACE_GLOB_DIR_BUDGET; + for segment in segments { + let mut next = Vec::new(); + for dir in dirs { + if segment == "**" { + // Zero or more components: `dir` itself and every real dir + // below it. + let mut stack = vec![dir]; + while let Some(dir) = stack.pop() { + budget = budget.checked_sub(1)?; + for entry in list_dir_sync(&dir).entries { + if entry.name_str != "node_modules" + && entry.file_type.is_some_and(|ft| ft.is_dir()) + { + stack.push(dir.join(&entry.name)); + } + } + next.push(dir); + } + } else if segment.contains(['*', '?', '[']) { + let pattern: Vec = segment.chars().collect(); + for entry in list_dir_sync(&dir).entries { + let name: Vec = entry.name_str.chars().collect(); + if entry.name_str != "node_modules" + && crate::utils::workspace_globs::segment_glob_matches(&pattern, &name) + && is_dir_sync(&dir.join(&entry.name)) + { + next.push(dir.join(&entry.name)); + } + } + } else { + next.push(dir.join(segment)); + } + } + dirs = next; + } + Some(dirs) +} + +/// How many dirs one `workspaces` `**` pattern may walk before the member +/// set is called unknown (see [`bun_workspace_member_node_modules_sync`]). +const WORKSPACE_GLOB_DIR_BUDGET: usize = 20_000; + +/// Paths among `paths` that are copies inside an orphaned `.bun` store +/// entry (see [`live_bun_store_entries_sync`]), judged by where each +/// canonicalizes, or, when that is out of every store (a global store +/// entry's shared dir, #635), by the `.bun` entry the path itself names; +/// each store is walked once. +fn orphaned_bun_store_copies_sync(paths: &[PathBuf]) -> HashSet { + let mut stores: HashMap>> = HashMap::new(); + let mut orphans = HashSet::new(); + for path in paths { + let Ok(real) = std::fs::canonicalize(path) else { + continue; + }; + let Some((store, entry)) = bun_store_entry_of(&real).or_else(|| { + let (store, entry) = bun_store_entry_of( + &crate::utils::relpath::normalize_lexically_keeping_escapes(path), + )?; + Some((std::fs::canonicalize(store).ok()?, entry)) + }) else { + continue; + }; + let live = stores.entry(store).or_insert_with_key(|store| { + let candidates = pnpm_shaped_store_candidates_sync(store, StoreLayout::Bun); + live_bun_store_entries_sync(store, &candidates).map(|live| live.into_names(&candidates)) + }); + if live.as_ref().is_some_and(|live| !live.contains(&entry)) { + orphans.insert(path.clone()); + } + } + orphans +} + +/// The `node_modules/.bun` store a real path lies in, and the name of the +/// entry holding it. +fn bun_store_entry_of(real: &Path) -> Option<(PathBuf, OsString)> { + let mut child: Option<&OsStr> = None; + for dir in real.ancestors() { + if dir + .file_name() + .and_then(OsStr::to_str) + .and_then(pnpm_shaped_store_layout) + == Some(StoreLayout::Bun) + && dir.parent().and_then(Path::file_name) == Some(OsStr::new("node_modules")) + { + return Some((dir.to_path_buf(), child?.to_os_string())); + } + child = dir.file_name(); + } + None +} + +/// Drop from each list every copy that sits in an orphaned Bun store entry +/// (#599): one no link from the install reaches, which nothing can load. +/// For a check of the live install (`vex`), never for an operation that +/// restores copies. Each store is walked once across all the lists. +pub async fn retain_live_store_copies<'a>(lists: impl IntoIterator>) { + let lists: Vec<&mut Vec> = lists.into_iter().collect(); + let paths: Vec = lists.iter().flat_map(|list| list.iter().cloned()).collect(); + if paths.is_empty() { + return; + } + let orphans = run_walk(move || orphaned_bun_store_copies_sync(&paths)).await; + if orphans.is_empty() { + return; + } + for list in lists { + list.retain(|path| !orphans.contains(path)); + } +} + +/// The quick walk's guesses: a link named for a package only one `.bun` +/// entry holds points at that entry, and a `@scope` dir none of whose +/// packages has a second entry links (at most) that scope's entries, so +/// neither is read. Only a package with several entries (where an orphan +/// hides) needs its links read. +struct BunStoreNames { + /// Package name to the one candidate (by index) holding it. + unique: HashMap, + scopes: HashMap>, +} + +impl BunStoreNames { + fn new(candidates: &[ListedEntry]) -> Self { + let mut by_package: HashMap> = HashMap::new(); + for (index, entry) in candidates.iter().enumerate() { + if let Some(package) = bun_store_entry_package(&entry.name_str) { + by_package + .entry(package) + .and_modify(|only| *only = None) + .or_insert(Some(index)); + } + } + let mut unique = HashMap::new(); + let mut scopes: HashMap>> = HashMap::new(); + for (package, only) in by_package { + let scope = package.split_once('/').map(|(scope, _)| scope.to_string()); + match only { + Some(entry) => { + if let Some(scope) = scope { + if let Some(entries) = scopes.entry(scope).or_insert(Some(Vec::new())) { + entries.push(entry); + } + } + unique.insert(package, entry); + } + None => { + if let Some(scope) = scope { + scopes.insert(scope, None); + } + } + } + } + let scopes = scopes + .into_iter() + .filter_map(|(scope, entries)| Some((scope, entries?))) + .collect(); + Self { unique, scopes } + } +} + +/// The package a `.bun` entry name is for, spelled the way a link to it +/// is named (`@scope+leaf@…` is `@scope/leaf`); `None` without a version. +fn bun_store_entry_package(entry_name: &str) -> Option { + let skip = usize::from(entry_name.starts_with('@')); + let at = skip + entry_name.get(skip..)?.find('@')?; + let package = entry_name.get(..at).filter(|p| p.len() > skip)?; + Some(if skip == 1 { + package.replacen('+', "/", 1) + } else { + package.to_string() + }) +} + +/// A `node_modules` dir's listing and the `.bun` entries its links reach +/// (see [`bun_store_link_targets_sync`]). +type BunStoreRead = (Option, Option>); + +/// Read the `node_modules` dir `nm` for the orphan walk. +fn read_bun_store_node_modules_sync( + nm: &Path, + dirs: &BunStoreDirs, + names: Option<&BunStoreNames>, +) -> BunStoreRead { + let listing = read_dir_entries_sync(nm) + .map(|(entries, complete)| Listing::from_entries(entries, complete)); + let targets = match &listing { + Some(listing) => bun_store_link_targets_sync(nm, listing, dirs, names), + None => Some(Vec::new()), + }; + (listing, targets) +} + +/// Add to `live` every `.bun` entry reachable through links from the +/// `seeds` dirs, following each reached entry's own `node_modules` links, +/// and record each candidate's listing. A candidate already read into +/// `read` is taken from there; the rest are read one frontier at a time, +/// each frontier's dirs in parallel. With `names`, targets are guessed by +/// name where they can be. `None` when a link reaches a global store +/// entry this store does not link (see [`BunStoreDirs::entry_of`]): what +/// lies past it cannot be told. +fn reach_bun_store_entries_sync( + seeds: &[PathBuf], + dirs: &BunStoreDirs, + names: Option<&BunStoreNames>, + live: &mut LiveBunStore, + mut read: Option<&mut [Option]>, +) -> Option<()> { + let mut frontier: Vec<(Option, Option)> = seeds + .iter() + .filter_map(|nm| Some((None, Some(std::fs::canonicalize(nm).ok()?)))) + .collect(); + while !frontier.is_empty() { + let mut visited = Vec::with_capacity(frontier.len()); + let mut unread = Vec::new(); + for (entry, nm) in frontier { + let cached = match (&entry, read.as_deref_mut()) { + (Some(Reached::Entry(index)), Some(read)) => read[*index].take(), + _ => None, + }; + match cached { + Some((listing, targets)) => visited.push((entry, listing, targets)), + None => { + let nm = match (nm, &entry) { + (Some(nm), _) => nm, + (None, Some(entry)) => dirs.entry_node_modules(entry), + (None, None) => continue, + }; + unread.push((entry, nm)); + } + } + } + visited.extend(par_map(unread, |(entry, nm)| { + let (listing, targets) = read_bun_store_node_modules_sync(&nm, dirs, names); + (entry, listing, targets) + })); + frontier = Vec::new(); + for (entry, listing, targets) in visited { + if let (Some(Reached::Entry(index)), Some(listing)) = (entry, listing) { + live.listings[index] = Some(listing); + } + for target in targets? { + if live.insert(&target) { + frontier.push((Some(target), None)); + } + } + } + } + Some(()) +} + +/// Where a `.bun` store's entries really live, for the orphan walk: in the +/// (canonical) store dir itself, or, for each entry that is a link into +/// Bun's global store (#635), in its shared `/links/-` +/// dir, whose dependency links point at sibling cache dirs. +struct BunStoreDirs<'a> { + real_store: PathBuf, + /// The store's candidate entries, which [`Reached::Entry`] indexes. + candidates: &'a [ListedEntry], + /// Each candidate's index, by name. + index: HashMap<&'a OsStr, usize>, + /// The real dir of each global store entry, and the entry linking it. + global: HashMap, + /// The same, by entry. + global_dirs: HashMap, + /// The `links` dirs those real dirs sit in. + global_links: HashSet, +} + +impl<'a> BunStoreDirs<'a> { + /// `None` when the store or one of its global store links does not + /// resolve. + fn new(store_path: &Path, candidates: &'a [ListedEntry]) -> Option { + let real_store = std::fs::canonicalize(store_path).ok()?; + let mut global = HashMap::new(); + let mut global_dirs = HashMap::new(); + let mut global_links = HashSet::new(); + for (index, entry) in candidates.iter().enumerate() { + if entry.file_type.is_some_and(|ft| ft.is_symlink()) { + let real = std::fs::canonicalize(store_path.join(&entry.name)).ok()?; + global_links.insert(real.parent()?.to_path_buf()); + global.insert(real.clone(), index); + global_dirs.insert(index, real); + } + } + let index = candidates + .iter() + .enumerate() + .map(|(index, entry)| (entry.name.as_os_str(), index)) + .collect(); + Some(Self { + real_store, + candidates, + index, + global, + global_dirs, + global_links, + }) + } + + /// The real `node_modules` dir of `entry`. + fn entry_node_modules(&self, entry: &Reached) -> PathBuf { + let name = match entry { + Reached::Entry(index) => { + if let Some(dir) = self.global_dirs.get(index) { + return dir.join("node_modules"); + } + self.candidates[*index].name.as_os_str() + } + Reached::Other(name) => name.as_os_str(), + }; + let mut nm = PathBuf::with_capacity( + self.real_store.as_os_str().len() + name.len() + "/node_modules".len() + 1, + ); + nm.push(&self.real_store); + nm.push(name); + nm.push("node_modules"); + nm + } + + /// The entry named `name` of this store. + fn reached(&self, name: &OsStr) -> Reached { + match self.index.get(name) { + Some(&index) => Reached::Entry(index), + None => Reached::Other(name.to_os_string()), + } + } + + /// The entry a (lexical or real) path lies in: `Some(Some(entry))` in + /// this store or one of its global store entries, `Some(None)` in some + /// other entry of the same global store (not linked from this store, + /// so its reach is unknown), `None` anywhere else. + fn entry_of(&self, path: &Path) -> Option> { + if let Ok(below) = path.strip_prefix(&self.real_store) { + return match below.components().next() { + Some(std::path::Component::Normal(name)) => Some(Some(self.reached(name))), + _ => None, + }; + } + if self.global.is_empty() { + return None; + } + for dir in path.ancestors() { + if let Some(&entry) = self.global.get(dir) { + return Some(Some(Reached::Entry(entry))); + } + if dir + .parent() + .is_some_and(|links| self.global_links.contains(links)) + { + return Some(None); + } + } + None + } +} + +/// The `.bun` entries the package links in `listing` (of the real dir +/// `nm`; scoped ones under `@scope/`) point into. With `names`, a link +/// or scope dir it can guess is not read (see [`BunStoreNames`]); any +/// other link is read and +/// resolved lexically first (Bun writes relative targets, and `nm` is +/// real, so each `..` climbs a real dir), and one that lands outside the +/// store's entries that way (an absolute Windows junction, a linked +/// `node_modules`) is canonicalized instead. `None` when a link reaches +/// an unknown global store entry (see [`BunStoreDirs::entry_of`]). +fn bun_store_link_targets_sync( + nm: &Path, + listing: &Listing, + dirs: &BunStoreDirs, + names: Option<&BunStoreNames>, +) -> Option> { + let mut targets = Vec::new(); + // A link whose package name guesses its entry is never read, and is + // looked up before any path is built: a quick walk visits every link + // of every live entry, so the allocations add up (#578). + let guess = |package: &str| names.and_then(|names| names.unique.get(package)); + let mut links: Vec = Vec::new(); + for entry in &listing.entries { + let Some(file_type) = entry.file_type else { + continue; + }; + if entry.name_str.starts_with('.') { + continue; + } + if file_type.is_symlink() { + match guess(&entry.name_str) { + Some(&target) => targets.push(Reached::Entry(target)), + None => links.push(nm.join(&entry.name)), + } + } else if file_type.is_dir() && entry.name_str.starts_with('@') { + if let Some(entries) = names.and_then(|names| names.scopes.get(&entry.name_str)) { + targets.extend(entries.iter().map(|&entry| Reached::Entry(entry))); + continue; + } + let scope = nm.join(&entry.name); + for scoped in list_dir_sync(&scope).entries { + if scoped.file_type.is_some_and(|ft| ft.is_symlink()) { + let package = format!("{}/{}", entry.name_str, scoped.name_str); + match guess(&package) { + Some(&target) => targets.push(Reached::Entry(target)), + None => links.push(scope.join(&scoped.name)), + } + } + } + } + } + for link in &links { + let lexical = std::fs::read_link(link) + .ok() + .and_then(|target| { + Some(crate::utils::relpath::normalize_lexically_keeping_escapes( + &link.parent()?.join(target), + )) + }) + .and_then(|path| dirs.entry_of(&path)); + if let Some(Some(entry)) = lexical { + targets.push(entry); + continue; + } + let real = std::fs::canonicalize(link) + .ok() + .and_then(|path| dirs.entry_of(&path)); + match real.or(lexical) { + Some(Some(entry)) => targets.push(entry), + Some(None) => return None, + None => {} + } + } + Some(targets) +} + /// The `node_modules` child that is npm's `install-strategy=linked` store, /// also written by Yarn 4's pnpm linker (see /// [`store_entry_own_package_sync`]). @@ -1242,47 +1968,141 @@ pub fn parse_pnpm_root_output(stdout: &str) -> Option { Some(path) } -/// Get the bun global `node_modules` path via `bun pm bin -g`. +/// Get the bun global `node_modules` path: the global dir `bun pm ls -g` +/// reports, else (no `bun` to ask, or an answer we can't read) the one +/// Bun's own resolution picks from the environment, see +/// [`bun_global_dir_from_env`]. `None` when neither names a dir; see +/// [`resolve_bun_global_prefix`] for why. +/// +/// The packages' dir is never derived from `bun pm bin -g` (#443): Bun +/// moves its bin dir (`BUN_INSTALL_BIN`, bunfig `globalBinDir`) and its +/// global dir (`BUN_INSTALL_GLOBAL_DIR`) independently, so `/..` +/// named a dir that didn't exist and every Bun global vanished from a +/// global scan. pub fn get_bun_global_prefix() -> Option { - get_bun_global_prefix_with(&GlobalProbeRunner) + resolve_bun_global_prefix().ok().flatten() +} + +/// [`get_bun_global_prefix`], telling "Bun is not in use" (`Ok(None)`) +/// apart from "Bun is in use but its global dir can't be told" (`Err` with +/// the reason, #443), so a global scan can say so instead of reporting a +/// clean, empty result. +pub fn resolve_bun_global_prefix() -> Result, String> { + resolve_bun_global_prefix_with( + &GlobalProbeRunner, + &|var| std::env::var_os(var), + crate::utils::process::resolve_tool("bun").is_some(), + ) +} + +/// [`resolve_bun_global_prefix`] over an injected runner and environment. +/// Bun counts as in use when `bun_on_path`, or when `BUN_INSTALL_GLOBAL_DIR` +/// or `BUN_INSTALL` is set; otherwise an undeterminable dir is `Ok(None)`. +pub fn resolve_bun_global_prefix_with( + runner: &dyn CommandRunner, + var: &impl Fn(&str) -> Option, + bun_on_path: bool, +) -> Result, String> { + if let Some(prefix) = get_bun_global_prefix_with(runner) { + return Ok(Some(prefix)); + } + match bun_global_dir_from_env(var) { + Ok(dir) => Ok(Some(dir.join("node_modules").to_string_lossy().to_string())), + Err(why) + if bun_on_path + || var("BUN_INSTALL_GLOBAL_DIR").is_some() + || var("BUN_INSTALL").is_some() => + { + Err(why) + } + Err(_) => Ok(None), + } } /// Version of `get_bun_global_prefix` that accepts an injected -/// `CommandRunner`. See `get_npm_global_prefix_with`. +/// `CommandRunner` and only asks `bun` (no environment fallback). See +/// `get_npm_global_prefix_with`. pub fn get_bun_global_prefix_with(runner: &dyn CommandRunner) -> Option { - parse_bun_bin_output( + parse_bun_ls_global_output( runner - .run("bun", &["pm", "bin", "-g"]) + .run("bun", &["pm", "ls", "-g"]) .as_deref() .unwrap_or(""), ) } -/// Pure parser for `bun pm bin -g` stdout. Extracted so the -/// derive-the-global-node_modules-path logic is unit-testable -/// without shelling out. -/// -/// Given output like `"/Users/foo/.bun/bin\n"` returns -/// `Some("/Users/foo/.bun/install/global/node_modules")`. Returns -/// `None` on empty input or a root-only path with no parent. -pub fn parse_bun_bin_output(stdout: &str) -> Option { - let bin_path = stdout.trim().to_string(); - if bin_path.is_empty() { +/// Pure parser for `bun pm ls -g` stdout, whose first line names the +/// global dir: ` node_modules (N)` (Bun 1.0 - 1.3) or +/// ` node_modules (N installed)` (1.4). Returns `/node_modules`, +/// or `None` when the first line has no such shape. The dir may itself +/// contain spaces, so the LAST ` node_modules (` splits it off. +pub fn parse_bun_ls_global_output(stdout: &str) -> Option { + let first = stdout.trim().lines().next()?; + let (dir, _) = first.rsplit_once(" node_modules (")?; + let dir = dir.trim(); + if dir.is_empty() { return None; } - - let bun_root = PathBuf::from(&bin_path); - let bun_root = bun_root.parent()?; Some( - bun_root - .join("install") - .join("global") + PathBuf::from(dir) .join("node_modules") .to_string_lossy() .to_string(), ) } +/// The global dir Bun installs `bun add -g` packages into, resolved the way +/// Bun does it (`openGlobalDir`): `BUN_INSTALL_GLOBAL_DIR`, else +/// `$BUN_INSTALL/install/global`, else `.bun/install/global` under +/// `XDG_CACHE_HOME` or the home dir (`USERPROFILE` on Windows). +/// +/// Bunfig is not consulted: measured on Bun 1.0.36 - 1.4.2, `bun add -g` +/// ignores `install.globalDir` in the global (`~/.bunfig.toml`, +/// `$XDG_CONFIG_HOME/.bunfig.toml`) and the local bunfig alike (it does +/// honor `globalBinDir`, which moves only the bins), and so does +/// `bun pm ls -g`. +/// +/// Bun uses a set variable as it is, so the first one set decides. One that +/// is empty or relative names a dir relative to wherever `bun add -g` ran, +/// which can't be known here: that is an `Err` naming the variable, as is +/// having no home dir at all. +pub fn bun_global_dir_from_env(var: &impl Fn(&str) -> Option) -> Result { + let lookup = |name: &str| { + let value = var(name)?; + let path = PathBuf::from(&value); + Some(if path.is_absolute() { + Ok(path) + } else { + Err(format!("{name} is {value:?}, not an absolute path")) + }) + }; + let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + if let Some(dir) = lookup("BUN_INSTALL_GLOBAL_DIR") { + return dir; + } + if let Some(dir) = lookup("BUN_INSTALL") { + return dir.map(|dir| dir.join("install").join("global")); + } + lookup("XDG_CACHE_HOME") + .or_else(|| lookup(home_var)) + .unwrap_or_else(|| Err(format!("neither XDG_CACHE_HOME nor {home_var} is set"))) + .map(|dir| dir.join(".bun").join("install").join("global")) +} + +/// Say once (muted only by `--silent`) that a global scan left Bun's global +/// packages out because their dir can't be told (#443), instead of +/// reporting a clean, empty result for them. +fn warn_bun_global_dir_undetermined(why: &str) { + static SHOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + crate::utils::notice::notice_once(crate::utils::notice::Notice::Warning, &SHOWN, || { + format!( + "Warning: could not determine Bun's global package directory ({why}), so Bun's \ + global packages were not scanned. Pass --global-prefix /node_modules to scan \ + them." + ) + }); +} + // --------------------------------------------------------------------------- // Helpers: synchronous wildcard directory resolver // --------------------------------------------------------------------------- @@ -1854,7 +2674,7 @@ impl NpmCrawler { return Vec::new(); } let store = nm_path.join(&entry.name); - let entries = Self::list_pnpm_shaped_store_entries_sync(&store, layout, false) + let entries = Self::list_pnpm_shaped_store_entries_sync(&store, layout, false, false) .into_iter() .map(|e| StoreEntry { advertised: e.advertised, @@ -2024,8 +2844,10 @@ impl NpmCrawler { if let Some(yarn_path) = get_yarn_global_prefix() { add(PathBuf::from(yarn_path)); } - if let Some(bun_path) = get_bun_global_prefix() { - add(PathBuf::from(bun_path)); + match resolve_bun_global_prefix() { + Ok(Some(bun_path)) => add(PathBuf::from(bun_path)), + Ok(None) => {} + Err(why) => warn_bun_global_dir_undetermined(&why), } // macOS-specific fallback paths @@ -2328,7 +3150,8 @@ impl NpmCrawler { } for (store_path, layout) in pnpm_shaped_stores { - let entries = Self::list_pnpm_shaped_store_entries_sync(&store_path, layout, true); + let entries = + Self::list_pnpm_shaped_store_entries_sync(&store_path, layout, true, true); events.extend(Self::gather_store_entries(entries)); } for store_path in legacy_stores { @@ -2568,36 +3391,59 @@ impl NpmCrawler { /// the `is_dir` stat so an unreadable-but-present dir keeps its /// flat-entry classification. fn list_pnpm_store_entries_sync(store_path: &Path, read_listings: bool) -> Vec { - Self::list_pnpm_shaped_store_entries_sync(store_path, StoreLayout::Pnpm, read_listings) + Self::list_pnpm_shaped_store_entries_sync( + store_path, + StoreLayout::Pnpm, + read_listings, + false, + ) } /// [`Self::list_pnpm_store_entries_sync`] for any pnpm-shaped store /// (see [`PNPM_SHAPED_STORES`]), entry names decoded under `layout`. + /// + /// With `live_only` (the scan) a Bun store's orphaned entries are + /// skipped (see [`live_bun_store_entries_sync`]); the resolver and the + /// peer-variant finder keep them, since rollback must still restore a + /// patched orphan a later install can re-link. fn list_pnpm_shaped_store_entries_sync( store_path: &Path, layout: StoreLayout, read_listings: bool, + live_only: bool, ) -> Vec { let decode = |name: &str| layout.decode_pnpm_shaped(name); - let candidates: Vec = list_dir_sync(store_path) - .entries - .into_iter() - .filter(|entry| { - !(entry.name_str.starts_with('.') || entry.name_str == "node_modules") - && entry.file_type.is_some_and(|ft| ft.is_dir()) - }) - .collect(); + let candidates = pnpm_shaped_store_candidates_sync(store_path, layout); + // pnpm prunes its store on install; Bun never does (#599), so the + // scan, a judgement of the live install, skips its orphans. + let live = (layout == StoreLayout::Bun && live_only) + .then(|| live_bun_store_entries_sync(store_path, &candidates)) + .flatten(); + let candidates: Vec<(ListedEntry, Option)> = match live { + Some(walked) => candidates + .into_iter() + .zip(walked.live) + .zip(walked.listings) + .filter(|((_, live), _)| *live) + .map(|((entry, _), listing)| (entry, listing.filter(|_| read_listings))) + .collect(), + None => candidates.into_iter().map(|entry| (entry, None)).collect(), + }; - par_map(candidates, |entry| { + let entry_dirs = |(entry, listing): (ListedEntry, Option)| { let entry_path = store_path.join(&entry.name); let entry_nm = entry_path.join("node_modules"); if read_listings { - if let Some((entries, complete)) = read_dir_entries_sync(&entry_nm) { + let listing = listing.or_else(|| { + read_dir_entries_sync(&entry_nm) + .map(|(entries, complete)| Listing::from_entries(entries, complete)) + }); + if let Some(listing) = listing { return vec![StoreEntryDir { advertised: decode(&entry.name_str), name: entry.name_str, node_modules: entry_nm, - listing: Some(Listing::from_entries(entries, complete)), + listing: Some(listing), }]; } } @@ -2619,10 +3465,16 @@ impl NpmCrawler { }) .collect() } - }) - .into_iter() - .flatten() - .collect() + }; + // With every listing already read (a live Bun walk) nothing is + // left to read, and a parallel pass would only wake the pool. + if read_listings && candidates.iter().all(|(_, listing)| listing.is_some()) { + return candidates.into_iter().flat_map(entry_dirs).collect(); + } + par_map(candidates, entry_dirs) + .into_iter() + .flatten() + .collect() } /// Async `(name, node_modules)` view of @@ -2647,7 +3499,7 @@ impl NpmCrawler { ) -> Vec { let store_path = store_path.to_path_buf(); run_walk(move || { - Self::list_pnpm_shaped_store_entries_sync(&store_path, layout, false) + Self::list_pnpm_shaped_store_entries_sync(&store_path, layout, false, false) .into_iter() .map(|entry| StoreEntry { advertised: entry.advertised, @@ -5656,9 +6508,22 @@ mod tests { link_dir(&number, &odd_entry.join("is-number")); let frame = store.join("@babel+code-frame@7.0.0/node_modules/@babel/code-frame"); write_pkg(&frame, "@babel/code-frame", "7.0.0"); - std::fs::create_dir_all(store.join("node_modules")).unwrap(); + std::fs::create_dir_all(store.join("node_modules/@babel")).unwrap(); link_dir(&number, &store.join("node_modules/is-number")); + link_dir(&hosted, &store.join("node_modules/to-regex-range")); + link_dir(&frame, &store.join("node_modules/@babel/code-frame")); link_dir(&odd_entry.join("is-odd"), &nm.join("is-odd")); + // Every entry is linked from somewhere, as Bun writes it (an + // unlinked one is an orphan, #599); the peer twin from a workspace + // member's importer. + std::fs::write( + root.join("package.json"), + r#"{"workspaces":["packages/*"]}"#, + ) + .unwrap(); + let member_nm = root.join("packages/a/node_modules"); + std::fs::create_dir_all(&member_nm).unwrap(); + link_dir(&number_twin, &member_nm.join("is-number")); assert_store_copies_found( &root, @@ -5683,6 +6548,517 @@ mod tests { .await; } + /// #635: with Bun's global store (`[install] globalStore`, Bun >= + /// 1.3.14) every `.bun/` is a link to + /// `/links/-`, shared across projects. The project's + /// transitive packages live only there, so the walks follow those links + /// (reporting each copy under `.bun`, where apply then refuses it as + /// shared), and still skip a `.bun` link to anything else. + #[tokio::test] + async fn test_bun_global_store_transitive_packages_are_found() { + let dir = tempfile::tempdir().unwrap(); + let tmp: PathBuf = dir.path().components().collect(); + let root = tmp.join("proj"); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + std::fs::create_dir_all(&store).unwrap(); + let links = tmp.join("bun-cache").join("links"); + let link_entry = |entry: &str, name: &str, version: &str| { + let shared = links.join(format!("{entry}-6a490709ba3c5c8f")); + write_pkg(&shared.join("node_modules").join(name), name, version); + link_dir(&shared, &store.join(entry)); + store.join(entry).join("node_modules").join(name) + }; + + let odd = link_entry("is-odd@3.0.1", "is-odd", "3.0.1"); + let number = link_entry("is-number@6.0.0", "is-number", "6.0.0"); + let number_twin = link_entry("is-number@6.0.0+3c4e1d2a", "is-number", "6.0.0"); + let frame = link_entry("@babel+code-frame@7.0.0", "@babel/code-frame", "7.0.0"); + link_dir(&number, &odd.parent().unwrap().join("is-number")); + link_dir(&odd, &nm.join("is-odd")); + // Bun's hoist dir links every package (#599: an entry nothing + // links is an orphan). + let hoist_scope = store.join("node_modules/@babel"); + std::fs::create_dir_all(&hoist_scope).unwrap(); + link_dir(&frame, &hoist_scope.join("code-frame")); + // A `.bun` link that is not into a global store entry. + let elsewhere = tmp.join("elsewhere"); + write_pkg( + &elsewhere.join("node_modules/left-pad"), + "left-pad", + "1.3.0", + ); + link_dir(&elsewhere, &store.join("left-pad@1.3.0")); + + assert_store_copies_found( + &root, + &[ + "pkg:npm/@babel/code-frame@7.0.0", + "pkg:npm/is-number@6.0.0", + "pkg:npm/is-odd@3.0.1", + ], + &[ + ( + "pkg:npm/is-number@6.0.0", + vec![number.clone(), number_twin.clone()], + ), + ("pkg:npm/@babel/code-frame@7.0.0", vec![frame.clone()]), + ("pkg:npm/is-odd@3.0.1", vec![nm.join("is-odd")]), + ("pkg:npm/left-pad@1.3.0", vec![]), + ], + &number, + vec![number_twin.clone()], + ) + .await; + } + + /// A link at `link` to `target`, relative where the platform allows + /// (Bun writes its `node_modules` links relative); a junction to the + /// resolved target on Windows. + fn rel_link(target: &str, link: &Path) { + std::fs::create_dir_all(link.parent().unwrap()).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + link_dir( + &crate::utils::relpath::normalize_lexically_keeping_escapes( + &link.parent().unwrap().join(target), + ), + link, + ); + } + + /// #599 with #635: Bun's global store (`globalStore = true`) never + /// prunes `.bun` either. The layout real Bun 1.3.14 and 1.4.2 write + /// after member `a` goes from `{is-odd 3.0.1, left-pad 1.3.0}` to + /// `{is-odd 3.0.1, is-number 7.0.0, left-pad 1.2.0}` with an in-place + /// `bun install`: every `.bun` entry is an absolute link into + /// `/links/-`, the member and hoist links are + /// relative into `.bun`, and is-odd's cache entry links is-number + /// 6.0.0 at its sibling cache dir, never back into `.bun`. The hoist + /// names is-number 7.0.0, so 6.0.0 is reached only through the cache; + /// the `left-pad@1.3.0` link is left behind with nothing reaching it. + /// The walk follows the cache links: the transitive 6.0.0 stays live, + /// and the orphan is dropped by the scan and the live-copy filter. + #[tokio::test] + async fn test_bun_global_store_orphaned_entries_are_not_live_copies() { + let dir = tempfile::tempdir().unwrap(); + let tmp: PathBuf = dir.path().components().collect(); + let root = tmp.join("proj"); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + std::fs::create_dir_all(&store).unwrap(); + std::fs::write( + root.join("package.json"), + r#"{"name":"root","version":"1.0.0","private":true,"workspaces":["packages/*"]}"#, + ) + .unwrap(); + let links = tmp.join("bun-cache").join("links"); + let link_entry = |entry: &str, hash: &str, name: &str, version: &str| { + let shared = links.join(format!("{entry}-{hash}")); + write_pkg(&shared.join("node_modules").join(name), name, version); + link_dir(&shared, &store.join(entry)); + store.join(entry).join("node_modules").join(name) + }; + let odd = link_entry("is-odd@3.0.1", "630ebdaa4b425d00", "is-odd", "3.0.1"); + let number6 = link_entry("is-number@6.0.0", "fe514fa0667977a7", "is-number", "6.0.0"); + let number7 = link_entry("is-number@7.0.0", "d7644ee3a163df00", "is-number", "7.0.0"); + let pad12 = link_entry("left-pad@1.2.0", "4791bc564980741c", "left-pad", "1.2.0"); + let pad13 = link_entry("left-pad@1.3.0", "6a490709ba3c5c8f", "left-pad", "1.3.0"); + rel_link( + "../../is-number@6.0.0-fe514fa0667977a7/node_modules/is-number", + &links.join("is-odd@3.0.1-630ebdaa4b425d00/node_modules/is-number"), + ); + let member = root.join("packages/a"); + write_pkg(&member, "a", "1.0.0"); + for (name, entry) in [ + ("is-odd", "is-odd@3.0.1"), + ("is-number", "is-number@7.0.0"), + ("left-pad", "left-pad@1.2.0"), + ] { + rel_link( + &format!("../{entry}/node_modules/{name}"), + &store.join("node_modules").join(name), + ); + rel_link( + &format!("../../../node_modules/.bun/{entry}/node_modules/{name}"), + &member.join("node_modules").join(name), + ); + } + + let candidates = pnpm_shaped_store_candidates_sync(&store, StoreLayout::Bun); + assert_eq!(candidates.len(), 5); + let live = live_bun_store_entries_sync(&store, &candidates).unwrap(); + let mut live: Vec = live + .into_names(&candidates) + .into_iter() + .map(|e| e.into_string().unwrap()) + .collect(); + live.sort(); + assert_eq!( + live, + [ + "is-number@6.0.0", + "is-number@7.0.0", + "is-odd@3.0.1", + "left-pad@1.2.0" + ] + ); + let scanned = scan_paths(&root).await; + let purls: Vec<&str> = scanned.iter().map(|(p, _)| p.as_str()).collect(); + for purl in [ + "pkg:npm/is-number@6.0.0", + "pkg:npm/is-number@7.0.0", + "pkg:npm/is-odd@3.0.1", + "pkg:npm/left-pad@1.2.0", + ] { + assert!(purls.contains(&purl), "{purl}: {scanned:?}"); + } + assert!(!purls.contains(&"pkg:npm/left-pad@1.3.0"), "{scanned:?}"); + + // vex's filter judges a copy by the `.bun` entry its path names. + let mut copies = vec![ + number6.clone(), + number7.clone(), + pad13, + pad12.clone(), + odd.clone(), + ]; + retain_live_store_copies([&mut copies]).await; + assert_eq!(copies, vec![number6, number7, pad12, odd]); + } + + /// #599 with #635: a cache entry linking a dependency at a global store + /// entry this project's `.bun` does not link cannot be followed (what + /// it reaches is unknown), so nothing is dropped. + #[tokio::test] + async fn test_bun_global_store_unknown_cache_entry_keeps_every_entry() { + let dir = tempfile::tempdir().unwrap(); + let tmp: PathBuf = dir.path().components().collect(); + let root = tmp.join("proj"); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + std::fs::create_dir_all(&store).unwrap(); + let links = tmp.join("bun-cache").join("links"); + for (entry, hash, name) in [ + ("is-odd@3.0.1", "630ebdaa4b425d00", "is-odd"), + ("is-number@6.0.0", "fe514fa0667977a7", "is-number"), + ] { + let shared = links.join(format!("{entry}-{hash}")); + write_pkg(&shared.join("node_modules").join(name), name, "1.0.0"); + link_dir(&shared, &store.join(entry)); + } + let foreign = links.join("x@1.0.0-0123456789abcdef/node_modules/x"); + write_pkg(&foreign, "x", "1.0.0"); + rel_link( + "../../x@1.0.0-0123456789abcdef/node_modules/x", + &links.join("is-odd@3.0.1-630ebdaa4b425d00/node_modules/x"), + ); + rel_link(".bun/is-odd@3.0.1/node_modules/is-odd", &nm.join("is-odd")); + + let candidates = pnpm_shaped_store_candidates_sync(&store, StoreLayout::Bun); + assert_eq!(candidates.len(), 2); + assert!(live_bun_store_entries_sync(&store, &candidates).is_none()); + } + + /// #599: Bun never prunes `.bun`. After an in-place `bun install` that + /// rewires packages to hosted tarballs (or bumps a version), the old + /// `@` entries stay on disk with nothing linking to + /// them, while the importers, the `.bun/node_modules` hoist links and + /// the dependents' entries all point at the new entries (the layout + /// real Bun 1.3.14 / 1.4.2 writes). An orphan is no installed copy, so + /// the scan and the live-copy filter `vex` uses skip it. The resolver + /// and the peer-variant finder keep it: rollback must restore a patched + /// orphan, which a later install that resolves back re-links as is. + /// + /// A live entry linked only from a workspace member (an unhoisted + /// second version) stays live wherever the member sits: under a dir + /// the workspace walk skips (`vendor/`), under a hidden dir listed only + /// by `bun.lock`'s `workspaces`, or under `packages/`. + #[tokio::test] + async fn test_bun_isolated_store_orphaned_entries_are_not_live_copies() { + let tmp = tempfile::tempdir().unwrap(); + let root: PathBuf = tmp.path().components().collect(); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + let hoist = store.join("node_modules"); + std::fs::write( + root.join("package.json"), + r#"{"name":"app","workspaces":["packages/*","vendor/*"]}"#, + ) + .unwrap(); + std::fs::write( + root.join("bun.lock"), + "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \ + \"name\": \"app\",\n },\n \".internal/c\": {\n \"name\": \"c\",\n \ + },\n },\n \"packages\": {\n }\n}\n", + ) + .unwrap(); + let member_a = root.join("packages/a/node_modules"); + let member_b = root.join("vendor/b/node_modules"); + let member_c = root.join(".internal/c/node_modules"); + for dir in [&hoist.join("@s"), &member_a, &member_b, &member_c] { + std::fs::create_dir_all(dir).unwrap(); + } + + // Live: the hosted rewires, and three left-pad versions (1.3.0 + // hoisted, 1.2.0 and 1.0.0 each linked only from one member). + let odd = store.join("is-odd@http+++127.0.0.1+is-odd.tgz/node_modules"); + write_pkg(&odd.join("is-odd"), "is-odd", "3.0.1"); + let number = store.join("is-number@http+++127.0.0.1+is-number.tgz/node_modules/is-number"); + write_pkg(&number, "is-number", "6.0.0"); + link_dir(&number, &odd.join("is-number")); + let pad = store.join("left-pad@1.3.0/node_modules/left-pad"); + write_pkg(&pad, "left-pad", "1.3.0"); + let vendor_pad = store.join("left-pad@1.2.0/node_modules/left-pad"); + write_pkg(&vendor_pad, "left-pad", "1.2.0"); + let hidden_pad = store.join("left-pad@1.0.0/node_modules/left-pad"); + write_pkg(&hidden_pad, "left-pad", "1.0.0"); + link_dir(&odd.join("is-odd"), &nm.join("is-odd")); + link_dir(&odd.join("is-odd"), &hoist.join("is-odd")); + link_dir(&number, &hoist.join("is-number")); + link_dir(&pad, &hoist.join("left-pad")); + link_dir(&number, &member_a.join("is-number")); + link_dir(&pad, &member_a.join("left-pad")); + link_dir(&vendor_pad, &member_b.join("left-pad")); + link_dir(&hidden_pad, &member_c.join("left-pad")); + let frame = store.join("@s+frame@http+++127.0.0.1+frame.tgz/node_modules/@s/frame"); + write_pkg(&frame, "@s/frame", "7.0.0"); + link_dir(&frame, &hoist.join("@s/frame")); + + // Orphans: the pre-rewire registry entries (still linked to each + // other) and a churned-away version. + let stale_odd = store.join("is-odd@3.0.1/node_modules"); + write_pkg(&stale_odd.join("is-odd"), "is-odd", "3.0.1"); + let stale_number = store.join("is-number@6.0.0/node_modules/is-number"); + write_pkg(&stale_number, "is-number", "6.0.0"); + link_dir(&stale_number, &stale_odd.join("is-number")); + let stale_frame = store.join("@s+frame@7.0.0/node_modules/@s/frame"); + write_pkg(&stale_frame, "@s/frame", "7.0.0"); + let churned = store.join("left-pad@1.1.0/node_modules/left-pad"); + write_pkg(&churned, "left-pad", "1.1.0"); + + let scanned = scan_paths(&root).await; + for (purl, path) in &scanned { + assert!( + !path.starts_with(&stale_odd) + && !path.starts_with(stale_number.parent().unwrap()) + && !path.starts_with(&stale_frame) + && purl != "pkg:npm/left-pad@1.1.0", + "an orphaned entry was scanned: {scanned:?}" + ); + } + for (purl, path) in [ + ("pkg:npm/left-pad@1.2.0", &vendor_pad), + ("pkg:npm/left-pad@1.0.0", &hidden_pad), + ] { + assert!( + scanned.contains(&(purl.to_string(), path.clone())), + "{purl}: {scanned:?}" + ); + } + + // Restoring operations still reach the orphans. + let purls: Vec = ["pkg:npm/is-number@6.0.0", "pkg:npm/left-pad@1.1.0"] + .map(String::from) + .to_vec(); + let found = NpmCrawler::new().find_by_purls(&nm, &purls).await.unwrap(); + let paths = |purl: &str| -> Vec { + found + .get(purl) + .map(|copies| copies.iter().map(|p| p.path.clone()).collect()) + .unwrap_or_default() + }; + assert!( + paths("pkg:npm/is-number@6.0.0").contains(&stale_number), + "{found:?}" + ); + assert_eq!(paths("pkg:npm/left-pad@1.1.0"), vec![churned.clone()]); + assert_eq!( + find_store_peer_variant_copies(&number).await, + vec![stale_number.clone()] + ); + + // The live-copy filter drops exactly the orphans. + let mut copies = vec![ + number.clone(), + stale_number.clone(), + churned.clone(), + vendor_pad.clone(), + hidden_pad.clone(), + stale_frame.clone(), + frame.clone(), + nm.join("is-odd"), + stale_odd.join("is-odd"), + ]; + let mut other = vec![pad.clone(), churned.clone()]; + retain_live_store_copies([&mut copies, &mut other]).await; + assert_eq!( + copies, + vec![ + number.clone(), + vendor_pad.clone(), + hidden_pad.clone(), + frame.clone(), + nm.join("is-odd"), + ] + ); + assert_eq!(other, vec![pad.clone()]); + } + + /// #599: when the workspace members cannot be known (a root + /// `package.json` that does not parse), an entry no other seed reaches + /// may be a member's, so nothing is dropped. + #[tokio::test] + async fn test_bun_isolated_store_unknown_members_keep_every_entry() { + let tmp = tempfile::tempdir().unwrap(); + let root: PathBuf = tmp.path().components().collect(); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + std::fs::create_dir_all(store.join("node_modules")).unwrap(); + std::fs::write(root.join("package.json"), "{ not json").unwrap(); + let pad = store.join("left-pad@1.3.0/node_modules/left-pad"); + write_pkg(&pad, "left-pad", "1.3.0"); + let member_pad = store.join("left-pad@1.2.0/node_modules/left-pad"); + write_pkg(&member_pad, "left-pad", "1.2.0"); + link_dir(&pad, &store.join("node_modules/left-pad")); + let member_nm = root.join("weird/place/node_modules"); + std::fs::create_dir_all(&member_nm).unwrap(); + link_dir(&member_pad, &member_nm.join("left-pad")); + + let scanned = scan_paths(&root).await; + assert!( + scanned.contains(&("pkg:npm/left-pad@1.2.0".to_string(), member_pad.clone())), + "{scanned:?}" + ); + let mut copies = vec![pad.clone(), member_pad.clone()]; + retain_live_store_copies([&mut copies]).await; + assert_eq!(copies, vec![pad, member_pad]); + } + + /// #599: a root `package.json` that does not parse leaves the + /// `bun.lock` `workspaces` section, which Bun rewrites on every + /// install, as the member set: an entry linked only from a member it + /// lists stays live, and an orphan is still dropped. + #[tokio::test] + async fn test_bun_isolated_store_lock_members_stand_in_for_package_json() { + let tmp = tempfile::tempdir().unwrap(); + let root: PathBuf = tmp.path().components().collect(); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + std::fs::create_dir_all(store.join("node_modules")).unwrap(); + std::fs::write(root.join("package.json"), "{ not json").unwrap(); + std::fs::write( + root.join("bun.lock"), + "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \ + \"name\": \"app\",\n },\n \"weird/place\": {\n \"name\": \"w\",\n \ + },\n },\n \"packages\": {\n }\n}\n", + ) + .unwrap(); + let pad = store.join("left-pad@1.3.0/node_modules/left-pad"); + write_pkg(&pad, "left-pad", "1.3.0"); + let member_pad = store.join("left-pad@1.2.0/node_modules/left-pad"); + write_pkg(&member_pad, "left-pad", "1.2.0"); + let churned = store.join("left-pad@1.1.0/node_modules/left-pad"); + write_pkg(&churned, "left-pad", "1.1.0"); + link_dir(&pad, &store.join("node_modules/left-pad")); + let member_nm = root.join("weird/place/node_modules"); + std::fs::create_dir_all(&member_nm).unwrap(); + link_dir(&member_pad, &member_nm.join("left-pad")); + + let mut copies = vec![pad.clone(), member_pad.clone(), churned]; + retain_live_store_copies([&mut copies]).await; + assert_eq!(copies, vec![pad, member_pad]); + } + + /// #599: the orphan filter's quick walk takes a link named for a + /// package only one entry holds to be that entry. An alias link + /// (`node_modules/lp` -> `left-pad@1.3.0`) beside an unrelated `lp@…` + /// entry defeats the guess, so the entry it really reaches must still + /// count as live (by the exact re-walk) and the never-linked `lp@` + /// not. + #[tokio::test] + async fn test_bun_isolated_store_alias_link_is_resolved_exactly() { + let tmp = tempfile::tempdir().unwrap(); + let root: PathBuf = tmp.path().components().collect(); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + let pad = store.join("left-pad@1.3.0/node_modules/left-pad"); + write_pkg(&pad, "left-pad", "1.3.0"); + let lp = store.join("lp@2.0.0/node_modules/lp"); + write_pkg(&lp, "lp", "2.0.0"); + std::fs::create_dir_all(store.join("node_modules")).unwrap(); + link_dir(&pad, &nm.join("lp")); + + let scanned = scan_paths(&root).await; + assert!( + scanned.iter().all(|(purl, _)| purl != "pkg:npm/lp@2.0.0"), + "{scanned:?}" + ); + assert!( + scanned + .iter() + .any(|(purl, _)| purl == "pkg:npm/left-pad@1.3.0"), + "{scanned:?}" + ); + let mut copies = vec![pad.clone(), lp]; + retain_live_store_copies([&mut copies]).await; + assert_eq!(copies, vec![pad]); + } + + #[test] + fn test_expand_workspace_pattern() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for dir in [ + "packages/a", + "packages/b", + ".github/actions/x", + "apps/web/sub", + "apps/node_modules/skip", + ] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + std::fs::write(root.join("packages/file"), "").unwrap(); + let expand = |pattern: &str| { + let mut got: Vec = expand_workspace_pattern_sync(root, pattern) + .unwrap() + .into_iter() + .map(|p| p.strip_prefix(root).unwrap().to_path_buf()) + .collect(); + got.sort(); + got + }; + assert_eq!( + expand("packages/*"), + ["packages/a", "packages/b"].map(PathBuf::from) + ); + assert_eq!(expand("./packages/a"), [PathBuf::from("packages/a")]); + assert_eq!(expand(".github/*/*"), [PathBuf::from(".github/actions/x")]); + assert_eq!( + expand("apps/**"), + ["apps", "apps/web", "apps/web/sub"].map(PathBuf::from) + ); + } + + #[test] + fn test_bun_store_entry_package() { + for (entry, want) in [ + ("is-number@6.0.0", Some("is-number")), + ("is-number@http+++127.0.0.1+t.tgz", Some("is-number")), + ("@babel+code-frame@7.0.0", Some("@babel/code-frame")), + ( + "@babel+code-frame@7.0.0+3c4e1d2a", + Some("@babel/code-frame"), + ), + ("no-version", None), + ("@scope+only", None), + ("@1.0.0", None), + ] { + assert_eq!(bun_store_entry_package(entry).as_deref(), want, "{entry}"); + } + } + /// #373: Deno's isolated `nodeModulesDir` keeps every npm package in /// `node_modules/.deno/@[_]/node_modules/` /// (scoped `@scope+leaf@…`), beside `.deno/.deno.lock` and the diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 3cdb08a39..5c4822b2d 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -39,7 +39,7 @@ use crate::patch::redirect::{ }; use crate::utils::pnpm_workspace::governing_workspace_file; use crate::utils::purl::purl_parts; -use crate::vendor::lock_inventory::{MemoryEntry, ProjectView}; +use crate::vendor::lock_inventory::{bun_text_lock_drives, MemoryEntry, ProjectView}; use super::guidance::{ npm_allow_remote_already_detail, npm_allow_remote_configured_detail, @@ -306,23 +306,11 @@ pub fn build_candidates( candidates } -/// Whether the text `bun.lock` is present (disk: `exists`). Text retains -/// Bun's precedence when both lock spellings are present. -pub fn bun_lock_present(view: &ProjectView<'_>) -> bool { - match view { - ProjectView::Disk(_) | ProjectView::Snapshot(_) => { - let cwd = view.disk_root().expect("a disk view has a root"); - cwd.join("bun.lock").exists() - } - ProjectView::Memory(project) => project.contains("bun.lock"), - } -} - /// Whether an npm candidate would rewrite a `bun.lockb` that is a symbolic /// link (atomic replacement cannot preserve a link; previews refuse too). pub fn bun_lockb_symlinked(view: &ProjectView<'_>, candidates: &[Candidate]) -> bool { candidates.iter().any(|c| c.dep.ecosystem == "npm") - && !bun_lock_present(view) + && !bun_text_lock_drives(view) && view.is_symlink("bun.lockb") } @@ -1687,7 +1675,7 @@ async fn rewrite_once( // candidate filter, so it can never disagree with `candidates`. let overrides: Vec = candidates.iter().map(|c| c.dep.clone()).collect(); let bun_lockb = bun_lockb_present(view); - let binary_bun = !bun_lock_present(view) && bun_lockb; + let binary_bun = !bun_text_lock_drives(view) && bun_lockb; let binary_content = if binary_bun && overrides.iter().any(|o| o.ecosystem == "npm") { Some( view.read_bytes("bun.lockb") @@ -1798,7 +1786,25 @@ async fn rewrite_once( }) .cloned() .collect(); - crate::patch::redirect::rewrite_bun_binary(&bytes, &binary_overrides, &mut rewrite) + crate::patch::redirect::rewrite_bun_binary(&bytes, &binary_overrides, &mut rewrite); + // A pinned default-trusted package loses Bun's default trust + // (#371); the text rewriter warns the same way. + for o in &binary_overrides { + let name = crate::patch::redirect::full_name(o); + if rewrite.confirmed_bun_binary_uuids.contains(&o.patch_uuid) + && crate::vendor::bun_lock_text::loses_default_trust( + files.get("package.json").map(String::as_str), + None, + &name, + ) + { + rewrite + .warnings + .push(crate::patch::redirect::bun_default_trust_warning( + &name, &o.version, + )); + } + } } Err(warning) => rewrite.warnings.push(warning), } @@ -3488,6 +3494,104 @@ snapshots: } } + /// REGRESSION (#371), binary lock: a default-trusted package pinned to + /// its hosted URL in a `bun.lockb` loses Bun 1.3.5+'s default trust, so + /// its install scripts are silently skipped; the run says so unless the + /// root manifest declares `trustedDependencies`. The fixture is real Bun + /// 1.4.2 output: `simple-git-hooks` is on the default list, `is-number` + /// is not. + #[tokio::test] + async fn issue_371_bun_lockb_default_trusted_package_warns_that_trust_is_lost() { + use crate::patch::redirect::Integrity; + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/bun-lockb-trusted"); + let candidate = |name: &str, version: &str, uuid: &str| Candidate { + purl: format!("pkg:npm/{name}@{version}"), + dep: DepOverride { + ecosystem: "npm".into(), + name: name.into(), + namespace: None, + version: version.into(), + token: "tok".into(), + patch_uuid: uuid.into(), + artifact_url: format!("https://patch.test/{name}-{version}.tgz"), + registry_override: None, + integrity: Integrity { + sha512: Some(format!("sha512-{}==", "A".repeat(86))), + ..Default::default() + }, + }, + }; + let candidates = vec![ + candidate("simple-git-hooks", "2.11.1", "uuid-hooks"), + candidate("is-number", "7.0.0", "uuid-isn"), + ]; + let manifest = std::fs::read_to_string(fixture.join("package.json")).unwrap(); + let declared = manifest.replacen( + "\"private\": true,", + "\"private\": true,\n \"trustedDependencies\": [\"simple-git-hooks\"],", + 1, + ); + assert_ne!(declared, manifest); + for trusted in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + std::fs::copy(fixture.join("bun.lockb"), tmp.path().join("bun.lockb")).unwrap(); + std::fs::write( + tmp.path().join("package.json"), + if trusted { &declared } else { &manifest }, + ) + .unwrap(); + let view = ProjectView::Disk(tmp.path()); + let outer = OuterAllowRemote::default; + let options = RewriteOptions { + dry_run: false, + targets_pipenv_lock: false, + pipenv_major: None, + pipenv_unknown_detail: String::new(), + trust_lockfile_config: true, + npm_allow_remote_config: true, + npm_outer: &outer, + yarn_classic_outer: &OuterYarnMirror::default, + blocking: false, + takeover_uuids: Default::default(), + patch_server_origins: Vec::new(), + prior_discovery: None, + }; + let read = read_candidate_files(&view, &BTreeSet::new(), &candidates).await; + let done = rewrite( + &view, + read, + &candidates, + BTreeMap::new(), + &BTreeSet::new(), + options, + ) + .await; + assert!( + done.rewrite.binary_files.contains_key("bun.lockb"), + "{:?}", + done.rewrite.warnings + ); + assert_eq!(done.confirmed.len(), 2, "{:?}", done.confirmed); + let lost: Vec<&RewriteWarning> = done + .rewrite + .warnings + .iter() + .filter(|w| w.code == "redirect_bun_default_trust_lost") + .collect(); + if trusted { + assert!(lost.is_empty(), "{:?}", done.rewrite.warnings); + } else { + assert_eq!(lost.len(), 1, "{:?}", done.rewrite.warnings); + assert!( + lost[0].detail.contains("simple-git-hooks@2.11.1"), + "{}", + lost[0].detail + ); + } + } + } + /// REGRESSION (#367), text lock: the root manifest is read beside a /// `bun.lock` even when the lock has no `workspaces` section to reach it /// through, and a package the project patches itself is never diff --git a/crates/socket-patch-core/src/patch/redirect/bun_binary.rs b/crates/socket-patch-core/src/patch/redirect/bun_binary.rs index be7970545..306016e7b 100644 --- a/crates/socket-patch-core/src/patch/redirect/bun_binary.rs +++ b/crates/socket-patch-core/src/patch/redirect/bun_binary.rs @@ -1,6 +1,7 @@ //! Native hosted redirects for bun.lockb. Structured package snapshots keep //! scoped rollback independent of other packages in the same binary lock. use super::{DepOverride, FileEdit, RewriteResult, RewriteWarning}; +use crate::vendor::bun_lock_text::user_tarball_version; use crate::vendor::bun_lockb::BunLockb; pub(crate) const KIND: &str = "redirect_bun_lockb_package"; @@ -64,6 +65,29 @@ pub fn rewrite_bun_binary(content: &[u8], overrides: &[DepOverride], result: &mu )) }) .collect(); + // A user URL / `file:` tarball record of this version installs + // from its own resolution beside any redirected copy and stays + // unpatched (#497): never rewired, always reported. + for p in packages.iter().filter(|p| { + p.name == name + && p.version.is_none() + && !matching.iter().any(|m| m.id == p.id) + && user_tarball_version(&name, &p.resolution) == Some(dep.version.as_str()) + }) { + skipped.push(RewriteWarning { + code: "redirect_bun_non_registry_entry_skipped".into(), + detail: format!( + "bun.lockb package #{} installs {name}@{} from a URL or local tarball, \ + not the registry, and CANNOT be redirected — bun installs it from that \ + resolution, so that copy stays UNPATCHED; depend on the registry \ + release to patch it", + p.id, dep.version, + ), + }); + } + if matching.is_empty() && !skipped.is_empty() { + return Ok((Vec::new(), false)); + } if matching.is_empty() { return Err(format!( "no rewritable bun.lockb entry for {name}@{}", @@ -219,6 +243,50 @@ mod tests { assert!(result.bundled_skipped_uuids.contains("7.0.0")); } + /// REGRESSION (#497): Bun 1.1.45 locks a root URL / `file:` tarball + /// dependency on is-number@6.0.0 beside is-odd's nested registry copy. + /// Bun installs the tarball record from its own resolution, so only the + /// registry record is redirected and the run says, loudly, that the + /// tarball copy stays unpatched; the in-run VEX must not assume it. + #[test] + fn user_tarball_records_are_reported_unpatched() { + let is_number_6 = DepOverride { + name: "is-number".into(), + artifact_url: "https://patch.example.test/6.0.0/is-number-6.0.0.tgz".into(), + ..dep("6.0.0") + }; + for shape in ["url", "file"] { + let lock = std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/bun-lockb-user-tarball") + .join(shape) + .join("bun.lockb"), + ) + .unwrap(); + let mut result = RewriteResult::default(); + rewrite_bun_binary(&lock, std::slice::from_ref(&is_number_6), &mut result); + assert_eq!(result.edits.len(), 1, "{shape}: {:?}", result.edits); + assert!(result.confirmed_bun_binary_uuids.contains("6.0.0")); + let codes: Vec<_> = result.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!( + codes, + ["redirect_bun_non_registry_entry_skipped"], + "{shape}: {:?}", + result.warnings + ); + assert!(result.warnings[0].detail.contains("UNPATCHED")); + assert!(result.bundled_skipped_uuids.contains("6.0.0"), "{shape}"); + let rewired = BunLockb::parse_packages(&result.binary_files["bun.lockb"]).unwrap(); + assert!( + rewired.iter().any(|p| p.name == "is-number" + && p.version.is_none() + && p.resolution.ends_with("is-number-6.0.0.tgz") + && !p.resolution.contains("patch.example.test")), + "{shape}: the tarball record keeps its resolution: {rewired:?}" + ); + } + } + #[test] fn malformed_metahash_cannot_confirm_an_existing_binary_redirect() { let mut result = RewriteResult::default(); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index a9a882c25..0fc91a184 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -4875,6 +4875,19 @@ fn parse_bun_hosted_lock( Ok((lines, entries)) } +/// The warning for a package the bun rewriters pinned to its hosted URL that +/// Bun 1.3.5+ no longer trusts by default (#371). +pub(crate) fn bun_default_trust_warning(name: &str, version: &str) -> RewriteWarning { + RewriteWarning { + code: "redirect_bun_default_trust_lost".into(), + detail: crate::vendor::bun_lock_text::default_trust_detail( + name, + version, + "a hosted tarball URL", + ), + } +} + /// Leave `dep` on its registry resolution when the project's own /// `patchedDependencies` patches it (#367): Bun applies that patch only to /// the registry `name@version`, so a hosted pin would silently drop it from @@ -4905,7 +4918,9 @@ fn rewrite_bun_lock( overrides: &[DepOverride], result: &mut RewriteResult, ) { - use crate::vendor::bun_lock_text::{decode_json_string, is_bundled_entry}; + use crate::vendor::bun_lock_text::{ + decode_json_string, is_bundled_entry, is_user_tarball_spec, + }; let npm: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); if npm.is_empty() { @@ -4969,6 +4984,9 @@ fn rewrite_bun_lock( let target_spec = format!("{fname}@{}", dep.version); let url_spec = format!("{fname}@{}", dep.artifact_url); let mut matched_any = false; + // A non-bundled instance now resolves to the hosted URL. + let mut wired = false; + let mut user_tarball_skipped = false; for (i, entry) in entries.iter().enumerate() { let Some(spec) = specs[i].as_deref() else { continue; @@ -5020,6 +5038,7 @@ fn rewrite_bun_lock( // `new` (`bun_lock_text::same_wiring_modulo_integrity`), so // the chain still unwinds to the pristine registry line. matched_any = true; + wired = true; if entry.elems.len() == 3 && entry.elems[2] == format!("\"{sha512}\"") { continue; } @@ -5041,10 +5060,28 @@ fn rewrite_bun_lock( deps_verbatim = entry.elems[1].clone(); } else { // Same-name-but-unowned entry (user file:/URL dep, other - // version) — never touched. + // version) — never touched. A user URL / `file:` tarball of + // this very version is installed from that spec beside the + // pinned copy and stays unpatched (#497, npm's #326): say so, + // and keep the in-run VEX from assuming the uuid patched. + if is_user_tarball_spec(spec, &fname, &dep.version) && spec != url_spec { + user_tarball_skipped = true; + result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); + result.warnings.push(RewriteWarning { + code: "redirect_bun_non_registry_entry_skipped".into(), + detail: format!( + "bun.lock entry `{}` installs {fname}@{} from a URL or local \ + tarball, not the registry, and CANNOT be redirected — bun installs \ + it from that spec, so that copy stays UNPATCHED; depend on the \ + registry release to patch it", + entry.key, dep.version + ), + }); + } continue; } matched_any = true; + wired = true; let original = lines[entry.line_idx].clone(); // Lines come from a bare `split('\n')`, so a CRLF lock's lines // carry a trailing `\r` (the grammar trims it away when parsing). @@ -5079,7 +5116,18 @@ fn rewrite_bun_lock( changed = true; } pinned_any |= matched_any; - if !matched_any { + if wired + && crate::vendor::bun_lock_text::loses_default_trust( + files.get("package.json").map(String::as_str), + Some(content), + &fname, + ) + { + result + .warnings + .push(bun_default_trust_warning(&fname, &dep.version)); + } + if !matched_any && !user_tarball_skipped { // Mirrors the pnpm/berry/uv rewriters: a granted dep that matched // no rewritable tuple (lock re-resolved to another version, entry // occupied by an unowned URL/file: spec) must be diagnosable, not @@ -11587,6 +11635,69 @@ mod tests { ); } + /// REGRESSION (#578): the bundled-copy check JSON-parses an entry's meta, + /// so running it before the spec compare cost one parse per dep × entry + /// (bun/hosted wall +110%). Only an entry whose spec matches the dep may + /// pay it, and the bundled copy must still be skipped and reported. + #[test] + fn bun_lock_bundled_check_runs_only_on_matching_entries() { + use crate::vendor::bun_lock_text::BUNDLED_CHECKS; + + const ENTRIES: usize = 200; + const DEPS: usize = 20; + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let mut body: Vec = (0..ENTRIES) + .map(|i| format!("\"pkg{i}\": [\"pkg{i}@1.0.0\", \"\", {{}}, \"sha512-OLD==\"],")) + .collect(); + body.push( + "\"parent/pkg0\": [\"pkg0@1.0.0\", \"\", { \"bundled\": true }, \"sha512-OLD==\"]," + .into(), + ); + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + bun_lock_file(&body.join("\n "), 1), + ); + let overrides: Vec = (0..DEPS) + .map(|i| { + npm_override( + &format!("pkg{i}"), + "1.0.0", + &format!("http://p.test/pkg{i}.tgz"), + &sha512, + ) + }) + .collect(); + + BUNDLED_CHECKS.with(|checks| checks.set(0)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, &overrides, &mut r); + let checks = BUNDLED_CHECKS.with(std::cell::Cell::get); + + let out = r.files.get("bun.lock").expect("matching deps are rewired"); + for i in 0..DEPS { + assert!(out.contains(&format!("http://p.test/pkg{i}.tgz")), "{out}"); + } + assert!( + out.contains("\"parent/pkg0\": [\"pkg0@1.0.0\", \"\", { \"bundled\": true }"), + "the bundled copy is never rewired: {out}" + ); + assert!( + r.warnings + .iter() + .any(|w| w.code == "redirect_bun_bundled_instance_skipped"), + "{:?}", + r.warnings + ); + // One check per spec-matching entry (DEPS registry tuples + the + // bundled copy), not DEPS × (ENTRIES + 1). + assert!( + checks <= DEPS + 1, + "{checks} bundled checks for {DEPS} deps over {} entries", + ENTRIES + 1 + ); + } + /// A bun.lock already redirected by an earlier run holds a URL 3-tuple — /// the registry `name@version` spec is gone — so when the artifact URL /// changes (patch republish rotates the uuid segment, token rotation @@ -11653,7 +11764,11 @@ mod tests { r.files.is_empty(), "foreign-origin URL dep must not be touched" ); - assert_eq!(r.warnings[0].code, "redirect_bun_entry_not_found"); + // ...but bun installs it from that URL, unpatched (#497). + assert_eq!( + r.warnings[0].code, + "redirect_bun_non_registry_entry_skipped" + ); // Our origin but ANOTHER version's leaf is never claimed either. let other_version_url = "https://patch.socket.dev/patch/npm/oldtoken-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.2.0.tgz"; @@ -12032,6 +12147,82 @@ mod tests { ); } + /// REGRESSION (#497): bun installs a remote-URL or `file:` tarball + /// dependency from its own spec, never the registry, so a registry copy + /// of the same version rewired beside it leaves the copy the app loads + /// unpatched. The tarball tuple is left alone LOUDLY (npm's #326), the + /// uuid is kept out of the in-run VEX's assumptions, and a lock holding + /// only the tarball copy says why instead of `redirect_bun_entry_not_found`. + #[test] + fn bun_lock_user_tarball_copy_is_skipped_with_loud_warning() { + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let ovr = npm_override( + "is-number", + "6.0.0", + "http://p.test/is-number-6.0.0.tgz", + &sha512, + ); + let nested = "\"is-odd/is-number\": [\"is-number@6.0.0\", \"\", {}, \"sha512-UP==\"],"; + for user in [ + "\"is-number\": [\"is-number@https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz\", {}, \"sha512-UP==\"],", + "\"is-number\": [\"is-number@./is-number-6.0.0.tgz\", {}, \"sha512-UP==\"],", + "\"is-number\": [\"is-number@vendor/is-number-6.0.0.tgz\", {}],", + ] { + let both = format!("{user}\n {nested}"); + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(&both, 2)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert_eq!(r.edits.len(), 1, "{user}: {:?}", r.edits); + assert_eq!(r.edits[0].key.as_deref(), Some("is-odd/is-number")); + let out = r.files.get("bun.lock").expect("nested copy rewired"); + assert!(out.contains(user), "{user}: tarball line untouched: {out}"); + assert_eq!( + warning_codes(&r), + vec!["redirect_bun_non_registry_entry_skipped"], + "{user}: {:?}", + r.warnings + ); + assert!( + r.warnings[0].detail.contains("`is-number`") + && r.warnings[0].detail.contains("UNPATCHED"), + "{}", + r.warnings[0].detail + ); + assert!(r.bundled_skipped_uuids.contains(&ovr.patch_uuid)); + + // The tarball copy alone: nothing to rewire, and the warning + // names the real reason. + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(user, 2)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.edits); + assert_eq!( + warning_codes(&r), + vec!["redirect_bun_non_registry_entry_skipped"], + "{user}: {:?}", + r.warnings + ); + } + + // A tarball of ANOTHER version, or one whose leaf names no version, + // is not this copy: no warning. + for other in [ + "\"is-number\": [\"is-number@https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz\", {}, \"sha512-UP==\"],", + "\"is-number\": [\"is-number@./is-number.tgz\", {}, \"sha512-UP==\"],", + ] { + let both = format!("{other}\n {nested}"); + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(&both, 2)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert_eq!(r.edits.len(), 1, "{other}: {:?}", r.edits); + assert!(r.warnings.is_empty(), "{other}: {:?}", r.warnings); + assert!(r.bundled_skipped_uuids.is_empty()); + } + } + /// #580: the bundled check runs only for entries whose spec matches the /// patch. A bundled copy of ANOTHER package (or another version) beside /// the target never warns or keeps the patch out of VEX, while a bundled @@ -12141,6 +12332,106 @@ mod tests { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } + /// REGRESSION (#371): from Bun 1.3.5 on, Bun's default trusted list + /// (better-sqlite3, esbuild, sharp, simple-git-hooks, …) applies only to + /// packages resolved from the npm registry, so a default-trusted package + /// rewired to a hosted URL has its install scripts skipped with exit 0. + /// The rewrite says so, on the first run and on an already-wired re-run; + /// a project that declares `trustedDependencies` (in package.json or + /// bun.lock's mirror) decides trust by name alone and is not warned, nor + /// is a package off the default list. + #[test] + fn bun_lock_default_trusted_package_warns_that_trust_is_lost() { + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let hooks = npm_override( + "simple-git-hooks", + "2.11.1", + "http://p.test/simple-git-hooks-2.11.1.tgz", + &sha512, + ); + let mut other = npm_override("is-number", "7.0.0", "http://p.test/isn.tgz", &sha512); + other.patch_uuid = "22222222-2222-4222-8222-222222222222".into(); + let entries = "\"is-number\": [\"is-number@7.0.0\", \"\", {}, \"sha512-UP==\"],\n \ + \"simple-git-hooks\": [\"simple-git-hooks@2.11.1\", \"\", { \"bin\": \ + { \"simple-git-hooks\": \"cli.js\" } }, \"sha512-OLD==\"],"; + let manifest = + r#"{"name":"app","dependencies":{"is-number":"7.0.0","simple-git-hooks":"2.11.1"}}"#; + let overrides = [hooks.clone(), other.clone()]; + let run = |lock: &str, manifest: Option<&str>| { + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), lock.to_string()); + if let Some(manifest) = manifest { + files.insert("package.json".to_string(), manifest.to_string()); + } + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, &overrides, &mut r); + r + }; + + let lock = bun_lock_file(entries, 1); + let first = run(&lock, Some(manifest)); + let wired = first.files.get("bun.lock").expect("both rewired").clone(); + assert_eq!(first.edits.len(), 2, "{:?}", first.edits); + assert_eq!( + warning_codes(&first), + vec!["redirect_bun_default_trust_lost"], + "{:?}", + first.warnings + ); + let detail = &first.warnings[0].detail; + assert!( + detail.contains("simple-git-hooks@2.11.1") + && detail.contains("trustedDependencies") + && detail.contains("1.3.5"), + "{detail}" + ); + // Without a readable manifest Bun still has no explicit list. + assert_eq!( + warning_codes(&run(&lock, None)), + vec!["redirect_bun_default_trust_lost"] + ); + // An already-wired re-run keeps saying so until trust is declared. + let rerun = run(&wired, Some(manifest)); + assert!(rerun.files.is_empty() && rerun.edits.is_empty()); + assert_eq!( + warning_codes(&rerun), + vec!["redirect_bun_default_trust_lost"] + ); + + // An explicit list (even one that omits the package: Bun then never + // trusted it by default) leaves trust unchanged by the rewire. + for declared in [ + r#"{"name":"app","trustedDependencies":["simple-git-hooks"]}"#, + r#"{"name":"app","trustedDependencies":[]}"#, + "{\n // JSONC, as Bun reads it\n \"trustedDependencies\": [\"simple-git-hooks\",],\n}", + ] { + let r = run(&lock, Some(declared)); + assert_eq!(r.edits.len(), 2); + assert!(r.warnings.is_empty(), "{declared}: {:?}", r.warnings); + } + let mirrored = lock.replacen( + " \"packages\": {", + " \"trustedDependencies\": [\n \"simple-git-hooks\",\n ],\n \"packages\": {", + 1, + ); + assert_ne!(mirrored, lock); + assert!(run(&mirrored, None).warnings.is_empty()); + + // A bundled copy is not rewired, so its trust is not the rewire's + // to lose (its own warning says it stays unpatched). + let bundled = bun_lock_file( + "\"p/simple-git-hooks\": [\"simple-git-hooks@2.11.1\", \"\", { \"bundled\": true }, \ + \"sha512-OLD==\"],", + 1, + ); + let r = run(&bundled, Some(manifest)); + assert!( + !warning_codes(&r).contains(&"redirect_bun_default_trust_lost"), + "{:?}", + r.warnings + ); + } + /// A CRLF bun.lock (Windows `core.autocrlf` checkout) must keep CRLF on /// the REWRITTEN line too — the vendored engine already does — so the /// file never ends up mixed-EOL, and the ledger `new` fragment carries @@ -21749,13 +22040,23 @@ packages: Some(format!(" {stale}").as_str()) ); - for unowned in [ - // Foreign origin, same leaf: a user's own URL dep. - "\"left-pad\": [\"left-pad@https://example.com/mirror/left-pad-1.3.0.tgz\", {}],", + for (unowned, code) in [ + // Foreign origin, same leaf: a user's own URL dep, which bun + // installs from that URL, unpatched (#497). + ( + "\"left-pad\": [\"left-pad@https://example.com/mirror/left-pad-1.3.0.tgz\", {}],", + "redirect_bun_non_registry_entry_skipped", + ), // Our origin, another version's leaf. - "\"left-pad\": [\"left-pad@https://patch.socket.dev/patch/npm/oldtoken-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.2.0.tgz\", {}],", + ( + "\"left-pad\": [\"left-pad@https://patch.socket.dev/patch/npm/oldtoken-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.2.0.tgz\", {}],", + "redirect_bun_entry_not_found", + ), // Registry spec in a 2-tuple: not bun's registry grammar. - "\"left-pad\": [\"left-pad@1.3.0\", {}],", + ( + "\"left-pad\": [\"left-pad@1.3.0\", {}],", + "redirect_bun_entry_not_found", + ), ] { let mut files = BTreeMap::new(); files.insert("bun.lock".to_string(), bun_lock_file(unowned, 1)); @@ -21767,7 +22068,7 @@ packages: ); assert_eq!( r.warnings.iter().map(|w| w.code.as_str()).collect::>(), - vec!["redirect_bun_entry_not_found"], + vec![code], "{unowned}" ); } @@ -21791,7 +22092,8 @@ packages: /// Fail-closed ownership legs of the URL-tuple takeover: an OTHER-name /// spec, a non-http `file:` spec, and a foreign-origin URL all survive /// byte-identically while the target registry tuple in the same lock is - /// rewritten. + /// rewritten. The foreign-origin URL names the target's own leaf, so it + /// is reported as a copy that stays unpatched (#497). #[test] fn bun_lock_unowned_url_and_file_tuples_survive_untouched() { let sha = format!("sha512-{}==", "A".repeat(86)); @@ -21826,7 +22128,13 @@ packages: ); } assert_eq!(r.edits.len(), 1, "only the target is edited: {:?}", r.edits); - assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!( + warning_codes(&r), + vec!["redirect_bun_non_registry_entry_skipped"], + "{:?}", + r.warnings + ); + assert!(r.warnings[0].detail.contains("`mirror/left-pad`")); } /// The uv block iteration must find the target mid-file and leave both diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs b/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs index 87c49a542..2e90bfb7c 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs @@ -17,7 +17,7 @@ use std::collections::BTreeSet; -use super::npm::{by_uuid, fetch_dists, refuse_all_in}; +use super::npm::{bun_tarball_url, by_uuid, fetch_dists_on, refuse_all_in, BunRegistrySettings}; use super::{Ctx, FormatResult, HostedPin, View}; use crate::vendor::bun_lockb::{BunLockb, NORMALIZED_FORMAT_1, NORMALIZED_WORKSPACE}; @@ -102,17 +102,26 @@ pub(super) async fn restore( .iter() .map(|(_, u, n, v)| (u.clone(), n.clone(), v.clone())) .collect(); - let dists = fetch_dists(&wanted, ctx, &mut result).await; + // The record keeps the tarball URL Bun fetches from, which is the + // project registry's for a mirror (#992). + let settings = BunRegistrySettings::read(view, rel).await; + let dists = fetch_dists_on( + &wanted, + |n| settings.registry_with_credentials(n), + ctx, + &mut result, + ) + .await; let mut changed = false; let mut restored = Vec::new(); for (id, uuid, name, version) in hits { if result.refused.contains_key(&uuid) { continue; } - let Some(dist) = dists.get(&(name.clone(), version.clone())) else { + let Some(found) = dists.get(&(name.clone(), version.clone())) else { continue; }; - let Some(integrity) = dist.integrity.as_deref() else { + let Some(integrity) = found.dist.integrity.as_deref() else { result.refuse( &uuid, format!("the registry records no integrity for {name}@{version}"), @@ -120,7 +129,9 @@ pub(super) async fn restore( continue; }; // Transactional per record: a failed rebuild leaves `lock` as is. - match lock.set_registry_package(id, &version, &dist.tarball, integrity) { + let tarball = + bun_tarball_url(settings.registry(&name).as_deref(), &name, &version, found); + match lock.set_registry_package(id, &version, &tarball, integrity) { Ok(()) => { restored.push(uuid); changed = true; @@ -290,6 +301,42 @@ mod tests { } } + /// #992: in a project whose `bunfig.toml` names a mirror, the rebuilt + /// record keeps the mirror's tarball URL (Bun fetches from the URL + /// the record holds), read from the mirror's own version document. + #[tokio::test] + #[serial_test::serial] + async fn hosted_record_restores_the_bunfig_registry_tarball() { + let original = fixture("1.2.23"); + let integrity = minimist(&original).integrity.unwrap(); + let server = registry(&integrity).await; + let mirror_url = format!("{}/mirror/minimist/-/minimist-1.2.2.tgz", server.uri()); + Mock::given(method("GET")) + .and(path("/mirror/minimist/1.2.2")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "dist": { "tarball": mirror_url, "integrity": integrity } + }))) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), hosted(&original)).unwrap(); + std::fs::write( + tmp.path().join("bunfig.toml"), + format!("[install]\nregistry = \"{}/mirror/\"\n", server.uri()), + ) + .unwrap(); + std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); + let discovery = crate::vex::discover_patched_refs(tmp.path()).await; + let pins = HostedPin::all(&discovery); + let outcome = restore_upstream(tmp.path(), &pins, &vendor_opts()).await; + std::env::remove_var("SOCKET_NPM_REGISTRY"); + assert_eq!(outcome.pins[0].status, PinStatus::Restored); + let after = std::fs::read(tmp.path().join("bun.lockb")).unwrap(); + let restored = minimist(&after); + assert_eq!(restored.resolution, mirror_url); + assert_eq!(restored.integrity.as_deref(), Some(integrity.as_str())); + } + /// Where the hosted rewrite had to normalize workspace dependency /// behaviors (not invertible), the restore refuses with the checkout /// remedy and writes nothing, instead of returning a non-exact lock. diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs index 63567b55d..1c055b800 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs @@ -150,8 +150,8 @@ pub(crate) const OFFLINE: &str = type Cache = Mutex>>; -/// [`Cache`] keyed by (registry base, name, version). -type RegistryCache = Mutex>>; +/// [`Cache`] keyed by (registry base, `Authorization` sent, name, version). +type RegistryCache = Mutex, String, String), Result>>; /// One client per restore run; every lookup is cached (success and /// failure alike) so a pin wired in several files costs one request. @@ -185,10 +185,27 @@ impl UpstreamClient { } async fn get_json(&self, url: &str) -> Result { - let resp = self - .http - .get(url) - .header("accept", "application/json") + self.get_json_authorized(url, None).await + } + + /// [`Self::get_json`] sending `authorization` (a private registry's + /// credentials) as the `Authorization` header. reqwest drops the header + /// on a redirect to another host. + async fn get_json_authorized( + &self, + url: &str, + authorization: Option<&str>, + ) -> Result { + let mut request = self.http.get(url).header("accept", "application/json"); + if let Some(authorization) = authorization { + let mut value = + reqwest::header::HeaderValue::from_str(authorization).map_err(|_| { + format!("GET {url}: the configured credentials are not a valid header") + })?; + value.set_sensitive(true); + request = request.header(reqwest::header::AUTHORIZATION, value); + } + let resp = request .send() .await .map_err(|e| format!("GET {url}: {e}"))?; @@ -221,13 +238,33 @@ impl UpstreamClient { base: &str, name: &str, version: &str, + ) -> Result { + self.npm_dist_authorized(base, None, name, version).await + } + + /// [`Self::npm_dist_on`] sending `authorization` as the `Authorization` + /// header: the credentials the project configures for the private + /// registry at `base` (#992). + pub(crate) async fn npm_dist_authorized( + &self, + base: &str, + authorization: Option<&str>, + name: &str, + version: &str, ) -> Result { let base = base.trim_end_matches('/'); - let key = (base.to_string(), name.to_string(), version.to_string()); + let key = ( + base.to_string(), + authorization.map(str::to_string), + name.to_string(), + version.to_string(), + ); if let Some(hit) = self.npm.lock().await.get(&key) { return hit.clone(); } - let result = self.fetch_npm_dist(base, name, version).await; + let result = self + .fetch_npm_dist(base, authorization, name, version) + .await; self.npm.lock().await.insert(key, result.clone()); result } @@ -235,6 +272,7 @@ impl UpstreamClient { async fn fetch_npm_dist( &self, base: &str, + authorization: Option<&str>, name: &str, version: &str, ) -> Result { @@ -246,7 +284,7 @@ impl UpstreamClient { "{base}/{encoded_name}/{}", crate::utils::uri::encode_uri_component(version) ); - let doc = self.get_json(&url).await?; + let doc = self.get_json_authorized(&url, authorization).await?; let dist = doc .get("dist") .ok_or_else(|| format!("{url} carries no `dist` block"))?; @@ -797,7 +835,7 @@ mod tests { .await; let client = UpstreamClient::new(false); client.npm.lock().await.insert( - (npm_registry_base(), "left-pad".into(), "1.3.0".into()), + (npm_registry_base(), None, "left-pad".into(), "1.3.0".into()), Ok(NpmDist { tarball: format!("{}/archive.tgz", server.uri()), integrity: registry_sri, @@ -857,7 +895,7 @@ mod tests { .await; let client = UpstreamClient::new(false); client.npm.lock().await.insert( - (npm_registry_base(), "left-pad".into(), "1.3.0".into()), + (npm_registry_base(), None, "left-pad".into(), "1.3.0".into()), Ok(NpmDist { tarball: format!("{}/archive.tgz", server.uri()), integrity: Some("sha512-other".into()), diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index a6bf9b577..9d0c00674 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -487,14 +487,28 @@ impl RestoreOutcome { } /// The remedy every refusal names: restore the file from version control. +/// For a Bun lock it also names `bun install --force`: Bun's hoisted linker +/// keeps the installed patched copy when the restored entry is the registry +/// copy of the same `name@version`, so a plain `bun install` after the +/// checkout reports no changes (#764). pub fn checkout_remedy(files: &[String]) -> String { if files.is_empty() { return "restore the lockfile from version control (`git checkout -- `)" .to_string(); } + use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB}; + let bun = files.iter().any(|f| { + let name = f.rsplit(['/', '\\']).next().unwrap_or(f); + name == BUN_LOCK || name == BUN_LOCKB + }); format!( - "restore it from version control instead (`git checkout -- {}`)", - files.join(" ") + "restore it from version control instead (`git checkout -- {}`){}", + files.join(" "), + if bun { + ", then run `bun install --force` (a plain `bun install` keeps the patched copy)" + } else { + "" + } ) } @@ -859,3 +873,25 @@ async fn restore_pass(view: &mut View<'_>, active: &[&HostedPin], ctx: &Ctx<'_>) npm::cleanup_side_config(view, ctx, &mut out).await; out } + +#[cfg(test)] +mod tests { + use super::checkout_remedy; + + #[test] + fn bun_lock_remedies_name_the_forced_reinstall() { + for file in ["bun.lockb", "bun.lock", "packages/app/bun.lockb"] { + let remedy = checkout_remedy(&[file.to_string()]); + assert!(remedy.contains(&format!("`git checkout -- {file}`")), "{remedy}"); + assert!(remedy.ends_with( + ", then run `bun install --force` (a plain `bun install` keeps the patched copy)" + ), "{remedy}"); + } + for file in ["yarn.lock", "package-lock.json", "bun.lock.bak"] { + assert_eq!( + checkout_remedy(&[file.to_string()]), + format!("restore it from version control instead (`git checkout -- {file}`)") + ); + } + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 3ececcc00..de0b0d04b 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -28,7 +28,7 @@ pub(super) async fn fetch_dists( ctx: &Ctx<'_>, result: &mut FormatResult, ) -> BTreeMap<(String, String), NpmDist> { - fetch_dists_on(wanted, |_| None, ctx, result) + fetch_dists_on(wanted, |_| None::, ctx, result) .await .into_iter() .map(|(key, found)| (key, found.dist)) @@ -42,39 +42,84 @@ pub(super) struct ProjectDist { pub from_project: bool, } +/// Whether `base` is registry.npmjs.org or its registry.yarnpkg.com alias +/// (either scheme). +fn is_npmjs_registry(base: &str) -> bool { + let base = base.trim().trim_end_matches('/'); + let host = base + .strip_prefix("https://") + .or_else(|| base.strip_prefix("http://")) + .unwrap_or(base); + matches!(host, "registry.npmjs.org" | "registry.yarnpkg.com") +} + /// The registry base a project names, unless it is the default registry /// (npmjs, its registry.yarnpkg.com alias, or `SOCKET_NPM_REGISTRY`), whose /// document [`fetch_dists`] already reads. pub(super) fn non_default_registry(base: &str) -> Option { use crate::vendor::registry_fetch::npm_registry_base; let base = base.trim().trim_end_matches('/'); - let host = base - .strip_prefix("https://") - .or_else(|| base.strip_prefix("http://")) - .unwrap_or(base); - let npmjs = matches!(host, "registry.npmjs.org" | "registry.yarnpkg.com"); - (!base.is_empty() && !npmjs && base != npm_registry_base()).then(|| base.to_string()) + (!base.is_empty() && !is_npmjs_registry(base) && base != npm_registry_base()) + .then(|| base.to_string()) +} + +/// A registry a project resolves a package against, with the +/// `Authorization` header value its settings configure for it (a private +/// registry's token or basic credentials). Never `Debug`: it holds a secret. +#[derive(Clone)] +pub(super) struct ProjectRegistry { + pub base: String, + pub authorization: Option, +} + +impl From for ProjectRegistry { + fn from(base: String) -> Self { + ProjectRegistry { + base, + authorization: None, + } + } } /// [`fetch_dists`], reading each version document from the registry the /// project resolves `name` against (`registry(name)`; `None` means the /// default registry), since a mirror's `dist.tarball` need not be the -/// default registry's (#521, #908). When the project's registry can't be -/// read (a private mirror that wants credentials the restore does not -/// send), the default registry's document is used, as before, and -/// `upstream_registry_fallback` says so. -pub(super) async fn fetch_dists_on( +/// default registry's (#521, #908), with the credentials the project +/// configures for it (#992). A registry with credentials is read even when +/// it is the default one (a private package on npmjs). When the project's +/// registry can't be read, the default registry's document is used, as +/// before, and `upstream_registry_fallback` says so. +pub(super) async fn fetch_dists_on>( wanted: &BTreeSet<(String, String, String)>, - registry: impl Fn(&str) -> Option, + registry: impl Fn(&str) -> Option, ctx: &Ctx<'_>, result: &mut FormatResult, ) -> BTreeMap<(String, String), ProjectDist> { let lookups = wanted.iter().map(|(uuid, name, version)| { - let project = registry(name).as_deref().and_then(non_default_registry); + let project = registry(name) + .map(Into::into) + .and_then(|r: ProjectRegistry| { + let base = non_default_registry(&r.base).or_else(|| { + r.authorization + .is_some() + .then(|| r.base.trim().trim_end_matches('/').to_string()) + })?; + Some(ProjectRegistry { + base, + authorization: r.authorization, + }) + }); async move { let mut fell_back = None; let found = match project { - Some(base) => match ctx.client.npm_dist_on(&base, name, version).await { + Some(ProjectRegistry { + base, + authorization, + }) => match ctx + .client + .npm_dist_authorized(&base, authorization.as_deref(), name, version) + .await + { Ok(dist) => Ok(ProjectDist { dist, from_project: true, @@ -1476,6 +1521,399 @@ pub(crate) async fn restore_pnpm_locks( // ── bun.lock ───────────────────────────────────────────────────────────────── +/// The registry Bun resolves `name` against, from the settings beside the +/// lock (#992): a scoped package's `.npmrc` `@scope:registry`, else its +/// `bunfig.toml` `[install.scopes]` entry; otherwise `env_registry` +/// (`BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY`), the `.npmrc` +/// `registry`, then `bunfig.toml` `[install] registry` — Bun's own order. +/// `None` means Bun's default registry, npmjs. +/// +/// The registry carries the credentials Bun sends it: the bunfig entry's +/// own `token` (Bearer) or `username` / `password` (Basic), else the +/// `.npmrc` `//host/path/:_authToken` / `:_auth` / `:username` + +/// `:_password` whose path covers the registry URL. `$VAR` / `${VAR}` in a +/// bunfig value and `${VAR}` in an `.npmrc` value read `var`, as Bun +/// expands them, but only for the [`BUN_EXPANDED_VARS`] token variables: +/// these files come with the project, and any other reference (say +/// `$GITHUB_TOKEN`) would hand that secret to a host the project names. +/// It expands to nothing instead. A private scope registry answers 401 +/// without them. +fn bun_lookup_registry( + npmrc: Option<&str>, + bunfig: Option<&str>, + env_registry: Option<&str>, + var: &dyn Fn(&str) -> Option, + name: &str, +) -> Option { + use super::super::npmrc::npmrc_top_level_value; + + let var = &|key: &str| BUN_EXPANDED_VARS.contains(&key).then(|| var(key)).flatten(); + fn url(value: &str) -> Option { + let value = value.trim().trim_matches(['"', '\'']); + (value.starts_with("https://") || value.starts_with("http://")).then(|| value.to_string()) + } + // A bunfig registry is a URL string or a table carrying `url` and + // maybe its credentials. + let toml_url = |item: Option<&toml_edit::Item>| -> Option { + let item = item?; + let value = item + .as_str() + .or_else(|| item.get("url").and_then(toml_edit::Item::as_str))?; + url(&expand_url_env(value, var, true)) + }; + let toml_auth = |item: &toml_edit::Item| -> Option { + let field = |key: &str| { + item.get(key) + .and_then(toml_edit::Item::as_str) + .map(|v| expand_env(v, var, true)) + .filter(|v| !v.is_empty()) + }; + if let Some(token) = field("token") { + return Some(format!("Bearer {token}")); + } + let (user, password) = (field("username")?, field("password")?); + use base64::Engine as _; + Some(format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}")) + )) + }; + let npmrc_value = |key: &str| { + npmrc + .and_then(|text| npmrc_top_level_value(text, key)) + .map(|v| expand_env(&v, var, false)) + }; + let npmrc_url = |key: &str| { + npmrc + .and_then(|text| npmrc_top_level_value(text, key)) + .and_then(|v| url(&expand_url_env(&v, var, false))) + }; + // Credentials in the URL itself (`https://user:${TOKEN}@host/`) go on + // the request only: the base is written into bun.lock and warnings. + let with_npmrc_auth = |base: String, own: Option| -> ProjectRegistry { + let (base, userinfo) = split_userinfo(&base); + let authorization = own + .or(userinfo) + .or_else(|| npmrc_registry_auth(&base, &npmrc_value)); + ProjectRegistry { + base, + authorization, + } + }; + let bunfig = bunfig.and_then(|text| text.parse::().ok()); + let install = bunfig.as_ref().and_then(|doc| doc.get("install")); + // The configured default registry before the environment applies. + let configured = || -> Option { + if let Some(base) = npmrc_url("registry") { + return Some(with_npmrc_auth(base, None)); + } + let item = install?.get("registry")?; + let base = toml_url(Some(item))?; + Some(with_npmrc_auth(base, toml_auth(item))) + }; + if let Some((scope, _)) = name.strip_prefix('@').and_then(|rest| rest.split_once('/')) { + if let Some(scoped) = npmrc_url(&format!("@{scope}:registry")) { + return Some(with_npmrc_auth(scoped, None)); + } + let entry = install.and_then(|i| i.get("scopes")).and_then(|scopes| { + scopes + .get(scope) + .or_else(|| scopes.get(format!("@{scope}"))) + }); + if let Some(entry) = entry { + if let Some(scoped) = toml_url(Some(entry)) { + return Some(with_npmrc_auth(scoped, toml_auth(entry))); + } + // A scope entry with no URL (a token only) takes the configured + // default registry, never the environment's, with its own + // credentials. With no default configured that is npmjs, which + // still gets the scope's token: a private npmjs scope 401s + // without it. + if entry.is_table_like() && entry.get("url").is_none() { + let own = toml_auth(entry); + return match configured() { + Some(r) => Some(ProjectRegistry { + authorization: own.or(r.authorization), + ..r + }), + None => own.map(|own| ProjectRegistry { + base: format!("{}/", crate::vendor::registry_fetch::DEFAULT_NPM_REGISTRY), + authorization: Some(own), + }), + }; + } + } + } + match env_registry.and_then(url) { + Some(base) => Some(with_npmrc_auth(base, None)), + None => configured(), + } +} + +/// The variables a project's bunfig.toml / .npmrc may expand into the +/// registry and credentials a Bun restore sends: the conventional npm +/// token variables, nothing else. +const BUN_EXPANDED_VARS: &[&str] = &["NPM_TOKEN", "NODE_AUTH_TOKEN", "BUN_AUTH_TOKEN"]; + +/// A registry URL with its variables expanded only inside its userinfo +/// (`https://user:${NPM_TOKEN}@host/`), which goes on the request's +/// `Authorization` header (see [`split_userinfo`]). A reference anywhere +/// else expands to nothing: the rest of the URL is requested, printed in +/// `upstream_registry_fallback` and written into the lock, so a token +/// there would leak into all three. +fn expand_url_env(value: &str, var: &dyn Fn(&str) -> Option, bare: bool) -> String { + let none = |_: &str| None; + let (scheme, rest) = value.split_once("://").unwrap_or(("", value)); + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let (userinfo, after) = match rest[..authority_end].rsplit_once('@') { + Some((userinfo, host)) => (Some(userinfo), &rest[authority_end - host.len()..]), + None => (None, rest), + }; + let mut out = String::with_capacity(value.len()); + if value.contains("://") { + out.push_str(scheme); + out.push_str("://"); + } + if let Some(userinfo) = userinfo { + out.push_str(&expand_env(userinfo, var, bare)); + out.push('@'); + } + out.push_str(&expand_env(after, &none, bare)); + out +} + +/// `value` with each `${VAR}` (and, for a bunfig value, `$VAR`) replaced +/// by `var(VAR)`, empty when unset. +fn expand_env(value: &str, var: &dyn Fn(&str) -> Option, bare: bool) -> String { + let is_name = |c: char| c.is_ascii_alphanumeric() || c == '_'; + let mut out = String::with_capacity(value.len()); + let mut rest = value; + while let Some(at) = rest.find('$') { + out.push_str(&rest[..at]); + let after = &rest[at + 1..]; + if let Some(braced) = after.strip_prefix('{') { + if let Some(end) = braced.find('}') { + out.push_str(&var(&braced[..end]).unwrap_or_default()); + rest = &braced[end + 1..]; + continue; + } + } else if bare { + let end = after.find(|c| !is_name(c)).unwrap_or(after.len()); + if end > 0 { + out.push_str(&var(&after[..end]).unwrap_or_default()); + rest = &after[end..]; + continue; + } + } + out.push('$'); + rest = after; + } + out.push_str(rest); + out +} + +/// `base` without its URL userinfo, and the Basic `Authorization` that +/// userinfo stood for (percent-decoded, as a URL parser reads it). +fn split_userinfo(base: &str) -> (String, Option) { + use crate::utils::purl::percent_decode_purl_component as decode; + let Some((scheme, rest)) = base.split_once("://") else { + return (base.to_string(), None); + }; + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let Some((userinfo, host)) = rest[..authority_end].rsplit_once('@') else { + return (base.to_string(), None); + }; + let stripped = format!("{scheme}://{host}{}", &rest[authority_end..]); + let (user, password) = userinfo.split_once(':').unwrap_or((userinfo, "")); + let (user, password) = (decode(user), decode(password)); + let authorization = (!user.is_empty() || !password.is_empty()).then(|| { + use base64::Engine as _; + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}")) + ) + }); + (stripped, authorization) +} + +/// The `Authorization` an `.npmrc` configures for the registry at `base`: +/// the `//host[:port]/path/:`-keyed `_authToken` (Bearer), `_auth` (Basic) +/// or `username` + base64 `_password` (Basic) of the longest path that +/// covers `base`'s, on the same host. +fn npmrc_registry_auth(base: &str, value: &dyn Fn(&str) -> Option) -> Option { + let rest = base + .strip_prefix("https://") + .or_else(|| base.strip_prefix("http://"))?; + let rest = rest.split(['?', '#']).next().unwrap_or(rest); + let (host, path) = rest.split_once('/').unwrap_or((rest, "")); + let host = host.rsplit_once('@').map_or(host, |(_, h)| h); + if host.is_empty() { + return None; + } + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let non_empty = |v: Option| v.filter(|v| !v.is_empty()); + for depth in (0..=segments.len()).rev() { + let mut dart = format!("//{host}/"); + for segment in &segments[..depth] { + dart.push_str(segment); + dart.push('/'); + } + if let Some(token) = non_empty(value(&format!("{dart}:_authToken"))) { + return Some(format!("Bearer {token}")); + } + if let Some(auth) = non_empty(value(&format!("{dart}:_auth"))) { + return Some(format!("Basic {auth}")); + } + if let (Some(user), Some(password)) = ( + non_empty(value(&format!("{dart}:username"))), + non_empty(value(&format!("{dart}:_password"))), + ) { + use base64::Engine as _; + let engine = base64::engine::general_purpose::STANDARD; + let password = engine.decode(password.trim()).ok()?; + let password = String::from_utf8(password).ok()?; + return Some(format!( + "Basic {}", + engine.encode(format!("{user}:{password}")) + )); + } + } + None +} + +/// The registry Bun takes from its environment: the first of +/// `BUN_CONFIG_REGISTRY`, `NPM_CONFIG_REGISTRY`, `npm_config_registry` +/// that is an http(s) URL — Bun skips a key that is not and reads the +/// next (`PackageManagerOptions.load`). +fn bun_env_registry(var: impl Fn(&str) -> Option) -> Option { + [ + "BUN_CONFIG_REGISTRY", + "NPM_CONFIG_REGISTRY", + "npm_config_registry", + ] + .iter() + .find_map(|key| var(key).filter(|v| v.starts_with("https://") || v.starts_with("http://"))) +} + +/// The settings beside a Bun lock that decide which registry Bun resolves +/// each package against, and so which tarball URL it recorded (#992). +pub(super) struct BunRegistrySettings { + npmrc: Option, + bunfig: Option, + env_registry: Option, +} + +impl BunRegistrySettings { + pub(super) async fn read(view: &mut View<'_>, rel: &str) -> Self { + let dir_prefix = match rel.rsplit_once('/') { + Some((dir, _)) => format!("{dir}/"), + None => String::new(), + }; + let npmrc = view + .read(&format!("{dir_prefix}.npmrc")) + .await + .ok() + .flatten(); + let bunfig = view + .read(&format!("{dir_prefix}bunfig.toml")) + .await + .ok() + .flatten(); + // Unit tests read no ambient registry: npm exports + // `npm_config_registry` to child processes whenever one is + // configured, which would otherwise steer the fixtures' restores. + let env_registry = if cfg!(test) { + None + } else { + bun_env_registry(|key| std::env::var(key).ok()) + }; + Self { + npmrc, + bunfig, + env_registry, + } + } + + /// The registry Bun resolves `name` against; `None` means npmjs. + pub(super) fn registry(&self, name: &str) -> Option { + self.registry_with_credentials(name).map(|r| r.base) + } + + /// [`Self::registry`] with the credentials Bun sends it. + pub(super) fn registry_with_credentials(&self, name: &str) -> Option { + // Unit tests read no ambient variables, as for `env_registry`. + let var = |key: &str| { + if cfg!(test) { + None + } else { + std::env::var(key).ok() + } + }; + bun_lookup_registry( + self.npmrc.as_deref(), + self.bunfig.as_deref(), + self.env_registry.as_deref(), + &var, + name, + ) + } +} + +/// The tarball URL Bun recorded for `name@version` resolved against +/// `project_registry`: the `dist.tarball` of the document read, except +/// that a fallback from an unreadable mirror to the default registry's +/// document re-bases its conventional URL on the mirror, the URL Bun +/// derived there. +pub(super) fn bun_tarball_url( + project_registry: Option<&str>, + name: &str, + version: &str, + found: &ProjectDist, +) -> String { + use crate::vendor::registry_fetch::{ + npm_registry_base, npm_tarball_is_conventional, npm_tarball_url, + }; + let tarball = &found.dist.tarball; + // Only a fallback from a registry the default document does not stand + // for is re-based: npmjs, its yarnpkg alias and `SOCKET_NPM_REGISTRY` + // advertise the very `dist.tarball` that was read, which is what Bun + // recorded. + match project_registry.and_then(non_default_registry) { + Some(base) + if !found.from_project + && npm_tarball_is_conventional(&npm_registry_base(), name, version, tarball) => + { + npm_tarball_url(&base, name, version) + } + _ => tarball.clone(), + } +} + +/// The registry slot Bun writes in a `bun.lock` 4-tuple: `""` for a +/// package from registry.npmjs.org (Bun's own prefix test), else the full +/// tarball URL — which Bun 1.1.39–1.3.6 need, as they read `""` as npmjs +/// whatever the project configures (#992). +fn bun_registry_slot( + project_registry: Option<&str>, + name: &str, + version: &str, + found: &ProjectDist, +) -> String { + use crate::vendor::registry_fetch::DEFAULT_NPM_REGISTRY; + // Bun resolved npmjs (unconfigured, or npmjs / its yarnpkg alias, + // whose documents advertise npmjs tarballs): the empty slot, whatever + // `SOCKET_NPM_REGISTRY` the restore itself read. + let Some(base) = project_registry.filter(|base| !is_npmjs_registry(base)) else { + return String::new(); + }; + let url = bun_tarball_url(Some(base), name, version, found); + if url.starts_with(DEFAULT_NPM_REGISTRY) { + String::new() + } else { + url + } +} + pub(crate) async fn restore_bun_locks( view: &mut View<'_>, pins: &[&HostedPin], @@ -1536,16 +1974,25 @@ pub(crate) async fn restore_bun_locks( .iter() .map(|(_, u, n, v, _)| (u.clone(), n.clone(), v.clone())) .collect(); - let dists = fetch_dists(&wanted, ctx, &mut result).await; + // Bun records the tarball URL of a package from any registry but + // npmjs, so the restore reads the project's registry settings. + let settings = BunRegistrySettings::read(view, rel).await; + let dists = fetch_dists_on( + &wanted, + |n| settings.registry_with_credentials(n), + ctx, + &mut result, + ) + .await; let mut changed = false; for (line_idx, uuid, name, version, deps) in hits { if result.refused.contains_key(&uuid) { continue; } - let Some(integrity) = dists - .get(&(name.clone(), version.clone())) - .and_then(|d| d.integrity.clone()) - else { + let Some(found) = dists.get(&(name.clone(), version.clone())) else { + continue; + }; + let Some(integrity) = found.dist.integrity.clone() else { result.refuse( &uuid, format!("the registry records no integrity for {name}@{version}"), @@ -1558,11 +2005,14 @@ pub(crate) async fn restore_bun_locks( let original = &lines[line_idx]; let cr = if original.ends_with('\r') { "\r" } else { "" }; let json = |s: &str| serde_json::to_string(s).expect("a str serializes to JSON"); + let slot = + bun_registry_slot(settings.registry(&name).as_deref(), &name, &version, found); lines[line_idx] = format!( - "{indent}{key}: [{spec}, \"\", {deps}, {integrity}]{comma}{cr}", + "{indent}{key}: [{spec}, {slot}, {deps}, {integrity}]{comma}{cr}", indent = entry.indent, key = entry.key_raw, spec = json(&format!("{name}@{version}")), + slot = json(&slot), integrity = json(&integrity), comma = if entry.trailing_comma { "," } else { "" }, ); @@ -1654,11 +2104,375 @@ pub(crate) async fn cleanup_side_config( #[cfg(test)] mod tests { use super::{ - berry_lookup_registry, berry_registry_locator, modules_yaml_pnpm_major, + berry_lookup_registry, berry_registry_locator, bun_env_registry, bun_lookup_registry, + bun_registry_slot, bun_tarball_url, expand_url_env, modules_yaml_pnpm_major, non_default_registry, package_json_pnpm_major, pnpm_include_tarball, pnpm_lookup_registry, pnpm_tarball_guess_warning, registry_derives_tarball, rush_json_pnpm_major, rush_lock_root, - yaml_top_level_value, PnpmTarballGuess, + split_userinfo, yaml_top_level_value, PnpmTarballGuess, ProjectDist, }; + use crate::patch::redirect::upstream::client::NpmDist; + + #[test] + fn bun_reads_the_registry_in_bun_s_own_order() { + let bunfig = "[install]\nregistry = \"https://b.example/\"\n\n\ + [install.scopes]\ns = \"https://s.example/\"\n\"@t\" = { url = \"https://t.example/\", token = \"x\" }\n"; + let npmrc = "registry=https://n.example/\n@u:registry=https://u.example/\n"; + let lookup = |npmrc, bunfig, env, name| { + bun_lookup_registry(npmrc, bunfig, env, &|_| None, name).map(|r| r.base) + }; + assert_eq!( + lookup(None, Some(bunfig), None, "a").as_deref(), + Some("https://b.example/") + ); + // A table registry carries `url`. + assert_eq!( + lookup( + None, + Some("[install.registry]\nurl = \"https://c.example\"\n"), + None, + "a" + ) + .as_deref(), + Some("https://c.example") + ); + // .npmrc wins over bunfig, the environment over both. + assert_eq!( + lookup(Some(npmrc), Some(bunfig), None, "a").as_deref(), + Some("https://n.example/") + ); + assert_eq!( + lookup(Some(npmrc), Some(bunfig), Some("https://e.example"), "a").as_deref(), + Some("https://e.example") + ); + // A scope's registry wins over every default one; either spelling. + for (name, want) in [ + ("@s/a", "https://s.example/"), + ("@t/a", "https://t.example/"), + ("@u/a", "https://u.example/"), + ("@v/a", "https://e.example"), + ] { + assert_eq!( + lookup(Some(npmrc), Some(bunfig), Some("https://e.example"), name).as_deref(), + Some(want), + "{name}" + ); + } + // A scope entry with no URL takes the configured default registry, + // not the environment's (Bun's `registry.url = base.url`). + let token_only = "[install]\nregistry = \"https://b.example/\"\n\n\ + [install.scopes]\nw = { token = \"x\" }\n"; + assert_eq!( + lookup(None, Some(token_only), Some("https://e.example"), "@w/a").as_deref(), + Some("https://b.example/") + ); + assert_eq!( + lookup(None, Some(token_only), Some("https://e.example"), "a").as_deref(), + Some("https://e.example") + ); + // Nothing configured, or nothing that is a URL: npmjs. + assert_eq!(lookup(None, None, None, "a"), None); + assert_eq!( + lookup(Some("registry=${R}\n"), Some("not toml ["), Some("x"), "a"), + None + ); + } + + #[test] + fn bun_registry_userinfo_goes_on_the_request_not_the_base() { + let vars = |key: &str| (key == "NPM_TOKEN").then(|| "s3cret".to_string()); + let lookup = |npmrc: Option<&str>, bunfig: Option<&str>, env: Option<&str>, name: &str| { + bun_lookup_registry(npmrc, bunfig, env, &vars, name).map(|r| (r.base, r.authorization)) + }; + let basic = |pair: &str| { + use base64::Engine as _; + Some(format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(pair) + )) + }; + // bunfig `$VAR` / `${VAR}`, an .npmrc `${VAR}` and the environment's + // registry: the expanded secret never reaches the base that + // bun.lock, bun.lockb and upstream_registry_fallback print. + let bunfig = "[install]\nregistry = \"https://ci:$NPM_TOKEN@b.example/npm/\"\n\n\ + [install.scopes]\n\ + corp = { url = \"https://u:${NPM_TOKEN}@corp.example/\", token = \"own\" }\n"; + assert_eq!( + lookup(None, Some(bunfig), None, "a"), + Some(("https://b.example/npm/".to_string(), basic("ci:s3cret"))) + ); + // An explicit token still wins; the userinfo is dropped all the same. + assert_eq!( + lookup(None, Some(bunfig), None, "@corp/w"), + Some(( + "https://corp.example/".to_string(), + Some("Bearer own".to_string()) + )) + ); + assert_eq!( + lookup( + Some("@s:registry=https://x:${NPM_TOKEN}@s.example/\n"), + None, + None, + "@s/w" + ), + Some(("https://s.example/".to_string(), basic("x:s3cret"))) + ); + assert_eq!( + lookup(None, None, Some("https://e%40m:p%3Aw@e.example/"), "a"), + Some(("https://e.example/".to_string(), basic("e@m:p:w"))) + ); + // No userinfo: unchanged. + assert_eq!( + split_userinfo("https://h.example/a@b"), + ("https://h.example/a@b".to_string(), None) + ); + } + + #[test] + fn bun_sends_the_credentials_its_settings_give_each_registry() { + let vars = |key: &str| match key { + "NODE_AUTH_TOKEN" => Some("from-env".to_string()), + "BUN_AUTH_TOKEN" => Some("npmrc-env".to_string()), + "GITHUB_TOKEN" => Some("not-for-registries".to_string()), + _ => None, + }; + let auth = |npmrc: Option<&str>, bunfig: Option<&str>, env: Option<&str>, name: &str| { + bun_lookup_registry(npmrc, bunfig, env, &vars, name).map(|r| (r.base, r.authorization)) + }; + let some = + |base: &str, auth: Option<&str>| Some((base.to_string(), auth.map(str::to_string))); + // The scope entry's own token, `$VAR` / `${VAR}` expanded; Basic + // from username + password. + let bunfig = "[install]\nregistry = { url = \"https://b.example/\", token = \"bt\" }\n\n\ + [install.scopes]\n\ + corp = { url = \"https://corp.example/npm/\", token = \"$NODE_AUTH_TOKEN\" }\n\ + braced = { url = \"https://br.example/\", token = \"x${NODE_AUTH_TOKEN}y\" }\n\ + basic = { url = \"https://ba.example/\", username = \"u\", password = \"p\" }\n\ + bare = \"https://bare.example/\"\n\ + own = { token = \"own\" }\n\ + unset = { url = \"https://un.example/\", token = \"$NOPE\" }\n\ + other = { url = \"https://ot.example/\", token = \"${GITHUB_TOKEN}\" }\n\ + inurl = \"https://u:$GITHUB_TOKEN@iu.example/\"\n\ + inpath = \"https://ip.example/$NODE_AUTH_TOKEN/\"\n"; + assert_eq!( + auth(None, Some(bunfig), None, "@corp/w"), + some("https://corp.example/npm/", Some("Bearer from-env")) + ); + assert_eq!( + auth(None, Some(bunfig), None, "@braced/w"), + some("https://br.example/", Some("Bearer xfrom-envy")) + ); + assert_eq!( + auth(None, Some(bunfig), None, "@basic/w"), + some("https://ba.example/", Some("Basic dTpw")) + ); + assert_eq!( + auth(None, Some(bunfig), None, "@bare/w"), + some("https://bare.example/", None) + ); + assert_eq!( + auth(None, Some(bunfig), None, "@unset/w"), + some("https://un.example/", None) + ); + // A variable that is not a registry token variable is never + // expanded: the project's files cannot send it anywhere. + assert_eq!( + auth(None, Some(bunfig), None, "@other/w"), + some("https://ot.example/", None) + ); + let (base, authorization) = auth(None, Some(bunfig), None, "@inurl/w").unwrap(); + assert_eq!(base, "https://iu.example/"); + let sent = authorization.and_then(|a| { + use base64::Engine as _; + let encoded = a.strip_prefix("Basic ")?.to_string(); + base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok() + }); + assert!( + !String::from_utf8_lossy(&sent.unwrap_or_default()).contains("not-for-registries"), + "the userinfo expanded $GITHUB_TOKEN" + ); + // Even an allowed token stays out of the URL's host, path and + // query, which are requested, printed and written into the lock; + // only userinfo (moved onto the request header) expands. + assert_eq!( + auth(None, Some(bunfig), None, "@inpath/w"), + some("https://ip.example//", None) + ); + assert_eq!( + auth( + Some("@p:registry=https://h.example/${BUN_AUTH_TOKEN}/?t=${BUN_AUTH_TOKEN}\n"), + None, + None, + "@p/w" + ), + some("https://h.example//?t=", None) + ); + assert_eq!( + expand_url_env( + "https://u:$NODE_AUTH_TOKEN@h.example/$NODE_AUTH_TOKEN", + &vars, + true + ), + "https://u:from-env@h.example/" + ); + // A token-only scope: the configured default registry, its own token. + assert_eq!( + auth(None, Some(bunfig), Some("https://e.example/"), "@own/w"), + some("https://b.example/", Some("Bearer own")) + ); + // ...and with no default registry configured, npmjs with that + // token (a private npmjs scope), still not the environment's. + let npmjs_scope = "[install.scopes]\nown = { token = \"$NODE_AUTH_TOKEN\" }\n\ + none = { username = \"u\" }\n"; + for env in [None, Some("https://e.example/")] { + assert_eq!( + auth(None, Some(npmjs_scope), env, "@own/w"), + some("https://registry.npmjs.org/", Some("Bearer from-env")), + "{env:?}" + ); + } + // A token-less scope entry with nothing configured stays npmjs. + assert_eq!(auth(None, Some(npmjs_scope), None, "@none/w"), None); + // The default registry's table token; the environment's registry + // carries none of bunfig's. + assert_eq!( + auth(None, Some(bunfig), None, "a"), + some("https://b.example/", Some("Bearer bt")) + ); + assert_eq!( + auth(None, Some(bunfig), Some("https://e.example/"), "a"), + some("https://e.example/", None) + ); + + // `.npmrc` nerf-darted credentials: the longest covering path on + // the same host; never another host's. + let npmrc = "@corp:registry=https://corp.example/npm/private/\n\ + @other:registry=https://other.example/\n\ + @basic:registry=https://nb.example/\n\ + @legacy:registry=https://lg.example/r/\n\ + registry=https://n.example/\n\ + //corp.example/:_authToken=host-wide\n\ + //corp.example/npm/private/:_authToken=${BUN_AUTH_TOKEN}\n\ + //n.example/:_authToken=default\n\ + //nb.example/:_auth=dTpw\n\ + //lg.example/r/:username=u\n//lg.example/r/:_password=cA==\n"; + assert_eq!( + auth(Some(npmrc), None, None, "@corp/w"), + some( + "https://corp.example/npm/private/", + Some("Bearer npmrc-env") + ) + ); + assert_eq!( + auth(Some(npmrc), None, None, "@other/w"), + some("https://other.example/", None) + ); + assert_eq!( + auth(Some(npmrc), None, None, "@basic/w"), + some("https://nb.example/", Some("Basic dTpw")) + ); + assert_eq!( + auth(Some(npmrc), None, None, "@legacy/w"), + some("https://lg.example/r/", Some("Basic dTpw")) + ); + assert_eq!( + auth(Some(npmrc), None, None, "a"), + some("https://n.example/", Some("Bearer default")) + ); + // A bunfig scope registry picks up the `.npmrc` credentials for + // its URL; its own token wins over them. + let corp_npmrc = "//corp.example/:_authToken=host-wide\n"; + assert_eq!( + auth( + Some(corp_npmrc), + Some("[install.scopes]\ncorp = \"https://corp.example/x/\"\n"), + None, + "@corp/w" + ), + some("https://corp.example/x/", Some("Bearer host-wide")) + ); + assert_eq!( + auth(Some(corp_npmrc), Some(bunfig), None, "@corp/w"), + some("https://corp.example/npm/", Some("Bearer from-env")) + ); + } + + #[test] + #[serial_test::serial] + fn bun_writes_the_tarball_url_unless_it_is_on_npmjs() { + let found = |tarball: &str, from_project| ProjectDist { + dist: NpmDist { + tarball: tarball.to_string(), + integrity: None, + shasum: None, + }, + from_project, + }; + let mirror = found("https://m.example/npm/a/-/a-1.0.0.tgz", true); + assert_eq!( + bun_registry_slot(Some("https://m.example/npm/"), "a", "1.0.0", &mirror), + "https://m.example/npm/a/-/a-1.0.0.tgz" + ); + // A mirror's off-path URL is kept as the mirror advertises it. + let cdn = found("https://cdn.example/f/a.tgz", true); + assert_eq!( + bun_registry_slot(Some("https://m.example"), "a", "1.0.0", &cdn), + "https://cdn.example/f/a.tgz" + ); + // npmjs, configured or not, or its aliases, is Bun's empty slot: + // their documents advertise npmjs tarballs, never re-based. + let npmjs = found("https://registry.npmjs.org/a/-/a-1.0.0.tgz", false); + for registry in [ + None, + Some("https://registry.npmjs.org/"), + Some("http://registry.npmjs.org/"), + Some("https://registry.yarnpkg.com/"), + ] { + assert_eq!(bun_registry_slot(registry, "a", "1.0.0", &npmjs), ""); + assert_eq!( + bun_tarball_url(registry, "a", "1.0.0", &npmjs), + "https://registry.npmjs.org/a/-/a-1.0.0.tgz", + "{registry:?}" + ); + } + // A fallback from an unreadable mirror re-bases the conventional URL. + assert_eq!( + bun_tarball_url(Some("https://m.example/npm/"), "a", "1.0.0", &npmjs), + "https://m.example/npm/a/-/a-1.0.0.tgz" + ); + } + + #[test] + fn bun_env_registry_skips_keys_that_are_not_urls() { + let env = |pairs: &'static [(&'static str, &'static str)]| { + move |key: &str| { + pairs + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v.to_string()) + } + }; + assert_eq!( + bun_env_registry(env(&[ + ("BUN_CONFIG_REGISTRY", "not-a-url"), + ("NPM_CONFIG_REGISTRY", ""), + ("npm_config_registry", "https://n.example/"), + ])) + .as_deref(), + Some("https://n.example/") + ); + assert_eq!( + bun_env_registry(env(&[ + ("BUN_CONFIG_REGISTRY", "http://b.example"), + ("npm_config_registry", "https://n.example/"), + ])) + .as_deref(), + Some("http://b.example") + ); + assert_eq!(bun_env_registry(env(&[("BUN_CONFIG_REGISTRY", "x")])), None); + } const HOSTED: &str = "https://patch.test/npm/u/a-1.0.0.tgz"; const WS_ON: &str = "packages:\n - '.'\nlockfileIncludeTarballUrl: true\n"; diff --git a/crates/socket-patch-core/src/patch/shared_store.rs b/crates/socket-patch-core/src/patch/shared_store.rs index f17845020..04efcb727 100644 --- a/crates/socket-patch-core/src/patch/shared_store.rs +++ b/crates/socket-patch-core/src/patch/shared_store.rs @@ -7,7 +7,7 @@ //! module cache), but not a package *directory* that is itself a symlink //! into a store every project on the machine links to: the rename then lands //! inside the shared directory, patching (or, on rollback, unpatching) every -//! other project that uses it. Two package managers install that way: +//! other project that uses it. Three package managers install that way: //! //! * **PDM's package cache** (`install.cache = true` with //! `install.cache_method = symlink`, PDM 2.0–2.12): `site-packages/` @@ -18,6 +18,11 @@ //! `node_modules/` is a symlink (a junction on Windows) into //! `/v/links/…/node_modules/`, beside the store's //! `files/` content directory. +//! * **Bun's global store** (`[install] globalStore = true` in +//! `bunfig.toml`, or `BUN_INSTALL_GLOBAL_STORE=1`, Bun >= 1.3.14, isolated +//! linker): each `node_modules/.bun/` is a symlink into +//! `/links/-/node_modules/` in Bun's install +//! cache. //! //! Detection is positive and marker-based, on the package directory's real //! path: a per-project store reached through a symlink (pnpm's @@ -74,6 +79,8 @@ pub enum SharedStoreKind { PdmPackageCache, /// `/v/links`, pnpm's global virtual store. PnpmGlobalVirtualStore, + /// `/links/-`, Bun's global store. + BunGlobalStore, /// A `node_modules` entry linked to first-party source outside every /// `node_modules` tree (a workspace member, a `file:` / `link:` /// directory dependency, an `npm link` target). @@ -106,6 +113,11 @@ impl SharedStore { "pnpm's global virtual store (enableGlobalVirtualStore)", "set enableGlobalVirtualStore to false and reinstall", ), + SharedStoreKind::BunGlobalStore => ( + "Bun's global store (install.globalStore / BUN_INSTALL_GLOBAL_STORE)", + "set `globalStore = false` under `[install]` in bunfig.toml (and unset \ + BUN_INSTALL_GLOBAL_STORE), then reinstall", + ), SharedStoreKind::OutsideInstallTree => { // A rollback has nothing to "patch directly": the bytes an // older in-place patch wrote there come back from the @@ -213,6 +225,14 @@ fn shared_store_of_blocking(pkg_path: &Path) -> Option { }); } + // Bun: /links/-/node_modules//…. + if name == "links" && real.strip_prefix(dir).is_ok_and(is_bun_global_store_path) { + return Some(SharedStore { + kind: SharedStoreKind::BunGlobalStore, + real_path: real.clone(), + }); + } + // PDM: /packages//lib/…, the entry carrying its // `referrers` registry. if name == "lib" @@ -403,6 +423,59 @@ pub(crate) fn is_pnpm_global_virtual_store_dir(dir: &Path) -> bool { && parent.is_some_and(|p| p.join("files").is_dir()) } +/// Whether `rest`, a real path below a `links` dir, is a package inside an +/// entry of Bun's global store: `-/node_modules//…`, +/// where `` is the `.bun` entry name the project links it under +/// (`@`, a scoped name's `/` written `+`, or `@` and a +/// mangled tarball URL), `` is hex, and `` is the package the +/// entry name starts with. A bundled dependency nested inside the package +/// is in the same entry, so it is matched too. +fn is_bun_global_store_path(rest: &Path) -> bool { + let mut parts = rest.components().map(|c| c.as_os_str().to_str()); + let (Some(Some(entry)), Some(Some("node_modules")), Some(Some(first))) = + (parts.next(), parts.next(), parts.next()) + else { + return false; + }; + if !entry + .rsplit_once('-') + .is_some_and(|(_, hash)| is_bun_store_hash(hash)) + { + return false; + } + let name = if first.starts_with('@') { + let Some(Some(leaf)) = parts.next() else { + return false; + }; + format!("{first}+{leaf}") + } else { + first.to_string() + }; + entry + .strip_prefix(&name) + .is_some_and(|tail| tail.starts_with('@')) +} + +/// Whether `real`, a real (canonical) directory, is the Bun global store +/// entry a `node_modules/.bun/` link points to: +/// `/links/-`. +pub(crate) fn is_bun_global_store_entry(real: &Path, entry_name: &str) -> bool { + real.parent() + .and_then(Path::file_name) + .is_some_and(|n| n == "links") + && real + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_prefix(entry_name)) + .and_then(|tail| tail.strip_prefix('-')) + .is_some_and(is_bun_store_hash) +} + +/// The hex hash Bun appends to a global store entry's name. +fn is_bun_store_hash(hash: &str) -> bool { + (1..=16).contains(&hash.len()) && hash.bytes().all(|b| b.is_ascii_hexdigit()) +} + /// `v3`, `v10`, `v11`, …: the layout-version directory of a pnpm store /// (and of pnpm 11+'s global install dir, `$PNPM_HOME/global/v11`). pub(crate) fn is_pnpm_store_version_dir(name: &str) -> bool { @@ -431,6 +504,22 @@ pub(crate) mod test_support { pkg } + /// `/bun-cache/links/-/node_modules/`, beside + /// the cache's own `@@@@1` extraction, for `name` + /// (`left-pad`, or scoped `@scope/leaf`) at `version`. + pub(crate) fn make_bun_global_store_entry(root: &Path, name: &str, version: &str) -> PathBuf { + let cache = root.join("bun-cache"); + let entry = format!("{}@{version}", name.replace('/', "+")); + std::fs::create_dir_all(cache.join(format!("{entry}@@@1"))).unwrap(); + let pkg = cache + .join("links") + .join(format!("{entry}-6a490709ba3c5c8f")) + .join("node_modules") + .join(name); + std::fs::create_dir_all(&pkg).unwrap(); + pkg + } + /// `/pdm-cache/packages/urllib3-1.26.18-py2.py3-none-any/{referrers,lib/urllib3}`. pub(crate) fn make_pdm_cache_entry(root: &Path) -> PathBuf { let entry = root @@ -540,6 +629,84 @@ mod tests { assert_eq!(shared_store_of(&nm.join("is-odd")).await, None); } + /// #635: with Bun's global store each `node_modules/.bun/` is a + /// link into `/links/-`, shared by every project on + /// the machine. A package reached through that link (the importer's + /// `node_modules/` or the `.bun` entry itself), scoped or not, a + /// file below it, and a bundled dependency inside it are all refused. + #[cfg(unix)] + #[tokio::test] + async fn bun_global_store_is_shared() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let nm = dir.path().join("proj").join("node_modules"); + let bun = nm.join(".bun"); + std::fs::create_dir_all(&bun).unwrap(); + std::fs::create_dir_all(nm.join("@isaacs")).unwrap(); + for (name, link) in [ + ("left-pad", nm.join("left-pad")), + ( + "@isaacs/string-locale-compare", + nm.join("@isaacs/string-locale-compare"), + ), + ] { + let pkg = make_bun_global_store_entry(dir.path(), name, "1.3.0"); + let entry = pkg + .ancestors() + .find(|a| a.parent().and_then(Path::file_name) == Some("links".as_ref())) + .unwrap(); + let bun_entry = bun.join(format!("{}@1.3.0", name.replace('/', "+"))); + symlink(entry, &bun_entry).unwrap(); + let via_bun = bun_entry.join("node_modules").join(name); + symlink(&via_bun, &link).unwrap(); + std::fs::create_dir_all(pkg.join("node_modules").join("bundled")).unwrap(); + for path in [ + link.clone(), + via_bun.clone(), + pkg.join("node_modules/bundled"), + ] { + let got = shared_store_of(&path).await.expect("shared"); + assert_eq!( + got.kind, + SharedStoreKind::BunGlobalStore, + "{}", + path.display() + ); + } + let got = shared_store_of_patch_dirs(&link, ["lib/new/a.js"]).await; + assert_eq!(got.map(|s| s.kind), Some(SharedStoreKind::BunGlobalStore)); + } + } + + /// Bun's per-project isolated store (`node_modules/.bun/` a real + /// dir), and `links` dirs that do not hold a store entry's package, are + /// not shared. + #[tokio::test] + async fn bun_per_project_store_is_not_shared() { + let dir = tempfile::tempdir().unwrap(); + let private = dir + .path() + .join("node_modules/.bun/left-pad@1.3.0/node_modules/left-pad"); + std::fs::create_dir_all(&private).unwrap(); + assert_eq!(shared_store_of(&private).await, None); + for not_entry in [ + // No hex hash after the entry name. + "links/left-pad@1.3.0/node_modules/left-pad", + "links/left-pad@1.3.0-xyz/node_modules/left-pad", + // The package is not the one the entry names. + "links/is-odd@3.0.1-6a490709ba3c5c8f/node_modules/left-pad", + "links/left-pad-6a490709ba3c5c8f/node_modules/left-pad", + // Not under the entry's `node_modules`. + "links/left-pad@1.3.0-6a490709ba3c5c8f/left-pad", + // A package that is itself named `links`. + "node_modules/links/lib", + ] { + let path = dir.path().join("x").join(not_entry); + std::fs::create_dir_all(&path).unwrap(); + assert_eq!(shared_store_of(&path).await, None, "{not_entry}"); + } + } + /// A PyPI patch is rooted at `site-packages` (keys `/`), so /// the directory link into PDM's cache sits below the root and is found /// through the keys, including a key under a subdir that does not exist @@ -924,6 +1091,13 @@ mod tests { real_path: PathBuf::from("/s/v10/links/x"), }; assert!(s.refusal("roll back").contains("enableGlobalVirtualStore")); + let s = SharedStore { + kind: SharedStoreKind::BunGlobalStore, + real_path: PathBuf::from("/c/links/x@1.0.0-abc/node_modules/x"), + }; + let msg = s.refusal("patch"); + assert!(msg.contains(SHARED_STORE_REFUSAL_MARKER), "{msg}"); + assert!(msg.contains("globalStore = false"), "{msg}"); let s = SharedStore { kind: SharedStoreKind::LinkedSource, real_path: PathBuf::from("/ws/packages/left-pad"), diff --git a/crates/socket-patch-core/src/utils/workspace_globs.rs b/crates/socket-patch-core/src/utils/workspace_globs.rs index 04cbf3fe3..bd16d629a 100644 --- a/crates/socket-patch-core/src/utils/workspace_globs.rs +++ b/crates/socket-patch-core/src/utils/workspace_globs.rs @@ -212,7 +212,7 @@ fn path_glob_matches(pattern: &[Vec], path: &[Vec], dot: bool) -> bo } } -fn segment_glob_matches(pattern: &[char], name: &[char]) -> bool { +pub(crate) fn segment_glob_matches(pattern: &[char], name: &[char]) -> bool { match pattern.split_first() { None => name.is_empty(), Some(('*', rest)) => (0..=name.len()).any(|skip| segment_glob_matches(rest, &name[skip..])), diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index 236e1bf56..6137e82e0 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -1,5 +1,8 @@ //! Native binary Bun vendoring. Package records are edited without re-resolving //! dependencies or requiring a Bun executable. +use super::bun_lock_text::{ + decode_json_string, packages_bounds, parse_entry_line, split_name_spec, user_tarball_version, +}; use super::bun_lockb::{BinaryPackage, BunLockb}; use super::common::refused; use super::npm_common::{ @@ -12,12 +15,15 @@ use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; use crate::manifest::schema::PatchRecord; #[cfg(test)] use crate::patch::apply::PatchSources; -use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_bytes_sync}; +use crate::utils::fs::{ + atomic_write_bytes_preserving_mode, read_regular_to_bytes_sync, read_regular_to_string, +}; use std::path::{Path, PathBuf}; pub(crate) const LOCK: &str = "bun.lockb"; pub(crate) const KIND: &str = "bun_lockb_package"; const MIRROR_KIND: &str = "bun_lockb_workspace_artifact"; +const TEXT_LOCK: &str = "bun.lock"; fn is_ours(package: &BinaryPackage, name: &str, leaf: &str) -> bool { package.name == name @@ -25,6 +31,74 @@ fn is_ours(package: &BinaryPackage, name: &str, leaf: &str) -> bool { && package.resolution.ends_with(&format!("/{leaf}")) } +/// A record vendoring `coords` rewires: the exact `name@version`, or one of +/// our own tarballs for it. +fn is_target(package: &BinaryPackage, coords: &NpmCoords, leaf: &str) -> bool { + (package.name == coords.name && package.version.as_deref() == Some(&coords.version)) + || is_ours(package, &coords.name, leaf) +} + +/// Bun's writer keeps one package record per resolution, but after a project +/// is vendored a new dependent of the package (a member added later, `bun +/// add` in a member) gets a second, nested registry record of the same +/// `name@version`, because the hoisted one is a local tarball now. Rewiring +/// it to the same tarball gives two records one isolated store directory, +/// and frozen installs then fail intermittently with `EEXIST` (#861). So +/// such records are folded into ONE kept record (the one already at +/// `target`, else one of ours, else the first), as Bun's own re-save +/// would, whether or not the package has dependencies of its own. A record +/// some bundled edge reaches is left to the rewrite: its parent's tarball +/// ships that copy. Where the records cannot fold exactly +/// ([`BunLockb::merge_packages`]: their dependencies resolve differently, +/// or the lock's hoisting is not one this codec reproduces) they are all +/// rewritten as before, with a warning. Returns the records left to +/// rewrite, re-read after renumbering, and whether the lock changed. +fn merge_duplicates( + lock: &mut BunLockb, + matches: Vec, + target: &str, + coords: &NpmCoords, + leaf: &str, + warnings: &mut Vec, +) -> Result<(Vec, bool), String> { + let mut candidates: Vec<_> = matches.iter().filter(|p| !p.bundled).collect(); + candidates.sort_by_key(|p| { + ( + p.resolution != target, + !is_ours(p, &coords.name, leaf), + p.id, + ) + }); + let Some((kept, duplicates)) = candidates.split_first() else { + return Ok((matches, false)); + }; + if duplicates.is_empty() { + return Ok((matches, false)); + } + let duplicates: Vec = duplicates.iter().map(|p| p.id).collect(); + if !lock.merge_packages(kept.id, &duplicates)? { + warnings.push(VendorWarning::new( + "vendor_bun_lockb_duplicate_records", + format!( + "{LOCK} has {} records of {}@{} that cannot be folded into one, so each is \ + rewired to the same tarball; Bun's isolated linker can fail to install two \ + records with one tarball (EEXIST); the hoisted linker \ + (`[install] linker = \"hoisted\"` in bunfig.toml) installs it", + duplicates.len() + 1, + coords.name, + coords.version + ), + )); + return Ok((matches, false)); + } + let matches = lock + .packages()? + .into_iter() + .filter(|p| is_target(p, coords, leaf) && !p.bundled_only) + .collect(); + Ok((matches, true)) +} + /// [`BunBinaryBackend`] through the shared driver, under the signature the /// suite below calls it by. #[cfg(test)] @@ -90,7 +164,14 @@ impl NpmLockBackend for BunBinaryBackend { matches, mirrors, bundled, + user_tarballs, } = preflight_package(&project, root, coords, &leaf)?; + warnings.extend(super::bun_lock::default_trust_warning( + project.manifest.as_deref(), + None, + &coords.name, + &coords.version, + )); for package in bundled { // LOUD: this copy ships inside its PARENT's tarball, which we do // not repack — it stays the unpatched bytes after vendor (#469). @@ -107,6 +188,18 @@ impl NpmLockBackend for BunBinaryBackend { ), )); } + for package in user_tarballs { + // LOUD: bun installs this copy from its own URL / `file:` + // resolution, which vendoring does not touch (#497). + warnings.push(VendorWarning::new( + "vendor_non_registry_entry_skipped", + super::bun_lock::user_tarball_detail( + &format!("{LOCK} package #{}", package.id), + &coords.name, + &coords.version, + ), + )); + } let BinaryProject { lock, .. } = project; Ok(BunBinaryPlan { lock, @@ -121,7 +214,7 @@ impl NpmLockBackend for BunBinaryBackend { plan: BunBinaryPlan, cx: &WireCx<'_>, staged: &mut NpmStagedPack, - _warnings: &mut Vec, + warnings: &mut Vec, ) -> Result, String> { let BunBinaryPlan { mut lock, @@ -131,6 +224,8 @@ impl NpmLockBackend for BunBinaryBackend { } = plan; let (root, coords) = (cx.project_root, cx.coords); let mut wiring = Vec::new(); + let (matches, merged) = + merge_duplicates(&mut lock, matches, &staged.rel_tgz, coords, &leaf, warnings)?; for package in matches { if package.resolution == staged.rel_tgz && package.integrity.as_deref() == Some(&staged.packed.integrity) @@ -168,7 +263,7 @@ impl NpmLockBackend for BunBinaryBackend { // locations as well as the canonical root artifact. let artifact = read_regular_to_bytes_sync(&root.join(&staged.rel_tgz)) .map_err(|e| format!("cannot read staged tarball: {e}"))?; - let lock_changed = !wiring.is_empty(); + let lock_changed = merged || !wiring.is_empty(); let mut mirror_backups: Vec<(PathBuf, Option>)> = Vec::new(); for (workspace, rel) in &mirrors { let path = root.join(rel); @@ -234,6 +329,8 @@ pub(super) struct BinaryProject { packages: Vec, /// The project's own `patchedDependencies` keys (#367). user_patched: Vec, + /// The root `package.json` text, `None` when unreadable. + manifest: Option, } /// Read the lock, refusing (before any write) a symlinked, unreadable, @@ -259,11 +356,13 @@ pub(super) async fn read_project(root: &Path) -> Result v, Err(e) => return Err(Box::new(refused("vendor_bun_lockb_invalid", e))), }; - let user_patched = super::bun_lock::read_user_patched(root, None).await; + let manifest = super::bun_lock::read_manifest(root).await; + let user_patched = super::bun_lock_text::patched_dependency_keys(manifest.as_deref(), None); Ok(BinaryProject { lock, packages, user_patched, + manifest, }) } @@ -275,6 +374,9 @@ pub(super) struct BinaryTargets { /// Matching records some bundled edge reaches (#469): each one's /// bundled copy stays unpatched, which vendoring reports loudly. bundled: Vec, + /// User URL / `file:` tarball records of the same version (#497): bun + /// installs them from their own resolution, so they stay unpatched. + user_tarballs: Vec, } /// The per-package pre-flight against an already-read lock: the records @@ -292,10 +394,7 @@ pub(super) fn preflight_package( let (bundled_only, matches): (Vec<_>, Vec<_>) = project .packages .iter() - .filter(|p| { - (p.name == coords.name && p.version.as_deref() == Some(&coords.version)) - || is_ours(p, &coords.name, leaf) - }) + .filter(|p| is_target(p, coords, leaf)) .cloned() .partition(|p| p.bundled_only); let bundled: Vec<_> = matches @@ -304,6 +403,18 @@ pub(super) fn preflight_package( .chain(&bundled_only) .cloned() .collect(); + let user_tarballs: Vec<_> = project + .packages + .iter() + .filter(|p| { + p.name == coords.name + && p.version.is_none() + && !p.bundled_only + && user_tarball_version(&coords.name, &p.resolution) + == Some(coords.version.as_str()) + }) + .cloned() + .collect(); if matches.is_empty() && !bundled_only.is_empty() { // Only a bundled edge reaches the record: Bun unpacks that copy // from the parent's tarball, so rewiring the record installs @@ -318,6 +429,22 @@ pub(super) fn preflight_package( ), ))); } + if matches.is_empty() && !user_tarballs.is_empty() { + // The package IS locked, from a URL / `file:` resolution bun + // installs as written (#497): "run `bun install`" would not help. + let ids: Vec = user_tarballs.iter().map(|p| format!("#{}", p.id)).collect(); + return Err(Box::new(refused( + "vendor_lock_entry_not_rewritable", + format!( + "every {LOCK} record for {}@{} ({}) installs it from a URL or local tarball, \ + not the registry, and cannot be rewritten — those copies stay UNPATCHED and \ + `bun install` will not help; depend on the registry release to vendor it", + coords.name, + coords.version, + ids.join(", ") + ), + ))); + } if matches.is_empty() { return Err(Box::new(refused( "vendor_lock_entry_not_found", @@ -349,6 +476,7 @@ pub(super) fn preflight_package( matches, mirrors, bundled, + user_tarballs, }) } @@ -384,47 +512,81 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - ); } let mut outcome = RevertOutcome::ok(); - let original_bytes = match read_regular_to_bytes_sync(&root.join(LOCK)) { - Ok(v) => v, + let (mut lock, original_bytes) = match read_regular_to_bytes_sync(&root.join(LOCK)) { + Ok(bytes) => match BunLockb::parse(&bytes) { + Ok(v) => (RevertLock::Binary(v), bytes), + Err(e) => return RevertOutcome::failed(e), + }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { if entry.wiring.is_empty() && opts.keep_artifact { return outcome; } - return RevertOutcome::failed(format!( - "{LOCK} is missing; cannot safely revert the binary lock" - )); + // `bun install --save-text-lockfile` deletes bun.lockb and + // carries the vendored tuples into bun.lock (#784): restore the + // recorded registry packages there instead. + match read_regular_to_string(&root.join(TEXT_LOCK)).await { + Ok(text) => ( + RevertLock::Migrated(text.split('\n').map(str::to_string).collect()), + text.into_bytes(), + ), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return RevertOutcome::failed(format!( + "{LOCK} is missing; cannot safely revert the binary lock" + )); + } + Err(e) => return RevertOutcome::failed(format!("cannot read {TEXT_LOCK}: {e}")), + } } Err(e) => return RevertOutcome::failed(format!("cannot read {LOCK}: {e}")), }; - let mut lock = match BunLockb::parse(&original_bytes) { - Ok(v) => v, - Err(e) => return RevertOutcome::failed(e), - }; if !entry.wiring.iter().any(|rec| rec.kind == KIND) && !opts.keep_artifact { - match lock.packages() { - Ok(packages) - if !packages.iter().any(|p| { - parse_vendor_path(&p.resolution).is_some_and(|p| p.uuid == entry.uuid) - }) => {} - _ => { - return RevertOutcome::failed(format!( - "{LOCK} still references {} but the original wiring is missing", - entry.uuid - )) - } + let referenced = match &lock { + RevertLock::Binary(lock) => lock.packages().map(|packages| { + packages + .iter() + .any(|p| parse_vendor_path(&p.resolution).is_some_and(|p| p.uuid == entry.uuid)) + }), + RevertLock::Migrated(lines) => Ok(lines + .iter() + .any(|line| migrated_vendor_uuid(line).as_deref() == Some(entry.uuid.as_str()))), + }; + if referenced != Ok(false) { + return RevertOutcome::failed(format!( + "{} still references {} but the original wiring is missing", + lock.file(), + entry.uuid + )); } } + // Several binary records can carry different registry originals, which + // the migration collapsed into the same text entries: never guess which + // one each entry had. + let originals: Vec<_> = entry + .wiring + .iter() + .filter(|rec| rec.kind == KIND) + .filter_map(|rec| rec.original.as_ref()) + .collect(); + let ambiguous_migration = matches!(lock, RevertLock::Migrated(_)) + && originals.windows(2).any(|pair| { + ["name", "version", "resolution", "integrity"] + .iter() + .any(|key| pair[0].get(key) != pair[1].get(key)) + }); // REMOVED, not drift (#1132): `bun remove ` (or an upgrade off the // patched version) leaves neither snapshot in the lock. When no package // resolves through this uuid dir any more, there is nothing to restore // and nothing an install needs the artifact for. Probed once, before any // record is restored; an unreadable package table fails closed (drift). let uuid_lower = entry.uuid.to_ascii_lowercase(); - let unreferenced = lock.packages().is_ok_and(|packages| { - !packages - .iter() - .any(|p| p.resolution.to_ascii_lowercase().contains(&uuid_lower)) - }); + let unreferenced = match &lock { + RevertLock::Binary(lock) => lock.packages().is_ok_and(|packages| { + !packages + .iter() + .any(|p| p.resolution.to_ascii_lowercase().contains(&uuid_lower)) + }), + RevertLock::Migrated(_) => false, + }; let mut mirrors_to_remove = Vec::new(); for rec in entry.wiring.iter().rev() { if rec.kind == MIRROR_KIND { @@ -476,6 +638,22 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - } // `Ok(true)`: the record left the lock (see `unreferenced`). let restore = (|| { + // A same-uuid re-vendor on the migrated bun.lock re-pins our own + // tuple as a text record next to the binary records it carried + // forward (#784): restore it like the text revert does. + if let RevertLock::Migrated(lines) = &mut lock { + if rec.file == TEXT_LOCK && rec.kind == super::bun_lock::KIND_LOCK_PACKAGE { + let mut dirty = false; + super::bun_lock::revert_one_record( + lines, + rec, + &entry.uuid, + &mut dirty, + &mut outcome.warnings, + ); + return Ok(false); + } + } if rec.file != LOCK || rec.kind != KIND { return Err("unexpected binary wiring file or kind".to_string()); } @@ -488,6 +666,27 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - .original .as_ref() .ok_or("missing pre-vendor binary package snapshot")?; + let lock = match &mut lock { + RevertLock::Binary(lock) => lock, + RevertLock::Migrated(_) if ambiguous_migration => { + return Err(format!( + "binary package #{id} has a different registry original than another \ + record of this package, and {TEXT_LOCK} no longer tells them apart" + )); + } + RevertLock::Migrated(lines) => { + if !restore_migrated(lines, original, &entry.uuid)? { + outcome.warnings.push(VendorWarning::new( + super::LOCK_ENTRY_REMOVED_CODE, + format!( + "{TEXT_LOCK} has no entry for binary package #{id}; nothing to \ + restore" + ), + )); + } + return Ok(false); + } + }; let new = rec .new .as_ref() @@ -525,13 +724,28 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - if opts.dry_run { return outcome; } - let bytes = lock.bytes(); + let bytes = match &lock { + RevertLock::Binary(lock) => lock.bytes(), + RevertLock::Migrated(lines) => lines.join("\n").into_bytes(), + }; if bytes != original_bytes { - if let Err(e) = atomic_write_bytes_preserving_mode(&root.join(LOCK), &bytes).await { - return RevertOutcome::failed(format!("cannot write {LOCK}: {e}")); + if let Err(e) = atomic_write_bytes_preserving_mode(&root.join(lock.file()), &bytes).await { + return RevertOutcome::failed(format!("cannot write {}: {e}", lock.file())); } } if !opts.keep_artifact { + if matches!(lock, RevertLock::Migrated(_)) + && super::npm_flavor::keep_artifact_while_lock_references_it( + &mut outcome, + root, + &[TEXT_LOCK], + &entry.uuid, + &dir, + ) + .await + { + return outcome; + } for mirror in mirrors_to_remove { if let Err(e) = remove_mirror(&mirror).await { return RevertOutcome::failed(format!( @@ -557,6 +771,110 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - outcome } +/// The lock a binary entry's wiring is reverted in. +enum RevertLock { + Binary(BunLockb), + /// The `bun.lock` Bun migrated the binary lock to, as lines (#784). + Migrated(Vec), +} + +impl RevertLock { + fn file(&self) -> &'static str { + match self { + RevertLock::Binary(_) => LOCK, + RevertLock::Migrated(_) => TEXT_LOCK, + } + } +} + +/// The vendor uuid a `bun.lock` package line's local tarball tuple points +/// into, if it is one. +fn migrated_vendor_uuid(line: &str) -> Option { + let entry = parse_entry_line(line).ok()?; + if !matches!(entry.elems.len(), 2 | 3) || !entry.elems[1].starts_with('{') { + return None; + } + let spec = decode_json_string(&entry.elems[0])?; + let vendored = parse_vendor_path(split_name_spec(&spec)?.1)?; + (vendored.eco == "npm").then_some(vendored.uuid) +} + +/// The registry tuple Bun writes in `bun.lock` for the binary `original` +/// snapshot, in place of `line`: that package's vendored tuple in a +/// `bun.lock` Bun migrated from the binary lock (#784). The key, indent, +/// dependency object and trailing comma stay verbatim, as Bun carried them +/// over. Bun leaves the registry slot empty for a tarball under its default +/// registry and writes the full URL for any other. +pub(crate) fn migrated_registry_line(line: &str, original: &serde_json::Value) -> Option { + let entry = parse_entry_line(line).ok()?; + if !matches!(entry.elems.len(), 2 | 3) || !entry.elems[1].starts_with('{') { + return None; + } + let field = |key| original.get(key).and_then(serde_json::Value::as_str); + let (name, version) = (field("name")?, field("version")?); + let (resolution, integrity) = (field("resolution")?, field("integrity")?); + let spec = decode_json_string(&entry.elems[0])?; + let (spec_name, path) = split_name_spec(&spec)?; + let vendored = parse_vendor_path(path)?; + if spec_name != name || vendored.eco != "npm" || vendored.leaf != tgz_rel_leaf(name, version) { + return None; + } + let slot = if resolution.starts_with("https://registry.npmjs.org") { + "" + } else { + resolution + }; + let json = |s: &str| serde_json::to_string(s).expect("a str serializes to JSON"); + Some(format!( + "{indent}{key}: [{spec}, {slot}, {deps}, {integrity}]{comma}{cr}", + indent = entry.indent, + key = entry.key_raw, + spec = json(&format!("{name}@{version}")), + slot = json(slot), + deps = entry.elems[1], + integrity = json(integrity), + comma = if entry.trailing_comma { "," } else { "" }, + cr = if line.ends_with('\r') { "\r" } else { "" }, + )) +} + +/// Put `original` back over every migrated `bun.lock` entry that still +/// resolves into `uuid`'s artifact. `false` when no entry does and none +/// already holds the restored tuple either (Bun dropped the package). +fn restore_migrated( + lines: &mut [String], + original: &serde_json::Value, + uuid: &str, +) -> Result { + let (start, end) = + packages_bounds(lines).ok_or(format!("{TEXT_LOCK} has no packages section"))?; + let restored = |line: &str| { + let entry = parse_entry_line(line).ok()?; + let integrity = original.get("integrity")?.as_str()?; + let spec = format!( + "{}@{}", + original.get("name")?.as_str()?, + original.get("version")?.as_str()? + ); + (entry.elems.len() == 4 + && decode_json_string(&entry.elems[0]) == Some(spec) + && decode_json_string(&entry.elems[3]).as_deref() == Some(integrity)) + .then_some(()) + }; + let mut found = false; + for line in &mut lines[start + 1..end] { + if migrated_vendor_uuid(line).as_deref() == Some(uuid) { + *line = migrated_registry_line(line, original).ok_or_else(|| { + format!("{TEXT_LOCK} entry for {uuid} no longer matches its binary original") + })?; + found = true; + } else if restored(line).is_some() { + found = true; + } + } + Ok(found) +} + /// Mirrors are confined to a workspace's own Socket artifact directory. /// Check every existing component so a workspace symlink cannot redirect a /// write or deletion outside the project. @@ -783,9 +1101,13 @@ mod rebuild_tests { } pub(super) async fn flip_fixture() -> Fixture { + fixture_with(ORIGINAL) + } + + fn fixture_with(lock: &[u8]) -> Fixture { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - std::fs::write(root.join(LOCK), ORIGINAL).unwrap(); + std::fs::write(root.join(LOCK), lock).unwrap(); let installed = root.join("node_modules/minimist"); std::fs::create_dir_all(&installed).unwrap(); std::fs::write(installed.join("package.json"), PACKAGE).unwrap(); @@ -1122,6 +1444,266 @@ mod rebuild_tests { } } + // ── bun.lockb migrated to bun.lock (#784) ───────────────────────────── + + const MIGRATED_LOCKB: &[u8] = include_bytes!("../../tests/fixtures/bun-lockb/1.2.23/bun.lockb"); + /// `(migrating Bun, pristine bun.lock, vendored bun.lock)`: the 1.2.23 + /// fixture lock migrated by `bun install --save-text-lockfile` before + /// and after vendoring (see that fixture directory's README). + const MIGRATIONS: [(&str, &str, &str); 2] = [ + ( + "1.2.23", + include_str!("../../tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.2.23.lock"), + include_str!("../../tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.2.23.lock"), + ), + ( + "1.4.2", + include_str!("../../tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.4.2.lock"), + include_str!("../../tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.4.2.lock"), + ), + ]; + + /// Vendor the binary fixture, then migrate it as `bun` would: bun.lockb + /// is gone and bun.lock carries the vendored tuple. + async fn vendored_then_migrated(vendored: &str) -> (Fixture, VendorEntry) { + let fx = fixture_with(MIGRATED_LOCKB); + let (result, entry, _) = ts::expect_done(flip_run(&fx, None).await); + assert!(result.success, "{result:?}"); + let entry = entry.expect("vendoring rewires bun.lockb"); + ts::persist(fx.root(), PURL, entry.clone()).await; + let integrity = entry.wiring[0].new.as_ref().unwrap()["integrity"] + .as_str() + .unwrap(); + if vendored.matches("sha512-").count() == 2 { + assert!( + vendored.contains(integrity), + "the fixture was captured from this packing" + ); + } + std::fs::remove_file(fx.root().join(LOCK)).unwrap(); + std::fs::write(fx.root().join(TEXT_LOCK), vendored).unwrap(); + (fx, entry) + } + + /// After Bun migrates a vendored bun.lockb to bun.lock, revert restores + /// the registry tuple Bun itself writes for the pristine binary lock, + /// instead of failing on the missing bun.lockb. + #[tokio::test] + async fn revert_restores_registry_tuple_after_text_lock_migration() { + for (bun, pristine, vendored) in MIGRATIONS { + let (fx, entry) = vendored_then_migrated(vendored).await; + let dry = + super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(true)) + .await; + // The preview's only advisory is the reinstall one the real + // revert also gives for the fixture's hoisted copy (#764). + let dry_codes: Vec<&str> = dry.warnings.iter().map(|w| w.code).collect(); + assert!( + dry.success && dry_codes == [super::super::bun_lock::REINSTALL_REQUIRED], + "{bun}: {dry:?}" + ); + assert_eq!( + std::fs::read_to_string(fx.root().join(TEXT_LOCK)).unwrap(), + vendored + ); + let outcome = + super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(false)) + .await; + assert!(outcome.success, "{bun}: {outcome:?}"); + // The fixture's hoisted node_modules/minimist is the only + // advisory: Bun keeps it after the restore (#764). + let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code).collect(); + assert_eq!( + codes, + [super::super::bun_lock::REINSTALL_REQUIRED], + "{bun}: {outcome:?}" + ); + assert_eq!( + std::fs::read_to_string(fx.root().join(TEXT_LOCK)).unwrap(), + pristine, + "{bun}" + ); + assert!(!fx.root().join(LOCK).exists()); + assert!(!fx.root().join(".socket/vendor/npm").exists(), "{bun}"); + } + } + + /// A superseding patch vendored on the migrated bun.lock rewrites our + /// own tuple, so it records no original itself: the ledger must carry + /// the binary record's registry original over to it, and revert must + /// then restore the pristine tuple. + #[tokio::test] + async fn superseding_vendor_after_migration_keeps_the_registry_original() { + const UUID2: &str = "22222222-2222-4222-8222-222222222222"; + for (bun, pristine, vendored) in MIGRATIONS { + let (fx, _) = vendored_then_migrated(vendored).await; + let record = PatchRecord { + uuid: UUID2.to_string(), + ..fx.record.clone() + }; + let blobs = fx.root().join(".socket/blobs"); + let (result, entry, _) = ts::expect_done( + crate::vendor::test_support::vendor_bun( + PURL, + &fx.installed(), + fx.root(), + &record, + &PatchSources::blobs_only(&blobs), + "", + false, + false, + None, + ) + .await, + ); + assert!(result.success, "{bun}: {result:?}"); + ts::persist(fx.root(), PURL, entry.expect("re-pinned")).await; + let state = crate::vendor::state::load_state(fx.root()).await.unwrap(); + let entry = state.entries[PURL].clone(); + let minimist = pristine + .lines() + .find(|l| l.contains("\"minimist\": [")) + .unwrap(); + assert_eq!( + entry.wiring[0].original, + Some(serde_json::Value::String(minimist.to_string())), + "{bun}" + ); + let outcome = + super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(false)) + .await; + assert!(outcome.success, "{bun}: {outcome:?}"); + // The fixture's hoisted node_modules/minimist is the only + // advisory: Bun keeps it after the restore (#764). + let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code).collect(); + assert_eq!( + codes, + [super::super::bun_lock::REINSTALL_REQUIRED], + "{bun}: {outcome:?}" + ); + assert_eq!( + std::fs::read_to_string(fx.root().join(TEXT_LOCK)).unwrap(), + pristine, + "{bun}" + ); + } + } + + /// A same-uuid re-run on the migrated bun.lock (artifact missing, or the + /// tuple's digest changed) re-pins our own tuple as a text record, and + /// the ledger carries the binary records forward beside it. Revert must + /// restore the pristine tuple from that mixed entry, not report the text + /// record as drift and leave the project vendored. + #[tokio::test] + async fn same_uuid_repin_after_migration_reverts_the_mixed_entry() { + for (bun, pristine, vendored) in MIGRATIONS { + let (fx, first) = vendored_then_migrated(vendored).await; + std::fs::remove_dir_all(fx.root().join(".socket/vendor/npm")).unwrap(); + let integrity = first.wiring[0].new.as_ref().unwrap()["integrity"] + .as_str() + .unwrap() + .to_string(); + if vendored.contains(&integrity) { + // Force a re-pin of the digest-carrying tuple too. + let lock = vendored.replace(&integrity, "sha512-AAAA"); + std::fs::write(fx.root().join(TEXT_LOCK), lock).unwrap(); + } + let blobs = fx.root().join(".socket/blobs"); + let (result, entry, _) = ts::expect_done( + crate::vendor::test_support::vendor_bun( + PURL, + &fx.installed(), + fx.root(), + &fx.record, + &PatchSources::blobs_only(&blobs), + "", + false, + false, + None, + ) + .await, + ); + assert!(result.success, "{bun}: {result:?}"); + ts::persist(fx.root(), PURL, entry.expect("re-pinned")).await; + let state = crate::vendor::state::load_state(fx.root()).await.unwrap(); + let entry = state.entries[PURL].clone(); + let kinds: Vec<_> = entry.wiring.iter().map(|r| r.kind.as_str()).collect(); + assert!( + kinds.contains(&"bun_lock_package") && kinds.contains(&KIND), + "{bun}: {kinds:?}" + ); + let dry = + super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(true)) + .await; + // The preview's only advisory is the reinstall one the real + // revert also gives for the fixture's hoisted copy (#764). + let dry_codes: Vec<&str> = dry.warnings.iter().map(|w| w.code).collect(); + assert!( + dry.success && dry_codes == [super::super::bun_lock::REINSTALL_REQUIRED], + "{bun}: {dry:?}" + ); + let outcome = + super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(false)) + .await; + assert!(outcome.success, "{bun}: {outcome:?}"); + // The fixture's hoisted node_modules/minimist is the only + // advisory: Bun keeps it after the restore (#764). + let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code).collect(); + assert_eq!( + codes, + [super::super::bun_lock::REINSTALL_REQUIRED], + "{bun}: {outcome:?}" + ); + assert_eq!( + std::fs::read_to_string(fx.root().join(TEXT_LOCK)).unwrap(), + pristine, + "{bun}" + ); + assert!(!fx.root().join(".socket/vendor/npm").exists(), "{bun}"); + } + } + + /// Bun writes the full tarball URL for any registry but its default one, + /// and the rebuilt tuple keeps the vendored line's spelling. + #[test] + fn migrated_registry_line_spells_the_registry_slot_like_bun() { + let original = |resolution: &str| { + serde_json::json!({ + "name": "@s/p", "version": "1.0.0", "resolution": resolution, + "integrity": "sha512-AA==", + }) + }; + let line = format!( + " \"x/@s/p\": [\"@s/p@.socket/vendor/npm/{UUID}/@s/p-1.0.0.tgz\", {{ \"bin\": {{}} }}],\r" + ); + assert_eq!( + migrated_registry_line( + &line, + &original("https://registry.npmjs.org/@s/p/-/p-1.0.0.tgz") + ), + Some( + " \"x/@s/p\": [\"@s/p@1.0.0\", \"\", { \"bin\": {} }, \"sha512-AA==\"],\r" + .to_string() + ) + ); + assert_eq!( + migrated_registry_line(&line, &original("http://127.0.0.1:4873/@s/p/-/p-1.0.0.tgz")), + Some( + " \"x/@s/p\": [\"@s/p@1.0.0\", \"http://127.0.0.1:4873/@s/p/-/p-1.0.0.tgz\", { \"bin\": {} }, \"sha512-AA==\"],\r" + .to_string() + ) + ); + let other = serde_json::json!({ + "name": "@s/p", "version": "2.0.0", + "resolution": "https://registry.npmjs.org/@s/p/-/p-2.0.0.tgz", "integrity": "sha512-AA==", + }); + assert_eq!( + migrated_registry_line(&line, &other), + None, + "another version's tarball" + ); + } + /// #920: the `package.json` advisory is emitted once, by the run that /// wires — an in-sync re-run of a manifest-rewriting patch is a quiet /// AlreadyPatched (and the run that wires says it once). @@ -1167,3 +1749,105 @@ mod rebuild_tests { assert_eq!(manifest_warnings(&warnings), 0, "{warnings:?}"); } } + +#[cfg(test)] +mod duplicate_tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::vendor::test_support as ts; + + /// The uuid the fixtures were first vendored under. + const UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; + const BEFORE: &[u8] = b"module.exports = 'original';\n"; + const AFTER: &[u8] = b"module.exports = 'patched';\n"; + + /// REGRESSION (#861): the vendored re-run after Bun gave a late + /// dependent its own registry record of minimist@1.2.2, or of + /// mkdirp@0.5.6 with its own dependency on minimist (`deps`; see + /// `bun_lockb::tests::LATE_DEPENDENT`) leaves ONE record, the tarball, + /// that every dependency edge resolves to — never two records with one + /// tarball resolution, which the isolated linker installs into the same + /// store directory (`EEXIST`). (The e2e `workspace_late_dependent_*` + /// test reverts it through the first run's ledger.) + #[tokio::test] + async fn rerun_folds_the_late_registry_copy_into_the_tarball_record() { + for name in [ + "1.3.9-late", + "1.3.9-adder", + "1.4.2-late", + "1.4.2-adder", + "1.3.9-deps-late", + "1.3.9-deps-adder", + "1.4.2-deps-late", + "1.4.2-deps-adder", + ] { + let (package, version) = if name.contains("-deps-") { + ("mkdirp", "0.5.6") + } else { + ("minimist", "1.2.2") + }; + let purl = format!("pkg:npm/{package}@{version}"); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let fixture = format!( + "{}/tests/fixtures/bun-lockb/late-dependent/{name}.lockb", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::copy(&fixture, root.join(LOCK)).unwrap(); + let installed = root.join("node_modules").join(package); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write( + installed.join("package.json"), + format!(r#"{{"name":"{package}","version":"{version}"}}"#), + ) + .unwrap(); + std::fs::write(installed.join("index.js"), BEFORE).unwrap(); + let blobs = root.join(".socket/blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + let after_hash = compute_git_sha256_from_bytes(AFTER); + std::fs::write(blobs.join(&after_hash), AFTER).unwrap(); + let record: PatchRecord = serde_json::from_value(serde_json::json!({ + "uuid": UUID, "exportedAt": "", "files": {"package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(BEFORE), "afterHash": after_hash, + }}, "vulnerabilities": {}, "description": "", "license": "MIT", "tier": "free", + })) + .unwrap(); + let (result, entry, warnings) = ts::expect_done( + ts::vendor_bun( + &purl, + &installed, + root, + &record, + &PatchSources::blobs_only(&blobs), + "", + false, + false, + None, + ) + .await, + ); + assert!(result.success, "{name}: {result:?}"); + assert!( + !ts::has_warning(&warnings, "vendor_bun_lockb_duplicate_records"), + "{name}: {warnings:?}" + ); + let entry = entry.expect("the lock changed"); + let lock = BunLockb::parse(&std::fs::read(root.join(LOCK)).unwrap()).unwrap(); + lock.validate_mutation().unwrap(); + let copies: Vec<_> = lock + .packages() + .unwrap() + .into_iter() + .filter(|p| p.name == package) + .collect(); + assert_eq!( + copies + .iter() + .map(|p| p.resolution.as_str()) + .collect::>(), + [entry.artifact.path.as_str()], + "{name}: one record per tarball resolution" + ); + } + } +} diff --git a/crates/socket-patch-core/src/vendor/bun_default_trusted.txt b/crates/socket-patch-core/src/vendor/bun_default_trusted.txt new file mode 100644 index 000000000..f8a85ba83 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/bun_default_trusted.txt @@ -0,0 +1,367 @@ +@airbnb/node-memwatch +@anthropic-ai/claude-code +@apollo/protobufjs +@apollo/rover +@appsignal/nodejs +@arkweid/lefthook +@aws-amplify/cli +@bahmutov/add-typescript-to-cypress +@bazel/concatjs +@bazel/cypress +@bazel/esbuild +@bazel/hide-bazel-files +@bazel/jasmine +@bazel/protractor +@bazel/rollup +@bazel/terser +@bazel/typescript +@bufbuild/buf +@cdktf/node-pty-prebuilt-multiarch +@ckeditor/ckeditor5-vue +@cloudflare/wrangler +@contrast/fn-inspect +@cubejs-backend/cubestore +@cubejs-backend/native +@cypress/snapshot +@danmarshall/deckgl-typings +@datadog/mobile-react-native +@discordjs/opus +@eversdk/lib-node +@evilmartians/lefthook +@ffmpeg-installer/darwin-arm64 +@ffmpeg-installer/darwin-x64 +@ffmpeg-installer/linux-arm +@ffmpeg-installer/linux-arm64 +@ffmpeg-installer/linux-ia32 +@ffmpeg-installer/linux-x64 +@ffprobe-installer/darwin-arm64 +@ffprobe-installer/darwin-x64 +@ffprobe-installer/linux-arm +@ffprobe-installer/linux-arm64 +@ffprobe-installer/linux-ia32 +@ffprobe-installer/linux-x64 +@fingerprintjs/fingerprintjs-pro-react +@ghaiklor/x509 +@go-task/cli +@injectivelabs/sdk-ts +@instana/autoprofile +@intlify/vue-i18n-bridge +@intlify/vue-router-bridge +@matteodisabatino/gc_info +@memlab/cli +@microsoft.azure/autorest-core +@microsoft/teamsfx-cli +@microsoft/ts-command-line +@napi-rs/pinyin +@nativescript/core +@netlify/esbuild +@newrelic/native-metrics +@notarize/qlc-cli +@nx-dotnet/core +@opensearch-project/oui +@pact-foundation/pact-node +@paloaltonetworks/postman-code-generators +@pdftron/pdfnet-node +@percy/core +@pnpm/exe +@prisma/client +@prisma/engines +@progress/kendo-licensing +@pulumi/aws-native +@pulumi/awsx +@pulumi/command +@pulumi/kubernetes +@railway/cli +@replayio/cypress +@replayio/playwright +@roots/bud-framework +@sap/hana-client +@sap/hana-performance-tools +@sap/hana-theme-vscode +@scarf/scarf +@sematext/gc-stats +@sentry/capacitor +@sentry/profiling-node +@serialport/bindings +@serialport/bindings-cpp +@shopify/ngrok +@shopify/plugin-cloudflare +@sitespeed.io/chromedriver +@sitespeed.io/edgedriver +@softvisio/core +@splunk/otel +@strapi/strapi +@sveltejs/kit +@syncfusion/ej2-angular-base +@taquito/taquito +@temporalio/core-bridge +@tensorflow/tfjs-node +@trufflesuite/bigint-buffer +@typescript-tools/rust-implementation +@vaadin/vaadin-usage-statistics +@vscode/ripgrep +@vscode/sqlite3 +abstract-socket +admin-lte +appdynamics +appium-chromedriver +appium-windows-driver +applicationinsights-native-metrics +argon2 +autorest +aws-crt +azure-functions-core-tools +azure-streamanalytics-cicd +backport +bcrypt +better-sqlite3 +bigint-buffer +blake-hash +bs-platform +bufferutil +bun +canvacord +canvas +cbor-extract +chromedriver +chromium +classic-level +cld +cldr-data +clevertap-react-native +clientjs +cmark-gfm +compresion +contentlayer +contextify +cordova.plugins.diagnostic +couchbase +cpu-features +cwebp-bin +cy2 +cypress +dd-trace +deasync +detox +detox-recorder +diskusage +dotnet-2.0.0 +dprint +drivelist +dtrace-provider +duckdb +dugite +eccrypto +egg-bin +egg-ci +electron +electron-chromedriver +electron-prebuilt +electron-winstaller +elm +elm-format +esbuild +esoftplay +event-loop-stats +exifreader +farmhash +fast-folder-size +faunadb +ffi +ffi-napi +ffmpeg-static +fibers +fmerge +free-email-domains +fs-xattr +full-icu +gatsby +gc-stats +gcstats.js +geckodriver +gentype +ghooks +gif2webp-bin +gifsicle +git-commit-msg-linter +git-validate +git-win +gl +go-ios +grpc +grpc-tools +handbrake-js +hasura-cli +heapdump +hiredis +hnswlib-node +hugo-bin +hummus +ibm_db +iconv +iedriver +iltorb +incremental-json-parser +install-peers +interruptor +iobroker.js-controller +iso-constants +isolated-vm +java +jest-preview +jpeg-recompress-bin +jpegtran-bin +keccak +kerberos +keytar +lefthook +leveldown +libpg-query +libpq +libxmljs +libxmljs2 +lightningcss-cli +lint +lmdb +lmdb-store +local-cypress +lz4 +lzma-native +lzo +macos-alias +mbt +memlab +microtime +minidump +mmmagic +modern-syslog +mongodb-client-encryption +mongodb-crypt-library-dummy +mongodb-crypt-library-version +mongodb-memory-server +mozjpeg +ms-chromium-edge-driver +msgpackr-extract +msnodesqlv8 +msw +muhammara +netlify-cli +ngrok +ngx-popperjs +nice-napi +node +node-expat +node-hid +node-jq +node-libcurl +node-mac-contacts +node-pty +node-rdkafka +node-sass +node-webcrypto-ossl +node-zopfli +node-zopfli-es +nodegit +nodejieba +nodent-runtime +nx +odiff-bin +oniguruma +opencode-ai +optipng-bin +oracledb +os-dns-native +parse-server +phantomjs +phantomjs-prebuilt +pkcs11js +playwright-chromium +playwright-firefox +playwright-webkit +pngout-bin +pngquant-bin +posix +pprof +pre-commit +pre-push +prisma +protoc +protoc-gen-grpc-web +puppeteer +purescript +re2 +react-jsx-parser +react-native-stylex +react-particles +react-tsparticles +react-vertical-timeline-component +realm +redis-memory-server +ref +ref-napi +registry-js +robotjs +sauce-connect-launcher +saucectl +secp256k1 +segfault-handler +shared-git-hooks +sharp +simple-git-hooks +sleep +slice2js +snyk +sockopt +sodium-native +sonar-scanner +spago +spectron +spellchecker +sq-native +sqlite3 +sse4_crc32 +ssh2 +storage-engine +subrequests +subrequests-express +subrequests-json-merger +supabase +svf-lib +swagger-ui +swiftlint +taiko +tldjs +tree-sitter +tree-sitter-cli +tree-sitter-json +tree-sitter-kotlin +tree-sitter-typescript +tree-sitter-yaml +truffle +tsparticles-engine +ttag-cli +ttf2woff2 +typemoq +unix-dgram +ursa-optional +usb +utf-8-validate +v8-profiler-next +vue-demi +vue-echarts +vue-inbrowser-compiler-demi +wd +wdeasync +weak-napi +webdev-toolkit +windows-build-tools +wix-style-react +wordpos +workerd +wrtc +xxhash +yo +yorkie +zeromq +zlib-sync +zopflipng-bin diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 7a13741b0..50d2a3251 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -41,9 +41,10 @@ use crate::patch::apply::PatchSources; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::socket_dir::remove_tree_and_prune; use crate::vendor::bun_lock_text::{ - decode_json_string, has_workspace_packages, is_bundled_entry, lock_version, packages_bounds, - parse_entry_line, patched_dependency_detail, patched_dependency_key, patched_dependency_keys, - split_name_spec, BunEntry, + decode_json_string, default_trust_detail, has_workspace_packages, is_bundled_entry, + is_user_tarball_entry, lock_version, loses_default_trust, packages_bounds, parse_entry_line, + patched_dependency_detail, patched_dependency_key, patched_dependency_keys, split_name_spec, + BunEntry, }; use super::common::refused; @@ -80,7 +81,7 @@ pub async fn cleanup_binary_workspace_artifacts( /// The `WiringRecord.kind` this backend owns: key = the `packages` map key, /// original/new = the verbatim entry LINE. -const KIND_LOCK_PACKAGE: &str = "bun_lock_package"; +pub(super) const KIND_LOCK_PACKAGE: &str = "bun_lock_package"; /// Workspace gate: a `workspace:` packages entry in a lock whose /// `lockfileVersion` is below 2 refuses with `vendor_bun_workspace_unsupported`. @@ -389,6 +390,20 @@ impl NpmLockBackend for BunTextBackend { ), )); } + warnings.extend(default_trust_warning( + project.manifest.as_deref(), + Some(&project.lock_text), + name, + version, + )); + for key in user_tarball_matches(&project.entries, name, version) { + // LOUD: bun installs this copy from its own URL / `file:` spec, + // never the vendored tuple, so it stays the unpatched bytes (#497). + warnings.push(VendorWarning::new( + "vendor_non_registry_entry_skipped", + user_tarball_detail(&format!("{BUN_LOCK} entry `{key}`"), name, version), + )); + } let BunProject { lines, entries, .. } = project; // BN3 spelling: BARE project-relative path, no `file:`/`./` prefix @@ -606,12 +621,12 @@ impl NpmLockBackend for BunTextBackend { } } -/// Whether the project's installs are driven by the native binary lock: -/// no `bun.lock` beside a `bun.lockb`. [`vendor_bun`] routes those to -/// [`super::bun_binary`], and the vendor loop's download plan follows the -/// same routing. +/// Whether the project's installs are driven by the native binary lock +/// ([`super::lock_inventory::bun_binary_lock_drives`]). [`vendor_bun`] +/// routes those to [`super::bun_binary`], and the vendor loop's download +/// plan follows the same routing. pub(super) fn binary_lock_drives(project_root: &Path) -> bool { - !project_root.join(BUN_LOCK).exists() && project_root.join("bun.lockb").exists() + super::lock_inventory::bun_binary_lock_drives(project_root) } /// The text lock, read and strictly parsed before any write: version- @@ -625,16 +640,33 @@ pub(super) struct BunProject { entries: Vec, /// The project's own `patchedDependencies` keys (#367). user_patched: Vec, + /// The root `package.json` text, `None` when unreadable. + manifest: Option, } -/// The root manifest's `patchedDependencies` keys, unioned with the copy -/// Bun mirrors into the text `lock` when there is one. An unreadable or -/// non-JSON manifest contributes none; the lock read stands on its own. -pub(super) async fn read_user_patched(project_root: &Path, lock: Option<&str>) -> Vec { - let manifest = read_regular_to_string(&project_root.join("package.json")) +/// The root `package.json` text, `None` when unreadable: the manifest +/// lookups ([`patched_dependency_keys`], [`loses_default_trust`]) then fall +/// back on what the lock mirrors. +pub(super) async fn read_manifest(project_root: &Path) -> Option { + read_regular_to_string(&project_root.join("package.json")) .await - .ok(); - patched_dependency_keys(manifest.as_deref(), lock) + .ok() +} + +/// The warning for a package vendored to a local tarball that Bun 1.3.5+ no +/// longer trusts by default (#371); `None` when its trust is unchanged. +pub(super) fn default_trust_warning( + manifest: Option<&str>, + lock: Option<&str>, + name: &str, + version: &str, +) -> Option { + loses_default_trust(manifest, lock, name).then(|| { + VendorWarning::new( + "vendor_bun_default_trust_lost", + default_trust_detail(name, version, "a vendored local tarball"), + ) + }) } /// Refuse to vendor a package the project patches itself with `bun patch` @@ -683,12 +715,14 @@ pub(super) async fn read_project(project_root: &Path) -> Result RevertOutcome { + let mut outcome = revert_bun_wiring(entry, project_root, opts).await; + // Only a revert that put a lock entry back can leave Bun keeping a + // copy: with the lock missing nothing was restored, and with the entry + // removed (`bun remove`) a plain `bun install` prunes the copy. + let restored_nothing = outcome.lock_entry_removed() + || outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lockfile_missing"); + if outcome.success && !outcome.kept_artifact && !restored_nothing { + let stale = stale_hoisted_copies(project_root, [entry.base_purl.as_str()]).await; + if !stale.is_empty() { + outcome.warnings.push(VendorWarning::new( + REINSTALL_REQUIRED, + reinstall_advisory(&stale), + )); + } + } + outcome +} + +/// A revert (or hosted unwind) left an installed copy Bun may keep: its +/// hoisted linker does not re-extract a package whose lock entry moves +/// from a local or URL tarball back to the registry record of the same +/// `name@version`, so a plain `bun install` (`--frozen-lockfile` too) +/// reports "no changes" and the patched bytes stay installed (#764, +/// measured on 1.1.45, 1.2.23, 1.3.9, 1.3.14 and 1.4.2). The isolated +/// linker relinks to the registry entry, and `bun install --force` +/// reinstalls on both without touching the lock. +pub const REINSTALL_REQUIRED: &str = "vendor_bun_reinstall_required"; + +/// The `name@version` of each npm purl in `purls` whose installed copy Bun +/// may keep after its lock entry returns to the registry (see +/// [`REINSTALL_REQUIRED`]): `node_modules/` is a real directory (an +/// isolated install links it into `node_modules/.bun/`), or the tree has +/// no `node_modules/.bun/` at all (a hoisted install, where a copy may sit +/// nested under another package). A project with no `node_modules/` has +/// nothing installed to keep. +pub async fn stale_hoisted_copies<'a>( + project_root: &Path, + purls: impl IntoIterator, +) -> Vec { + let modules = project_root.join("node_modules"); + if !tokio::fs::metadata(&modules) + .await + .is_ok_and(|m| m.is_dir()) + { + return Vec::new(); + } + let hoisted_tree = tokio::fs::symlink_metadata(modules.join(".bun")) + .await + .is_err(); + let mut stale = Vec::new(); + for purl in purls { + let Some((name, version)) = super::npm_common::parse_npm_purl(purl) else { + continue; + }; + let copy = tokio::fs::symlink_metadata(modules.join(&name)).await; + let kept = match copy { + Ok(m) => m.is_dir(), + Err(_) => hoisted_tree, + }; + let label = format!("{name}@{version}"); + if kept && !stale.contains(&label) { + stale.push(label); + } + } + stale +} + +/// The [`REINSTALL_REQUIRED`] detail for the `name@version` labels in +/// `stale` (shared with the hosted unwind's run-level advisory). +pub fn reinstall_advisory(stale: &[String]) -> String { + format!( + "Bun's hoisted linker keeps the installed copy of {} when its lock entry returns to \ + the registry: a plain `bun install` reports no changes and node_modules may still \ + hold the patched bytes; run `bun install --force` (or delete node_modules and run \ + `bun install`) to reinstall the upstream copy", + stale.join(", ") + ) +} + +/// The wiring restore behind [`revert_bun_opts`]. +async fn revert_bun_wiring( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, ) -> RevertOutcome { if entry .wiring .iter() .any(|r| r.kind == super::bun_binary::KIND || r.kind == "bun_lockb_workspace_artifact") - || (entry.wiring.is_empty() - && !project_root.join(BUN_LOCK).exists() - && project_root.join("bun.lockb").exists()) + || (entry.wiring.is_empty() && binary_lock_drives(project_root)) { return super::bun_binary::revert(entry, project_root, opts).await; } @@ -846,9 +984,10 @@ pub(crate) async fn revert_bun_opts( return blocked; } } - if dry_run { - return RevertOutcome::ok(); - } + // A dry run replays the lock in memory like the wet run, only without + // the write and the artifact deletion: its preview then sees the same + // `vendor_lockfile_missing` / `vendor_lock_entry_removed` the wet run + // would, and names the same reinstall advice. let mut outcome = RevertOutcome::ok(); // SECURITY: revert writes are restricted to the one file vendor edits — a @@ -892,7 +1031,7 @@ pub(crate) async fn revert_bun_opts( for rec in entry.wiring.iter().rev().filter(|r| r.file == BUN_LOCK) { revert_one_record(lines, rec, &entry.uuid, &mut dirty, &mut outcome.warnings); } - if dirty { + if dirty && !dry_run { if let Err(e) = atomic_write_bytes_preserving_mode( &project_root.join(BUN_LOCK), lines.join("\n").as_bytes(), @@ -913,6 +1052,9 @@ pub(crate) async fn revert_bun_opts( outcome.keep_artifact(&uuid_dir_rel); return outcome; } + if dry_run { + return outcome; + } // `--preserve-state` (`keep_artifact`): the wiring restore above already // ran; the artifact dir stays behind (and the caller keeps the ledger @@ -941,7 +1083,7 @@ pub(crate) async fn revert_bun_opts( outcome } -fn revert_one_record( +pub(super) fn revert_one_record( lines: &mut [String], rec: &WiringRecord, entry_uuid: &str, @@ -1044,7 +1186,8 @@ enum TupleShape { /// [`classify`] for the instances vendoring may rewrite: a bundled entry /// ([`is_bundled_entry`]) is unpacked from its parent's tarball and never -/// read, so it is no rewrite target whatever its spec says (#469). +/// read, so it is no rewrite target whatever its spec says (#469). The +/// bundled check JSON-parses the meta, so it runs only on a match (#578). fn classify_rewritable( entry: &BunEntry, target_spec: &str, @@ -1071,6 +1214,28 @@ fn bundled_matches( .collect() } +/// Keys of the non-bundled entries that install `name@version` from a user +/// URL / `file:` tarball (#497): bun installs those from their own spec, so +/// no vendored tuple reaches them. The spec check runs first: the bundled +/// check JSON-parses the meta, so it may run only on a match (#578). +fn user_tarball_matches(entries: &[BunEntry], name: &str, version: &str) -> Vec { + entries + .iter() + .filter(|e| is_user_tarball_entry(e, name, version) && !is_bundled_entry(e)) + .map(|e| e.key.clone()) + .collect() +} + +/// The stays-UNPATCHED detail for a user tarball copy at `label`, shared +/// by the text and binary backends. +pub(super) fn user_tarball_detail(label: &str, name: &str, version: &str) -> String { + format!( + "{label} installs {name}@{version} from a URL or local tarball, not the registry, \ + and CANNOT be rewritten — bun installs it from that spec, so that copy stays \ + UNPATCHED; depend on the registry release to vendor it" + ) +} + /// Classify an entry against the target: `Some(Registry)` for the exact /// `name@version` registry tuple, `Some(Ours{..})` for one of our own /// `.socket/vendor/npm/` tuples for the same `name@version` (any uuid), @@ -1909,7 +2074,7 @@ mod tests { for entry in [&entry_b, &entry_a] { let outcome = revert_bun(entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); } assert_eq!(fx.read_lock().await, BN4C_BEFORE_LOCK, "lock byte-restored"); assert!(!fx @@ -1991,7 +2156,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); assert_eq!( fx.read_lock().await, crlf_before, @@ -2061,6 +2226,58 @@ mod tests { ); } + /// REGRESSION (#497): bun installs a remote-URL or `file:` tarball + /// dependency from its own spec, so vendoring the registry copy beside + /// it leaves the copy the app loads unpatched. The tarball tuple is + /// never rewired and is reported loudly (npm's #326); with no registry + /// copy the vendor refuses with the real reason and writes nothing. + #[tokio::test] + async fn user_tarball_copy_is_never_rewired_silently() { + let regular_line = BN3_BEFORE_LOCK + .lines() + .find(|l| l.contains("\"left-pad\": [")) + .unwrap(); + let nested_line = regular_line.replace("\"left-pad\": [", "\"dep/left-pad\": ["); + for user_line in [ + r#" "left-pad": ["left-pad@https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="],"#, + r#" "left-pad": ["left-pad@./left-pad-1.3.0.tgz", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="],"#, + ] { + // The tarball copy only. + let lock = BN3_BEFORE_LOCK.replace(regular_line, user_line); + let fx = fixture_with(&lock, "node_modules/left-pad").await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_rewritable"); + assert!( + detail.contains("left-pad") && detail.contains("UNPATCHED"), + "{detail}" + ); + assert_eq!(fx.read_lock().await, lock, "refusal writes nothing"); + assert!(!fx.root().join(".socket/vendor").exists()); + + // Beside a nested registry copy of the same version. + let lock = + BN3_BEFORE_LOCK.replace(regular_line, &format!("{user_line}\n\n{nested_line}")); + let fx = fixture_with(&lock, "node_modules/dep/node_modules/left-pad").await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + assert_eq!(entry.wiring.len(), 1); + assert_eq!(entry.wiring[0].key.as_deref(), Some("dep/left-pad")); + assert!( + fx.read_lock().await.contains(user_line), + "the tarball line keeps its bytes" + ); + let skipped = warnings + .iter() + .find(|w| w.code == "vendor_non_registry_entry_skipped") + .unwrap_or_else(|| panic!("{user_line}: {warnings:?}")); + assert!( + skipped.detail.contains("`left-pad`") && skipped.detail.contains("UNPATCHED"), + "{}", + skipped.detail + ); + } + } + /// REGRESSION (#469), `bun.lockb`: a record only a bundled edge /// reaches refuses with the real reason; a record Bun shares between a /// regular and a bundled install is vendored for the regular install, @@ -2126,6 +2343,65 @@ mod tests { ); } + /// REGRESSION (#497), `bun.lockb`: Bun 1.1.45's record of a root URL / + /// `file:` tarball dependency is installed from its own resolution, so + /// vendoring is-odd's nested registry copy reports the tarball copy as + /// staying unpatched instead of succeeding silently, and that record is + /// left alone. + #[tokio::test] + async fn binary_user_tarball_record_is_reported_unpatched() { + for shape in ["url", "file"] { + let fx = fixture_with("", "node_modules/is-odd/node_modules/is-number").await; + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/bun-lockb-user-tarball") + .join(shape); + tokio::fs::remove_file(fx.root().join(BUN_LOCK)) + .await + .unwrap(); + for file in ["bun.lockb", "package.json"] { + tokio::fs::copy(dir.join(file), fx.root().join(file)) + .await + .unwrap(); + } + tokio::fs::write( + fx.installed.join("package.json"), + br#"{"name":"is-number","version":"6.0.0"}"#, + ) + .await + .unwrap(); + let blobs = fx.root().join(".socket/blobs"); + let outcome = crate::vendor::test_support::vendor_bun( + "pkg:npm/is-number@6.0.0", + &fx.installed, + fx.root(), + &fx.record, + &PatchSources::blobs_only(&blobs), + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + let (result, entry, warnings) = expect_done(outcome); + assert!(result.success, "{shape}: {:?}", result.error); + assert_eq!(entry.unwrap().wiring.len(), 1, "{shape}: the registry copy"); + let skipped = warnings + .iter() + .find(|w| w.code == "vendor_non_registry_entry_skipped") + .unwrap_or_else(|| panic!("{shape}: {warnings:?}")); + assert!(skipped.detail.contains("UNPATCHED"), "{}", skipped.detail); + let lock = tokio::fs::read(fx.root().join("bun.lockb")).await.unwrap(); + let packages = crate::vendor::bun_lockb::BunLockb::parse_packages(&lock).unwrap(); + assert!( + packages.iter().any(|p| p.name == "is-number" + && p.version.is_none() + && parse_vendor_path(&p.resolution).is_none() + && p.resolution.ends_with("is-number-6.0.0.tgz")), + "{shape}: the tarball record keeps its resolution: {packages:?}" + ); + } + } + /// REGRESSION (#367): a package the project patches itself with /// `bun patch` (package.json `patchedDependencies`, mirrored in /// bun.lock) is keyed on its registry `name@version`; a local tarball @@ -2234,6 +2510,109 @@ mod tests { assert!(!fx.root().join(".socket/vendor").exists()); } + /// Vendor `purl` from `fx` and return the run's + /// `vendor_bun_default_trust_lost` details. + async fn trust_lost_details(fx: &Fixture, purl: &str) -> Vec { + let blobs = fx.root().join(".socket/blobs"); + let outcome = crate::vendor::test_support::vendor_bun( + purl, + &fx.installed, + fx.root(), + &fx.record, + &PatchSources::blobs_only(&blobs), + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + let (result, _, warnings) = expect_done(outcome); + assert!(result.success, "{:?}", result.error); + warnings + .into_iter() + .filter(|w| w.code == "vendor_bun_default_trust_lost") + .map(|w| w.detail) + .collect() + } + + /// REGRESSION (#371): from Bun 1.3.5 on, Bun's default trusted list + /// applies only to packages resolved from the npm registry, so a + /// default-trusted package vendored to a local tarball has its install + /// scripts skipped with exit 0. Vendoring says so, unless the project + /// declares `trustedDependencies` (package.json, or bun.lock's mirror), + /// which decides trust by name alone. + #[tokio::test] + async fn default_trusted_package_warns_that_trust_is_lost() { + let lock = BN3_BEFORE_LOCK.replace("left-pad", "simple-git-hooks"); + let purl = "pkg:npm/simple-git-hooks@1.3.0"; + let fx = fixture_with(&lock, "node_modules/simple-git-hooks").await; + let lost = trust_lost_details(&fx, purl).await; + assert_eq!(lost.len(), 1, "{lost:?}"); + assert!( + lost[0].contains("simple-git-hooks@1.3.0") && lost[0].contains("trustedDependencies"), + "{}", + lost[0] + ); + + let mirrored = lock.replacen( + " \"packages\": {", + " \"trustedDependencies\": [\n \"simple-git-hooks\",\n ],\n \"packages\": {", + 1, + ); + assert_ne!(mirrored, lock); + let fx = fixture_with(&mirrored, "node_modules/simple-git-hooks").await; + assert!(trust_lost_details(&fx, purl).await.is_empty()); + + let fx = fixture_with(&lock, "node_modules/simple-git-hooks").await; + tokio::fs::write( + fx.root().join("package.json"), + BN3_PKG.replacen( + "\"version\": \"1.0.0\",", + "\"version\": \"1.0.0\",\n \"trustedDependencies\": [],", + 1, + ), + ) + .await + .unwrap(); + assert!(trust_lost_details(&fx, purl).await.is_empty()); + + // A package off the default list keeps the plain run quiet. + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + assert!(trust_lost_details(&fx, "pkg:npm/left-pad@1.3.0") + .await + .is_empty()); + } + + /// REGRESSION (#371), `bun.lockb`: the binary lock has no text mirror, + /// so the root manifest's `trustedDependencies` alone decides. Real Bun + /// 1.4.2 fixture: `simple-git-hooks` is on the default list. + #[tokio::test] + async fn binary_default_trusted_package_warns_that_trust_is_lost() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/bun-lockb-trusted"); + let manifest = std::fs::read_to_string(dir.join("package.json")).unwrap(); + let declared = manifest.replacen( + "\"private\": true,", + "\"private\": true,\n \"trustedDependencies\": [\"simple-git-hooks\"],", + 1, + ); + assert_ne!(declared, manifest); + for (manifest, warns) in [(&manifest, true), (&declared, false)] { + let fx = fixture_with("", "node_modules/simple-git-hooks").await; + tokio::fs::remove_file(fx.root().join(BUN_LOCK)) + .await + .unwrap(); + tokio::fs::copy(dir.join("bun.lockb"), fx.root().join("bun.lockb")) + .await + .unwrap(); + tokio::fs::write(fx.root().join("package.json"), manifest) + .await + .unwrap(); + let lost = trust_lost_details(&fx, "pkg:npm/simple-git-hooks@2.11.1").await; + assert_eq!(lost.len(), usize::from(warns), "{lost:?}"); + } + } + #[tokio::test] async fn unparseable_entry_line_fails_closed_before_any_write() { for bad in [ @@ -2498,7 +2877,7 @@ mod tests { ); let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); assert_eq!( fx.read_lock().await, lock, @@ -2809,7 +3188,7 @@ mod tests { let entry = entry.expect("success carries a ledger entry"); let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); assert_eq!(fx.read_lock().await, lock, "lock byte-restored"); } @@ -2978,7 +3357,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.is_empty(), + lock_warnings(&outcome).is_empty(), "an unmoved entry is ours, not drift: {:?}", outcome.warnings ); @@ -2993,6 +3372,17 @@ mod tests { .exists()); } + /// A revert's lock-wiring advisories: every fixture installs a hoisted + /// `node_modules/left-pad`, whose [`REINSTALL_REQUIRED`] the tests below + /// that are about the lock leave out. + fn lock_warnings(outcome: &RevertOutcome) -> Vec<&VendorWarning> { + outcome + .warnings + .iter() + .filter(|w| w.code != REINSTALL_REQUIRED) + .collect() + } + #[tokio::test] async fn dry_run_writes_nothing() { let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; @@ -3028,7 +3418,9 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + // The hoisted install's copy is the only advisory (#764). + let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code).collect(); + assert_eq!(codes, [REINSTALL_REQUIRED], "{:?}", outcome.warnings); assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK, "lock byte-restored"); assert!(!tgz_path.exists()); assert!(!fx @@ -3037,6 +3429,142 @@ mod tests { .exists()); } + /// #764: Bun's hoisted linker keeps `node_modules/` when the lock + /// entry returns from the vendored tarball to the registry record, so a + /// plain `bun install` leaves the vendored bytes installed. The revert + /// says so and names the install that does reinstall (measured on Bun + /// 1.2.23, 1.3.14 and 1.4.2: `bun install --force` reinstalls and keeps + /// the lock). + #[tokio::test] + async fn revert_warns_that_bun_keeps_a_hoisted_copy() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + // The preview names the same install the real run will: the + // rollback's generic reinstall note otherwise promises that the + // next plain install refreshes the tree, which Bun's hoisted + // linker doesn't do. + let dry = revert_bun(&entry, fx.root(), true).await; + assert!(dry.success, "{:?}", dry.error); + let codes: Vec<&str> = dry.warnings.iter().map(|w| w.code).collect(); + assert_eq!(codes, [REINSTALL_REQUIRED], "{:?}", dry.warnings); + assert!( + dry.warnings[0].detail.contains("`bun install --force`"), + "{}", + dry.warnings[0].detail + ); + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + let w = outcome + .warnings + .iter() + .find(|w| w.code == REINSTALL_REQUIRED) + .expect("the hoisted copy is reported"); + assert!(w.detail.contains("left-pad@1.3.0"), "{}", w.detail); + assert!(w.detail.contains("`bun install --force`"), "{}", w.detail); + } + + /// A preview names no reinstall the wet run would not: with `bun.lock` + /// gone, or the vendored entry removed (`bun remove`), nothing is + /// restored and the dry run says so like the wet run, without writing. + #[tokio::test] + async fn dry_run_revert_skips_the_advisory_when_nothing_is_restored() { + for removed_entry in [false, true] { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let lock = fx.root().join(BUN_LOCK); + if removed_entry { + let text = tokio::fs::read_to_string(&lock).await.unwrap(); + let kept: Vec<&str> = text + .split('\n') + .filter(|l| !l.contains(&entry.uuid)) + .collect(); + tokio::fs::write(&lock, kept.join("\n")).await.unwrap(); + } else { + tokio::fs::remove_file(&lock).await.unwrap(); + } + let before = tokio::fs::read(&lock).await.ok(); + + let dry = revert_bun(&entry, fx.root(), true).await; + assert!(dry.success, "{:?}", dry.error); + assert!( + dry.warnings.iter().all(|w| w.code != REINSTALL_REQUIRED), + "removed_entry={removed_entry}: {:?}", + dry.warnings + ); + assert_eq!(tokio::fs::read(&lock).await.ok(), before, "dry run wrote"); + let wet = revert_bun(&entry, fx.root(), false).await; + assert!( + wet.warnings.iter().all(|w| w.code != REINSTALL_REQUIRED), + "removed_entry={removed_entry}: {:?}", + wet.warnings + ); + } + } + + /// The isolated linker relinks `node_modules/` to the restored + /// registry entry, and a project with nothing installed has nothing to + /// keep: neither warns. + #[cfg(unix)] + #[tokio::test] + async fn revert_skips_the_advisory_without_a_hoisted_copy() { + for isolated in [true, false] { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let modules = fx.root().join("node_modules"); + if isolated { + let store = modules.join(".bun/left-pad@1.3.0/node_modules/left-pad"); + tokio::fs::create_dir_all(store.parent().unwrap()) + .await + .unwrap(); + tokio::fs::rename(modules.join("left-pad"), &store) + .await + .unwrap(); + std::os::unix::fs::symlink(&store, modules.join("left-pad")).unwrap(); + } else { + tokio::fs::remove_dir_all(&modules).await.unwrap(); + } + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome + .warnings + .iter() + .all(|w| w.code != REINSTALL_REQUIRED), + "isolated={isolated}: {:?}", + outcome.warnings + ); + } + } + + /// A hoisted tree (no `node_modules/.bun/`) may hold the copy nested + /// under another package; only an isolated tree proves it absent. + #[tokio::test] + async fn stale_hoisted_copies_reads_the_linker_layout() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let purls = ["pkg:npm/@scope/a@1.0.0", "pkg:npm/b@2.0.0"]; + assert!(stale_hoisted_copies(root, purls).await.is_empty()); + + tokio::fs::create_dir_all(root.join("node_modules/@scope/a")) + .await + .unwrap(); + assert_eq!( + stale_hoisted_copies(root, purls).await, + ["@scope/a@1.0.0", "b@2.0.0"] + ); + + tokio::fs::create_dir_all(root.join("node_modules/.bun")) + .await + .unwrap(); + assert_eq!(stale_hoisted_copies(root, purls).await, ["@scope/a@1.0.0"]); + } + /// bun.lock is a user-owned file we merely edit: the vendor rewrite and /// the revert restore must keep its permission bits (a 0600 private lock /// must not silently become umask-default 0644). @@ -3184,7 +3712,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.is_empty(), + lock_warnings(&outcome).is_empty(), "no drift left after the undo: {:?}", outcome.warnings ); @@ -3301,7 +3829,7 @@ mod tests { deletion guard must not fire: {:?}", outcome.error ); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); assert!(!outcome.kept_artifact, "preserve-state is not a drift-keep"); assert!(tgz_path.exists(), "artifact kept"); assert_eq!( @@ -3407,6 +3935,14 @@ mod tests { "{:?}", outcome.warnings ); + assert!( + outcome + .warnings + .iter() + .all(|w| w.code != REINSTALL_REQUIRED), + "nothing restored, so nothing for Bun to keep (#764): {:?}", + outcome.warnings + ); assert!(!outcome.kept_artifact, "a missing lock is not a drift-keep"); assert!( !fx.root() @@ -3464,7 +4000,7 @@ mod tests { ) .await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); assert!(!outcome.kept_artifact, "preserve-state is not a drift-keep"); assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK, "wiring restored"); assert!(tgz_path.exists(), "artifact deliberately kept"); @@ -3472,7 +4008,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.is_empty(), + lock_warnings(&outcome).is_empty(), "converged records are silent: {:?}", outcome.warnings ); @@ -3598,6 +4134,14 @@ mod tests { "{:?}", outcome.warnings ); + assert!( + outcome + .warnings + .iter() + .all(|w| w.code != REINSTALL_REQUIRED), + "a removed dependency is pruned by a plain `bun install` (#764): {:?}", + outcome.warnings + ); assert!(!outcome.kept_artifact, "{:?}", outcome.warnings); assert_eq!(fx.read_lock().await, without_entry, "nothing rewritten"); assert!( @@ -3667,7 +4211,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.is_empty(), + lock_warnings(&outcome).is_empty(), "already-converged is silent: {:?}", outcome.warnings ); @@ -3758,7 +4302,7 @@ mod tests { // The healed lock reverts through the ORIGINAL entry byte-exactly. let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK); assert!(!fx .root() @@ -3782,7 +4326,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.is_empty(), + lock_warnings(&outcome).is_empty(), "a digest-less spelling of our own tuple is not drift: {:?}", outcome.warnings ); @@ -3824,7 +4368,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!( - outcome.success && outcome.warnings.is_empty(), + outcome.success && lock_warnings(&outcome).is_empty(), "{outcome:?}" ); assert_eq!(fx.read_lock().await, crlf_before); @@ -3862,7 +4406,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!( - outcome.success && outcome.warnings.is_empty(), + outcome.success && lock_warnings(&outcome).is_empty(), "v{version}: {outcome:?}" ); let original_line = entry.wiring[0] @@ -4196,7 +4740,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.is_empty(), + lock_warnings(&outcome).is_empty(), "the converged re-run is silent: {:?}", outcome.warnings ); @@ -4332,4 +4876,51 @@ mod tests { assert_eq!(looped, Err("vendor_lockfile_missing")); assert_eq!(planned, looped); } + + /// REGRESSION (#578): the vendor twin of the hosted hot loop. Every + /// per-target scan (`classify_rewritable`, `bundled_matches`) ran the + /// JSON-parsing bundled check on EVERY entry before the cheap spec + /// match; it may run only on the entries that resolve the target. + #[test] + fn bundled_check_runs_only_on_matching_entries() { + use crate::vendor::bun_lock_text::BUNDLED_CHECKS; + + const ENTRIES: usize = 200; + let mut entries: Vec = (0..ENTRIES) + .map(|i| { + parse_entry_line(&format!( + "\"pkg{i}\": [\"pkg{i}@1.0.0\", \"\", {{}}, \"sha512-OLD==\"]," + )) + .unwrap() + }) + .collect(); + entries.push( + parse_entry_line( + "\"parent/pkg0\": [\"pkg0@1.0.0\", \"\", { \"bundled\": true }, \"sha512-OLD==\"],", + ) + .unwrap(), + ); + + BUNDLED_CHECKS.with(|checks| checks.set(0)); + let (spec, leaf) = ("pkg0@1.0.0", tgz_rel_leaf("pkg0", "1.0.0")); + let rewritable = entries + .iter() + .filter(|e| classify_rewritable(e, spec, "pkg0", &leaf).is_some()) + .count(); + let bundled = bundled_matches(&entries, spec, "pkg0", &leaf); + // The #497 user-tarball scan is a third per-target pass; with no + // tarball entry for the target it must make no bundled check. + let user_tarballs = user_tarball_matches(&entries, "pkg0", "1.0.0"); + let checks = BUNDLED_CHECKS.with(std::cell::Cell::get); + + assert_eq!(rewritable, 1, "only the registry tuple is rewritable"); + assert_eq!(bundled, vec!["parent/pkg0".to_string()]); + assert!(user_tarballs.is_empty()); + // Two matching entries, two scans: at most four checks, not 3 × 201. + assert!( + checks <= 4, + "{checks} bundled checks over {} entries", + ENTRIES + 1 + ); + } } diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index 064cc9d71..9fcafc4e8 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -33,12 +33,7 @@ const SUPPORTED_LOCK_VERSIONS: [u64; 3] = [0, 1, 2]; /// plainly. pub(crate) fn patched_dependency_keys(manifest: Option<&str>, lock: Option<&str>) -> Vec { let mut keys: Vec = manifest - .map(crate::formats::text::strip_bom) - .and_then(|text| { - serde_json::from_str::(text) - .or_else(|_| serde_json::from_str(&strip_jsonc(text))) - .ok() - }) + .and_then(parse_manifest) .and_then(|value| match value.get("patchedDependencies") { Some(serde_json::Value::Object(map)) => Some(map.keys().cloned().collect()), _ => None, @@ -59,6 +54,70 @@ pub(crate) fn patched_dependency_keys(manifest: Option<&str>, lock: Option<&str> keys } +/// A root `package.json` parsed as Bun reads it: a leading BOM, comments +/// and trailing commas allowed. `None` when Bun could not parse it either. +fn parse_manifest(text: &str) -> Option { + let text = crate::formats::text::strip_bom(text); + serde_json::from_str(text) + .or_else(|_| serde_json::from_str(&strip_jsonc(text))) + .ok() +} + +/// Bun's built-in default-trusted package names, the union of every release +/// up to 1.4.2 (`src/install/default-trusted-dependencies.txt` upstream). +const DEFAULT_TRUSTED: &str = include_str!("bun_default_trusted.txt"); + +/// Whether Bun runs `name`'s lifecycle scripts only through its built-in +/// default trust, which a hosted or vendored rewire takes away (#371). +/// +/// Bun trusts a package when the project's `trustedDependencies` lists it, +/// or, when the project declares no `trustedDependencies` at all, when its +/// name is on Bun's default list. From Bun 1.3.5 on that default applies +/// only to packages resolved from the npm registry, so a package on a hosted +/// URL or a local tarball loses it: `bun install` skips its `install` / +/// `postinstall` script without failing, and a native addon is left +/// unbuilt. An explicit list (in the root manifest, or the copy Bun mirrors +/// at the top of a text `bun.lock`) already decides trust by name alone, +/// so the rewire changes nothing there. +pub(crate) fn loses_default_trust(manifest: Option<&str>, lock: Option<&str>, name: &str) -> bool { + // The list lookup is cheap and almost always false, so it runs first: + // the hosted rewriter calls this per wired dep, and the manifest parse + // plus whole-lock scan must not land in its per-dep loop (#578). + static TRUSTED: std::sync::OnceLock> = + std::sync::OnceLock::new(); + if !TRUSTED + .get_or_init(|| DEFAULT_TRUSTED.lines().collect()) + .contains(name) + { + return false; + } + let declared = manifest.and_then(parse_manifest).is_some_and(|value| { + value + .get("trustedDependencies") + .is_some_and(|v| v.is_array()) + }) || lock.is_some_and(|lock| { + lock.split('\n') + .map(|l| l.strip_suffix('\r').unwrap_or(l)) + .any(|l| l.starts_with(" \"trustedDependencies\": [")) + }); + !declared +} + +/// The user-facing warning for a rewired package that loses Bun's default +/// trust ([`loses_default_trust`]), shared by the hosted and vendored paths. +/// `target` names what the package now resolves to. +pub(crate) fn default_trust_detail(name: &str, version: &str, target: &str) -> String { + format!( + "{name}@{version} is on Bun's default trusted list, which Bun 1.3.5 and later apply \ + only to packages installed from the npm registry; now that it resolves to {target}, \ + `bun install` skips its install scripts without failing (`bun pm untrusted` lists \ + it), which can leave native bindings unbuilt. Add \"{name}\" to \ + \"trustedDependencies\" in package.json and run `bun install`. Declaring \ + trustedDependencies replaces Bun's default list, so also list any other \ + default-trusted dependency whose scripts you rely on" + ) +} + /// `text` with the JSONC Bun accepts in a `package.json` removed: `//` and /// `/* */` comments and a comma before a closing `}` or `]`, all outside /// strings. Everything else, strings included, is kept byte for byte. @@ -170,6 +229,56 @@ pub(crate) fn split_name_spec(s: &str) -> Option<(&str, &str)> { Some((&s[..at], &s[at + 1..])) } +/// The version a user tarball dependency of `name` installs (#497). Bun +/// records a remote-URL or `file:` tarball dependency as `name@` +/// (text) or as a tarball resolution (binary) with no version of its own, +/// and installs it from that spec, never from the registry, so no rewire +/// of a registry `name@version` reaches it. The version is read from the +/// artifact leaf, `-.tgz` (or `.tar.gz`) with a semver +/// `` (`` = the name without its `@scope/`): the leaf every +/// registry tarball and `npm pack` output carries. A leaf naming no version +/// yields `None`, and so does our own vendored path. +pub(crate) fn user_tarball_version<'t>(name: &str, target: &'t str) -> Option<&'t str> { + // The leaf checks are cheap and reject a registry spec (`name@1.2.3`) + // at once; the vendor-path parse runs only for a tarball leaf (#578). + let path = target.split(['?', '#']).next().unwrap_or(target); + let leaf = path.rsplit(['/', '\\']).next()?; + let bare = name.rsplit('/').next().unwrap_or(name); + let version = leaf.strip_prefix(bare)?.strip_prefix('-')?; + let version = version + .strip_suffix(".tgz") + .or_else(|| version.strip_suffix(".tar.gz"))?; + if crate::vendor::path::parse_vendor_path(target).is_some() { + return None; + } + semver::Version::parse(version).is_ok().then_some(version) +} + +/// [`user_tarball_version`] for a text `packages` entry: true when the +/// entry's spec is `name@` and the tarball's leaf names `version`. +pub(crate) fn is_user_tarball_entry(entry: &BunEntry, name: &str, version: &str) -> bool { + entry + .elems + .first() + .and_then(|raw| decode_json_string(raw)) + .is_some_and(|spec| is_user_tarball_spec(&spec, name, version)) +} + +/// [`is_user_tarball_entry`] on an already-decoded `name@` spec, +/// for hot loops that decoded it once already. The name is matched by a +/// prefix strip, so an entry of another package costs one compare. +#[inline] +pub(crate) fn is_user_tarball_spec(spec: &str, name: &str, version: &str) -> bool { + // Length and separator first: the hosted rewriter asks this of every + // lock entry per patch, nearly all of another package (#578). + if spec.as_bytes().get(name.len()) != Some(&b'@') { + return false; + } + spec.strip_prefix(name) + .and_then(|rest| rest.strip_prefix('@')) + .is_some_and(|target| user_tarball_version(name, target) == Some(version)) +} + /// `"lockfileVersion": ` head check — only the fixture-pinned text /// lockfile versions are spliced (fail-closed on anything newer/older). /// @@ -217,6 +326,14 @@ pub(crate) fn has_workspace_packages(entries: &[BunEntry]) -> bool { }) } +#[cfg(test)] +thread_local! { + /// [`is_bundled_entry`] calls this thread made. Each one JSON-parses the + /// entry's meta, so a rewrite loop over N deps × M entries must check the + /// cheap spec match first and pay it only for matching entries (#578). + pub(crate) static BUNDLED_CHECKS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// True when `entry` is a `bundleDependencies` copy: bun records it as its /// own `parent/child` entry whose `{meta}` object carries `"bundled": true`, /// and unpacks it from the PARENT's tarball without ever reading the @@ -226,6 +343,8 @@ pub(crate) fn has_workspace_packages(entries: &[BunEntry]) -> bool { /// tarball tuple). A meta that does not parse as JSON but mentions /// `"bundled"` counts as bundled: fail closed, never rewire or attest it. pub(crate) fn is_bundled_entry(entry: &BunEntry) -> bool { + #[cfg(test)] + BUNDLED_CHECKS.with(|checks| checks.set(checks.get() + 1)); let Some(meta) = entry.elems.iter().skip(1).find(|e| e.starts_with('{')) else { return false; }; @@ -654,6 +773,71 @@ pub(crate) fn heal_workspace_literals( mod tests { use super::*; + /// #497: a user tarball's version is its `-.tgz` leaf, + /// for remote URLs and local paths alike; a registry version, a leaf + /// naming no version or another package, and our own vendored path are + /// not user tarballs. + #[test] + fn user_tarball_version_reads_the_leaf() { + for (name, target, want) in [ + ( + "is-number", + "https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz", + Some("6.0.0"), + ), + ("is-number", "./is-number-6.0.0.tgz", Some("6.0.0")), + ("is-number", "file:./is-number-6.0.0.tgz", Some("6.0.0")), + ("is-number", "vendor\\is-number-6.0.0.tar.gz", Some("6.0.0")), + ( + "@s/p", + "https://h.test/@s/p/-/p-1.0.0-2.tgz?t=1", + Some("1.0.0-2"), + ), + ("is-number", "6.0.0", None), + ("is-number", "./is-number.tgz", None), + ("is-number", "./is-number-latest.tgz", None), + ("is-number", "./is-odd-6.0.0.tgz", None), + ("is-number", "github:jonschlinkert/is-number#6.0.0", None), + ( + "is-number", + ".socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/is-number-6.0.0.tgz", + None, + ), + ] { + assert_eq!(user_tarball_version(name, target), want, "{name} {target}"); + } + } + + #[test] + fn is_user_tarball_spec_checks_the_name_first() { + assert!(is_user_tarball_spec( + "left-pad@./left-pad-1.3.0.tgz", + "left-pad", + "1.3.0" + )); + assert!(is_user_tarball_spec( + "@s/p@file:./p-1.0.0.tgz", + "@s/p", + "1.0.0" + )); + assert!(!is_user_tarball_spec( + "left-pad@./left-pad-1.2.0.tgz", + "left-pad", + "1.3.0" + )); + assert!(!is_user_tarball_spec("left-pad@1.3.0", "left-pad", "1.3.0")); + assert!(!is_user_tarball_spec( + "left-pad-x@./left-pad-1.3.0.tgz", + "left-pad", + "1.3.0" + )); + assert!(!is_user_tarball_spec( + "is-odd@./left-pad-1.3.0.tgz", + "left-pad", + "1.3.0" + )); + } + /// #367: the keys come from the manifest and from the lock's mirror, /// and match the exact `name@version` (scoped too) or a bare name. #[test] diff --git a/crates/socket-patch-core/src/vendor/bun_lockb.rs b/crates/socket-patch-core/src/vendor/bun_lockb.rs index 10173ec37..7cb80debf 100644 --- a/crates/socket-patch-core/src/vendor/bun_lockb.rs +++ b/crates/socket-patch-core/src/vendor/bun_lockb.rs @@ -14,6 +14,7 @@ use base64::{engine::general_purpose::STANDARD, Engine}; use serde_json::{json, Value}; use sha2::{Digest, Sha512_256}; use std::cmp::Ordering; +use std::collections::VecDeque; use std::ops::Range; const HEADER: &[u8] = b"#!/usr/bin/env bun\nbun-lockfile-format-v0\n"; @@ -25,6 +26,15 @@ const INTEGRITY_LEN: usize = 65; /// parent (verified against a real Bun 1.3.14 lock, fixture /// `bun-lockb-bundled`). const BEHAVIOR_BUNDLED: u8 = 0x40; +/// Bun's other `Dependency.Behavior` bits. +const BEHAVIOR_PROD: u8 = 0x02; +const BEHAVIOR_OPTIONAL: u8 = 0x04; +const BEHAVIOR_DEV: u8 = 0x08; +const BEHAVIOR_PEER: u8 = 0x10; +const BEHAVIOR_WORKSPACE: u8 = 0x20; +/// Bun's `Tree.invalid_id` (no parent) and `Tree.root_dep_id`. +const INVALID_TREE: usize = u32::MAX as usize; +const ROOT_DEPENDENCY: usize = u32::MAX as usize - 1; /// Written by this codec in the last eight bytes of the root package's /// resolution (its value union, which a root resolution never reads — early /// writers leave uninitialized bytes there, and every supported reader @@ -53,6 +63,10 @@ pub(crate) struct BinaryPackage { /// EVERY edge resolving to this record is bundled (and there is at /// least one): rewiring its resolution installs nothing. pub(crate) bundled_only: bool, + /// Bun installs the record from its own spec — a local or remote + /// tarball, git, a folder or a link — not from the registry, and it is + /// neither the root nor a workspace member (#497). + pub(crate) own_source: bool, } #[derive(Clone, Debug)] @@ -291,6 +305,7 @@ impl BunLockb { integrity, bundled: false, bundled_only: false, + own_source: !matches!(tag, 0 | 1 | 2 | 72), }) } @@ -355,6 +370,271 @@ impl BunLockb { Ok(found) } + /// Fold the `duplicates` of package `kept` into it, the lock Bun itself + /// writes for one package that several dependents resolve to: their + /// dependency edges resolve to `kept`, their rows leave every package + /// column (later IDs moving down) and their own dependency edges leave + /// the dependency and resolution buffers, and the trees are re-hoisted + /// with Bun's hoister ([`hoist`]): Bun's frozen install re-hoists the + /// lock and refuses one whose trees differ. Done only where that is + /// exact: each duplicate's dependencies resolve to the same packages as + /// `kept`'s (so dropping them orphans nothing), and [`hoist`] models the + /// lock — it reproduces the lock's own trees and needs no rule it does + /// not model for the merged one. `Ok(false)` leaves the lock unchanged. + pub(crate) fn merge_packages( + &mut self, + kept: usize, + duplicates: &[usize], + ) -> Result { + let mut candidate = self.clone(); + let merged = candidate.merge_packages_inner(kept, duplicates)?; + if merged { + *self = candidate; + } + Ok(merged) + } + + fn merge_packages_inner(&mut self, kept: usize, duplicates: &[usize]) -> Result { + self.check_editable()?; + self.check_id(kept)?; + for &id in duplicates { + self.check_id(id)?; + if id == 0 || id == kept { + return Err("bun.lockb: invalid duplicate package".into()); + } + } + if duplicates.is_empty() { + return Ok(false); + } + let style = self.hash_style()?; + // The same normalizations as any record edit (`set_package`). + self.promote_legacy_format()?; + self.normalize_workspace_behaviors()?; + let graph = self.hoist_graph()?; + let (trees, hoisted) = (self.buffer_array(0)?, self.buffer_array(1)?); + let own = ( + self.data[trees.data].to_vec(), + self.data[hoisted.data].to_vec(), + ); + if hoist(&graph).as_ref() != Some(&own) { + return Ok(false); + } + + let survivor = |id: usize| if duplicates.contains(&id) { kept } else { id }; + let targets = |id: usize| { + let mut targets: Vec<_> = graph.lists[id] + .clone() + .map(|edge| (graph.edges[edge].hash, survivor(graph.edges[edge].package))) + .collect(); + targets.sort_unstable(); + targets + }; + let kept_targets = targets(kept); + if duplicates.iter().any(|&id| targets(id) != kept_targets) { + return Ok(false); + } + let mut removed = vec![false; graph.edges.len()]; + for &id in duplicates { + for edge in graph.lists[id].clone() { + removed[edge] = true; + } + } + let survivors: Vec = (0..self.count) + .filter(|id| !duplicates.contains(id)) + .collect(); + if survivors + .iter() + .any(|&id| graph.lists[id].clone().any(|edge| removed[edge])) + { + return Ok(false); + } + let new_id = |id: usize| id - duplicates.iter().filter(|&&d| d < id).count(); + // `new_edge[edge]`: the edge's index once the removed ones leave. + let new_edge: Vec = removed + .iter() + .scan(0, |next, &gone| { + let index = *next; + *next += usize::from(!gone); + Some(index) + }) + .chain([graph.edges.len() - removed.iter().filter(|&&r| r).count()]) + .collect(); + let merged = HoistGraph { + lists: survivors + .iter() + .map(|&id| { + let range = &graph.lists[id]; + new_edge[range.start]..new_edge[range.start] + range.len() + }) + .collect(), + folder: survivors.iter().map(|&id| graph.folder[id]).collect(), + edges: (0..graph.edges.len()) + .filter(|&edge| !removed[edge]) + .map(|edge| { + let mut edge = graph.edges[edge].clone(); + if edge.package < self.count { + edge.package = new_id(survivor(edge.package)); + } + edge + }) + .collect(), + }; + let Some((tree_bytes, hoisted_bytes)) = hoist(&merged) else { + return Ok(false); + }; + + let dependencies = self.dependency_array()?; + let resolutions = self.buffer_array(2)?; + let (mut dependency_bytes, mut resolution_bytes) = (Vec::new(), Vec::new()); + for edge in (0..graph.edges.len()).filter(|&edge| !removed[edge]) { + let at = dependencies.data.start + edge * 26; + dependency_bytes.extend_from_slice(&self.data[at..at + 26]); + let raw = u32_at(&self.data, resolutions.data.start + edge * 4)?; + let id = if (raw as usize) < self.count { + new_id(survivor(raw as usize)) as u32 + } else { + raw + }; + resolution_bytes.extend_from_slice(&id.to_le_bytes()); + } + let slices = self.package_start + self.count * (16 + self.resolution_size); + for (&id, range) in survivors.iter().zip(&merged.lists) { + for column in [slices, slices + self.count * 8] { + let at = column + id * 8; + self.data[at..at + 4].copy_from_slice(&(range.start as u32).to_le_bytes()); + } + } + let meta = self.package_start + self.count * (32 + self.resolution_size); + for row in 0..self.count { + let at = meta + row * 88 + 8; + if u32_at(&self.data, at)? as usize == row { + self.data[at..at + 4].copy_from_slice(&(new_id(row) as u32).to_le_bytes()); + } + } + let mut widths = vec![8, 8, self.resolution_size, 8, 8, 88, 20]; + if self.fields == 8 { + widths.push(49); + } + let mut columns = Vec::new(); + let mut column = self.package_start; + for width in widths { + for &row in &survivors { + let at = column + row * width; + columns.extend_from_slice(&self.data[at..at + width]); + } + column += self.count * width; + } + *self = self.relayout( + survivors.len(), + &columns, + [ + Some(tree_bytes), + Some(hoisted_bytes), + Some(resolution_bytes), + Some(dependency_bytes), + ], + )?; + self.normalize_production_pool()?; + self.update_hash(style)?; + Ok(true) + } + + /// The package graph [`hoist`] walks, as the lock records it. + fn hoist_graph(&self) -> Result { + let dependencies = self.dependency_array()?; + let resolutions = self.buffer_array(2)?; + let count = dependencies.data.len() / 26; + if resolutions.data.len() / 4 != count { + return Err("bun.lockb: dependency and resolution buffer lengths differ".into()); + } + let edges = (0..count) + .map(|edge| { + let at = dependencies.data.start + edge * 26; + Ok(HoistEdge { + name: self.string_at(at)?.into_bytes(), + hash: u64_at(&self.data, at + 8)?, + behavior: self.data[at + 16], + package: u32_at(&self.data, resolutions.data.start + edge * 4)? as usize, + }) + }) + .collect::>()?; + Ok(HoistGraph { + lists: (0..self.count) + .map(|id| self.package_dependency_range(id)) + .collect::>()?, + folder: (0..self.count) + .map(|id| self.data[self.resolution_at(id)] == 4) + .collect(), + edges, + }) + } + + /// The lock re-laid with `count` packages in `columns` and the first + /// buffers replaced, the way Bun's serializer writes one: each buffer + /// keeps its descriptor and type prefix, then zero padding to an + /// eight-byte data start; everything after the six buffers moves by the + /// size difference, which must keep the extensions' alignment. + fn relayout( + &self, + count: usize, + columns: &[u8], + replaced: [Option>; 4], + ) -> Result { + let arrays = (0..6) + .map(|index| self.buffer_array(index)) + .collect::, _>>()?; + let mut data = self.data[..self.package_start].to_vec(); + data.extend_from_slice(columns); + put_u64(&mut data, PACKAGES_AT, count); + let package_end = data.len(); + put_u64(&mut data, PACKAGES_AT + 32, package_end); + for (index, array) in arrays.iter().enumerate() { + let bytes = match replaced.get(index) { + Some(Some(bytes)) => bytes.as_slice(), + _ => &self.data[array.data.clone()], + }; + let padding = self.data[array.descriptor + 16..array.data.start] + .iter() + .rev() + .take_while(|b| **b == 0) + .count(); + let descriptor = data.len(); + data.extend_from_slice(&self.data[array.descriptor..array.data.start - padding]); + if !bytes.is_empty() { + data.resize(data.len().next_multiple_of(8), 0); + } + let start = data.len(); + data.extend_from_slice(bytes); + let end = data.len(); + put_u64(&mut data, descriptor, start); + put_u64(&mut data, descriptor + 8, end); + } + let end = arrays[5].data.end; + if (data.len() as i128 - end as i128) % 8 != 0 { + return Err("bun.lockb: re-laid buffers would misalign the extensions".into()); + } + let shift = |value: usize| (value as i128 + data.len() as i128 - end as i128) as usize; + let total = shift(self.total); + let extensions: Vec<_> = self + .extensions + .iter() + .map(|array| { + ( + shift(array.descriptor), + shift(array.data.start), + shift(array.data.end), + ) + }) + .collect(); + data.extend_from_slice(&self.data[end..]); + put_u64(&mut data, TOTAL_AT, total); + for (descriptor, start, end) in extensions { + put_u64(&mut data, descriptor, start); + put_u64(&mut data, descriptor + 8, end); + } + Self::parse(&data) + } + pub(crate) fn set_package( &mut self, id: usize, @@ -1613,6 +1893,213 @@ fn compare_prerelease(a: &str, b: &str) -> Ordering { } } +/// One dependency edge as Bun's hoister reads it. +#[derive(Clone)] +struct HoistEdge { + name: Vec, + hash: u64, + behavior: u8, + package: usize, +} + +/// What Bun's hoister walks: each package's dependency edges, whether it is +/// a folder (placed where declared, never hoisted), and the edges. +struct HoistGraph { + lists: Vec>, + folder: Vec, + edges: Vec, +} + +/// Where [`hoist_dependency`] puts an edge. +enum Hoisted { + /// An ancestor already holds the same package. + Deduplicated, + /// A different package of that name is in the way. + Conflict, + Placed(usize), +} + +/// Bun's `Dependency.Behavior.cmp`: workspace, dev, optional, prod, then +/// peer edges first. +fn behavior_order(l: u8, r: u8) -> Ordering { + if l == r { + return Ordering::Equal; + } + let optional = |b: u8| b & BEHAVIOR_OPTIONAL != 0 && b & BEHAVIOR_PEER == 0; + let tests: [&dyn Fn(u8) -> bool; 5] = [ + &|b| b & BEHAVIOR_WORKSPACE != 0, + &|b| b & BEHAVIOR_DEV != 0, + &optional, + &|b| b & BEHAVIOR_PROD != 0, + &|b| b & BEHAVIOR_PEER != 0, + ]; + for test in tests { + if test(l) != test(r) { + return if test(l) { + Ordering::Less + } else { + Ordering::Greater + }; + } + } + Ordering::Equal +} + +/// The `(trees, hoisted dependencies)` buffers Bun's hoister +/// (`Lockfile.hoist(.resolvable)`, `Tree.processSubtree` and +/// `hoistDependency` in Bun 1.3 and 1.4) writes for `graph`: breadth first +/// from the root, each package's edges in `DepSorter` order go to the +/// highest tree (up to a bundled edge's) with no same-name edge in the way, +/// deduplicated where one resolves to the same package, and a tree that +/// places nothing is dropped. An unresolved edge is skipped, except an +/// optional peer nothing installs (such as `ws`'s `bufferutil`), which +/// hoists like any edge but is left out of the written buffer, as Bun's +/// `Tree.Builder.clean` does. `None` where the result depends on a rule the +/// releases differ on or this does not model: an optional peer that would +/// resolve to (or be resolved by) a same-name edge it meets, edges tied in +/// that order, a package listing one name for two packages, a peer edge +/// meeting another package of its name (a semver check), or a peer or +/// cyclic folder edge. +fn hoist(graph: &HoistGraph) -> Option<(Vec, Vec)> { + // (dependency, parent, placed edges) per tree. + let mut trees: Vec<(usize, usize, Vec)> = Vec::new(); + let mut queue = VecDeque::from([(INVALID_TREE, ROOT_DEPENDENCY, INVALID_TREE)]); + while let Some((parent, dependency, hoist_root)) = queue.pop_front() { + let package = match dependency { + ROOT_DEPENDENCY => 0, + edge => graph.edges[edge].package, + }; + let list = graph.lists[package].clone(); + if list.is_empty() { + continue; + } + let mut sorted: Vec = list.clone().collect(); + let order = |l: &usize, r: &usize| { + let (l, r) = (&graph.edges[*l], &graph.edges[*r]); + behavior_order(l.behavior, r.behavior).then_with(|| l.name.cmp(&r.name)) + }; + sorted.sort_by(order); + // Bun's sort is unstable: tied edges would hoist in either order. + if sorted + .windows(2) + .any(|pair| order(&pair[0], &pair[1]).is_eq()) + { + return None; + } + let next = trees.len(); + trees.push((dependency, parent, Vec::new())); + for edge in sorted { + let HoistEdge { + behavior, package, .. + } = graph.edges[edge]; + let resolved = package < graph.lists.len(); + let bundled = behavior & BEHAVIOR_BUNDLED != 0; + let hoisted = if bundled { + Hoisted::Placed(next) + } else if !resolved { + if behavior & (BEHAVIOR_OPTIONAL | BEHAVIOR_PEER) + != BEHAVIOR_OPTIONAL | BEHAVIOR_PEER + { + // Bun skips an unresolvable edge that is no optional peer. + continue; + } + hoist_dependency(graph, &trees, true, next, hoist_root, edge, &list)? + } else if graph.folder[package] { + let mut tree = next; + while tree != INVALID_TREE { + let (ancestor, parent, _) = trees[tree]; + if ancestor < graph.edges.len() && graph.edges[ancestor].package == package { + return None; + } + tree = parent; + } + if behavior & BEHAVIOR_PEER != 0 { + return None; + } + Hoisted::Placed(next) + } else { + hoist_dependency(graph, &trees, true, next, hoist_root, edge, &list)? + }; + if let Hoisted::Placed(tree) = hoisted { + trees[tree].2.push(edge); + if resolved && !graph.lists[package].is_empty() { + queue.push_back((tree, edge, if bundled { tree } else { hoist_root })); + } + } + } + if trees[next].2.is_empty() { + trees.pop(); + } + } + let (mut tree_bytes, mut hoisted_bytes) = (Vec::new(), Vec::new()); + for (id, (dependency, parent, placed)) in trees.iter().enumerate() { + // An optional peer that never resolved holds its place while + // hoisting but is not written. + let placed: Vec = placed + .iter() + .copied() + .filter(|&edge| graph.edges[edge].package < graph.lists.len()) + .collect(); + for value in [ + id, + *dependency, + *parent, + hoisted_bytes.len() / 4, + placed.len(), + ] { + tree_bytes.extend_from_slice(&(value as u32).to_le_bytes()); + } + for edge in placed { + hoisted_bytes.extend_from_slice(&(edge as u32).to_le_bytes()); + } + } + Some((tree_bytes, hoisted_bytes)) +} + +/// Bun's `hoistDependency` for `edge` (of the package whose edges are +/// `list`) from `tree` up: deduplicated against the same package, kept +/// below a different one, else placed in the highest tree reached. An +/// unresolved optional peer meeting another one of its name is set aside +/// (Bun's `resolve_later`, which only a later resolution acts on); meeting +/// a resolved one either way round re-resolves it, which is `None`. +fn hoist_dependency( + graph: &HoistGraph, + trees: &[(usize, usize, Vec)], + as_defined: bool, + tree: usize, + hoist_root: usize, + edge: usize, + list: &Range, +) -> Option { + let wanted = &graph.edges[edge]; + let (_, parent, placed) = &trees[tree]; + if let Some(&other) = placed + .iter() + .find(|&&other| graph.edges[other].hash == wanted.hash) + { + let unresolved = |edge: &HoistEdge| edge.package >= graph.lists.len(); + match (unresolved(&graph.edges[other]), unresolved(wanted)) { + (true, true) => return Some(Hoisted::Deduplicated), + (true, false) | (false, true) => return None, + (false, false) => {} + } + if graph.edges[other].package == wanted.package { + return Some(Hoisted::Deduplicated); + } + if list.contains(&other) || wanted.behavior & BEHAVIOR_PEER != 0 { + return None; + } + return Some(Hoisted::Conflict); + } + if *parent != INVALID_TREE && tree != hoist_root { + let hoisted = hoist_dependency(graph, trees, false, *parent, hoist_root, edge, list)?; + if !as_defined || !matches!(hoisted, Hoisted::Conflict) { + return Some(hoisted); + } + } + Some(Hoisted::Placed(tree)) +} + fn take(data: &[u8], at: usize, len: usize) -> Result<&[u8], String> { at.checked_add(len) .and_then(|end| data.get(at..end)) @@ -2393,4 +2880,280 @@ mod tests { assert_eq!(lock.package(1).unwrap().resolution, "a.tgz"); assert_eq!(lock.bytes().len(), original_len); } + + /// The `bun.lockb` each Bun wrote for a workspace vendored once (uuid + /// `80630680-…`) after a late dependent of the patched package was + /// added: a new member (`late`, hoisted after the vendored record, so it + /// nests its registry copy) or `bun add` in an existing one (`adder`, + /// hoisted first, so the vendored record nests instead). The patched + /// package is minimist@1.2.2, or (`deps`) mkdirp@0.5.6, whose own + /// dependency on minimist each record lists. The `ws` locks also depend + /// on ws@8.18.0 at the root, whose two optional peers nothing installs: + /// unresolved edges, as most real locks have. + const LATE_DEPENDENT: [(&str, &[u8]); 16] = [ + ( + "1.3.9-late", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-late.lockb"), + ), + ( + "1.3.9-adder", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-adder.lockb"), + ), + ( + "1.4.2-late", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-late.lockb"), + ), + ( + "1.4.2-adder", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-adder.lockb"), + ), + ( + "1.3.9-deps-late", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-late.lockb"), + ), + ( + "1.3.9-deps-adder", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-adder.lockb"), + ), + ( + "1.4.2-deps-late", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-late.lockb"), + ), + ( + "1.4.2-deps-adder", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-adder.lockb"), + ), + ( + "1.3.9-ws-late", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-late.lockb"), + ), + ( + "1.3.9-ws-adder", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-adder.lockb"), + ), + ( + "1.3.9-ws-deps-late", + include_bytes!( + "../../tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-late.lockb" + ), + ), + ( + "1.3.9-ws-deps-adder", + include_bytes!( + "../../tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-adder.lockb" + ), + ), + ( + "1.4.2-ws-late", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-late.lockb"), + ), + ( + "1.4.2-ws-adder", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-adder.lockb"), + ), + ( + "1.4.2-ws-deps-late", + include_bytes!( + "../../tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-late.lockb" + ), + ), + ( + "1.4.2-ws-deps-adder", + include_bytes!( + "../../tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-adder.lockb" + ), + ), + ]; + + /// Each hoisting tree as `(parent, names of the packages it places)`. + fn tree_placements(lock: &BunLockb) -> Vec<(u32, Vec)> { + let trees = lock.buffer_array(0).unwrap(); + let hoisted = lock.buffer_array(1).unwrap(); + let resolutions = lock.buffer_array(2).unwrap(); + let packages = lock.packages().unwrap(); + trees + .data + .step_by(20) + .map(|at| { + let field = |i: usize| u32_at(&lock.data, at + i * 4).unwrap() as usize; + let mut names: Vec<_> = (field(3)..field(3) + field(4)) + .map(|i| { + let edge = u32_at(&lock.data, hoisted.data.start + i * 4).unwrap(); + let id = u32_at(&lock.data, resolutions.data.start + edge as usize * 4); + packages[id.unwrap() as usize].name.clone() + }) + .collect(); + names.sort(); + (field(2) as u32, names) + }) + .collect() + } + + /// REGRESSION (#861): the late dependent's registry record folds into + /// the vendored tarball record — one record per resolution, as Bun + /// writes it, so the isolated linker gets one store directory — and the + /// trees become what Bun's frozen install re-hoists: the nested copy is + /// deduplicated against the hoisted one and its emptied tree dropped. + /// A patched package with a dependency of its own (`deps`) folds too: + /// the duplicate's edge to minimist leaves with it, every other + /// package's dependency slice moves down past it. The re-laid buffers + /// keep Bun's eight-byte data alignment. + #[test] + fn late_duplicate_folds_into_the_tarball_record_as_bun_hoists_it() { + for (label, bytes) in LATE_DEPENDENT { + let member = label.rsplit('-').next().unwrap(); + let target = if label.contains("-deps-") { + "mkdirp" + } else { + "minimist" + }; + let mut lock = BunLockb::parse(bytes).unwrap(); + let edges = lock.dependency_array().unwrap().data.len() / 26; + let copies: Vec<_> = lock + .packages() + .unwrap() + .into_iter() + .filter(|p| p.name == target) + .collect(); + assert_eq!(copies.len(), 2, "{label}"); + let kept = copies.iter().find(|p| p.resolution.starts_with(".socket/")); + let kept = kept.unwrap().id; + let duplicate = copies.iter().find(|p| p.id != kept).unwrap().id; + let own = lock.package_dependency_range(duplicate).unwrap().len(); + assert_eq!(tree_placements(&lock).len(), 2, "{label}: Bun nests a copy"); + + assert!(lock.merge_packages(kept, &[duplicate]).unwrap(), "{label}"); + lock.validate_mutation().unwrap(); + let merged = BunLockb::parse(&lock.bytes()).unwrap(); + let packages = merged.packages().unwrap(); + let ws = label.contains("-ws-"); + assert_eq!( + packages.len(), + 5 + usize::from(target == "mkdirp") + usize::from(ws), + "{label}" + ); + let copies: Vec<_> = packages.iter().filter(|p| p.name == target).collect(); + assert_eq!(copies.len(), 1, "{label}: {packages:?}"); + assert!(copies[0] + .resolution + .starts_with(".socket/vendor/npm/80630680-")); + assert_eq!( + merged.dependency_array().unwrap().data.len() / 26, + edges - own, + "{label}: the duplicate's own edges leave" + ); + let graph = merged.hoist_graph().unwrap(); + let unresolved: Vec<_> = graph + .edges + .iter() + .filter(|e| e.package >= merged.count) + .map(|e| (e.name.as_slice(), e.package)) + .collect(); + let none = u32::MAX as usize; + let peers: &[(&[u8], usize)] = &[(b"bufferutil", none), (b"utf-8-validate", none)]; + assert_eq!( + unresolved, + if ws { peers } else { &[] }, + "{label}: only ws's optional peers stay unresolved" + ); + let meta = merged.package_start + merged.count * (32 + merged.resolution_size); + for row in 0..merged.count { + assert_eq!( + u32_at(&merged.data, meta + row * 88 + 8).unwrap() as usize, + row + ); + } + let mut expected = vec!["consumer", member, "is-number", "minimist"]; + if target == "mkdirp" { + expected.push("mkdirp"); + } + if ws { + expected.push("ws"); + } + expected.sort(); + assert_eq!( + tree_placements(&merged), + [(u32::MAX, expected.iter().map(|n| n.to_string()).collect())], + "{label}" + ); + for array in (0..6) + .map(|i| merged.buffer_array(i).unwrap()) + .chain(merged.extensions.iter().cloned()) + .filter(|a| !a.data.is_empty()) + { + assert_eq!(array.data.start % 8, 0, "{label}: aligned"); + } + } + } + + /// Records whose dependencies resolve to different packages cannot + /// fold (dropping one's edges would orphan what only it reaches): the + /// merge declines, lock unchanged. + #[test] + fn merge_declines_records_whose_dependencies_differ() { + let bytes = + include_bytes!("../../tests/fixtures/bun-lockb/0.8.1-production-complex/bun.lockb"); + let mut lock = BunLockb::parse(bytes).unwrap(); + let parent = (1..lock.count) + .find(|&id| !lock.package_dependency_range(id).unwrap().is_empty()) + .unwrap(); + let other = (1..lock.count).find(|&id| id != parent).unwrap(); + assert!(!lock.merge_packages(parent, &[other]).unwrap()); + assert!(!lock.merge_packages(other, &[parent]).unwrap()); + assert_eq!(lock.bytes(), bytes); + } + + /// The merge's hoister is Bun's: re-hoisting the lock every captured + /// Bun release wrote reproduces its trees byte for byte, unresolved + /// optional peers included. The 0.1.x writers hoisted differently; the + /// merge's check that the lock's own trees come back makes it decline + /// those locks. + #[test] + fn hoist_reproduces_every_captured_writers_trees() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let mut stack = vec![root.clone()]; + let (mut checked, mut unresolved) = (0, 0); + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + stack.push(path); + continue; + } + let Ok(mut lock) = BunLockb::parse(&std::fs::read(&path).unwrap()) else { + continue; + }; + lock.promote_legacy_format().unwrap(); + lock.normalize_workspace_behaviors().unwrap(); + let own = ( + lock.data[lock.buffer_array(0).unwrap().data].to_vec(), + lock.data[lock.buffer_array(1).unwrap().data].to_vec(), + ); + let graph = lock.hoist_graph().unwrap(); + let hoisted = hoist(&graph); + // Joined with `/` on every platform: `display()` would use `\` + // on Windows and miss the `bun-lockb/0.1.` era check below. + let label = path + .strip_prefix(&root) + .unwrap() + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + if label.starts_with("bun-lockb/0.1.") { + assert_ne!(hoisted, Some(own), "{label}"); + } else { + assert_eq!(hoisted, Some(own), "{label}"); + checked += 1; + if graph.edges.iter().any(|e| e.package >= graph.lists.len()) { + unresolved += 1; + } + } + } + } + assert!(checked >= 30, "{checked}"); + // REGRESSION (#861): locks with an optional peer nothing installs + // (the `late-dependent/*-ws-*` ones) hoist too. + assert!(unresolved >= 8, "{unresolved}"); + } } diff --git a/crates/socket-patch-core/src/vendor/bun_workspace.rs b/crates/socket-patch-core/src/vendor/bun_workspace.rs index ab40ca7ac..d1c1af2f4 100644 --- a/crates/socket-patch-core/src/vendor/bun_workspace.rs +++ b/crates/socket-patch-core/src/vendor/bun_workspace.rs @@ -15,7 +15,9 @@ fn required_mirrors( ) -> Result, String> { if entry.ecosystem != "npm" || entry.flavor.as_deref() != Some("bun") - || root.join("bun.lock").exists() + || super::lock_inventory::bun_text_lock_drives(&super::lock_inventory::ProjectView::Disk( + root, + )) { return Ok(Vec::new()); } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs index d1cda817f..fa8647d92 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs @@ -29,17 +29,41 @@ pub(crate) fn bun_text_entries(text: &str) -> Result, String> { // ── file selection ── -/// Whether the project root has a text `bun.lock` (lstat, so a dangling -/// symlink counts): bun reads it whenever it exists, so the binary -/// `bun.lockb` beside it is not the live lock. Lockfile discovery answers -/// the same question with `DiscoverCtx::exists` (the same lstat). -pub(crate) async fn bun_text_lock_present(root: &Path) -> bool { - bun_text_lock_present_in(&ProjectView::Disk(root)).await +/// Whether bun installs from the text `bun.lock` rather than a binary +/// `bun.lockb` beside it — the ONE answer every Bun-aware path routes +/// through (inventory, hosted, vendored, GC, repair, lockfile discovery). +/// +/// Bun opens `bun.lock` following symlinks and falls back to `bun.lockb` +/// only when that open fails with ENOENT, so this is `stat`, not `lstat`, +/// and only `NotFound` means absent: a dangling link leaves the binary +/// lock live (#735). An open that fails with anything but ENOENT — a +/// self-referencing link (ELOOP), a link through a regular file +/// (ENOTDIR), a link into an unreadable directory (EACCES) — shadows the +/// binary lock just like an entry bun finds but cannot read (a directory, +/// a FIFO): bun then ignores BOTH locks ("Ignoring lockfile"), so the +/// text lock is chosen and its guarded read refuses rather than wiring a +/// `bun.lockb` bun would not install from. (Bun 1.2.23 and 1.3.14, +/// `bun install --frozen-lockfile`.) An in-memory link has no target to +/// follow, so it keeps shadowing. +pub fn bun_text_lock_drives(view: &ProjectView<'_>) -> bool { + match view { + ProjectView::Disk(_) | ProjectView::Snapshot(_) => { + let root = view + .disk_root_reading([BUN_LOCK]) + .expect("a disk view has a root"); + match std::fs::metadata(root.join(BUN_LOCK)) { + Ok(_) => true, + Err(error) => error.kind() != std::io::ErrorKind::NotFound, + } + } + ProjectView::Memory(project) => project.contains(BUN_LOCK) || project.is_dir(BUN_LOCK), + } } -/// [`bun_text_lock_present`] over a [`ProjectView`]. -pub(crate) async fn bun_text_lock_present_in(view: &ProjectView<'_>) -> bool { - view.exists_no_follow(BUN_LOCK).await +/// Whether the binary `bun.lockb` is the lock bun installs from: present, +/// and not shadowed by [`bun_text_lock_drives`]. +pub fn bun_binary_lock_drives(root: &Path) -> bool { + !bun_text_lock_drives(&ProjectView::Disk(root)) && root.join(BUN_LOCKB).exists() } // ── registry view ── diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs index 5abd61dee..a73578882 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs @@ -69,6 +69,7 @@ pub(crate) mod vlt; pub(crate) mod wired; pub(crate) mod yarn; +pub use self::bun::{bun_binary_lock_drives, bun_text_lock_drives}; pub(crate) use self::npm::{ npm_lock_bundled_nodes, npm_lock_entries, npm_lock_legacy_mirror_nodes, npm_lock_located_nodes, NpmLockEntry, NpmLockNode, NpmLockSection, diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs b/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs index 52344f094..208934cf1 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs @@ -11,7 +11,7 @@ use crate::formats::yarn::{grammar as yarn_grammar, YarnLockGrammar}; use crate::utils::purl::npm_purl; use crate::vendor::npm_flavor::NpmLockFlavor; -use super::bun::{bun_text_lock_present_in, inventory_bun_binary_in, inventory_bun_in}; +use super::bun::{bun_text_lock_drives, inventory_bun_binary_in, inventory_bun_in}; use super::npm::inventory_package_lock_in; use super::pnpm::{ inventory_pnpm_lock_in, inventory_pnpm_lock_rel_in, inventory_pnpm_member_locks_in, @@ -172,7 +172,7 @@ pub(super) async fn inventory_npm_lock_raw_in( NpmLockFlavor::YarnClassic => inventory_yarn_classic_in(view).await, NpmLockFlavor::YarnBerry => inventory_yarn_berry_in(view).await, NpmLockFlavor::Bun => { - if bun_text_lock_present_in(view).await { + if bun_text_lock_drives(view) { inventory_bun_in(view).await } else { Some(inventory_bun_binary_in(view).await?) @@ -196,9 +196,18 @@ pub(super) async fn inventory_npm_lock_raw_in( pub(super) async fn inventory_live_sibling_lock_in( view: &ProjectView<'_>, ) -> Option<(NpmLockFlavor, Vec)> { + // A `bun.lock` that exists but can't be stat'd (dangling symlink, + // ELOOP, EACCES) still shadows `bun.lockb` for Bun, so its presence + // comes from the same predicate the router uses. + let bun_text = bun_text_lock_drives(view); let mut present = Vec::new(); for file in npm_lock_files() { - if view.exists(file).await { + let here = if file == BUN_LOCK { + bun_text + } else { + view.exists(file).await + }; + if here { present.push(file); } } @@ -211,7 +220,7 @@ pub(super) async fn inventory_live_sibling_lock_in( // The router's bun step runs BEFORE its pnpm sniff, so when the // version refusal fired no bun lock can actually be present; kept // for the table's sake. The text lock wins over the binary one. - NpmLockFamily::Bun if present.contains(&BUN_LOCK) => ( + NpmLockFamily::Bun if bun_text => ( NpmLockFlavor::Bun, inventory_bun_in(view).await.unwrap_or_default(), ), diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs index b6bc8df76..e2a38852a 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -3699,6 +3699,110 @@ async fn requirements_index_option_in_an_include_spans_the_tree() { ); } +/// REGRESSION (#735): Bun opens `bun.lock` through symlinks, so a +/// dangling `bun.lock` link is absent to it and it installs from the +/// `bun.lockb` beside it (verified with Bun 1.2.23 and 1.3.14: +/// `bun install --frozen-lockfile` installs from the binary lock). The +/// inventory, the wired-integrity probe and vendored routing must all pick +/// `bun.lockb` too, instead of losing every package of the live lock. +#[cfg(unix)] +#[tokio::test] +async fn bun_dangling_text_lock_link_leaves_the_binary_lock_live() { + let bytes = include_bytes!("../../../tests/fixtures/bun-lockb/1.3.14/bun.lockb"); + let mut lock = super::super::bun_lockb::BunLockb::parse(bytes).unwrap(); + let minimist = lock + .packages() + .unwrap() + .into_iter() + .find(|package| package.name == "minimist") + .unwrap(); + let rel = ".socket/vendor/npm/11111111-1111-4111-8111-111111111111/minimist-1.2.2.tgz"; + let sri = format!("sha512-{}", "A".repeat(86) + "=="); + lock.set_package(minimist.id, rel, &sri).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes()) + .await + .unwrap(); + std::os::unix::fs::symlink("missing-target", tmp.path().join("bun.lock")).unwrap(); + + let (entries, diagnoses) = inventory_project_diagnosed(tmp.path()).await; + assert!(diagnoses.is_empty(), "{diagnoses:?}"); + assert_eq!( + sorted_pairs(&entries), + vec![("is-number".into(), "7.0.0".into())], + "the binary lock's registry packages (minimist is vendored)" + ); + assert_eq!( + wired_vendor_integrity(tmp.path(), rel).await, + Some(LockIntegrity::Sri(sri)) + ); + assert!(super::super::bun_lock::binary_lock_drives(tmp.path())); + // The hosted engine's view-level answer (disk and snapshot) agrees. + assert!(!bun_text_lock_drives(&ProjectView::Disk(tmp.path()))); + let snapshot = DiskSnapshot::new(tmp.path()); + assert!(!bun_text_lock_drives(&ProjectView::Snapshot(&snapshot))); +} + +/// The #735 control: a `bun.lock` DIRECTORY is not absent to Bun — it +/// opens it, fails to read it and ignores BOTH locks ("warn: Ignoring +/// lockfile", Bun 1.2.23 and 1.3.14). The binary lock is therefore not +/// live; every reader keeps choosing the text lock, whose unreadable read +/// refuses rather than wiring a `bun.lockb` Bun would not install from. +#[tokio::test] +async fn bun_text_lock_directory_still_shadows_the_binary_lock() { + let bytes = include_bytes!("../../../tests/fixtures/bun-lockb/1.3.14/bun.lockb"); + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), bytes) + .await + .unwrap(); + tokio::fs::create_dir(tmp.path().join("bun.lock")) + .await + .unwrap(); + + let (entries, _) = inventory_project_diagnosed(tmp.path()).await; + assert!(entries.is_empty(), "{entries:?}"); + assert!(!super::super::bun_lock::binary_lock_drives(tmp.path())); + assert!(bun_text_lock_drives(&ProjectView::Disk(tmp.path()))); +} + +/// The #735 errno control: Bun falls back to `bun.lockb` only when opening +/// `bun.lock` fails with ENOENT. A self-referencing link fails with ELOOP +/// and a link through a regular file with ENOTDIR; Bun then prints +/// "Ignoring lockfile" and installs from NEITHER lock (Bun 1.2.23 and +/// 1.3.14). So the text lock keeps shadowing the binary one and nothing is +/// inventoried from a `bun.lockb` Bun would not install from. +#[cfg(unix)] +#[tokio::test] +async fn bun_text_lock_link_failing_with_other_errno_still_shadows_the_binary_lock() { + let bytes = include_bytes!("../../../tests/fixtures/bun-lockb/1.3.14/bun.lockb"); + for target in ["bun.lock", "package.json/x"] { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), bytes) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("package.json"), "{}") + .await + .unwrap(); + std::os::unix::fs::symlink(target, tmp.path().join("bun.lock")).unwrap(); + + let (entries, _) = inventory_project_diagnosed(tmp.path()).await; + assert!(entries.is_empty(), "{target}: {entries:?}"); + assert!( + !super::super::bun_lock::binary_lock_drives(tmp.path()), + "{target}" + ); + assert!( + bun_text_lock_drives(&ProjectView::Disk(tmp.path())), + "{target}" + ); + let snapshot = DiskSnapshot::new(tmp.path()); + assert!( + bun_text_lock_drives(&ProjectView::Snapshot(&snapshot)), + "{target}" + ); + } +} + #[tokio::test] async fn lookup_matches_by_purl_identity() { let entry = |ecosystem: &'static str, name: &str, version: &str| LockfileEntry { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs index bf2e64bf3..252b5b27a 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs @@ -87,7 +87,7 @@ pub async fn wired_vendor_integrity( // Read active binary resolution records, never the append-only string // pool: it can retain paths and digests from earlier patch generations. - if !super::bun::bun_text_lock_present(project_root).await { + if !super::bun::bun_text_lock_drives(&super::ProjectView::Disk(project_root)) { if let Ok(bytes) = read_regular_to_bytes(&project_root.join(BUN_LOCKB)).await { if let Ok(packages) = BunLockb::parse_packages(&bytes) { let mut pinned: Option = None; diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 68d5d6053..1502122cf 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -2239,6 +2239,45 @@ mod tests { ); } + /// REGRESSION (#735): a dangling `bun.lock` link is absent to Bun, + /// which installs from `bun.lockb`, so the GC probe resolves through + /// the binary lock instead of reading the link and never deciding. + #[cfg(unix)] + #[tokio::test] + async fn binary_bun_in_use_sees_through_a_dangling_text_lock_link() { + let tmp = tempfile::tempdir().unwrap(); + let bytes = include_bytes!("../../tests/fixtures/bun-lockb/1.3.14/bun.lockb"); + let mut lock = super::super::bun_lockb::BunLockb::parse(bytes).unwrap(); + let package = lock + .packages() + .unwrap() + .into_iter() + .find(|package| package.name == "minimist") + .unwrap(); + let mut entry = probe_entry(Some("bun")); + let target = format!(".socket/vendor/npm/{UUID}/minimist-1.2.2.tgz"); + entry.base_purl = "pkg:npm/minimist@1.2.2".into(); + entry.artifact.path = target.clone(); + let sri = format!("sha512-{}", "A".repeat(86) + "=="); + lock.set_package(package.id, &target, &sri).unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes()) + .await + .unwrap(); + std::os::unix::fs::symlink("missing-target", tmp.path().join("bun.lock")).unwrap(); + assert_eq!(in_use(&entry, tmp.path()).await, Some(true)); + + lock.set_package( + package.id, + "https://registry.example/minimist-1.2.2.tgz", + &sri, + ) + .unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes()) + .await + .unwrap(); + assert_eq!(in_use(&entry, tmp.path()).await, Some(false)); + } + /// An entry stamped `flavor="pnpm-legacy"` must dispatch to the legacy /// backend's structural packages-key probe — not fall into the /// unknown-flavor `Some(_) => None` arm (a typo'd match string would diff --git a/crates/socket-patch-core/src/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs index aad7a0687..66e4ab551 100644 --- a/crates/socket-patch-core/src/vendor/state.rs +++ b/crates/socket-patch-core/src/vendor/state.rs @@ -563,7 +563,9 @@ pub fn carry_forward_wiring(prev: &VendorEntry, entry: &mut VendorEntry) { .wiring .iter() .filter(|p| wiring_surface_matches(p, rec)); - if let Some(prev_rec) = candidates.next() { + if rec.kind == "bun_lock_package" && !prev.wiring.iter().any(|p| p.kind == rec.kind) { + rec.original = migrated_bun_original(prev, rec); + } else if let Some(prev_rec) = candidates.next() { // Multiple equal binary resolutions can have different // registry originals. Renumbered IDs cannot disambiguate // them, so do not attach a guessed restore payload. @@ -607,6 +609,23 @@ pub fn carry_forward_wiring(prev: &VendorEntry, entry: &mut VendorEntry) { } } +/// The pre-vendor original of a `bun.lock` record that re-pinned a tuple +/// Bun migrated from the binary lock (#784): the previous entry recorded it +/// as a `bun.lockb` package snapshot, rebuilt here as the registry tuple Bun +/// writes for it. `None` when the binary records disagree on it. +fn migrated_bun_original(prev: &VendorEntry, current: &WiringRecord) -> Option { + let line = current.new.as_ref()?.as_str()?; + let mut lines = prev + .wiring + .iter() + .filter(|p| p.kind == "bun_lockb_package") + .filter_map(|p| super::bun_binary::migrated_registry_line(line, p.original.as_ref()?)); + let first = lines.next()?; + lines + .all(|other| other == first) + .then_some(serde_json::Value::String(first)) +} + /// Binary IDs are offsets into Bun's package array and may change after an /// installer re-save. Match the predecessor's semantic resolution instead. fn wiring_surface_matches(previous: &WiringRecord, current: &WiringRecord) -> bool { diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index e8a7e7c70..53c471469 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -4,8 +4,10 @@ //! ## Which lock //! //! Exactly ONE of the two is read, because bun itself reads exactly one: -//! `bun.lock` whenever it exists (lstat — a squatting FIFO / dangling link -//! still counts, and is then diagnosed unreadable), else `bun.lockb`. A +//! `bun.lock` whenever its open finds something (stat, through links — a +//! squatting FIFO or directory still counts, and is then diagnosed +//! unreadable; a dangling link does not, #735), else `bun.lockb`: the +//! shared [`bun_text_lock_drives`] predicate. A //! stale binary lock left beside a text lock wires nothing, so it must not //! become a ref (rule 10 — the same gate `vendor::bun_workspace` and //! `lock_inventory::wired_vendor_integrity` apply). @@ -84,23 +86,29 @@ //! //! Non-goals: nested workspace-member locks (bun keeps one lock at the //! workspace root); git / github / workspace / folder entries (never -//! Socket-written). +//! Socket-written). Those entries still contest: a URL / `file:` tarball, +//! git, folder or link copy whose version the lock does not record (Bun +//! keeps none for it) withdraws every ref of the same package in that lock +//! ([`Unwired`], #497). use super::{ - npm_purl, DiscoverCtx, Discovery, LocateOpts, Located, PatchedRef, DIAG_LOCKFILE_UNPARSEABLE, - DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, + canonical_base_purl, npm_purl, DiscoverCtx, Discovery, LocateOpts, Located, PatchedRef, + DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB}; use crate::patch::redirect::hosted_url_version; use crate::utils::digest::is_sri_pin; -use crate::vendor::bun_lock_text::{decode_json_string, is_bundled_entry, split_name_spec}; +use crate::vendor::bun_lock_text::{ + decode_json_string, is_bundled_entry, split_name_spec, user_tarball_version, +}; use crate::vendor::bun_lockb::BunLockb; use crate::vendor::lock_inventory::bun::bun_text_entries; +use crate::vendor::lock_inventory::bun_text_lock_drives; use crate::vendor::lock_inventory::LockIntegrity; use crate::vendor::npm_common::tgz_leaf_version; pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { - if ctx.exists(BUN_LOCK).await { + if bun_text_lock_drives(&ctx.view) { extract_text(ctx, out).await; // bun reads bun.lock whenever it exists: whatever a leftover // bun.lockb still names is recognized as unwired (rule 11). @@ -150,11 +158,21 @@ async fn extract_text(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { recorded_version: None, integrity, shape_ok: tarball_tuple, + // Every other spec is a registry `name@` or a member's + // `name@workspace:`. + own_source: !target.starts_with(|c: char| c.is_ascii_digit()) + && !target.starts_with("workspace:"), }; if is_bundled_entry(entry) { bundled.record(ctx, BUN_LOCK, classified, out); } else { - unwired.record(classify(ctx, BUN_LOCK, classified, out), &entry.key); + let user_tarball = user_tarball_version(name, target).is_some(); + unwired.record( + classify(ctx, BUN_LOCK, classified, out), + name, + &entry.key, + user_tarball, + ); } } bundled.contest(BUN_LOCK, out); @@ -279,6 +297,7 @@ async fn extract_binary(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { // The codec only yields a resolution STRING for registry and // tarball-like records; git records come back empty. shape_ok: true, + own_source: p.own_source, }; // A record some bundled edge reaches installs (also) as a copy // unpacked from that parent's tarball. Bun keeps ONE record for a @@ -287,7 +306,14 @@ async fn extract_binary(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { if p.bundled { bundled.record(ctx, BUN_LOCKB, classified, out); } else { - unwired.record(classify(ctx, BUN_LOCKB, classified, out), &label); + let user_tarball = + p.version.is_none() && user_tarball_version(&p.name, &p.resolution).is_some(); + unwired.record( + classify(ctx, BUN_LOCKB, classified, out), + &p.name, + &label, + user_tarball, + ); } } bundled.contest(BUN_LOCKB, out); @@ -311,18 +337,29 @@ struct Entry<'a> { integrity: Option, /// The entry is in a shape bun reads as a tarball tuple. shape_ok: bool, + /// Bun installs the entry from its own spec (a URL or local tarball, + /// git, a folder or a link), never the registry or a workspace member. + own_source: bool, +} + +/// What [`classify`] found an entry that is not a ref to install. +enum Install { + /// Nothing that contests a ref: a ref itself, an invalid wiring, or a + /// registry spec naming no exact version. + Nothing, + /// An unpatched copy of this exact purl. + Unpatched(String), + /// An own-source copy whose version the lock does not record (#497): + /// a tarball whose leaf names no version, git, a folder. + Unversioned, } /// Push `entry`'s ref when it is Socket-wired (see the module docs); stay -/// silent for anything else. Returns the purl of a registry entry (an exact -/// version resolved from a non-Socket source), which contests a ref for the -/// same version in this lock ([`Unwired::contest`]) and in any other. -fn classify( - ctx: &DiscoverCtx<'_>, - file: &str, - entry: Entry<'_>, - out: &mut Discovery, -) -> Option { +/// silent for anything else. Returns what a non-Socket entry installs: the +/// purl of an exact version, which contests a ref for the same version in +/// this lock ([`Unwired::contest`]) and in any other, or a copy of unknown +/// version, which contests every ref of the package in this lock. +fn classify(ctx: &DiscoverCtx<'_>, file: &str, entry: Entry<'_>, out: &mut Discovery) -> Install { let Entry { label, name, @@ -330,6 +367,7 @@ fn classify( recorded_version, integrity, shape_ok, + own_source, } = entry; let Located { vendored, @@ -346,20 +384,33 @@ fn classify( .socket/vendor/npm// path; it is ignored" ), ); - return None; + return Install::Nothing; } if vendored.is_none() && hosted_uuid.is_none() { // Registry / git / workspace / user tarball dependency: not ours. A - // registry entry (an exact version) is evidence against another - // lock's wiring of the same package. - let version = recorded_version.or_else(|| { - target - .starts_with(|c: char| c.is_ascii_digit()) - .then_some(target) - }); - let purl = version.and_then(|version| npm_purl(name, version)); + // registry entry (an exact version), or a user URL / `file:` + // tarball whose leaf names its version (#497), is an unpatched + // install of that version: evidence against wiring of the same + // package in this lock and any other. Any other own-source copy + // (a tarball whose leaf names no version, git, a folder) may be + // the wired version: Bun records no version for it. + let version = recorded_version + .or_else(|| { + target + .starts_with(|c: char| c.is_ascii_digit()) + .then_some(target) + }) + .or_else(|| user_tarball_version(name, target)); + let Some(version) = version else { + return if own_source { + Install::Unversioned + } else { + Install::Nothing + }; + }; + let purl = npm_purl(name, version); out.resolved_elsewhere(file, purl.clone()); - return purl; + return purl.map_or(Install::Nothing, Install::Unpatched); } let invalid = |out: &mut Discovery, why: String| { out.diag(DIAG_REF_INVALID, file, format!("{file}: {label}: {why}")); @@ -371,12 +422,12 @@ fn classify( "{name}@{target} is not in bun's tarball tuple shape [spec, {{meta}}, integrity]" ), ); - return None; + return Install::Nothing; } let version = match &vendored { Some(vref) if vref.eco != "npm" => { invalid(out, format!("{target:?} is not a vendored npm tarball")); - return None; + return Install::Nothing; } Some(vref) => tgz_leaf_version(name, &vref.leaf) .filter(|version| semver::Version::parse(version).is_ok()), @@ -387,7 +438,7 @@ fn classify( out, format!("{target:?} is not an artifact of {name:?} (its leaf must be the package's own -.tgz)"), ); - return None; + return Install::Nothing; }; if recorded_version.is_some_and(|recorded| recorded != version) { invalid( @@ -397,14 +448,14 @@ fn classify( recorded_version.unwrap_or_default() ), ); - return None; + return Install::Nothing; } let Some(purl) = npm_purl(name, version) else { invalid( out, format!("Socket-wired entry {name:?}@{version:?} has unsafe coordinates"), ); - return None; + return Install::Nothing; }; // Hosted: both bun rewriters always write the sha512 (see module docs). if let Some(vref) = vendored { @@ -419,41 +470,110 @@ fn classify( true, )); } - None + Install::Nothing } /// The registry copies one lock records, keyed by purl (#588): bun installs /// every entry, so a second entry resolving a wired `name@version` from the /// registry (e.g. a workspace member added after the rewire, then `bun -/// install`) installs unpatched beside the rewired one. +/// install`) installs unpatched beside the rewired one. A user URL / +/// `file:` tarball of the version (#497) is such a copy too, one no re-run +/// can rewire: bun installs it from its own spec. So is an own-source copy +/// whose version the lock does not record (a tarball named `pkg.tgz`, a +/// codeload URL, git, a folder): it may be the wired version, so it +/// contests every ref of the package. Only this lock's refs: without a +/// version it is no `name@version` evidence for the cross-lock contest. #[derive(Default)] struct Unwired { - /// purl → the first such entry's label. - copies: std::collections::BTreeMap, + /// purl → the first such entry's label (a user tarball's in preference + /// to a registry copy's), and whether it is a user tarball. + copies: std::collections::BTreeMap, + /// Version-less purl (`pkg:npm/`) → the first unversioned + /// own-source entry's label. + unversioned: std::collections::BTreeMap, +} + +/// `purl` without its `@`. +fn package_of(purl: &str) -> &str { + purl.rsplit_once('@').map_or(purl, |(package, _)| package) } impl Unwired { - fn record(&mut self, purl: Option, label: &str) { - if let Some(purl) = purl { - self.copies.entry(purl).or_insert_with(|| label.to_string()); + fn record(&mut self, install: Install, name: &str, label: &str, user_tarball: bool) { + let purl = match install { + Install::Nothing => return, + Install::Unpatched(purl) => purl, + Install::Unversioned => { + if let Some(purl) = npm_purl(name, "0") { + let package = package_of(&canonical_base_purl(&purl)).to_string(); + self.unversioned + .entry(package) + .or_insert_with(|| label.to_string()); + } + return; + } + }; + // A user-tarball copy wins over a registry one: a re-run rewires the + // registry copy but never the tarball, so the diagnostic must name + // the copy whose remedy is "depend on the registry version". + match self.copies.entry(purl) { + std::collections::btree_map::Entry::Vacant(v) => { + v.insert((label.to_string(), user_tarball)); + } + std::collections::btree_map::Entry::Occupied(mut o) => { + if user_tarball && !o.get().1 { + o.insert((label.to_string(), true)); + } + } } } /// Withdraw every ref of `file` whose `name@version` another entry of - /// the same lock resolves from the registry. + /// the same lock resolves from elsewhere, or whose package an + /// unversioned own-source entry installs. fn contest(&self, file: &str, out: &mut Discovery) { - if self.copies.is_empty() { + if self.copies.is_empty() && self.unversioned.is_empty() { return; } let refs = std::mem::take(&mut out.refs); for r in refs { - let unwired_at = (r.source_file == std::path::Path::new(file)) - .then(|| self.copies.get(&r.purl)) - .flatten(); - let Some(label) = unwired_at else { + if r.source_file != std::path::Path::new(file) { out.refs.push(r); continue; + } + let Some((label, user_tarball)) = self.copies.get(&r.purl) else { + if let Some(label) = self.unversioned.get(package_of(&r.purl)) { + out.diag( + DIAG_REF_UNATTRIBUTABLE, + file, + format!( + "{file}: {} is wired to a Socket patch but entry {label:?} of the \ + same lock installs that package from a URL, local tarball, git \ + or folder spec whose version the lock does not record; bun \ + installs it from that spec, so if it is this version that copy \ + stays UNPATCHED, and nothing is attested", + r.purl, + ), + ); + } else { + out.refs.push(r); + } + continue; }; + if *user_tarball { + out.diag( + DIAG_REF_UNATTRIBUTABLE, + file, + format!( + "{file}: {} is wired to a Socket patch but entry {label:?} of the same \ + lock installs that version from a URL or local tarball, not the \ + registry; bun installs it from that spec, so that copy stays \ + UNPATCHED and nothing is attested", + r.purl, + ), + ); + continue; + } out.diag( DIAG_REF_UNATTRIBUTABLE, file, @@ -771,6 +891,308 @@ mod tests { } } + /// The `DIAG_REF_UNATTRIBUTABLE` diagnostics that name a user tarball. + fn user_tarball_contests(out: &Discovery) -> usize { + out.diagnostics + .iter() + .filter(|d| { + d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains("from a URL or local tarball") + && d.detail.contains("UNPATCHED") + }) + .count() + } + + /// REGRESSION (#497): a root dependency on `left-pad` by remote URL or + /// `file:` tarball is installed from that spec, never the registry, so + /// a hosted / vendored rewire of the nested registry copy of the same + /// version leaves the copy the app loads unpatched. Nothing may be + /// attested from the lock alone (npm's #326 contest). A tarball of + /// another version contests nothing. + #[tokio::test] + async fn issue_497_user_tarball_copy_contests_the_ref() { + let hosted = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let vendored = format!("left-pad@.socket/vendor/npm/{UUID_A}/left-pad-1.3.0.tgz"); + for (label, spec) in [ + ("hosted", format!("left-pad@{hosted}")), + ("vendored", vendored), + ] { + for user in [ + "left-pad@https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "left-pad@./left-pad-1.3.0.tgz", + "left-pad@vendor/left-pad-1.3.0.tgz", + ] { + let p = Project::new(); + p.write( + "bun.lock", + text_lock( + 2, + &[ + tuple("left-pad", user, Some(SRI)), + tuple("dep/left-pad", &spec, Some(SRI)), + ], + ), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{label} {user}: {:#?}", out.refs); + assert_eq!( + user_tarball_contests(&out), + 1, + "{label} {user}: {:#?}", + out.diagnostics + ); + } + for other in ["left-pad@https://registry.npmjs.org/left-pad/-/left-pad-1.2.0.tgz"] { + let p = Project::new(); + p.write( + "bun.lock", + text_lock( + 2, + &[ + tuple("left-pad", other, Some(SRI)), + tuple("dep/left-pad", &spec, Some(SRI)), + ], + ), + ); + let out = run(&p).await; + assert_eq!(out.refs.len(), 1, "{label} {other}: {:#?}", out.refs); + assert_eq!(user_tarball_contests(&out), 0, "{label} {other}"); + } + } + } + + /// The `DIAG_REF_UNATTRIBUTABLE` diagnostics that name an own-source + /// copy of unknown version. + fn unversioned_contests(out: &Discovery) -> usize { + out.diagnostics + .iter() + .filter(|d| { + d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains("whose version the lock does not record") + }) + .count() + } + + /// REGRESSION (#497 follow-up): Bun records no version for a URL / + /// `file:` tarball whose leaf names none (`pkg.tgz`, a codeload URL), + /// nor for a git or folder dependency, and installs it from that spec. + /// It may be the wired version, so it contests every ref of the package + /// in that lock, scoped or not. A copy of another package, a workspace + /// member and a registry copy of another version contest nothing. + #[tokio::test] + async fn issue_497_unversioned_own_source_copy_contests_the_ref() { + for (name, file) in [ + ("left-pad", "left-pad-1.3.0.tgz"), + ("@s/pad", "pad-1.3.0.tgz"), + ] { + let hosted = hosted_url("npm", name, "1.3.0", UUID_A, file); + let vendored = format!("{name}@.socket/vendor/npm/{UUID_A}/{name}-1.3.0.tgz"); + for (label, spec) in [ + ("hosted", format!("{name}@{hosted}")), + ("vendored", vendored), + ] { + for user in [ + format!("{name}@./pkg.tgz"), + format!("{name}@https://codeload.github.com/o/r/tar.gz/refs/tags/v1.3.0"), + format!("{name}@github:o/r#c12a808"), + format!("{name}@file:vend/pad"), + format!("{name}@link:pad"), + ] { + let p = Project::new(); + p.write( + "bun.lock", + text_lock( + 2, + &[ + tuple(name, &user, None), + tuple(&format!("dep/{name}"), &spec, Some(SRI)), + ], + ), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{label} {user}: {:#?}", out.refs); + assert_eq!( + unversioned_contests(&out), + 1, + "{label} {user}: {:#?}", + out.diagnostics + ); + } + for other in [ + "other@./pkg.tgz".to_string(), + format!("{name}@workspace:packages/pad"), + format!("{name}@1.2.0"), + ] { + let p = Project::new(); + p.write( + "bun.lock", + text_lock( + 2, + &[ + tuple("x", &other, None), + tuple(&format!("dep/{name}"), &spec, Some(SRI)), + ], + ), + ); + let out = run(&p).await; + assert_eq!(out.refs.len(), 1, "{label} {other}: {:#?}", out.refs); + assert_eq!(unversioned_contests(&out), 0, "{label} {other}"); + } + } + } + } + + /// REGRESSION (#497 follow-up), real Bun locks: the root depends on + /// minimist by `file:./pkg.tgz`, a codeload tarball URL or + /// `github:…#v1.2.2` (Bun 1.4.2 / 1.2.23 / 1.1.45 text, 1.1.45 binary), + /// and a folder dependency on minimist@1.2.2. Wiring that nested + /// registry copy (hosted or vendored) is never attested; dropping the + /// root copy from the text lock attests it. + #[tokio::test] + async fn issue_497_real_unversioned_copies_contest_the_nested_ref() { + let hosted = hosted_url("npm", "minimist", "1.2.2", UUID_A, "minimist-1.2.2.tgz"); + let vendored = format!(".socket/vendor/npm/{UUID_A}/minimist-1.2.2.tgz"); + let root = fixture_path("bun-unversioned-copy"); + for dir in [ + "text-0/file", + "text-1/file", + "text-2/file", + "text-2/url", + "text-2/git", + ] { + let text = std::fs::read_to_string(root.join(dir).join("bun.lock")).unwrap(); + for (mode, wired) in [("hosted", &hosted), ("vendored", &vendored)] { + let lock: String = text + .lines() + .map( + |line| match line.trim_start().strip_prefix("\"dep/minimist\": ") { + Some(_) => format!( + " \"dep/minimist\": [\"minimist@{wired}\", {{}}, \"{SRI}\"],\n" + ), + None => format!("{line}\n"), + }, + ) + .collect(); + assert!(lock.contains(wired.as_str()), "{dir}"); + let p = Project::new(); + p.write("bun.lock", &lock); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!( + unversioned_contests(&out), + 1, + "{dir} {mode}: {:#?}", + out.diagnostics + ); + + let without_root: String = lock + .lines() + .filter(|line| !line.trim_start().starts_with("\"minimist\": ")) + .map(|line| format!("{line}\n")) + .collect(); + let p = Project::new(); + p.write("bun.lock", &without_root); + let out = run(&p).await; + assert_eq!(out.refs.len(), 1, "{dir} {mode} control: {:#?}", out); + assert_eq!(unversioned_contests(&out), 0, "{dir} {mode} control"); + } + } + for shape in ["file", "url", "git"] { + let bytes = std::fs::read(root.join("lockb").join(shape).join("bun.lockb")).unwrap(); + for (mode, wired) in [("hosted", &hosted), ("vendored", &vendored)] { + let mut lock = crate::vendor::bun_lockb::BunLockb::parse(&bytes).unwrap(); + let id = lock + .packages() + .unwrap() + .into_iter() + .find(|p| p.name == "minimist" && p.version.as_deref() == Some("1.2.2")) + .expect("nested registry record") + .id; + lock.set_package(id, wired, SRI).unwrap(); + let p = Project::new(); + p.write("bun.lockb", lock.bytes()); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!( + unversioned_contests(&out), + 1, + "{shape} {mode}: {:#?}", + out.diagnostics + ); + } + } + } + + /// REGRESSION (#497 review): when the same version has both an unwired + /// registry copy and a user-tarball copy, and the registry copy comes + /// first, the diagnostic still names the tarball copy, since a re-run + /// cannot rewire it. The "re-run to rewire every copy" remedy would be + /// wrong here. + #[tokio::test] + async fn issue_497_user_tarball_copy_outranks_an_earlier_registry_copy() { + let hosted = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let p = Project::new(); + p.write( + "bun.lock", + text_lock( + 2, + &[ + format!("\"a/left-pad\": [\"left-pad@1.3.0\", \"\", {{}}, \"{SRI}\"]"), + tuple("left-pad", "left-pad@./left-pad-1.3.0.tgz", Some(SRI)), + tuple("z/left-pad", &format!("left-pad@{hosted}"), Some(SRI)), + ], + ), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{:#?}", out.refs); + assert_eq!(user_tarball_contests(&out), 1, "{:#?}", out.diagnostics); + assert!( + !out.diagnostics + .iter() + .any(|d| d.detail.contains("rewire every copy")), + "{:#?}", + out.diagnostics + ); + } + + /// REGRESSION (#497), `bun.lockb`: Bun 1.1.45's tarball record of a + /// root URL / `file:` dependency contests a hosted or vendored rewire + /// of is-odd's nested registry copy of the same version. + #[tokio::test] + async fn issue_497_binary_user_tarball_record_contests_the_ref() { + let hosted = hosted_url("npm", "is-number", "6.0.0", UUID_A, "is-number-6.0.0.tgz"); + let vendored = format!(".socket/vendor/npm/{UUID_A}/is-number-6.0.0.tgz"); + for shape in ["url", "file"] { + for (mode, wired) in [("hosted", &hosted), ("vendored", &vendored)] { + let bytes = std::fs::read( + fixture_path("bun-lockb-user-tarball") + .join(shape) + .join("bun.lockb"), + ) + .expect("user tarball fixture"); + let mut lock = crate::vendor::bun_lockb::BunLockb::parse(&bytes).unwrap(); + let id = lock + .packages() + .unwrap() + .into_iter() + .find(|p| p.name == "is-number" && p.version.as_deref() == Some("6.0.0")) + .expect("nested registry record") + .id; + lock.set_package(id, wired, SRI).unwrap(); + let p = Project::new(); + p.write("bun.lockb", lock.bytes()); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!( + user_tarball_contests(&out), + 1, + "{shape} {mode}: {:#?}", + out.diagnostics + ); + } + } + } + /// The `DIAG_REF_UNATTRIBUTABLE` diagnostics that name a bundled copy. fn bundled_contests(out: &Discovery) -> usize { out.diagnostics @@ -1428,6 +1850,28 @@ mod tests { assert_eq!(diag_codes(&out), vec![DIAG_LOCKFILE_UNREADABLE]); } + /// REGRESSION (#735): a dangling `bun.lock` link is absent to bun, + /// which installs from `bun.lockb` — so discovery reads the binary + /// lock's refs instead of diagnosing the link unreadable. + #[cfg(unix)] + #[tokio::test] + async fn dangling_text_lock_link_leaves_the_binary_lock_live() { + let bytes = std::fs::read(fixture_path("bun-lockb/1.3.14/bun.lockb")).expect("fixture"); + let p = Project::new(); + p.write( + "bun.lockb", + rewire(&bytes, &[hosted_minimist("1.2.2", UUID_A)]), + ); + std::os::unix::fs::symlink("missing-target", p.root().join("bun.lock")).unwrap(); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:npm/minimist@1.2.2", UUID_A, WiringMode::Hosted)], + ); + assert!(out.diagnostics.is_empty(), "{:?}", out.diagnostics); + assert_eq!(out.refs[0].source_file, Path::new("bun.lockb")); + } + /// The full orchestrator picks the bun refs up. #[tokio::test] async fn orchestrator_includes_bun() { diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index cef842932..d2ab7e18f 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -6,11 +6,11 @@ use std::path::Path; use socket_patch_core::crawlers::npm_crawler::{ - build_npm_purl, get_bun_global_prefix, get_bun_global_prefix_with, get_npm_global_prefix, - get_npm_global_prefix_with, get_pnpm_global_prefix, get_pnpm_global_prefix_with, - get_yarn_global_prefix, get_yarn_global_prefix_with, parse_bun_bin_output, - parse_npm_root_output, parse_package_name, parse_pnpm_root_output, parse_yarn_dir_output, - read_package_json, + build_npm_purl, bun_global_dir_from_env, get_bun_global_prefix, get_bun_global_prefix_with, + get_npm_global_prefix, get_npm_global_prefix_with, get_pnpm_global_prefix, + get_pnpm_global_prefix_with, get_yarn_global_prefix, get_yarn_global_prefix_with, + parse_bun_ls_global_output, parse_npm_root_output, parse_package_name, parse_pnpm_root_output, + parse_yarn_dir_output, read_package_json, resolve_bun_global_prefix_with, }; use socket_patch_core::crawlers::types::CrawlerOptions; use socket_patch_core::crawlers::NpmCrawler; @@ -209,41 +209,40 @@ async fn get_node_modules_paths_global_mode_no_prefix() { let _paths = crawler.get_node_modules_paths(&opts).await.unwrap(); } -// ── parse_bun_bin_output ─────────────────────────────────────── +// ── parse_bun_ls_global_output ───────────────────────────────── -/// Bun's global node_modules lives at `/install/global/node_modules` -/// — the parser strips the trailing `bin` segment and joins the well-known -/// suffix. +/// Bun's global node_modules is `/node_modules`, where `` heads +/// the `bun pm ls -g` tree. Both the pre-1.4 `(N)` and the 1.4 +/// `(N installed)` count suffixes parse, and a dir with spaces survives. /// -/// Skipped on Windows: `PathBuf::join` uses `\` there, which produces -/// `/home/foo/.bun\install\global\node_modules` from Unix-style input. -/// The pure-parser semantics are still correct (parent stripping + -/// suffix join), just expressed in the host's path-separator. Real -/// bun installs on Windows would feed Windows-style paths into the -/// same parser. +/// Skipped on Windows: `PathBuf::join` uses `\` there, so the joined +/// suffix is expressed in the host's separator. #[cfg(unix)] #[test] #[serial_test::parallel] -fn parse_bun_bin_output_well_formed_unix() { - let parsed = parse_bun_bin_output("/home/foo/.bun/bin\n"); - assert_eq!( - parsed.as_deref(), - Some("/home/foo/.bun/install/global/node_modules") - ); -} - -#[test] -#[serial_test::parallel] -fn parse_bun_bin_output_empty_returns_none() { - assert_eq!(parse_bun_bin_output(""), None); - assert_eq!(parse_bun_bin_output(" \n "), None); +fn parse_bun_ls_global_output_well_formed_unix() { + for (stdout, want) in [ + ( + "/home/foo/.bun/install/global node_modules (1)\n\u{2514}\u{2500}\u{2500} is-number@7.0.0\n", + "/home/foo/.bun/install/global/node_modules", + ), + ( + "/home/foo/g dir \u{fc} node_modules (2 installed)\n", + "/home/foo/g dir \u{fc}/node_modules", + ), + ] { + assert_eq!(parse_bun_ls_global_output(stdout).as_deref(), Some(want)); + } } -/// Root-only path has no parent — must yield None instead of panicking. #[test] #[serial_test::parallel] -fn parse_bun_bin_output_root_path_returns_none() { - assert_eq!(parse_bun_bin_output("/"), None); +fn parse_bun_ls_global_output_empty_or_unrecognized_returns_none() { + assert_eq!(parse_bun_ls_global_output(""), None); + assert_eq!(parse_bun_ls_global_output(" \n "), None); + // `bun pm bin -g`'s answer is not a global dir. + assert_eq!(parse_bun_ls_global_output("/home/foo/.bun/bin\n"), None); + assert_eq!(parse_bun_ls_global_output(" node_modules (1)"), None); } // ── shell-out wrappers via PATH stubbing ────────────────────── @@ -293,11 +292,16 @@ fn get_pnpm_global_prefix_returns_none_when_pnpm_not_on_path() { }); } +/// #443: with no `bun` to ask, the bun prefix is still Bun's own +/// resolution of its global dir from the environment. #[test] #[serial_test::serial] -fn get_bun_global_prefix_returns_none_when_bun_not_on_path() { +fn get_bun_global_prefix_falls_back_to_env_when_bun_not_on_path() { with_empty_path(|| { - assert_eq!(get_bun_global_prefix(), None); + let want = bun_global_dir_from_env(&|var| std::env::var_os(var)) + .ok() + .map(|dir| dir.join("node_modules").to_string_lossy().to_string()); + assert_eq!(get_bun_global_prefix(), want); }); } @@ -327,7 +331,7 @@ fn get_npm_global_prefix_with_mock_runner_empty_stdout_returns_err() { } // Skipped on Windows: same path-separator reason as -// `parse_bun_bin_output_well_formed_unix` above. +// `parse_bun_ls_global_output_well_formed_unix` above. #[cfg(unix)] #[test] #[serial_test::parallel] @@ -358,15 +362,15 @@ fn get_pnpm_global_prefix_with_mock_runner_success() { } // Skipped on Windows: same path-separator reason as -// `parse_bun_bin_output_well_formed_unix` above. +// `parse_bun_ls_global_output_well_formed_unix` above. #[cfg(unix)] #[test] #[serial_test::parallel] fn get_bun_global_prefix_with_mock_runner_success() { let runner = common::MockCommandRunner::new().with_response( "bun", - &["pm", "bin", "-g"], - Some("/Users/foo/.bun/bin\n"), + &["pm", "ls", "-g"], + Some("/Users/foo/.bun/install/global node_modules (1 installed)\n"), ); assert_eq!( get_bun_global_prefix_with(&runner).as_deref(), @@ -374,6 +378,102 @@ fn get_bun_global_prefix_with_mock_runner_success() { ); } +// ── bun global dir: env resolution and the undetermined case (#443) ── + +/// An environment holding exactly `vars`. +fn env_of<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |name| { + vars.iter() + .find(|(n, _)| *n == name) + .map(|(_, v)| std::ffi::OsString::from(v)) + } +} + +/// Bun uses the first of `BUN_INSTALL_GLOBAL_DIR`, `BUN_INSTALL`, +/// `XDG_CACHE_HOME`, home that is SET, as it is: one that is empty or +/// relative names a dir relative to wherever `bun add -g` ran, so the dir +/// can't be told, and a later variable is not a stand-in for it. +#[cfg(unix)] +#[test] +#[serial_test::parallel] +fn bun_global_dir_from_env_follows_the_first_set_variable() { + let ok = |vars: &[(&str, &str)], want: &str| { + assert_eq!( + bun_global_dir_from_env(&env_of(vars)), + Ok(std::path::PathBuf::from(want)), + "{vars:?}" + ); + }; + ok( + &[ + ("BUN_INSTALL_GLOBAL_DIR", "/g"), + ("BUN_INSTALL", "/b"), + ("HOME", "/h"), + ], + "/g", + ); + ok( + &[("BUN_INSTALL", "/b"), ("XDG_CACHE_HOME", "/x")], + "/b/install/global", + ); + ok( + &[("XDG_CACHE_HOME", "/x"), ("HOME", "/h")], + "/x/.bun/install/global", + ); + ok(&[("HOME", "/h")], "/h/.bun/install/global"); + + for (vars, named) in [ + ( + &[("BUN_INSTALL_GLOBAL_DIR", "rel/g"), ("HOME", "/h")][..], + "BUN_INSTALL_GLOBAL_DIR", + ), + ( + &[("BUN_INSTALL_GLOBAL_DIR", ""), ("BUN_INSTALL", "/b")][..], + "BUN_INSTALL_GLOBAL_DIR", + ), + (&[("BUN_INSTALL", "rel"), ("HOME", "/h")][..], "BUN_INSTALL"), + ( + &[("XDG_CACHE_HOME", "rel"), ("HOME", "/h")][..], + "XDG_CACHE_HOME", + ), + (&[][..], "HOME"), + ] { + let why = bun_global_dir_from_env(&env_of(vars)).unwrap_err(); + assert!(why.contains(named), "{vars:?}: {why}"); + } +} + +/// #443: when `bun pm ls -g` can't answer and the environment can't name +/// the dir either, a global scan learns why (to say so) as long as Bun is +/// in use: `bun` on PATH, or a `BUN_INSTALL*` variable set. Without Bun +/// there is nothing to report. +#[test] +#[serial_test::parallel] +fn resolve_bun_global_prefix_reports_an_undeterminable_dir() { + let silent_bun = common::MockCommandRunner::new(); + let relative = [("BUN_INSTALL_GLOBAL_DIR", "rel/g"), ("HOME", "/h")]; + let why = resolve_bun_global_prefix_with(&silent_bun, &env_of(&relative), false).unwrap_err(); + assert!(why.contains("BUN_INSTALL_GLOBAL_DIR"), "{why}"); + assert!(resolve_bun_global_prefix_with(&silent_bun, &env_of(&[]), true).is_err()); + assert_eq!( + resolve_bun_global_prefix_with(&silent_bun, &env_of(&[]), false), + Ok(None), + "no Bun in use: nothing to report" + ); + + // Bun's own answer wins over an environment we can't read. + let answering_bun = common::MockCommandRunner::new().with_response( + "bun", + &["pm", "ls", "-g"], + Some("/g node_modules (1 installed)\n"), + ); + let want = std::path::PathBuf::from("/g").join("node_modules"); + assert_eq!( + resolve_bun_global_prefix_with(&answering_bun, &env_of(&relative), true), + Ok(Some(want.to_string_lossy().to_string())) + ); +} + // ── parse_npm_root_output ────────────────────────────────────── #[test] @@ -2373,6 +2473,16 @@ async fn find_by_purls_returns_bundled_copy_of_an_already_found_target() { .unwrap(); nm.join("left-pad") } else { + // Bun counts only linked entries as installed (#599); its + // hoist dir links a transitive-only package. + if store_name == ".bun" { + std::fs::create_dir_all(store.join("node_modules")).unwrap(); + std::os::unix::fs::symlink( + normal_nm.join("left-pad"), + store.join("node_modules/left-pad"), + ) + .unwrap(); + } normal_nm.join("left-pad") }; diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bun.lockb new file mode 100755 index 000000000..8d31f258e Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bun.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bunfig.toml b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bunfig.toml new file mode 100644 index 000000000..30e63e0e1 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bunfig.toml @@ -0,0 +1,2 @@ +[install] +saveTextLockfile = false diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/package.json b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/package.json new file mode 100644 index 000000000..5d2f020d9 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/package.json @@ -0,0 +1,9 @@ +{ + "name": "trusted-lockb-fixture", + "version": "1.0.0", + "private": true, + "dependencies": { + "simple-git-hooks": "2.11.1", + "is-number": "7.0.0" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/provenance.json new file mode 100644 index 000000000..07e9e3a15 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.4.2", + "os": "darwin", + "arch": "aarch64", + "command": "bun install --ignore-scripts", + "sha256": "d556a6f991edb902cf74833fc00c8f639e13301871a63e231ce53477f3b32350", + "note": "simple-git-hooks is on Bun's default trusted list (#371); is-number is not" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/bun.lockb new file mode 100755 index 000000000..d270a85d0 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/bun.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/package.json b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/package.json new file mode 100644 index 000000000..2aa20fc1f --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"is-odd":"3.0.1","is-number":"file:./is-number-6.0.0.tgz"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/provenance.json new file mode 100644 index 000000000..a92eccc59 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.1.45", + "os": "darwin", + "arch": "aarch64", + "command": "bun install (default binary lockfile)", + "note": "the root depends on is-number by a local file: tarball (npm pack of is-number@6.0.0, not committed); is-odd@3.0.1 depends on is-number@^6.0.0, a nested registry copy of the same version", + "sha256": "96f1d971c3debeb726a5b6adb93c155d61e3735a1b613d49b2f003dfe88fb411" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/bun.lockb new file mode 100755 index 000000000..dd3c18365 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/bun.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/package.json b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/package.json new file mode 100644 index 000000000..b97dfcfee --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"is-odd":"3.0.1","is-number":"https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/provenance.json new file mode 100644 index 000000000..a36b871e3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.1.45", + "os": "darwin", + "arch": "aarch64", + "command": "bun install (default binary lockfile)", + "note": "the root depends on is-number by its registry tarball URL; is-odd@3.0.1 depends on is-number@^6.0.0, a nested registry copy of the same version", + "sha256": "7da8ccbaf8f73baf34c31091d5ff2f97c3e6c3ef8661980fdedb43ed09789086" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/README.md b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/README.md new file mode 100644 index 000000000..f0f8e4c18 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/README.md @@ -0,0 +1,14 @@ +# `bun.lockb` migrated to `bun.lock` + +The `1.2.23` fixture lock (`../1.2.23/bun.lockb`) migrated to the text lock +with `bun install --save-text-lockfile --ignore-scripts` (macOS arm64, empty +`BUN_INSTALL_CACHE_DIR`, `node_modules` removed first), by the Bun release +named in each file: + +- `pristine-.lock`: migrated straight from the fixture. +- `vendored-.lock`: migrated after socket-patch vendored + `minimist@1.2.2` into the binary lock with the `rebuild_tests` patch in + `src/vendor/bun_binary.rs` (uuid `11111111-1111-4111-8111-111111111111`). + 1.2.23 drops the local tarball's integrity; 1.4.2 keeps it. + +A vendored revert after the migration must write the pristine lock (#784). diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.2.23.lock b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.2.23.lock new file mode 100644 index 000000000..79eb6053a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.2.23.lock @@ -0,0 +1,17 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "binary-lock-fixture", + "dependencies": { + "is-number": "7.0.0", + "minimist": "1.2.2", + }, + }, + }, + "packages": { + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.4.2.lock b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.4.2.lock new file mode 100644 index 000000000..d148dc057 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.4.2.lock @@ -0,0 +1,18 @@ +{ + "lockfileVersion": 2, + "configVersion": 0, + "workspaces": { + "": { + "name": "binary-lock-fixture", + "dependencies": { + "is-number": "7.0.0", + "minimist": "1.2.2", + }, + }, + }, + "packages": { + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.2.23.lock b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.2.23.lock new file mode 100644 index 000000000..4acf49428 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.2.23.lock @@ -0,0 +1,17 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "binary-lock-fixture", + "dependencies": { + "is-number": "7.0.0", + "minimist": "1.2.2", + }, + }, + }, + "packages": { + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "minimist": ["minimist@.socket/vendor/npm/11111111-1111-4111-8111-111111111111/minimist-1.2.2.tgz", {}], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.4.2.lock b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.4.2.lock new file mode 100644 index 000000000..39d398ddf --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.4.2.lock @@ -0,0 +1,18 @@ +{ + "lockfileVersion": 2, + "configVersion": 0, + "workspaces": { + "": { + "name": "binary-lock-fixture", + "dependencies": { + "is-number": "7.0.0", + "minimist": "1.2.2", + }, + }, + }, + "packages": { + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "minimist": ["minimist@.socket/vendor/npm/11111111-1111-4111-8111-111111111111/minimist-1.2.2.tgz", {}, "sha512-tJoY6Sh0/e+0aiqRNMYyJXEdNdp95czoj6rBpEerH5xRDOb3J889JCSXuyZuRm/KRNl0PHCP/7j+iiHAyeJAbQ=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md b/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md index a8b22805f..ff3556d89 100644 --- a/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md @@ -22,6 +22,16 @@ and an empty `BUN_INSTALL_CACHE_DIR` to regenerate. Bun 1.2+ fixtures include 1.0.0, 1.1.45 and 1.3.14, pinning the metadata hash's semver order. - `0.8.1-production-complex`: two production patch targets, a transitive dependency with a bin, root lifecycle scripts, and development dependencies. +- `late-dependent/-.lockb`: the lock Bun 1.3.9 / 1.4.2 + wrote for the `e2e_bun_lockb` workspace (vendored once, uuid + `80630680-…`) after a late dependent of minimist@1.2.2 — a new `late` + member, or `bun add` in the existing `adder` member — gave it a second, + nested registry record (#861). `late-dependent/-deps-.lockb` + are the same flow with mkdirp@0.5.6 patched, a package with a dependency + (minimist) of its own. The `-ws-*` locks repeat both flows with + ws@8.18.0 also at the root, whose optional peers (bufferutil, + utf-8-validate) nothing installs: unresolved edges, as most real locks + hold. Other releases capture the stable major/minor eras. `two-versions` covers multiple package versions and scoped restoration. The earliest writers include diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-adder.lockb new file mode 100755 index 000000000..d5838145f Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-adder.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-adder.lockb new file mode 100644 index 000000000..0cbfa8961 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-adder.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-late.lockb new file mode 100644 index 000000000..362c5f1b3 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-late.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-late.lockb new file mode 100755 index 000000000..6b30f5966 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-late.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-adder.lockb new file mode 100644 index 000000000..d593d6be9 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-adder.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-adder.lockb new file mode 100644 index 000000000..acc7eadf7 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-adder.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-late.lockb new file mode 100644 index 000000000..4d27e1c53 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-late.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-late.lockb new file mode 100644 index 000000000..fe3e869b5 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-late.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-adder.lockb new file mode 100755 index 000000000..376bbf00a Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-adder.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-adder.lockb new file mode 100644 index 000000000..b23d7a4a4 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-adder.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-late.lockb new file mode 100644 index 000000000..6bbfd5523 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-late.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-late.lockb new file mode 100755 index 000000000..694d4ca4e Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-late.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-adder.lockb new file mode 100644 index 000000000..6305077d9 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-adder.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-adder.lockb new file mode 100644 index 000000000..163e9f6af Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-adder.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-late.lockb new file mode 100644 index 000000000..a8e64e79d Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-late.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-late.lockb new file mode 100644 index 000000000..5dfd31ad0 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-late.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/bun.lockb new file mode 100755 index 000000000..06d770542 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/bun.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/package.json new file mode 100644 index 000000000..a32db8d7d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"file:./pkg.tgz","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/provenance.json new file mode 100644 index 000000000..ca8d78a37 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.1.45", + "os": "darwin", + "arch": "aarch64", + "command": "bun install (default binary lockfile)", + "note": "the root depends on minimist by a file: tarball named pkg.tgz (the minimist-1.2.2 registry tarball, renamed); the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "27c7816d285c04441e4c21fa5f05cd5a2be755a439463ef7ec61ddccf03292f4" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/bun.lockb new file mode 100755 index 000000000..ac0500980 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/bun.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/package.json new file mode 100644 index 000000000..f0e2c65b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"github:minimistjs/minimist#v1.2.2","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/provenance.json new file mode 100644 index 000000000..aea9125c3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.1.45", + "os": "darwin", + "arch": "aarch64", + "command": "bun install (default binary lockfile)", + "note": "the root depends on minimist by github:minimistjs/minimist#v1.2.2; the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "ce8fd5434678e14e6f559d2636c54c9599df24e8d7ab9a8e6a2315dd3d061da4" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/bun.lockb new file mode 100755 index 000000000..b57680953 Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/bun.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/package.json new file mode 100644 index 000000000..51a3bcfe8 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"https://codeload.github.com/minimistjs/minimist/tar.gz/refs/tags/v1.2.2","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/provenance.json new file mode 100644 index 000000000..54a02f6c1 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.1.45", + "os": "darwin", + "arch": "aarch64", + "command": "bun install (default binary lockfile)", + "note": "the root depends on minimist by a codeload.github.com tarball URL of tag v1.2.2; the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "5b49af15e9e354b16e6746645200a54b2985ba0ff7a6ca167f040e25311a0f4c" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/bun.lock b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/bun.lock new file mode 100644 index 000000000..60d659d10 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/bun.lock @@ -0,0 +1,19 @@ +{ + "lockfileVersion": 0, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "dep": "file:./dep", + "minimist": "file:./pkg.tgz", + }, + }, + }, + "packages": { + "dep": ["dep@file:dep", { "dependencies": { "minimist": "1.2.2" } }], + + "minimist": ["minimist@./pkg.tgz", {}], + + "dep/minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/package.json new file mode 100644 index 000000000..a32db8d7d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"file:./pkg.tgz","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/provenance.json new file mode 100644 index 000000000..16967a838 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.1.45", + "os": "darwin", + "arch": "aarch64", + "command": "bun install --save-text-lockfile", + "note": "the root depends on minimist by a file: tarball named pkg.tgz (the minimist-1.2.2 registry tarball, renamed); the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "126159615d2e80170534d3764670a391595b1c8a7837280c6469f2210f7c18b7" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/bun.lock b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/bun.lock new file mode 100644 index 000000000..0f2e65ad6 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/bun.lock @@ -0,0 +1,19 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "dep": "file:./dep", + "minimist": "file:./pkg.tgz", + }, + }, + }, + "packages": { + "dep": ["dep@file:dep", { "dependencies": { "minimist": "1.2.2" } }], + + "minimist": ["minimist@./pkg.tgz", {}], + + "dep/minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/package.json new file mode 100644 index 000000000..a32db8d7d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"file:./pkg.tgz","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/provenance.json new file mode 100644 index 000000000..f125d4452 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.2.23", + "os": "darwin", + "arch": "aarch64", + "command": "bun install", + "note": "the root depends on minimist by a file: tarball named pkg.tgz (the minimist-1.2.2 registry tarball, renamed); the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "0c175213adde5a2c042b0200575c8103d0f6162e2fd262146040dcf536331b01" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/bun.lock b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/bun.lock new file mode 100644 index 000000000..b9986c91f --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "dep": "file:./dep", + "minimist": "file:./pkg.tgz", + }, + }, + }, + "packages": { + "dep": ["dep@file:dep", { "dependencies": { "minimist": "1.2.2" } }], + + "minimist": ["minimist@./pkg.tgz", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + + "dep/minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/package.json new file mode 100644 index 000000000..a32db8d7d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"file:./pkg.tgz","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/provenance.json new file mode 100644 index 000000000..38f14075e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.4.2", + "os": "darwin", + "arch": "aarch64", + "command": "bun install", + "note": "the root depends on minimist by a file: tarball named pkg.tgz (the minimist-1.2.2 registry tarball, renamed); the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "b61aa666ff9f0dc4311987f0ef1225ac18bd364f86bde63d207f985016410891" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/bun.lock b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/bun.lock new file mode 100644 index 000000000..a9fa0132e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "dep": "file:./dep", + "minimist": "github:minimistjs/minimist#v1.2.2", + }, + }, + }, + "packages": { + "dep": ["dep@file:dep", { "dependencies": { "minimist": "1.2.2" } }], + + "minimist": ["minimist@github:minimistjs/minimist#c12a808", {}, "minimistjs-minimist-c12a808", "sha512-0W/PkdzQok01aYvzupeEUiLDVdl+KxE8JD35lx23fMz2xi1LP844aaBqSg7IxqXVwwQpZJyCrNdbw9hoLkyZ6w=="], + + "dep/minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/package.json new file mode 100644 index 000000000..f0e2c65b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"github:minimistjs/minimist#v1.2.2","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/provenance.json new file mode 100644 index 000000000..b8a42e566 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.4.2", + "os": "darwin", + "arch": "aarch64", + "command": "bun install", + "note": "the root depends on minimist by github:minimistjs/minimist#v1.2.2; the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "05009e304b2d84819797c873e0b0444e297bdd45ee1e3d0d04843fc8efe49cf2" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/bun.lock b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/bun.lock new file mode 100644 index 000000000..3b0f7e3e6 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "dep": "file:./dep", + "minimist": "https://codeload.github.com/minimistjs/minimist/tar.gz/refs/tags/v1.2.2", + }, + }, + }, + "packages": { + "dep": ["dep@file:dep", { "dependencies": { "minimist": "1.2.2" } }], + + "minimist": ["minimist@https://codeload.github.com/minimistjs/minimist/tar.gz/refs/tags/v1.2.2", {}, "sha512-sW82WEAaqJ7QaESr+23ttzPbDAvjqsV1lgV56rlSYb+2MrIMo6wGZJPqUb06uRI51KpGdsMrzTUAFpgaDNkQow=="], + + "dep/minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/package.json new file mode 100644 index 000000000..51a3bcfe8 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"https://codeload.github.com/minimistjs/minimist/tar.gz/refs/tags/v1.2.2","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/provenance.json new file mode 100644 index 000000000..55f02c074 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.4.2", + "os": "darwin", + "arch": "aarch64", + "command": "bun install", + "note": "the root depends on minimist by a codeload.github.com tarball URL of tag v1.2.2; the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "00826b8f468df16110cf007cd95b9cdb122f859a0702a4dd201dc0638aeeef64" +} diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-trusted.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-trusted.json new file mode 100644 index 000000000..a0f9be4a8 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-trusted.json @@ -0,0 +1,19 @@ +{ + "bun-lockb-trusted": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/is-number@7.0.0", + "file": "bun.lockb" + }, + { + "purl": "pkg:npm/simple-git-hooks@2.11.1", + "file": "bun.lockb" + } + ], + "live_claims": [] + } +} diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-user-tarball.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-user-tarball.json new file mode 100644 index 000000000..de5b452a3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-user-tarball.json @@ -0,0 +1,36 @@ +{ + "bun-lockb-user-tarball/file": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/is-number@6.0.0", + "file": "bun.lockb" + }, + { + "purl": "pkg:npm/is-odd@3.0.1", + "file": "bun.lockb" + } + ], + "live_claims": [] + }, + "bun-lockb-user-tarball/url": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/is-number@6.0.0", + "file": "bun.lockb" + }, + { + "purl": "pkg:npm/is-odd@3.0.1", + "file": "bun.lockb" + } + ], + "live_claims": [] + } +} diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json index c9896da89..50fd530f2 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json @@ -354,6 +354,14 @@ "elsewhere": [], "live_claims": [] }, + "bun-lockb/1.2.23-migrated": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "bun-lockb/1.3.0": { "refs": [], "diagnostics": [], @@ -430,6 +438,14 @@ "elsewhere": [], "live_claims": [] }, + "bun-lockb/late-dependent": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "bun-lockb/prerelease-pair-0.8.1": { "refs": [], "diagnostics": [], diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-unversioned-copy.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-unversioned-copy.json new file mode 100644 index 000000000..2cda4120d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-unversioned-copy.json @@ -0,0 +1,106 @@ +{ + "bun-unversioned-copy/lockb/file": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lockb" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/lockb/git": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lockb" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/lockb/url": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lockb" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/text-0/file": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lock" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/text-1/file": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lock" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/text-2/file": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lock" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/text-2/git": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lock" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/text-2/url": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lock" + } + ], + "live_claims": [] + } +} diff --git a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs index e447bf955..4cd9f219d 100644 --- a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs +++ b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs @@ -162,7 +162,8 @@ fn npm_family_global_probes_find_the_installed_shims() { let bun_bin = l.home.join(".bun").join("bin"); fake_tool(&l.bin, "npm", Some(&npm_root.to_string_lossy())); fake_tool(&l.bin, "pnpm", Some(&pnpm_root.to_string_lossy())); - fake_tool(&l.bin, "bun", Some(&bun_bin.to_string_lossy())); + let bun_global = l.home.join(".bun").join("install").join("global"); + fake_bun(&l.bin, &bun_bin, &bun_global); let mut env = Env::new(); point_env_at(&mut env, &l); @@ -174,13 +175,7 @@ fn npm_family_global_probes_find_the_installed_shims() { assert_eq!(get_pnpm_global_prefix().map(PathBuf::from), Some(pnpm_root)); assert_eq!( get_bun_global_prefix().map(PathBuf::from), - Some( - l.home - .join(".bun") - .join("install") - .join("global") - .join("node_modules") - ) + Some(bun_global.join("node_modules")) ); } @@ -366,3 +361,200 @@ async fn composer_home_falls_back_to_xdg_config_home() { .unwrap(); assert_eq!(paths, vec![vendor]); } + +// ──────────────────────────── bun global dir (#443) ──────────────────────────── + +/// A fake `bun` that answers like real Bun with `BUN_INSTALL_BIN` and/or +/// `BUN_INSTALL_GLOBAL_DIR` set: `bun pm bin -g` prints the (relocated) +/// bin dir, `bun pm ls -g` heads its tree with the global dir the packages +/// actually live in (` node_modules (N installed)` on 1.4.x). +fn fake_bun(bin: &Path, bin_dir: &Path, global_dir: &Path) { + std::fs::create_dir_all(bin).unwrap(); + let (bin_dir, global_dir) = (bin_dir.display(), global_dir.display()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let path = bin.join("bun"); + std::fs::write( + &path, + format!( + "#!/bin/sh\n\ + if [ \"$2\" = ls ]; then\n\ + printf '%s\\n' '{global_dir} node_modules (1 installed)' '└── semver@7.6.0'\n\ + else\n\ + printf '%s\\n' '{bin_dir}'\n\ + fi\n" + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + #[cfg(windows)] + std::fs::write( + bin.join("bun.cmd"), + format!( + "@echo off\r\n\ + if \"%2\"==\"ls\" goto ls\r\n\ + echo {bin_dir}\r\n\ + exit /b 0\r\n\ + :ls\r\n\ + echo {global_dir} node_modules ^(1 installed^)\r\n\ + echo semver@7.6.0\r\n" + ), + ) + .unwrap(); +} + +/// #443: with `BUN_INSTALL_BIN` moved (here to `~/.local/bin`), the global +/// packages stay in `$BUN_INSTALL/install/global/node_modules`. The probe +/// must report where Bun keeps the packages, not guess `/..`. +#[test] +#[serial] +fn bun_global_probe_ignores_a_relocated_bin_dir() { + let l = layout(); + let global = l.home.join(".bun").join("install").join("global"); + fake_bun(&l.bin, &l.home.join(".local").join("bin"), &global); + let mut env = Env::new(); + point_env_at(&mut env, &l); + + assert_eq!( + get_bun_global_prefix().map(PathBuf::from), + Some(global.join("node_modules")) + ); +} + +/// #443: with `BUN_INSTALL_GLOBAL_DIR` set, `bun pm bin -g` still prints +/// the default bin dir, while the packages live in `/node_modules`. +#[test] +#[serial] +fn bun_global_probe_follows_bun_install_global_dir() { + let l = layout(); + let global = l.home.join("gdir with space ü"); + fake_bun(&l.bin, &l.home.join(".bun").join("bin"), &global); + let mut env = Env::new(); + point_env_at(&mut env, &l); + + assert_eq!( + get_bun_global_prefix().map(PathBuf::from), + Some(global.join("node_modules")) + ); +} + +/// #443: with no `bun` to ask, Bun's own resolution of its global dir is +/// followed (`BUN_INSTALL_GLOBAL_DIR`, then `$BUN_INSTALL/install/global`, +/// then `$XDG_CACHE_HOME/.bun/install/global`, then `~/.bun/install/global`), +/// so a global scan still finds the packages instead of reporting a clean, +/// empty result. +#[tokio::test] +#[serial] +async fn bun_global_dir_falls_back_to_bun_env_resolution() { + use socket_patch_core::crawlers::NpmCrawler; + + let l = layout(); + let mut env = Env::new(); + point_env_at(&mut env, &l); + let nm = |dir: &Path| { + let nm = dir.join("node_modules"); + std::fs::create_dir_all(nm.join("semver")).unwrap(); + nm + }; + let explicit = nm(&l.home.join("gdir")); + let bun_install = nm(&l.home.join("bun-install").join("install").join("global")); + let xdg = nm(&l + .home + .join("xdg") + .join(".bun") + .join("install") + .join("global")); + let home = nm(&l.home.join(".bun").join("install").join("global")); + let cases: [(&[(&'static str, PathBuf)], &PathBuf); 4] = [ + ( + &[ + ("BUN_INSTALL_GLOBAL_DIR", l.home.join("gdir")), + ("BUN_INSTALL", l.home.join("bun-install")), + ("XDG_CACHE_HOME", l.home.join("xdg")), + ], + &explicit, + ), + ( + &[ + ("BUN_INSTALL", l.home.join("bun-install")), + ("XDG_CACHE_HOME", l.home.join("xdg")), + ], + &bun_install, + ), + (&[("XDG_CACHE_HOME", l.home.join("xdg"))], &xdg), + (&[], &home), + ]; + for (vars, want) in cases { + let mut case_env = Env::new(); + for name in ["BUN_INSTALL_GLOBAL_DIR", "BUN_INSTALL", "XDG_CACHE_HOME"] { + let value = vars.iter().find(|(n, _)| *n == name).map(|(_, v)| v); + case_env.set(name, value.map(|v| v.as_os_str())); + } + let paths = NpmCrawler + .get_node_modules_paths(&CrawlerOptions { + cwd: l.proj.clone(), + global: true, + global_prefix: None, + }) + .await + .unwrap(); + assert!( + paths.contains(want), + "{vars:?}: global paths must include {}; got {paths:?}", + want.display() + ); + for other in [&explicit, &bun_install, &xdg, &home] { + if other != want { + assert!( + !paths.contains(other), + "{vars:?}: Bun does not use {}; got {paths:?}", + other.display() + ); + } + } + drop(case_env); + } +} + +/// #443: Bun's global dir does not follow bunfig. Measured on Bun 1.0.36 - +/// 1.4.2, `bun add -g` installs into `~/.bun/install/global` even when the +/// global bunfig (`~/.bunfig.toml`, `$XDG_CONFIG_HOME/.bunfig.toml`) sets +/// `install.globalDir` (its `globalBinDir` moves only the bins), so the +/// environment fallback must not follow it either. +#[test] +#[serial] +fn bun_global_dir_fallback_ignores_bunfig_global_dir() { + let l = layout(); + let mut env = Env::new(); + point_env_at(&mut env, &l); + for name in ["BUN_INSTALL_GLOBAL_DIR", "BUN_INSTALL", "XDG_CACHE_HOME"] { + env.set(name, None); + } + let config = l.home.join("xdg-config"); + env.set("XDG_CONFIG_HOME", Some(config.as_os_str())); + let bunfig = |dir: &Path, global: &str| { + std::fs::create_dir_all(dir).unwrap(); + let toml = format!( + "[install]\nglobalDir = {:?}\nglobalBinDir = {:?}\n", + l.home.join(global).to_string_lossy(), + l.home.join("bunfig-bin").to_string_lossy(), + ); + std::fs::write(dir.join(".bunfig.toml"), toml).unwrap(); + std::fs::create_dir_all(l.home.join(global).join("node_modules")).unwrap(); + }; + bunfig(&l.home, "home-bunfig-global"); + bunfig(&config, "xdg-bunfig-global"); + + assert_eq!( + get_bun_global_prefix().map(PathBuf::from), + Some( + l.home + .join(".bun") + .join("install") + .join("global") + .join("node_modules") + ) + ); +} diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 5a6d0ff53..afbab09cf 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -233,8 +233,22 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. hosted bun packages to its upstream registry entry. A hosted `bun.lockb` entry is not rolled back (v5.0 keeps no hosted ledger, and a rebuilt binary record is not byte-exact for every lock): rollback and remove refuse it with the `git checkout -- bun.lockb` - remedy, while the hosted → vendored takeover rebuilds its npm registry record natively - and vendors over it. + remedy (then `bun install --force`: a plain install keeps the patched copy), while the hosted → vendored takeover rebuilds its npm registry record natively + and vendors over it. Each restore reads the package's version document from the + registry Bun resolves it against (`.npmrc` / `bunfig.toml` scope and default + registries, `BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY`), sending the credentials + those settings give it — a bunfig `token` or `username` / `password` (`$VAR` + expanded only for `NPM_TOKEN`, `NODE_AUTH_TOKEN` and `BUN_AUTH_TOKEN`; any other + variable expands to nothing, so a project's config cannot send other secrets; in a + registry URL only its `user:password@` part expands, which is sent as the + `Authorization` header and never printed or written into the lock), else the `.npmrc` `//host/path/:_authToken` / `_auth` / `username` + + `_password` covering the registry URL (`BUN_CONFIG_TOKEN` is not read); a registry + that still cannot be read falls back to the default registry's document with an + `upstream_registry_fallback` warning. Bun's hoisted linker keeps an installed copy whose lock entry + returns to the registry record (a plain `bun install` reports no changes), so after + `rollback`, `remove` or `vendor --revert` the patched bytes stay in `node_modules` until + `bun install --force` (or deleting `node_modules`); `redirect_bun_reinstall_required` / + `vendor_bun_reinstall_required` say so whenever such a copy may be installed. Bun verifies the sha512 of URL and local-tarball tuples only from 1.3.10 (registry tuples from 1.2.0), so on 1.1.39–1.3.9 a hosted or vendored rewrite removes digest enforcement for the patched package. Every boundary here is measured against real @@ -277,12 +291,19 @@ links on to other entries. A workspace member's `node_modules` has no `.modules.yaml` of its own (pnpm writes it only at the workspace root), so the root's record is used, and the member's own links seed the walk. Agent-mode `apply` and `rollback` then fail on those copies, direct and transitive alike, instead of writing through -them, and never report a transitive one as "not installed". PDM's symlink install +them, and never report a transitive one as "not installed". Bun's global +store (`[install] globalStore = true` in `bunfig.toml`, or +`BUN_INSTALL_GLOBAL_STORE=1`, Bun 1.3.14 and later) is handled the same +way: each `node_modules/.bun/` is then a link into +`/links/-` in Bun's install cache, and those linked +entries are walked (so `vex` checks their bytes) but refused by agent-mode +`apply` and `rollback`. PDM's symlink install cache gets the same treatment: with `install.cache` and `cache_method = symlink` (PDM 2.0–2.12), `site-packages/` links into `/packages//lib`, and that package is refused too. The error names the store and how to get a private copy (disable the global virtual -store, or `pdm config install.cache_method hardlink`, then reinstall), or +store or Bun's `globalStore`, or `pdm config install.cache_method +hardlink`, then reinstall), or use hosted or vendored mode. Agent-mode `apply` and `rollback` also fail, dry run included, on a diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index b98a5cec4..26ef8e5d5 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -44,6 +44,7 @@ other npm lockfile flavors. | Version-2 lock (Bun 1.4+) with `workspace:` packages, nested versions included | Rewritten (golden `lock-v2-workspace-nested` — provenance: its nested same-version `consumer/left-pad` entry is a synthetic, grammar-valid extension of the 1.4.2 capture; bun hoists identical resolutions and never writes that entry itself, but bun 1.4.2 installs the fixture unchanged, and it is the only case pinning the rewrite of every matching tuple in one lock). | Vendored (matrix 1.4.0 / 1.4.2 `workspace`, `workspace-nested`, `already-vendored-workspace`). | Works. | | Binary `bun.lockb` (binary format revisions 1, 2 and 3) | Package resolution and integrity records are rewritten in place. The CLI does not spawn Bun or produce a text lock. `rollback` / `remove` cannot restore a hosted `bun.lockb` entry to its upstream registry entry (v5.0 keeps no ledger to replay, and the binary lock is not re-derived), so they refuse it with the `git checkout -- bun.lockb` remedy. | Native local-tarball wiring, committed artifact, repair and vendored → hosted takeover. Hosted → vendored rebuilds a hosted `bun.lockb` pin's npm registry record from the registry (byte-exact for a lock socket-patch wired hosted), then vendors; `vendor --revert` returns the pre-hosted lock. Offline it refuses (`redirect_revert_failed`), leaving it hosted. | Registry package records are inventoried directly, including lockfile-only projects without `node_modules`. | | A package the project patches itself with `bun patch` (a `patchedDependencies` key for its `name@version`, or its bare name, in the root `package.json` or mirrored in `bun.lock`) (#367) | Left on its registry tuple (text and binary lock): Bun applies the user's patch only to the registry `name@version`, so a hosted URL would drop it from every install with exit 0. Warns `redirect_bun_patched_dependency_skipped` naming the key, and the in-run VEX never assumes the patch applied; other packages in the lock are still rewired. | Refused `vendor_lock_entry_unsupported` before any write or download (text and binary lock), naming the key. | The installed tree is patched in place, as for any package. | +| A package on Bun's default trusted list (better-sqlite3, esbuild, sharp, …) in a project that declares no `trustedDependencies` (root `package.json`, or mirrored in `bun.lock`) (#371) | Rewired (text and binary lock), with warning `redirect_bun_default_trust_lost`: Bun 1.3.5+ apply the default list only to npm-registry resolutions, so a hosted URL makes `bun install` skip the package's install scripts with exit 0 (measured: 1.3.4 runs them, 1.3.5–1.4.2 do not). The remedy is adding the package to `trustedDependencies`, which replaces the default list. | Same, `vendor_bun_default_trust_lost`, for the local tarball tuple (text and binary lock). | Not affected: the installed tree keeps its registry resolution. | | Truncated, corrupt or unrecognized binary `bun.lockb` | Refused with `redirect_bun_lockb_invalid`, preserving the lock. | Refused with `vendor_bun_lockb_invalid` before downloads or artifact creation. | The inventory reports the malformed lock. | | `bun.lock` with a `lockfileVersion` ≥ 3, no integer version, or a `packages` section outside bun's single-line grammar | Refused `redirect_bun_lock_unsupported`. | Refused `vendor_lockfile_version_unsupported` (preflight and engine). | The inventory skips the lock. | @@ -92,7 +93,8 @@ Binary locks are parsed and patched directly. The codec understands the original version-1 representation, the version-2 URL representation, the later scripts package field, and version 3's wider semantic-version representation. It keeps package IDs, dependency edges, hoisting data, package metadata and optional -extensions intact. Historical workspace dependency flags and literals are +extensions intact, except where a vendored re-run folds a duplicate record of +the patched package into its tarball record (#861, below). Historical workspace dependency flags and literals are normalized to the equivalent representation accepted by old and new readers; the original encoding is retained for rollback. Unknown versions and invalid offsets fail closed. @@ -234,6 +236,44 @@ runners) from the GitHub releases and verifies it against `SHASUMS256.txt`. Ever writes the bare path itself, are left alone. Covered by `e2e_bun_lockb::workspace_text_migration_heals_on_rerun` on the 1.4.2 leg. +- **Binary → text migration of a vendored lock.** The migration deletes + `bun.lockb` and carries the vendored tuples into `bun.lock`, while the + vendor state still records `bun.lockb` package snapshots. `vendor + --revert`, `rollback` and the hosted takeover then restore each recorded + registry package as the tuple Bun writes for it (an empty registry slot + under `https://registry.npmjs.org`, the tarball URL otherwise), and a + superseding re-vendor carries that tuple over as its pre-vendor original + (#784). Covered by `e2e_bun_lockb::vendored_text_migration_reverts_to_registry` + (skipped below Bun 1.2) on the 1.4.2 leg. +- **Late dependents of a vendored package (#861).** After a workspace + `bun.lockb` is vendored, a new dependent of the patched `name@version` (a + member added later, or `bun add` in a member) makes Bun write a second, + nested registry record of it, since the hoisted record is now a local + tarball. Rewiring that record to the same tarball leaves two records with + one resolution, which Bun's own writer never produces: on the isolated + linker both map to one `node_modules/.bun/` store directory and cold + frozen installs fail intermittently with `EEXIST` (measured on 1.3.9 and + 1.4.2; 1.2.23, the hoisted linker and text `bun.lock` were unaffected). + The vendored re-run instead folds the duplicate into the tarball record: + its dependents resolve to that record, the record and its own dependency + edges are dropped and later package IDs and dependency slices renumbered, + and the hoisting trees are re-derived with Bun's hoister (1.3.x refuses a + frozen binary lock whose re-hoisted trees differ). The fold needs the + duplicate's dependencies to resolve to the same packages as the tarball + record's (so nothing is orphaned), and the codec's hoister to reproduce + the lock's own trees and need no rule it does not model (a peer meeting + another version, a cyclic folder); otherwise every record is rewired as + before, with `vendor_bun_lockb_duplicate_records`. A patched package with + dependencies of its own (mkdirp@0.5.6) folds the same way, and an + unfrozen install by 1.3.9 and 1.4.2 leaves the folded lock byte-identical. + An optional peer nothing installs (ws@8's `bufferutil` and + `utf-8-validate`, common in real locks) is an unresolved edge: the hoister + skips it from the written trees as Bun does, so it does not block the + fold; the e2e `-adder` and `-deps` shapes depend on ws@8.18.0 for this. + Covered by `e2e_bun_lockb::workspace_late_dependent_rerun_shares_the_tarball_record` + and `workspace_late_dependent_with_dependencies_rerun_shares_the_tarball_record` + on the 1.3.14 and 1.4.2 legs, and hermetically by the + `bun-lockb/late-dependent/` fixtures. - **Workspace-member local tarballs.** Bun 1.2.x–1.3.x resolve a local-tarball dependency declared by a workspace member relative to the member (`.socket/vendor/…` → ENOENT on `bun install`); 1.4.x resolve it @@ -346,7 +386,8 @@ workspace` (vendor a plain project, add a workspace member, `bun install`, re-run — must be `already_vendored`; then `repair` rebuilds a deleted tarball), `crlf` (CRLF manifest), `crlf-lock` (CRLF `bun.lock`), `space-unicode` (a path with spaces and Unicode), `custom-registry` (a -non-empty registry slot the rewrite must drop), `text` (`--save-text-lockfile` +non-empty registry slot the rewrite must drop; the project configures no +registry, so a hosted rollback restores Bun's `""` npmjs slot, #992), `text` (`--save-text-lockfile` opt-in, Bun ≥ 1.1.39 only — asserts `bun.lock` exists after the baseline), `isolated` / `hoisted` linkers, `lockfile-only` (no `node_modules`), `production`, `get-uuid`, `get-search`, `legacy-lockb` (the baseline is @@ -360,7 +401,12 @@ boundaries above — not the CLI's own output — and every cell asserts `supported` against it and the refusal codes EXACTLY, after removing an explicit informational allowlist (`vendor_prebuilt_downloaded`, `vendor_fetched_missing`, `reinstall_required`, -…); substring matching is never used. A configuration expected to be +`vendor_bun_reinstall_required` / `redirect_bun_reinstall_required`, +…); substring matching is never used. `*_bun_default_trust_lost`, +`*_non_registry_entry_skipped` and `vendor_bun_lockb_duplicate_records` are +deliberately not on it: no fixture rewires a default-trusted package, holds a +non-registry copy or a duplicate `bun.lockb` record, so each would be a +misclassification. A configuration expected to be supported FAILS on unexpected warnings, `redirect_bun_entry_not_found` or `redirect_revert_failed`. Exit codes are recorded for every invocation and asserted: supported → 0; hosted refusals → 0 with `redirect.redirected == 0` @@ -388,11 +434,24 @@ asserted: supported → 0; hosted refusals → 0 with `redirect.redirected == 0` - `rejectCorruptDigest`: a tampered sha512 on the PATCHED tuple is rejected on Bun ≥ 1.3.10; below that the observation is RECORDED (`legacyDigestBehavior`) rather than asserted; -- rollback restores the original manifest / lock bytes, removes the - `.socket/vendor` state, and a clean install reproduces the record's - `beforeHash` bytes; text projects retain `bun.lock`, and binary projects - retain `bun.lockb` without creating a text lock. The original lock presence - and SHA-256 are both checked. +- rollback (run over a patched install) restores the original manifest / + lock bytes, removes the `.socket/vendor` state, and the reinstall reproduces + the record's `beforeHash` bytes; text projects retain `bun.lock`, and binary + projects retain `bun.lockb` without creating a text lock. The original lock + presence and SHA-256 are both checked. The reinstall is a plain + `bun install` over the kept `node_modules`; Bun's hoisted linker keeps the + patched copy there (#764), so when it does the rollback must have emitted + `vendor_bun_reinstall_required` / `redirect_bun_reinstall_required` + (`rollbackReinstallAdvised`; for the refused hosted `bun.lockb`, the + refusal's checkout remedy names it) and the cell follows that advice with + `bun install --force`. The isolated linker links the registry entry and + leaves the superseded patched store entry under `node_modules/.bun` + unlinked; the byte oracle counts only store entries something links to. + Bun 1.3.0 (only; 1.3.1 fixed it, measured 1.3.0–1.3.14) also leaves its + hidden hoist link `node_modules/.bun/node_modules/minimist` on that + superseded entry, even under `--force`; when that link is the only patched + copy left, the cell records it under `upstreamLimitations` instead of + failing. The runner captures the exact project manifests, lockfiles, the ledgers (and a `.socket/manifest.json` only where the `preexisting-manifest` shape seeded one), diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py index d35b524aa..c7f10aa77 100644 --- a/scripts/backtest-bun.py +++ b/scripts/backtest-bun.py @@ -67,10 +67,14 @@ Every cell records the CLI exit codes (main, repeat, rollback, conversion), the exact refusal-code set, the repeat-run envelope semantics, digest enforcement, and after rollback the lockfile presence rules and byte identity. -A hosted rollback restores the DEFAULT upstream registry entry re-resolved -from the npm registry (a custom-registry slot comes back as bun's default +A hosted rollback restores the upstream entry of the project's registry +(#992: the full tarball URL for a non-default registry, bun's `""` for npmjs — +the custom-registry fixture configures none, so its injected slot comes back as `""`); a hosted bun.lockb is binary, so its rollback refuses with the -`git checkout -- bun.lockb` remedy and the cell applies that remedy. +`git checkout -- bun.lockb` remedy and the cell applies that remedy. Rollback +runs over a patched install; the reinstall is a plain `bun install` over the +kept node_modules, and where Bun keeps the patched copy (#764) the rollback must +have advised `bun install --force`, which the cell then runs. Provenance: `--cli-revision` is the branch-resolvable commit the row is about (PR head, or the pushed commit); `--cli-build-sha` (or the CLI_BUILD_SHA @@ -130,12 +134,28 @@ LINKER_FROM = (1, 3, 0) # bunfig [install] linker TARBALL_INTEGRITY_ENFORCED_FROM = (1, 3, 10) # URL/local tarball sha512 verified NO_PEER_OR_OVERRIDE = ('0.8.1', '1.0.0') # peers not installed, overrides ignored - +# Bun 1.3.0's isolated linker leaves `node_modules/.bun/node_modules/` +# (the hidden hoist link) on a superseded store entry, even under +# `bun install --force`; 1.3.1 repoints it. Measured on 1.3.0-1.3.14. +STALE_HIDDEN_HOIST = ('1.3.0',) + +# Bun's hoisted linker keeps the installed copy when a lock entry moves back to +# the registry copy of the same name@version, so `rollback` / `vendor --revert` +# name `bun install --force` whenever node_modules may still hold it (#764). +# The rollback step follows that advice (see `rollbackReinstallAdvised`). +BUN_REINSTALL_ADVISORIES = {'vendor_bun_reinstall_required', 'redirect_bun_reinstall_required'} # Advisory codes a SUPPORTED run may carry; everything else is a refusal. +# Deliberately NOT here: `*_bun_default_trust_lost` (#371 — minimist is not on +# Bun's default-trusted list), `*_non_registry_entry_skipped` (#497 — every +# fixture resolves minimist from the registry) and +# `vendor_bun_lockb_duplicate_records` (#861 — no fixture carries a nested +# duplicate record). On these fixtures each would be a misclassification, so it +# must fail the cell rather than be waved through. INFORMATIONAL = { 'vendor_prebuilt_downloaded', 'vendor_prebuilt_unavailable', 'vendor_prebuilt_pending', 'vendor_fetched_missing', 'reinstall_required', 'vendor_takeover_reverted_redirect', 'redirect_takeover_reverted_vendored', + *BUN_REINSTALL_ADVISORIES, } # Codes that mean the rewriter or the takeover broke on a supported configuration. REGRESSION_CODES = { @@ -168,6 +188,11 @@ def save(path, data): r'^error: (?:Connection\w+|FailedToOpenSocket|Timeout|TLSHandshakeTimeout) downloading ' r'|^error: GET \S+ - 5\d\d\b', re.M) +# The harness's own fetches (published record, hosted tarball digest) that +# still fail after their in-place retries surface as the cell's `error`: +# ``. +HARNESS_TRANSPORT_FAILURE = re.compile(r'^`) holding + `path`, or None outside one (`.bun/node_modules` is Bun's hoist dir).""" + parts = path.parts + for i in range(len(parts) - 2): + if parts[i] == 'node_modules' and parts[i + 1] == '.bun' and parts[i + 2] != 'node_modules': + return Path(*parts[:i + 3]) + return None + + +def hidden_hoist(directory): + """Whether `directory` is Bun's hidden hoist dir (`node_modules/.bun/node_modules`).""" + parts = directory.parts + return parts[-3:] == ('node_modules', '.bun', 'node_modules') + + +def linked_store_entries(project, skip_hidden_hoist=False): + """Every isolated store entry some symlink outside it resolves into. Bun's + isolated linker leaves the entry of a superseded resolution (the patched + `minimist@https+++patch.socket.dev+…`) on disk, unlinked, after the lock + moves back to the registry; nothing can `require` it (the #599 orphans). + `skip_hidden_hoist` ignores the links in Bun's hidden hoist dir (see + STALE_HIDDEN_HOIST).""" + live = set() + for directory, subdirs, files in os.walk(project): + if '.socket' in Path(directory).relative_to(project).parts: + subdirs[:] = [] + continue + if skip_hidden_hoist and hidden_hoist(Path(directory)): + continue + for name in [*subdirs, *files]: + link = Path(directory) / name + # Bun links with junctions on Windows (Path.is_junction: 3.12+). + if not (link.is_symlink() or getattr(link, 'is_junction', lambda: False)()): + continue + entry = store_entry(link.resolve()) + if entry is not None and store_entry(Path(directory).resolve() / name) != entry: + live.add(entry) + return live + + +def installed_targets(project, skip_hidden_hoist=False): + """The installed minimist@1.2.2 copies node can load: every copy outside + an isolated store, and the store copies something links to.""" targets = [] + live = None for manifest in project.rglob('package.json'): if 'node_modules' not in manifest.parts or '.socket' in manifest.parts: continue + entry = store_entry(manifest) + if entry is not None: + live = linked_store_entries(project, skip_hidden_hoist) if live is None else live + if entry.resolve() not in live: + continue data = json.loads(manifest.read_text(encoding='utf-8')) if data.get('name') == 'minimist' and data.get('version') == '1.2.2': targets.append(manifest.parent) return targets -def oracle(project, record, side): - targets = installed_targets(project) +def oracle(project, record, side, skip_hidden_hoist=False): + targets = installed_targets(project, skip_hidden_hoist) checks = {} for target in targets: for filename, hashes in record['files'].items(): @@ -1234,6 +1309,11 @@ def vex(label, *extra, via='vex'): version, 'rejected' if row['rejectsCorruptDigest'] else 'accepted', '.'.join(map(str, TARBALL_INTEGRITY_ENFORCED_FROM)))) lock.write_bytes(patched_lock) + # Roll back from what a user actually has: a patched install + # (the corrupt-digest install above removed or broke it), so the + # reinstall below judges whether Bun keeps the patched copy (#764). + code, _ = install(bun, 'pre-rollback', ['--frozen-lockfile'], cache='cache-pre-rollback') + row['preRollbackPatched'] = code == 0 and oracle(project, record, 'after')[0] code, output = run([cli, 'rollback', '--cwd', project, '--json', '--yes', '--no-telemetry'], project, env, case / 'rollback.log', False) exit_codes['rollback'] = code @@ -1241,6 +1321,10 @@ def vex(label, *extra, via='vex'): rollback_codes = [w.get('code') for w in rolled.get('warnings', [])] row['rollbackWarnings'] = rollback_codes expected_files = dict(original) + # Whether the rollback told the user a plain `bun install` is not + # enough (#764): the reinstall advisory, or — when it refused — + # the refusal's own remedy. + reinstall_advised = bool(set(rollback_codes) & BUN_REINSTALL_ADVISORIES) if main_mode == 'hosted' and lockb_origin: # bun.lockb is binary: the v5 upstream restore refuses it # with the version-control remedy and writes nothing; the @@ -1251,12 +1335,20 @@ def vex(label, *extra, via='vex'): 'git checkout -- bun.lockb' in (f.get('error') or '') for f in failed) and lock.read_bytes() == patched_lock) + reinstall_advised = any(f.get('purl') == PURL and + 'bun install --force' in (f.get('error') or '') + for f in failed) lock.write_bytes(original['bun.lockb']) else: checks['rollbackSucceeded'] = code == 0 and rolled.get('status') == 'success' if main_mode == 'hosted' and shape == 'custom-registry': - # The upstream restore writes the DEFAULT registry entry: - # bun's "" slot, i.e. the lock before the slot injection. + # The upstream restore writes the slot Bun itself would + # write for the PROJECT's registry (#992): the full + # tarball URL for a non-default registry, `""` for + # npmjs and its aliases — on every Bun release. This + # fixture configures no registry (the injected URL is + # npmjs's own tarball), so the restore is bun's `""` + # slot, i.e. the lock before the slot injection. expected_files['bun.lock'] = pre_injection checks['rollbackOriginalFiles'] = all( (project / n).exists() and (project / n).read_bytes() == b @@ -1270,9 +1362,35 @@ def vex(label, *extra, via='vex'): checks['rollbackWarningsClean'] = not set(rollback_codes) - INFORMATIONAL if shape == 'crlf-lock': checks['rollbackEolPreserved'] = crlf_only(lock.read_bytes()) - code, _ = install(bun, 'reinstall', cache='cache-rollback') - checks['rollbackOriginalBytes'], row['rollbackFiles'] = oracle(project, record, 'before') - checks['rollbackOriginalBytes'] = code == 0 and checks['rollbackOriginalBytes'] + # The user's next step: a plain `bun install` over the KEPT + # node_modules. Bun's hoisted linker does not re-extract a + # package whose entry returned to the registry copy of the same + # name@version (#764), so when the plain install leaves patched + # bytes the rollback must have said so (`*_bun_reinstall_required`) + # and the cell then follows that advice: `bun install --force`. + code, _ = run([bun, 'install', '--ignore-scripts'], project, + env_for(bun, 'cache-rollback'), case / 'reinstall.log', False) + plain_ok, row['rollbackFiles'] = oracle(project, record, 'before') + row['rollbackPlainReinstallOriginal'] = code == 0 and plain_ok + if not row['rollbackPlainReinstallOriginal']: + checks['rollbackReinstallAdvised'] = reinstall_advised + code, _ = run([bun, 'install', '--ignore-scripts', '--force'], project, + env_for(bun, 'cache-rollback'), case / 'reinstall-force.log', False) + plain_ok, row['rollbackFiles'] = oracle(project, record, 'before') + if code == 0 and not plain_ok and version in STALE_HIDDEN_HOIST: + # Every copy a declared dependency reaches is original; + # only Bun's own stale hidden hoist link (which no install + # flag repoints on this release) still reaches the + # superseded patched entry. Recorded, not asserted. + plain_ok, row['rollbackFiles'] = oracle(project, record, 'before', + skip_hidden_hoist=True) + if plain_ok: + checks.pop('rollbackReinstallAdvised', None) + row.setdefault('upstreamLimitations', []).append( + 'Bun %s keeps node_modules/.bun/node_modules/minimist on the ' + 'superseded patched store entry after rollback, even under ' + '`bun install --force` (fixed in 1.3.1)' % version) + checks['rollbackOriginalBytes'] = code == 0 and plain_ok row['passed'] = all(checks.values()) except Exception as error: # noqa: BLE001 — every cell must produce a row row['error'] = str(error) diff --git a/scripts/tests/test_backtest_harnesses.py b/scripts/tests/test_backtest_harnesses.py index edecfbb0d..5bb56de5c 100644 --- a/scripts/tests/test_backtest_harnesses.py +++ b/scripts/tests/test_backtest_harnesses.py @@ -56,6 +56,12 @@ def run_case(_job): self.assertEqual((evidence / 'cli.log').read_text(), 'failed request evidence') self.assertFalse((evidence / 'cache').exists()) + def test_a_harness_fetch_reset_is_a_transport_failure(self): + self.assertTrue(bun.has_transport_failure( + {'error': ''})) + self.assertTrue(bun.has_transport_failure({'error': '[Errno 54] Connection reset by peer'})) + self.assertFalse(bun.has_transport_failure({'error': 'KeyError: bun.lock'})) + def test_a_patch_api_5xx_is_a_transport_failure(self): self.assertTrue(bun.has_transport_failure({'repeat': {'error': ( 'failed to resolve patch references: API request failed with status 503: upstream ' @@ -93,6 +99,82 @@ def run_case(_job): self.assertEqual(len(row['networkRetryAttempts']), 2) +class BunInformationalCodeTests(unittest.TestCase): + def test_reinstall_advisories_are_informational(self): + # #764: rollback / vendor --revert name `bun install --force`; the + # advisory is not a refusal, and rerun_clean must accept it too. + for code in ('vendor_bun_reinstall_required', 'redirect_bun_reinstall_required'): + self.assertIn(code, bun.INFORMATIONAL) + self.assertIn(code, bun.BUN_REINSTALL_ADVISORIES) + envelope = {'status': 'success', 'redirect': {'redirected': 1, 'warnings': [ + {'code': 'redirect_bun_reinstall_required'}]}} + self.assertTrue(bun.rerun_clean(0, envelope, 'hosted')) + + def test_misclassification_codes_stay_refusals(self): + # No fixture rewires a default-trusted package (#371), holds a + # non-registry copy (#497) or a duplicate bun.lockb record (#861). + for code in ('redirect_bun_default_trust_lost', 'vendor_bun_default_trust_lost', + 'redirect_bun_non_registry_entry_skipped', 'vendor_non_registry_entry_skipped', + 'vendor_bun_lockb_duplicate_records'): + self.assertNotIn(code, bun.INFORMATIONAL) + + +class BunInstalledTargetsTests(unittest.TestCase): + def test_unlinked_isolated_store_entry_is_not_installed(self): + # After a rollback Bun's isolated linker links the registry entry and + # leaves the patched one on disk, unlinked: only the linked copy counts. + with tempfile.TemporaryDirectory() as tmp: + project = Path(tmp).resolve() + store = project / 'node_modules/.bun' + for key in ('minimist@1.2.2', 'minimist@https+++patch.socket.dev+x'): + pkg = store / key / 'node_modules/minimist' + pkg.mkdir(parents=True) + (pkg / 'package.json').write_text('{"name": "minimist", "version": "1.2.2"}') + (store / 'node_modules').mkdir() + try: + (store / 'node_modules/minimist').symlink_to('../minimist@1.2.2/node_modules/minimist') + (project / 'node_modules/minimist').symlink_to('.bun/minimist@1.2.2/node_modules/minimist') + except OSError: + self.skipTest('symlinks unavailable') + self.assertEqual(bun.installed_targets(project), + [store / 'minimist@1.2.2/node_modules/minimist']) + self.assertIsNone(bun.store_entry(store / 'node_modules/minimist')) + + def test_stale_hidden_hoist_link_is_skipped_only_on_request(self): + # Bun 1.3.0 leaves `.bun/node_modules/minimist` on the superseded + # patched entry while the member links the registry one. + with tempfile.TemporaryDirectory() as tmp: + project = Path(tmp).resolve() + store = project / 'node_modules/.bun' + for key in ('minimist@1.2.2', 'minimist@https+++patch.socket.dev+x'): + pkg = store / key / 'node_modules/minimist' + pkg.mkdir(parents=True) + (pkg / 'package.json').write_text('{"name": "minimist", "version": "1.2.2"}') + (store / 'node_modules').mkdir() + member = project / 'packages/consumer/node_modules' + member.mkdir(parents=True) + try: + (store / 'node_modules/minimist').symlink_to( + '../minimist@https+++patch.socket.dev+x/node_modules/minimist') + (member / 'minimist').symlink_to( + '../../../node_modules/.bun/minimist@1.2.2/node_modules/minimist') + except OSError: + self.skipTest('symlinks unavailable') + registry = store / 'minimist@1.2.2/node_modules/minimist' + patched = store / 'minimist@https+++patch.socket.dev+x/node_modules/minimist' + self.assertEqual(sorted(bun.installed_targets(project)), sorted([registry, patched])) + self.assertEqual(bun.installed_targets(project, skip_hidden_hoist=True), [registry]) + self.assertEqual(bun.STALE_HIDDEN_HOIST, ('1.3.0',)) + + def test_hoisted_copies_are_always_installed(self): + with tempfile.TemporaryDirectory() as tmp: + project = Path(tmp).resolve() + pkg = project / 'node_modules/minimist' + pkg.mkdir(parents=True) + (pkg / 'package.json').write_text('{"name": "minimist", "version": "1.2.2"}') + self.assertEqual(bun.installed_targets(project), [pkg]) + + class BunManifestlessVexHelperTests(unittest.TestCase): UUID = '80630680-4da6-45f9-bba8-b888e0ffd58c' VULNS = {'GHSA-xvch-5gv4-984h': ['CVE-2021-44906']}