diff --git a/CHANGELOG.md b/CHANGELOG.md
index d2370a539..4c9895e03 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -163,6 +163,15 @@ limits, and required install commands.
fetches honor `GOPROXY` and private-module settings.
- Yarn Berry preserves supported line endings and checksum spellings. Mode
preflights, including Bun's, run before discarding existing protection.
+- Yarn Berry hosted references no longer send npm registry credentials to the
+ patch server. The old `npm:` locator made yarn attach `npmAuthToken` /
+ `YARN_NPM_AUTH_TOKEN` to scoped packages (and to every package under
+ `npmAlwaysAuth`). Hosted mode now pins the way yarn does for a root
+ `resolutions` entry: `package.json` routes the locked descriptor to the
+ hosted tarball and the lock entry is keyed by it, which also passes yarn's
+ hardened mode (on by default for public pull request CI). A user-authored
+ `resolutions` entry for the package is never overwritten. Locks pinned by
+ earlier releases are re-pinned on the next hosted `scan`.
- Composer hosted references remove upstream source fallbacks and mirrors;
RubyGems hosted locks preserve source order; NuGet edits use the active config
and survive `` entries.
diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md
index 4d8c3b65f..dc04373fa 100644
--- a/crates/socket-patch-cli/CLI_CONTRACT.md
+++ b/crates/socket-patch-cli/CLI_CONTRACT.md
@@ -163,7 +163,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc
`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes).
-The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed).
+The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed).
**Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** among this run's fetched records (v5.0: hosted mode persists no records, so a purl whose `/patches/view` fetch failed this run is not judged; the warning re-fires on every re-scan whose fetch succeeds, until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `/.gem` when present and not proven to be the patched artifact, since bundler installs from its cache dir in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed cache-dir archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The cache dir is bundler's `cache_path` setting (`Bundler.app_cache`), resolved in `Bundler::Settings` priority: `BUNDLE_CACHE_PATH:` in the bundler app config (`$BUNDLE_APP_CONFIG/config`, else `.bundle/config`) first, then the `BUNDLE_CACHE_PATH` environment variable, else `vendor/cache`; a relative value is read against the project root. With `BUNDLE_IGNORE_CONFIG` set (any value) bundler reads no config file, so the app config is skipped here too and only the environment and the default count — the same holds for the `BUNDLE_GEMFILE:` app-config setting. The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract.
@@ -360,7 +360,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, 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 `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 |
+| 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. |
| 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 |
diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs
index d2621b28f..8ce80d9f1 100644
--- a/crates/socket-patch-cli/src/commands/scan/mod.rs
+++ b/crates/socket-patch-cli/src/commands/scan/mod.rs
@@ -4343,6 +4343,32 @@ mod tests {
assert!(takeover.vendored.is_empty(), "{takeover:?}");
}
+ #[tokio::test]
+ async fn hosted_direction_provable_for_berry_tarball_locator() {
+ // Today's berry pin is the plain tarball-URL locator (#404).
+ let tmp = tempfile::tempdir().unwrap();
+ let root = tmp.path();
+ write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await;
+ tokio::fs::write(
+ root.join("yarn.lock"),
+ format!(
+ "__metadata:\n version: 8\n cacheKey: 10c0\n\n\
+ \"minimist@npm:1.2.2\":\n version: 1.2.2\n \
+ resolution: \"minimist@https://patch.socket.dev/patch/npm/{TAKEOVER_TOKEN}/{TAKEOVER_UUID}/minimist-1.2.2.tgz\"\n"
+ ),
+ )
+ .await
+ .unwrap();
+
+ let takeover = classify_overlap_takeover(&common_at(root), root).await;
+ assert_eq!(
+ takeover.redirect,
+ vec!["pkg:npm/minimist@1.2.2".to_string()],
+ "a berry tarball locator must prove hosted is live"
+ );
+ assert!(takeover.vendored.is_empty(), "{takeover:?}");
+ }
+
#[tokio::test]
async fn vendored_path_uuid_is_not_a_hosted_pin() {
// The vendored wiring embeds the SAME patch uuid in its
diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs
index c18c0312e..69098cbb6 100644
--- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs
+++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs
@@ -1467,16 +1467,19 @@ fn yarn_berry_hosted_install_proof() {
}
assert_pristine(&minimist_entry(&fx.proj), PATCH_MARKER, LEG);
let registry_lock = std::fs::read(fx.proj.join("yarn.lock")).expect("registry yarn.lock");
+ // The hosted pin also routes through package.json `resolutions` (#404),
+ // so a revert to the registry restores both files.
+ let registry_pkg = std::fs::read(fx.proj.join("package.json")).expect("registry package.json");
let env_json = scan_hosted(&fx.proj, &[]);
assert_redirected(&env_json, "yarn.lock");
let lock = read(&fx.proj.join("yarn.lock"));
- // Berry pins the hosted artifact through a percent-encoded `__archiveUrl`
- // resolution field, so the plain host string is encoded — check both the
- // encoded host and the (unencoded) patch UUID.
+ // Berry pins the hosted artifact as a plain tarball-URL locator — never
+ // an `npm:` one (`::__archiveUrl=`), whose fetcher would send the npm
+ // registry token to the patch host (#404).
assert!(
- lock.contains("__archiveUrl") && lock.contains("patch.socket.dev"),
- "{LEG}: berry lock carries no __archiveUrl pointing at the patch \
+ lock.contains("@https://patch.socket.dev/") && !lock.contains("__archiveUrl"),
+ "{LEG}: berry lock carries no tarball locator pointing at the patch \
host:\n{lock}"
);
assert!(
@@ -1497,7 +1500,7 @@ fn yarn_berry_hosted_install_proof() {
);
assert_patched(&minimist_entry(&fx.proj), PATCH_MARKER, LEG);
- yarn_berry_hosted_manifestless_vex(&fx, ®istry_lock, &env);
+ yarn_berry_hosted_manifestless_vex(&fx, ®istry_lock, ®istry_pkg, &env);
}
/// One `vex --json --output /berry.vex.json` run in `proj` (production
@@ -1582,7 +1585,12 @@ fn berry_skip_code(env: &serde_json::Value) -> String {
/// `--offline` (`record_unavailable`), and not once the lock is reverted to
/// the registry and reinstalled (nothing names the patch, even with
/// `--no-verify`).
-fn yarn_berry_hosted_manifestless_vex(fx: &NpmFixture, registry_lock: &[u8], env: &[(&str, &str)]) {
+fn yarn_berry_hosted_manifestless_vex(
+ fx: &NpmFixture,
+ registry_lock: &[u8],
+ registry_pkg: &[u8],
+ env: &[(&str, &str)],
+) {
const LEG: &str = "yarn_berry_hosted_install_proof (manifest-less vex)";
let proj = &fx.proj;
assert!(
@@ -1600,8 +1608,10 @@ fn yarn_berry_hosted_manifestless_vex(fx: &NpmFixture, registry_lock: &[u8], env
assert_eq!(berry_skip_code(&env_json), "record_unavailable", "{LEG}");
assert!(doc.is_none(), "{LEG}: no document");
- // Revert the lock to the registry and reinstall.
+ // Revert the lock and the package.json `resolutions` pin to the
+ // registry and reinstall.
std::fs::write(proj.join("yarn.lock"), registry_lock).unwrap();
+ std::fs::write(proj.join("package.json"), registry_pkg).unwrap();
std::fs::remove_dir_all(proj.join("node_modules")).ok();
let reinstall = tool(proj, "yarn", &["install", "--immutable"], env);
assert!(
diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs
index 0ffb717d4..e021d9015 100644
--- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs
+++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs
@@ -3,8 +3,8 @@
//! `e2e_redirect_npm_build.rs`.
//!
//! `scan --mode hosted` never lands patched bytes in the repo: it rewrites
-//! `yarn.lock` so the patched dependency resolves via
-//! `npm:::__archiveUrl=` with `checksum: 10c0/` (yarn's
+//! `yarn.lock` so the patched dependency resolves via the tarball-URL
+//! locator `@` with `checksum: 10c0/` (yarn's
//! cache-zip sha512); v5 keeps no redirect ledger — the lock pin is the
//! whole hosted state. This test proves every link against the REAL
//! `corepack yarn@4.12.0`:
@@ -16,16 +16,19 @@
//! extract the EXACT `10c0/` checksum yarn computes for that
//! tarball's cache zip — the value the redirect mock must hand back
//! (yarn recomputes the same zip checksum whether the locator is `file:`
-//! or `::__archiveUrl=`, so `--check-cache` will accept it).
+//! or a tarball URL, so `--check-cache` will accept it).
//! 3. `scan --mode hosted --json --vex` (the real binary): yarn.lock now
-//! pins the hosted `__archiveUrl` + the `10c0` checksum, NO ledger is
+//! pins the hosted tarball URL + the `10c0` checksum, NO ledger is
//! written, the in-run VEX is the `(redirected)` attestation.
//! 4. FRESH-CHECKOUT PROOF: only package.json + yarn.lock + .yarnrc.yml +
//! .socket/ travel; `yarn install --immutable --check-cache` (offline
//! from the registry, `unsafeHttpWhitelist` for the wiremock host) MUST
-//! install the patched bytes from the hosted tarball.
+//! install the patched bytes from the hosted tarball — with an npm
+//! registry token configured (`YARN_NPM_AUTH_TOKEN` + `npmAlwaysAuth`)
+//! that the patch host must never receive (#404: an `npm:` locator made
+//! yarn's npm fetcher send it).
//!
-//! The negative twin serves a DIFFERENT tarball at the archiveUrl while the
+//! The negative twin serves a DIFFERENT tarball at the hosted URL while the
//! lock keeps the real `10c0` checksum: the fresh `--check-cache` install MUST
//! fail with a YN0018 checksum error — the lock pin is enforcement.
//!
@@ -267,6 +270,9 @@ struct BerryRedirectFixture {
host: String,
/// `yarn.lock` as the real yarn wrote it, BEFORE the hosted rewrite.
registry_lock: Vec,
+ /// The root `package.json` BEFORE the hosted rewrite (#404 option C
+ /// pins through its `resolutions`, so a revert restores both files).
+ registry_pkg: Vec,
_server: MockServer,
}
@@ -286,7 +292,7 @@ enum HostedDriver {
/// Steps 1–3: real install, patched tarball + bootstrap checksum + API mocks,
/// the hosted rewrite (per `driver`: `scan --mode hosted --vex` or
/// `get --mode hosted`), and the envelope/lockfile/ledger assertions.
-/// `tamper_served_tarball` serves DIFFERENT bytes at the archiveUrl than the
+/// `tamper_served_tarball` serves DIFFERENT bytes at the hosted URL than the
/// checksum pins. `None` = skip (message printed).
async fn berry_hosted_project(
tag: &str,
@@ -347,6 +353,7 @@ async fn berry_hosted_project(
let installed_dir = proj.join("node_modules").join(DEP);
let orig = std::fs::read(installed_dir.join("index.js")).expect("installed index.js");
let registry_lock = std::fs::read(proj.join("yarn.lock")).expect("registry yarn.lock");
+ let registry_pkg = std::fs::read(proj.join("package.json")).expect("registry package.json");
assert!(
!orig.starts_with(MARKER.as_bytes()),
"pristine install must not carry the marker"
@@ -549,12 +556,33 @@ async fn berry_hosted_project(
);
}
- // Lockfile pin: the encoded __archiveUrl + the 10c0 checksum.
+ // Lockfile pin: the tarball-URL locator + the 10c0 checksum. Never an
+ // `npm:` locator (`::__archiveUrl=`): yarn's npm fetcher sends registry
+ // auth to whatever host that locator names (#404).
let lock = std::fs::read_to_string(proj.join("yarn.lock")).unwrap();
- let encoded = socket_patch_core::utils::uri::encode_uri_component(&hosted_url);
assert!(
- lock.contains("::__archiveUrl=") && lock.contains(&encoded),
- "yarn.lock must carry the encoded __archiveUrl; got:\n{lock}"
+ lock.contains(&format!("\n resolution: \"{DEP}@{hosted_url}\"")),
+ "yarn.lock must pin the hosted tarball locator; got:\n{lock}"
+ );
+ // #404 option C: the entry is keyed by the tarball descriptor, and the
+ // root package.json routes the locked descriptor there.
+ assert!(
+ lock.lines()
+ .any(|l| l.trim_end_matches('\r') == format!("\"{DEP}@{hosted_url}\":")),
+ "yarn.lock entry must be keyed by the tarball descriptor; got:\n{lock}"
+ );
+ let root_pkg = std::fs::read_to_string(proj.join("package.json")).unwrap();
+ let root_pkg: serde_json::Value = serde_json::from_str(&root_pkg).unwrap();
+ assert!(
+ root_pkg["resolutions"]
+ .as_object()
+ .is_some_and(|r| r.iter().any(|(sel, v)| sel.starts_with(&format!("{DEP}@npm:"))
+ && v.as_str() == Some(hosted_url.as_str()))),
+ "package.json must route {DEP} to the hosted tarball: {root_pkg}"
+ );
+ assert!(
+ !lock.contains("__archiveUrl"),
+ "the hosted pin must not be an npm: locator; got:\n{lock}"
);
let checksum_line = yarn_berry_common::expected_checksum_line(
&String::from_utf8_lossy(®istry_lock),
@@ -579,6 +607,7 @@ async fn berry_hosted_project(
patched,
host,
registry_lock,
+ registry_pkg,
_server: server,
})
}
@@ -598,7 +627,11 @@ fn fresh_yarnrc(fx: &BerryRedirectFixture) -> String {
/// Fresh dir with only the committable files, then `yarn install --immutable
/// --check-cache` offline-from-registry (the wiremock host is whitelisted for
-/// http). Returns the fresh dir + the install output.
+/// http). The install runs with an npm registry token that yarn must apply to
+/// every registry request (`YARN_NPM_AUTH_TOKEN` + `YARN_NPM_ALWAYS_AUTH`),
+/// the CI shape #404 leaked to the patch host: [`assert_patch_host_got_no_auth`]
+/// checks the hosted tarball request carried none. Returns the fresh dir +
+/// the install output.
fn fresh_checkout_yarn_install(fx: &BerryRedirectFixture) -> (PathBuf, Output) {
let fresh = fx.tmp.path().join("fresh");
std::fs::create_dir_all(&fresh).unwrap();
@@ -619,11 +652,62 @@ fn fresh_checkout_yarn_install(fx: &BerryRedirectFixture) -> (PathBuf, Output) {
&[
("YARN_GLOBAL_FOLDER", fresh_global.to_str().unwrap()),
("YARN_ENABLE_GLOBAL_CACHE", "false"),
+ ("YARN_NPM_AUTH_TOKEN", REGISTRY_TOKEN),
+ ("YARN_NPM_ALWAYS_AUTH", "true"),
+ // Hardened mode (yarn enables it on its own for public-PR CI)
+ // re-validates every lock resolution against its descriptor; a
+ // tarball locator under an `npm:` key fails it with YN0078 —
+ // why the pin routes through `resolutions` (#404).
+ ("YARN_ENABLE_HARDENED_MODE", "true"),
],
);
(fresh, ci)
}
+/// The npm registry token the fresh install is configured with.
+const REGISTRY_TOKEN: &str = "SOCKET-E2E-REGISTRY-TOKEN";
+
+/// #404: the patch host fetched the hosted tarball, and no request it
+/// received carried an `Authorization` header (or the registry token in any
+/// header) — the hosted pin must never hand registry credentials to it.
+async fn assert_patch_host_got_no_auth(fx: &BerryRedirectFixture) {
+ let requests = fx
+ ._server
+ .received_requests()
+ .await
+ .expect("wiremock request recording is on");
+ let tarball_gets: Vec<_> = requests
+ .iter()
+ .filter(|r| r.url.path().ends_with(".tgz"))
+ .collect();
+ assert!(
+ !tarball_gets.is_empty(),
+ "the fresh install must fetch the hosted tarball from the patch host"
+ );
+ // The same wiremock also plays the Socket API, whose requests carry the
+ // CLI's own API token — only the yarn-made tarball fetches are judged
+ // for an Authorization header; the registry token must appear nowhere.
+ for r in &tarball_gets {
+ assert!(
+ !r.headers.contains_key("authorization"),
+ "{} {} carried an Authorization header to the patch host: {:?}",
+ r.method,
+ r.url,
+ r.headers.get("authorization")
+ );
+ }
+ for r in &requests {
+ for (name, value) in r.headers.iter() {
+ assert!(
+ !value.to_str().unwrap_or("").contains(REGISTRY_TOKEN),
+ "{} {} leaked the registry token in header {name}",
+ r.method,
+ r.url
+ );
+ }
+ }
+}
+
/// The manifest-less VEX matrix over the hosted rewrite `driver` produced
/// (see `yarn_berry_common`): fresh checkouts without the manifest, without
/// the ledgers, offline, tampered, reverted to the registry and installed
@@ -631,7 +715,10 @@ fn fresh_checkout_yarn_install(fx: &BerryRedirectFixture) -> (PathBuf, Output) {
/// the REAL binary against a mock patch API.
fn hosted_manifestless_vex_matrix(fx: &BerryRedirectFixture, driver: HostedDriver) {
let yarnrc = fresh_yarnrc(fx);
- let registry_state = [("yarn.lock", fx.registry_lock.clone())];
+ let registry_state = [
+ ("yarn.lock", fx.registry_lock.clone()),
+ ("package.json", fx.registry_pkg.clone()),
+ ];
let yarn =
|cwd: &Path, args: &[&str], env: &[(&str, &str)]| corepack(cwd, yarn_berry(), args, env);
let api_url = fx._server.uri();
@@ -698,6 +785,7 @@ async fn berry_redirect_fresh_checkout_installs_patched_bytes() {
installed, fx.patched,
"fresh install must be byte-identical to the patched content"
);
+ assert_patch_host_got_no_auth(&fx).await;
hosted_manifestless_vex_matrix(&fx, HostedDriver::Scan);
}
@@ -706,7 +794,7 @@ async fn berry_redirect_fresh_checkout_installs_patched_bytes() {
/// routes through the SAME hosted engine as `scan --mode hosted`, so the
/// berry chain must hold unchanged — including the `10c0` cacheKey bootstrap
/// (the fixture still resolves the patched tarball with a real yarn to pin
-/// the exact cache-zip checksum) and the lock's `::__archiveUrl=` +
+/// the exact cache-zip checksum) and the lock's tarball-URL locator +
/// `checksum: 10c0/` splice — and the fresh `yarn install --immutable
/// --check-cache` pulls the patched bytes from the hosted tarball. The uuid
/// identifier path is exempt from installed narrowing, so only the view +
@@ -740,7 +828,7 @@ async fn berry_get_uuid_hosted_fresh_checkout_installs() {
hosted_manifestless_vex_matrix(&fx, HostedDriver::GetUuid);
}
-/// Negative twin: the archiveUrl serves a DIFFERENT tarball while the lock
+/// Negative twin: the hosted URL serves a DIFFERENT tarball while the lock
/// pins the real `10c0` checksum — the fresh `--check-cache` install must fail
/// with YN0018.
#[tokio::test(flavor = "multi_thread")]
diff --git a/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs b/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs
index d5440efab..efae4ab74 100644
--- a/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs
+++ b/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs
@@ -14,7 +14,7 @@
//! (`e2e_redirect_yarn_berry_build.rs` / `e2e_vendor_yarn_berry_build.rs`):
//!
//! * hosted — `scan --mode hosted` rewires `yarn.lock` to the hosted
-//! `__archiveUrl` + `10c0` checksum (bootstrap-resolution trick, see the
+//! tarball-URL locator + `10c0` checksum (bootstrap-resolution trick, see the
//! redirect sibling); a fresh checkout of only the committable files
//! passes `yarn install --immutable --check-cache` offline-from-registry
//! and serves the patched bytes THROUGH the `.store` symlink layout.
@@ -557,10 +557,29 @@ async fn yarn4_pnpm_linker_hosted_redirect_fresh_checkout_installs_patched_bytes
);
let lock = std::fs::read_to_string(proj.join("yarn.lock")).unwrap();
- let encoded = socket_patch_core::utils::uri::encode_uri_component(&hosted_url);
assert!(
- lock.contains("::__archiveUrl=") && lock.contains(&encoded),
- "yarn.lock must carry the encoded __archiveUrl; got:\n{lock}"
+ lock.contains(&format!("\n resolution: \"{DEP}@{hosted_url}\"")),
+ "yarn.lock must pin the hosted tarball locator; got:\n{lock}"
+ );
+ // #404 option C: the entry is keyed by the tarball descriptor, and the
+ // root package.json routes the locked descriptor there.
+ assert!(
+ lock.lines()
+ .any(|l| l.trim_end_matches('\r') == format!("\"{DEP}@{hosted_url}\":")),
+ "yarn.lock entry must be keyed by the tarball descriptor; got:\n{lock}"
+ );
+ let root_pkg = std::fs::read_to_string(proj.join("package.json")).unwrap();
+ let root_pkg: serde_json::Value = serde_json::from_str(&root_pkg).unwrap();
+ assert!(
+ root_pkg["resolutions"]
+ .as_object()
+ .is_some_and(|r| r.iter().any(|(sel, v)| sel.starts_with(&format!("{DEP}@npm:"))
+ && v.as_str() == Some(hosted_url.as_str()))),
+ "package.json must route {DEP} to the hosted tarball: {root_pkg}"
+ );
+ assert!(
+ !lock.contains("__archiveUrl"),
+ "the hosted pin must not be an npm: locator (#404); got:\n{lock}"
);
let checksum_line = yarn_berry_common::expected_checksum_line(
&String::from_utf8_lossy(®istry_lock),
@@ -571,11 +590,14 @@ async fn yarn4_pnpm_linker_hosted_redirect_fresh_checkout_installs_patched_bytes
"yarn.lock must carry the cache checksum in yarn's own spelling \
({checksum_line:?}); got:\n{lock}"
);
- assert_eq!(
- std::fs::read(proj.join("package.json")).unwrap(),
- pkg_before,
- "hosted redirect must not touch package.json"
- );
+ // #404 option C: the only package.json change is the `resolutions` pin.
+ {
+ let mut after: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(proj.join("package.json")).unwrap()).unwrap();
+ let before: serde_json::Value = serde_json::from_slice(&pkg_before).unwrap();
+ after.as_object_mut().unwrap().shift_remove("resolutions");
+ assert_eq!(after, before, "the hosted pin only adds `resolutions` to package.json");
+ }
eprintln!("HOSTED REWIRE OK");
// FRESH-CHECKOUT PROOF: committable files only, offline from the
@@ -603,7 +625,7 @@ async fn yarn4_pnpm_linker_hosted_redirect_fresh_checkout_installs_patched_bytes
eprintln!("FRESH INSTALL + YARN NODE RESOLUTION OK");
// MANIFEST-LESS VEX over the hosted wiring (see `yarn_berry_common`).
- let registry_state = [("yarn.lock", registry_lock)];
+ let registry_state = [("yarn.lock", registry_lock), ("package.json", pkg_before.clone())];
let yarn =
|cwd: &Path, args: &[&str], env: &[(&str, &str)]| corepack(cwd, yarn_berry(), args, env);
let api_url = server.uri();
diff --git a/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs b/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs
index 2c8ca559b..d2aec0078 100644
--- a/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs
+++ b/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs
@@ -17,8 +17,8 @@
//! `corepack yarn@4.12.0` (network for fixture setup only) and prove:
//!
//! * hosted — `scan --mode hosted` from the root rewires the member's
-//! `left-pad@npm:1.3.0` lock entry to the hosted `__archiveUrl` + `10c0`
-//! checksum (bootstrap-resolution trick, see the redirect sibling)
+//! `left-pad@npm:1.3.0` lock entry to the hosted tarball-URL locator +
+//! `10c0` checksum (bootstrap-resolution trick, see the redirect sibling)
//! without touching either package.json; a fresh checkout of only the
//! committable files installs the patched bytes offline-from-registry,
//! and the member resolves them through `yarn node`.
@@ -552,10 +552,29 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
// The single root lock carries the member dep's hosted pin; the
// workspace entries stay workspace-resolved and no package.json moved.
let lock = std::fs::read_to_string(proj.join("yarn.lock")).unwrap();
- let encoded = socket_patch_core::utils::uri::encode_uri_component(&hosted_url);
assert!(
- lock.contains("::__archiveUrl=") && lock.contains(&encoded),
- "yarn.lock must carry the encoded __archiveUrl; got:\n{lock}"
+ lock.contains(&format!("\n resolution: \"{DEP}@{hosted_url}\"")),
+ "yarn.lock must pin the hosted tarball locator; got:\n{lock}"
+ );
+ // #404 option C: the entry is keyed by the tarball descriptor, and the
+ // root package.json routes the locked descriptor there.
+ assert!(
+ lock.lines()
+ .any(|l| l.trim_end_matches('\r') == format!("\"{DEP}@{hosted_url}\":")),
+ "yarn.lock entry must be keyed by the tarball descriptor; got:\n{lock}"
+ );
+ let root_pkg = std::fs::read_to_string(proj.join("package.json")).unwrap();
+ let root_pkg: serde_json::Value = serde_json::from_str(&root_pkg).unwrap();
+ assert!(
+ root_pkg["resolutions"]
+ .as_object()
+ .is_some_and(|r| r.iter().any(|(sel, v)| sel.starts_with(&format!("{DEP}@npm:"))
+ && v.as_str() == Some(hosted_url.as_str()))),
+ "package.json must route {DEP} to the hosted tarball: {root_pkg}"
+ );
+ assert!(
+ !lock.contains("__archiveUrl"),
+ "the hosted pin must not be an npm: locator (#404); got:\n{lock}"
);
let checksum_line = yarn_berry_common::expected_checksum_line(
&String::from_utf8_lossy(®istry_lock),
@@ -570,11 +589,15 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
lock.contains("\"app@workspace:packages/app\""),
"the workspace member entry must stay workspace-resolved:\n{lock}"
);
- assert_eq!(
- std::fs::read(proj.join("package.json")).unwrap(),
- root_pkg_before,
- "hosted redirect must not touch the root package.json"
- );
+ // #404 option C: the root package.json gains only the `resolutions` pin
+ // (it is the one yarn reads resolutions from); the member is untouched.
+ {
+ let mut after: serde_json::Value =
+ serde_json::from_slice(&std::fs::read(proj.join("package.json")).unwrap()).unwrap();
+ let before: serde_json::Value = serde_json::from_slice(&root_pkg_before).unwrap();
+ after.as_object_mut().unwrap().shift_remove("resolutions");
+ assert_eq!(after, before, "the hosted pin only adds `resolutions` to the root package.json");
+ }
assert_eq!(
std::fs::read(proj.join("packages/app/package.json")).unwrap(),
member_pkg_before,
@@ -607,7 +630,7 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
eprintln!("FRESH INSTALL + MEMBER RESOLUTION OK");
// MANIFEST-LESS VEX over the hosted wiring (see `yarn_berry_common`).
- let registry_state = [("yarn.lock", registry_lock)];
+ let registry_state = [("yarn.lock", registry_lock), ("package.json", root_pkg_before.clone())];
let yarn =
|cwd: &Path, args: &[&str], env: &[(&str, &str)]| corepack(cwd, yarn_berry(), args, env);
let api_url = server.uri();
diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs
index d9e51ca5b..7ae650809 100644
--- a/crates/socket-patch-cli/tests/in_process_redirect.rs
+++ b/crates/socket-patch-cli/tests/in_process_redirect.rs
@@ -697,8 +697,10 @@ fn write_berry_project_spelled(root: &Path, spell: impl Fn(&str) -> String) {
.unwrap();
}
-/// The berry leg: the yarn.lock entry is repointed via `::__archiveUrl=` (the
-/// URL percent-encoded) and its `checksum:` becomes the yarnBerry10c0. The
+/// The berry leg: the yarn.lock entry's resolution becomes the hosted
+/// tarball-URL locator (never an `npm:` one, whose fetcher sends npm
+/// registry auth to the patch host — #404) and its `checksum:` becomes the
+/// yarnBerry10c0. The
/// descriptor KEY is preserved (so `--immutable` still passes), no ledger is
/// written, and a second run is a no-op.
#[tokio::test]
@@ -715,20 +717,30 @@ async fn scan_redirect_rewrites_yarn_berry_lock() {
assert_eq!(code, 0, "scan --mode hosted (berry) should succeed");
let lock = std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap();
- // yarn writes `__archiveUrl=`; assert both the
- // binding marker and the encoded URL landed.
- let encoded = socket_patch_core::utils::uri::encode_uri_component(HOSTED_URL);
assert!(
- lock.contains("::__archiveUrl=") && lock.contains(&encoded),
- "resolution must carry the encoded __archiveUrl; got:\n{lock}"
+ lock.contains(&format!("\n resolution: \"{NAME}@{HOSTED_URL}\"\n")),
+ "resolution must be the hosted tarball locator; got:\n{lock}"
+ );
+ assert!(
+ !lock.contains("__archiveUrl") && !lock.contains(&format!("resolution: \"{NAME}@npm:")),
+ "the hosted pin must not be an npm: locator; got:\n{lock}"
);
assert!(
lock.contains(BERRY_CHECKSUM),
"checksum must be the yarnBerry10c0"
);
+ // Option C (#404): the entry is re-keyed by the tarball descriptor, and
+ // the root package.json routes the original descriptor there.
assert!(
- lock.contains(&format!("\"{NAME}@npm:^{VERSION}\":")),
- "the descriptor key must be preserved verbatim; got:\n{lock}"
+ lock.contains(&format!("\"{NAME}@{HOSTED_URL}\":")),
+ "the entry is keyed by the tarball descriptor; got:\n{lock}"
+ );
+ let pkg = std::fs::read_to_string(tmp.path().join("package.json")).unwrap();
+ let pkg: serde_json::Value = serde_json::from_str(&pkg).unwrap();
+ assert_eq!(
+ pkg["resolutions"],
+ serde_json::json!({ format!("{NAME}@npm:^{VERSION}"): HOSTED_URL }),
+ "{pkg}"
);
vlt_hosted_common::assert_no_ledger(tmp.path());
@@ -770,9 +782,7 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto
Some(tarball),
)
.await;
- let encoded = socket_patch_core::utils::uri::encode_uri_component(
- &HOSTED_URL.replace("http://patch.test", &server.uri()),
- );
+ let hosted_url = HOSTED_URL.replace("http://patch.test", &server.uri());
for (label, bom) in [("crlf", ""), ("bom+crlf", "\u{feff}")] {
let tmp = tempfile::tempdir().unwrap();
@@ -792,7 +802,7 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto
);
let lock = std::fs::read_to_string(&lock_path).unwrap();
assert!(
- lock.contains(&format!("::__archiveUrl={encoded}\"\r\n"))
+ lock.contains(&format!(" resolution: \"{NAME}@{hosted_url}\"\r\n"))
&& lock.contains(&format!(" checksum: {BERRY_CHECKSUM}\r\n")),
"{label}: the entry is redirected in CRLF: {lock:?}"
);
@@ -841,10 +851,89 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto
"{label}: rollback restores the pristine CRLF lock (upstream checksum \
re-derived from the registry tarball)"
);
+ let pkg: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(tmp.path().join("package.json")).unwrap())
+ .unwrap();
+ assert!(
+ pkg.get("resolutions").is_none(),
+ "{label}: rollback drops the resolutions pin: {pkg}"
+ );
vlt_hosted_common::assert_no_ledger(tmp.path());
}
}
+/// #404 upgrade path: a lock pinned by an earlier release carries the old
+/// `npm:::__archiveUrl=` resolution, which makes yarn's npm fetcher
+/// send registry auth to the patch host. `rollback` must still recognize and
+/// restore that legacy pin, and a repeat hosted `scan` must re-pin it to the
+/// tarball-URL locator.
+#[tokio::test]
+#[serial]
+async fn yarn_berry_legacy_archive_url_pin_is_rolled_back_and_repinned() {
+ let server = MockServer::start().await;
+ mock_discovery(&server).await;
+ let hosted_url = HOSTED_URL.replace("http://patch.test", &server.uri());
+ mock_reference_with_berry_url(&server, &hosted_url).await;
+ mock_view(&server).await;
+ let tarball = upstream_tarball();
+ mock_npm_registry(
+ &server,
+ &vlt_hosted_common::sha512_sri(&tarball),
+ Some(tarball),
+ )
+ .await;
+ let legacy = |t: &str| {
+ t.replace(
+ &format!("resolution: \"{NAME}@npm:{VERSION}\""),
+ &format!(
+ "resolution: \"{NAME}@npm:{VERSION}::__archiveUrl={}\"",
+ socket_patch_core::utils::uri::encode_uri_component(&hosted_url)
+ ),
+ )
+ .replace(&format!("10c0/{}", "3".repeat(128)), BERRY_CHECKSUM)
+ };
+
+ // Rollback of the legacy pin restores the registry entry.
+ let tmp = tempfile::tempdir().unwrap();
+ write_berry_project_spelled(tmp.path(), legacy);
+ let lock_path = tmp.path().join("yarn.lock");
+ assert!(std::fs::read_to_string(&lock_path)
+ .unwrap()
+ .contains("::__archiveUrl="));
+ let (code, env) = rollback_json_with_origin(tmp.path(), &server, &server.uri());
+ assert_eq!(code, Some(0), "rollback: {env:#}");
+ assert_eq!(
+ env["hosted"]["reverted"],
+ serde_json::json!([PURL]),
+ "{env:#}"
+ );
+ let restored = std::fs::read_to_string(&lock_path).unwrap();
+ assert!(
+ restored.contains(&format!("\n resolution: \"{NAME}@npm:{VERSION}\"\n"))
+ && !restored.contains("__archiveUrl")
+ && !restored.contains(BERRY_CHECKSUM),
+ "the registry entry is restored: {restored}"
+ );
+
+ // A repeat hosted scan re-pins the legacy entry as a tarball locator.
+ let tmp = tempfile::tempdir().unwrap();
+ write_berry_project_spelled(tmp.path(), legacy);
+ let lock_path = tmp.path().join("yarn.lock");
+ let env = run_redirect_subprocess_with(
+ tmp.path(),
+ &server.uri(),
+ &["--patch-server-url", &server.uri()],
+ );
+ assert_eq!(env["redirect"]["redirected"], 1, "{env:#}");
+ let lock = std::fs::read_to_string(&lock_path).unwrap();
+ assert!(
+ lock.contains(&format!("\n resolution: \"{NAME}@{hosted_url}\"\n"))
+ && !lock.contains("__archiveUrl"),
+ "the legacy pin is migrated: {lock}"
+ );
+ vlt_hosted_common::assert_no_ledger(tmp.path());
+}
+
/// A berry lock whose line endings are MIXED (CRLF and LF, or a bare CR)
/// cannot be kept in one style — and yarn itself rejects it under
/// `--immutable` — so the hosted run refuses it untouched with a code that
diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs
index 461b6d41a..41d9734ab 100644
--- a/crates/socket-patch-cli/tests/in_process_vendor.rs
+++ b/crates/socket-patch-cli/tests/in_process_vendor.rs
@@ -1260,7 +1260,8 @@ async fn berry_crlf_takeovers_round_trip_both_directions() {
npm_tgz("left-pad", "1.3.0", ORIG_INDEX),
)
.await;
- let encoded = socket_patch_core::utils::uri::encode_uri_component(&hosted_url);
+ // The hosted pin is the tarball-URL locator `left-pad@` (#404).
+ let encoded = format!("left-pad@{hosted_url}\"");
let (pkg, lock) = (
windows_shape(BERRY_WIN_PKG, true),
windows_shape(&berry_win_lock(), false),
@@ -1347,11 +1348,20 @@ async fn berry_crlf_takeovers_round_trip_both_directions() {
.contains("redirect_takeover_reverted_vendored"),
"the takeover is surfaced: {env:#}"
);
- assert_eq!(
- std::fs::read_to_string(root.join("package.json")).unwrap(),
- pkg,
- "the vendored resolutions entry is reverted byte-exactly (BOM + CRLF kept)"
+ // The vendored `resolutions` entry is gone and the hosted pin (#404
+ // option C) took its place, in the manifest's own layout: BOM + CRLF.
+ let hosted_pkg = std::fs::read_to_string(root.join("package.json")).unwrap();
+ assert!(hosted_pkg.starts_with('\u{feff}'), "BOM kept: {hosted_pkg:?}");
+ let pin_line = format!(" \"left-pad@npm:1.3.0\": \"{hosted_url}\"\r\n");
+ assert!(
+ hosted_pkg.contains(&pin_line) && !hosted_pkg.contains(".socket/vendor/"),
+ "the hosted pin replaced the vendored resolutions entry: {hosted_pkg:?}"
+ );
+ let unpinned = hosted_pkg.replace(
+ &format!(",\r\n \"resolutions\": {{\r\n{pin_line} }}"),
+ "",
);
+ assert_eq!(unpinned, pkg, "nothing else in package.json changed");
let hosted_lock = std::fs::read_to_string(root.join("yarn.lock")).unwrap();
assert!(
hosted_lock.contains(&encoded) && !hosted_lock.contains(".socket/vendor/"),
diff --git a/crates/socket-patch-cli/tests/mode_migration_npm.rs b/crates/socket-patch-cli/tests/mode_migration_npm.rs
index ab7f98ee8..7fb28b5f0 100644
--- a/crates/socket-patch-cli/tests/mode_migration_npm.rs
+++ b/crates/socket-patch-cli/tests/mode_migration_npm.rs
@@ -948,7 +948,7 @@ async fn berry_hosted_then_vendored_takeover_round_trips_to_registry() {
assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}");
let lock = read(&proj, "yarn.lock");
assert!(
- lock.contains("::__archiveUrl="),
+ lock.contains(&format!("@{hosted_url}\"")),
"hosted wiring present:\n{lock}"
);
@@ -1240,9 +1240,8 @@ async fn berry_vendored_then_hosted_takeover_leaves_pure_hosted() {
"the berry resolutions entry must be reverted"
);
let lock = read(&proj, "yarn.lock");
- let encoded = socket_patch_core::utils::uri::encode_uri_component(&hosted_url);
assert!(
- lock.contains("::__archiveUrl=") && lock.contains(&encoded) && lock.contains(&checksum),
+ lock.contains(&format!("@{hosted_url}\"")) && lock.contains(&checksum),
"lock points hosted:\n{lock}"
);
assert!(
diff --git a/crates/socket-patch-cli/tests/yarn_berry_common/mod.rs b/crates/socket-patch-cli/tests/yarn_berry_common/mod.rs
index 04ce2881e..e8ff74216 100644
--- a/crates/socket-patch-cli/tests/yarn_berry_common/mod.rs
+++ b/crates/socket-patch-cli/tests/yarn_berry_common/mod.rs
@@ -371,7 +371,7 @@ pub fn off_runtime(f: impl FnOnce() -> R + Send) -> R {
/// How the flow wired the patched dependency.
#[derive(Clone, Debug)]
pub enum BerryWiring {
- /// `yarn.lock` resolves it via `::__archiveUrl=` on `patch_server`
+ /// `yarn.lock` resolves it via a tarball-URL locator on `patch_server`
/// (the mock tarball host — not a Socket host, so every VEX run passes
/// it as `--patch-server-url`).
Hosted { patch_server: String },
diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs
index 0b8ff09bc..aebeac1ab 100644
--- a/crates/socket-patch-core/src/hosted/engine.rs
+++ b/crates/socket-patch-core/src/hosted/engine.rs
@@ -424,12 +424,23 @@ pub async fn read_candidate_files(
}
}
- // The root manifest's `overrides` decide which git / url / `file:`
- // dependent specs npm really installs from (#490). Only the npm lock
- // rewriter reads it, as advisory input: no rewriter edits it, so a link
- // or an unreadable in-memory entry is left out (the rewriter then keeps
- // its conservative reading) rather than refused.
+ // A yarn berry lock is pinned through the root manifest's `resolutions`
+ // (see `patch::redirect::rewrite_yarn_berry`), so beside one the
+ // manifest is a rewrite target: read strictly, a link or an unreadable
+ // in-memory entry refused like any other file the rewrite writes.
if candidates.iter().any(|c| c.dep.ecosystem == "npm")
+ && out
+ .files
+ .get("yarn.lock")
+ .is_some_and(|lock| crate::patch::redirect::is_berry_lock(lock))
+ {
+ out.read(view, unreadable, "package.json").await;
+ // Otherwise the root manifest's `overrides` decide which git / url /
+ // `file:` dependent specs npm really installs from (#490). Only the npm
+ // lock rewriter reads it, as advisory input: no rewriter edits it, so a
+ // link or an unreadable in-memory entry is left out (the rewriter then
+ // keeps its conservative reading) rather than refused.
+ } else if candidates.iter().any(|c| c.dep.ecosystem == "npm")
&& NPM_LOCKS.iter().any(|lock| out.files.contains_key(*lock))
{
let rel = crate::hosted::memory::select::NPM_MANIFEST_REL;
@@ -777,8 +788,9 @@ enum ProbeStep {
/// ([`artifact_url_spellings`], raw or the `\/`-escaped slashes an old
/// composer.lock spells them with), so a writer's spelling can never be
/// one this probe misses.
-/// - The percent-encoded URL: the berry rewriter writes it into the lock's
-/// `::__archiveUrl=` binding, so the raw form is absent.
+/// - The percent-encoded URL: releases up to 5.0 wrote it into a berry
+/// lock's `::__archiveUrl=` binding (today's berry pin is the raw URL), so
+/// a lock pinned by them carries no raw form.
/// - The registry index URL and the maven suffixed version, when present.
pub fn candidate_presence_needles(dep: &DepOverride) -> Vec {
let artifact_url = dep.artifact_url.as_str();
@@ -1334,15 +1346,20 @@ fn confirm(
// artifact failed the preflight beside another npm-family lock) may
// still hold an earlier run's pin: only the sibling lock this run
// rewrote can confirm that dep.
+ // The yarn berry pin's `package.json` `resolutions` entry is only half of
+ // it — the URL-keyed `yarn.lock` entry is what installs — so a hosted URL
+ // left in the manifest (an earlier run, a refused rewrite) proves
+ // nothing on its own: the manifest never feeds the probe.
let final_texts: Vec<(&str, &String)> = files
.iter()
.filter(|(name, _)| !(pdm_inactive && name.as_str() == "pdm.lock"))
+ .filter(|(name, _)| name.as_str() != "package.json")
.map(|(name, content)| (name.as_str(), rewrite.files.get(name).unwrap_or(content)))
.chain(
rewrite
.files
.iter()
- .filter(|(name, _)| !files.contains_key(*name))
+ .filter(|(name, _)| !files.contains_key(*name) && name.as_str() != "package.json")
.map(|(name, content)| (name.as_str(), content)),
)
.collect();
@@ -1407,6 +1424,13 @@ fn confirm(
if rewrite.refused_pnpm_uuids.contains(uuid) {
return ProbeStep::Decided(false);
}
+ // A yarn berry pin is the URL-keyed lock entry AND the manifest
+ // `resolutions` routing to it; the URL in `yarn.lock` alone (the
+ // routing removed, a refused re-pin) installs nothing, so the
+ // berry rewriter's own report decides every dep its lock holds.
+ if rewrite.yarn_berry_uuids.contains(uuid) {
+ return ProbeStep::Decided(rewrite.confirmed_yarn_berry_uuids.contains(uuid));
+ }
// Cargo is transactional: the rewriter reports exactly which
// patch uuids FULLY landed (manifest pin + lock + registry
// block). Substring presence must never confirm a cargo dep —
@@ -1672,6 +1696,143 @@ mod tests {
assert!(read.unreadable_reads.is_empty());
}
+ /// A hosted URL left in a berry project's `package.json` `resolutions`
+ /// while `yarn.lock` still resolves the registry entry confirms nothing:
+ /// only the lock pin installs (#404).
+ #[test]
+ fn a_resolutions_url_alone_does_not_confirm_a_berry_redirect() {
+ use crate::patch::redirect::Integrity;
+ let url = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/tok/uuid/left-pad-1.3.0.tgz";
+ let candidate = Candidate {
+ purl: "pkg:npm/left-pad@1.3.0".into(),
+ dep: DepOverride {
+ ecosystem: "npm".into(),
+ name: "left-pad".into(),
+ namespace: None,
+ version: "1.3.0".into(),
+ token: "tok".into(),
+ patch_uuid: "uuid".into(),
+ artifact_url: url.into(),
+ registry_override: None,
+ integrity: Integrity::default(),
+ },
+ };
+ let lock = "__metadata:\n version: 8\n cacheKey: 10c0\n\n\"left-pad@npm:^1.3.0\":\n \
+ version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n";
+ let mut files = BTreeMap::new();
+ files.insert("yarn.lock".to_string(), lock.to_string());
+ files.insert(
+ "package.json".to_string(),
+ format!("{{\"resolutions\": {{\"left-pad@npm:^1.3.0\": \"{url}\"}}}}"),
+ );
+ let none = confirm(
+ &files,
+ &RewriteResult::default(),
+ std::slice::from_ref(&candidate),
+ false,
+ &BTreeSet::new(),
+ );
+ assert!(none.is_empty(), "{none:?}");
+ // The lock pin itself still confirms.
+ files.insert(
+ "yarn.lock".to_string(),
+ lock.replace(
+ "resolution: \"left-pad@npm:1.3.0\"",
+ &format!("resolution: \"left-pad@{url}\""),
+ ),
+ );
+ let confirmed = confirm(
+ &files,
+ &RewriteResult::default(),
+ std::slice::from_ref(&candidate),
+ false,
+ &BTreeSet::new(),
+ );
+ assert_eq!(confirmed.len(), 1, "{confirmed:?}");
+ }
+
+ /// Review of #465: a hosted berry pin whose `resolutions` routing was
+ /// removed keeps its URL-keyed lock entry. The rewriter refuses to re-pin
+ /// it (the routing is not ours to recreate silently), so nothing
+ /// installs the patch — and the URL in `yarn.lock` must not confirm it
+ /// (a confirmed dep feeds the in-run VEX `not_affected` exemption).
+ #[test]
+ fn an_orphaned_berry_lock_pin_is_not_confirmed() {
+ use crate::patch::redirect::{rewrite_registry_redirect, Integrity};
+ let url = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/tok/uuid/left-pad-1.3.0.tgz";
+ let candidate = Candidate {
+ purl: "pkg:npm/left-pad@1.3.0".into(),
+ dep: DepOverride {
+ ecosystem: "npm".into(),
+ name: "left-pad".into(),
+ namespace: None,
+ version: "1.3.0".into(),
+ token: "tok".into(),
+ patch_uuid: "uuid".into(),
+ artifact_url: url.into(),
+ registry_override: None,
+ integrity: Integrity {
+ yarn_berry10c0: Some(format!("10c0/{}", "7".repeat(128))),
+ ..Default::default()
+ },
+ },
+ };
+ let manifest = "{\n \"name\": \"app\"\n}\n".to_string();
+ let mut files = BTreeMap::new();
+ files.insert(
+ "yarn.lock".to_string(),
+ format!(
+ "__metadata:\n version: 8\n cacheKey: 10c0\n\n\"left-pad@npm:^1.3.0\":\n \
+ version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/{}\n \
+ languageName: node\n linkType: hard\n",
+ "3".repeat(128)
+ ),
+ );
+ files.insert("package.json".to_string(), manifest.clone());
+ let run = |files: &BTreeMap| {
+ let rewrite = rewrite_registry_redirect(files, std::slice::from_ref(&candidate.dep));
+ let confirmed = confirm(
+ files,
+ &rewrite,
+ std::slice::from_ref(&candidate),
+ false,
+ &BTreeSet::new(),
+ );
+ (rewrite, confirmed)
+ };
+ let (first, confirmed) = run(&files);
+ assert_eq!(confirmed.len(), 1, "{:?}", first.warnings);
+ let pinned_lock = first.files["yarn.lock"].clone();
+ assert!(
+ pinned_lock.contains(&format!("\"left-pad@{url}\":")),
+ "{pinned_lock}"
+ );
+
+ // Rescan with the pin intact: still confirmed.
+ let mut pinned = files.clone();
+ pinned.insert("yarn.lock".to_string(), pinned_lock.clone());
+ pinned.insert(
+ "package.json".to_string(),
+ first.files["package.json"].clone(),
+ );
+ assert_eq!(run(&pinned).1.len(), 1);
+
+ // The routing removed: the URL is still in the lock, nothing installs it.
+ let mut orphan = files;
+ orphan.insert("yarn.lock".to_string(), pinned_lock);
+ orphan.insert("package.json".to_string(), manifest);
+ let (rewrite, confirmed) = run(&orphan);
+ assert!(
+ rewrite
+ .warnings
+ .iter()
+ .any(|w| w.code == "redirect_yarn_berry_resolutions_conflict"),
+ "{:?}",
+ rewrite.warnings
+ );
+ assert!(confirmed.is_empty(), "{confirmed:?}");
+ }
+
fn left_pad_candidate() -> Candidate {
use crate::patch::redirect::Integrity;
Candidate {
diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs
index d6d282eae..38ce4b433 100644
--- a/crates/socket-patch-core/src/patch/redirect/mod.rs
+++ b/crates/socket-patch-core/src/patch/redirect/mod.rs
@@ -19,7 +19,7 @@ use std::borrow::Cow;
use std::collections::BTreeMap;
use std::sync::LazyLock;
-use regex::Regex;
+use regex::{NoExpand, Regex};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -65,7 +65,7 @@ mod python_lock_equivalence_tests;
mod requirements;
mod staged;
mod state;
-mod hosted_url;
+pub(crate) mod hosted_url;
pub mod upstream;
pub mod vlt;
pub mod vlt_heal;
@@ -239,6 +239,24 @@ pub struct RewriteResult {
/// An incomplete pnpm rewrite must not be confirmed by finding its URL
/// in another instance, a comment, or another lockfile.
pub refused_pnpm_uuids: std::collections::BTreeSet,
+ /// Patch uuids whose package version a yarn berry `yarn.lock` locks, so
+ /// the berry rewriter alone decides them: the hosted pin is the
+ /// URL-keyed lock entry AND the root `package.json` `resolutions`
+ /// routing to it, and a URL found in the lock proves only the first half.
+ // Unserialized when empty, so the blessed rewrite goldens (which hash the
+ // whole result) are unchanged by these fields for every non-berry case.
+ #[cfg_attr(
+ test,
+ serde(skip_serializing_if = "std::collections::BTreeSet::is_empty")
+ )]
+ pub yarn_berry_uuids: std::collections::BTreeSet,
+ /// The [`Self::yarn_berry_uuids`] whose pin is complete — lock entry and
+ /// manifest routing — written by this run or already in place.
+ #[cfg_attr(
+ test,
+ serde(skip_serializing_if = "std::collections::BTreeSet::is_empty")
+ )]
+ pub confirmed_yarn_berry_uuids: std::collections::BTreeSet,
pub python_lock_uuids: std::collections::BTreeSet,
pub confirmed_python_lock_uuids: std::collections::BTreeSet,
pub refused_python_lock_uuids: std::collections::BTreeSet,
@@ -538,6 +556,8 @@ fn merge_group_delta(result: &mut RewriteResult, delta: RewriteResult) {
confirmed_pdm_uuids,
refused_pdm_uuids,
refused_pnpm_uuids,
+ yarn_berry_uuids,
+ confirmed_yarn_berry_uuids,
python_lock_uuids,
confirmed_python_lock_uuids,
refused_python_lock_uuids,
@@ -564,6 +584,10 @@ fn merge_group_delta(result: &mut RewriteResult, delta: RewriteResult) {
result.confirmed_pdm_uuids.extend(confirmed_pdm_uuids);
result.refused_pdm_uuids.extend(refused_pdm_uuids);
result.refused_pnpm_uuids.extend(refused_pnpm_uuids);
+ result.yarn_berry_uuids.extend(yarn_berry_uuids);
+ result
+ .confirmed_yarn_berry_uuids
+ .extend(confirmed_yarn_berry_uuids);
result.python_lock_uuids.extend(python_lock_uuids);
result
.confirmed_python_lock_uuids
@@ -3221,15 +3245,43 @@ fn yarn_classic_block_head(block: &str) -> Option<(String, Option)> {
}
// ── yarn.lock (berry / v2+) ──────────────────────────────────────────────────
-// Berry derives its fetch URL from the descriptor's `npm:` resolution and
+// Berry fetches each package from its lock entry's `resolution:` locator and
// verifies the CONVERTED CACHE ZIP against the lock's `checksum:` (a
// `10c0/` over the zip, not the tarball). To redirect ONE dep we
-// rewrite only the lock entry: `resolution:` gains yarn's own
-// `::__archiveUrl=` binding, and `checksum:` becomes
-// our precomputed `integrity.yarnBerry10c0`. The descriptor KEY + package.json
-// are untouched (the `name@npm:^range` descriptor still satisfies, so
-// `--immutable` passes). Byte-for-byte twin of the TS `rewriteYarnBerry` on
-// LF locks; the CRLF / BOM round trip below has no TS counterpart yet.
+// rewrite only the lock entry: `resolution:` becomes the plain tarball-URL
+// locator `@`, and `checksum:` becomes our precomputed
+// `integrity.yarnBerry10c0`. The descriptor KEY + package.json are untouched
+// (yarn maps the `name@npm:^range` descriptor to the stored locator, so
+// `--immutable` passes).
+//
+// The locator must NOT keep the `npm:` protocol (e.g. yarn's own
+// `npm:::__archiveUrl=` binding, which releases up to 5.0 wrote):
+// yarn fetches `npm:` locators with its npm fetcher, which attaches the npm
+// registry's credentials (`npmAuthToken`, `YARN_NPM_AUTH_TOKEN`, `npmScopes`)
+// to the request for every scoped package, and for every package under
+// `npmAlwaysAuth` — handing the registry token to the patch host (#404). A
+// tarball-URL locator goes through yarn's tarball fetcher, which sends no
+// registry auth and builds the identical cache zip, so the checksum is the
+// same. An old `::__archiveUrl=` pin is re-pinned by the next hosted run.
+
+/// Whether yarn berry fetches `url`, as a `name@` locator, with its
+/// tarball HTTP fetcher: an `http(s)://` URL whose path ends in `.tgz` /
+/// `.tar.gz` (yarn's `TARBALL_REGEXP` + `PROTOCOL_REGEXP`). A query or
+/// fragment is refused too — yarn's regex rejects a `?`, and a `#` would be
+/// read as a range selector — as is anything that could break out of the
+/// lock's double-quoted `resolution:` string or yarn's `::` binding grammar.
+fn yarn_berry_tarball_url_ok(url: &str) -> bool {
+ let Some((scheme, rest)) = url.split_once("://") else {
+ return false;
+ };
+ matches!(scheme, "https" | "http")
+ && !rest.is_empty()
+ && !url.contains("::")
+ && !url
+ .chars()
+ .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '"' | '\\' | '?' | '#'))
+ && (url.ends_with(".tgz") || url.ends_with(".tar.gz"))
+}
/// Only cacheKey `10c0` (yarn 4, compressionLevel 0 default) has a checksum we
/// can reproduce offline; matches the vendored backend's `SUPPORTED_CACHE_KEY`.
@@ -3351,13 +3403,39 @@ fn rewrite_yarn_berry(
preflight_yarn_berry_hosted(raw, files.get(".yarnrc.yml").map(String::as_str))
{
result.warnings.push(warning);
+ // Nothing is verified, so nothing is confirmed — but a dep this lock
+ // locks is still this rewriter's to decide: an earlier run's URL in
+ // the lock must not confirm it through the text probe.
+ let lf = to_lf(body);
+ for dep in &npm {
+ if berry_lock_locks(&lf, &full_name(dep), &dep.version) {
+ result.yarn_berry_uuids.insert(dep.patch_uuid.clone());
+ }
+ }
return;
}
let eol = LineEndings::of(body);
let normalized = to_lf(body);
let content: &str = &normalized;
- let mut blocks: Vec = content.split("\n\n").map(String::from).collect();
+ // The trailing newline(s) ride outside the blocks, so an entry moved to
+ // its sorted position (see [`berry_reposition_blocks`]) never carries
+ // the file's final newline into the middle of the lock.
+ let trimmed = content.trim_end_matches('\n');
+ let trailing_newlines = &content[trimmed.len()..];
+ let mut blocks: Vec = trimmed.split("\n\n").map(String::from).collect();
+ let was_sorted = berry_entries_sorted(&blocks);
+ // The root manifest whose `resolutions` route each pinned descriptor to
+ // the hosted tarball (see the section header). Parsed once; written back
+ // in its own layout when a pin changes it.
+ let manifest_text = files.get(BERRY_MANIFEST).map(String::as_str);
+ let mut manifest: Option = manifest_text
+ .and_then(|t| serde_json::from_str::(t.strip_prefix('\u{feff}').unwrap_or(t)).ok())
+ .filter(Value::is_object);
+ let mut manifest_changed = false;
+ // Keys of the entries re-keyed to a tarball descriptor; yarn sorts lock
+ // entries by key, so each one moves to its sorted position at the end.
+ let mut moved_keys: Vec = Vec::new();
let resolution_re =
Regex::new(r#"\n {2}resolution: "[^"]*""#).expect("static resolution-line regex is valid");
let checksum_re =
@@ -3367,28 +3445,28 @@ fn rewrite_yarn_berry(
let fname = full_name(dep);
// The API hands the prefixed `10c0/`; a yarn 4.0.x lock spells
// its checksums bare, and `--immutable` rejects a respelled one.
- let Some(checksum) = dep
+ // Only needed when the entry must change (checked below): a pin
+ // already complete keeps the checksum it was written with.
+ let checksum: Option = dep
.integrity
.yarn_berry10c0
.as_deref()
- .map(|c| crate::vendor::yarn_berry_lock::checksum_in_lock_spelling(content, c))
- else {
- result.warnings.push(RewriteWarning {
- code: "redirect_yarn_berry_missing_checksum".into(),
- detail: format!(
- "{fname}@{} has no yarnBerry10c0 cache checksum",
- dep.version
- ),
- });
- continue;
- };
+ .map(|c| crate::vendor::yarn_berry_lock::checksum_in_lock_spelling(content, c));
// Berry versions are UNQUOTED (` version: 1.3.0`).
let version_re =
Regex::new(&(String::from(r"\n {2}version: ") + ®ex::escape(&dep.version) + "\n"))
.expect("version regex from the escaped version is valid");
let mut matched_any = false;
let mut alias_skipped = false;
- for block in blocks.iter_mut() {
+ // Every entry this dep's pin would re-key: `(index, npm ranges)` —
+ // the ranges are empty for an entry an earlier hosted run already
+ // keyed by its tarball URL (they are recovered from the manifest).
+ let mut targets: Vec<(usize, Vec)> = Vec::new();
+ // A non-npm entry of the same package version (yarn's builtin
+ // `patch:` compat entries, `workspace:`, …): its descriptor wraps or
+ // shares the npm one, so re-keying the npm entry would break it.
+ let mut shared_descriptor = false;
+ for (block_idx, block) in blocks.iter().enumerate() {
// A block's key is its first line up to a trailing colon; skip
// header comment blocks and the leading `__metadata` block.
let Some(first_line) = block.lines().next() else {
@@ -3463,6 +3541,23 @@ fn rewrite_yarn_berry(
// under such a key corrupts the key/resolution protocol pairing.
// Mirrors the vendor backend's fail-closed gate
// (vendor/yarn_berry_lock.rs).
+ // A fork alias (`@npm:@`) installs another
+ // package under this name: its entry is not the patched package,
+ // whatever its version, so it is never re-keyed.
+ if parsed.iter().any(|p| {
+ p.and_then(|(_, range)| berry_npm_alias_target(range))
+ .is_some_and(|real| real != fname)
+ }) {
+ continue;
+ }
+ // An entry an earlier hosted run already keyed by its tarball
+ // descriptor (`"@"`): ours to re-pin.
+ if let [Some((_, range))] = parsed.as_slice() {
+ if berry_hosted_pin_is_ours(range, &fname, Some(&dep.version), &dep.artifact_url) {
+ targets.push((block_idx, Vec::new()));
+ continue;
+ }
+ }
if !parsed.iter().all(|p| {
p.expect("every pattern parsed — None-bearing keys are skipped above")
.1
@@ -3521,6 +3616,7 @@ fn rewrite_yarn_berry(
.collect();
protocols.sort();
protocols.dedup();
+ shared_descriptor = true;
result.warnings.push(RewriteWarning {
code: "redirect_yarn_berry_unsupported_protocol".into(),
detail: format!(
@@ -3535,61 +3631,489 @@ fn rewrite_yarn_berry(
});
continue;
}
- // Rewrite the resolution wholesale from name+version — handles a
- // pre-existing `::__archiveUrl=` (custom-registry lock) for free.
- let resolution = format!(
- "{fname}@npm:{}::__archiveUrl={}",
- dep.version,
- crate::utils::uri::encode_uri_component(&dep.artifact_url)
- );
- let mut rewritten = resolution_re
- .replace(block, format!("\n resolution: \"{resolution}\"").as_str())
- .to_string();
- if checksum_re.is_match(&rewritten) {
+ targets.push((
+ block_idx,
+ parsed
+ .iter()
+ .map(|p| {
+ p.expect("every pattern parsed — None-bearing keys are skipped above")
+ .1
+ .to_string()
+ })
+ .collect(),
+ ));
+ }
+ if !targets.is_empty() {
+ matched_any = true;
+ }
+ // This lock locks the package version, so this rewriter alone decides
+ // the dep from here on (see [`RewriteResult::yarn_berry_uuids`]); a
+ // lock that does not lock it leaves the dep to the other lockfiles.
+ if matched_any || shared_descriptor {
+ result.yarn_berry_uuids.insert(dep.patch_uuid.clone());
+ }
+ if shared_descriptor && !targets.is_empty() {
+ result.warnings.push(RewriteWarning {
+ code: "redirect_yarn_berry_shared_descriptor".into(),
+ detail: format!(
+ "{fname}@{} is also locked through a non-npm entry (e.g. yarn's builtin \
+ `patch:` compatibility entry) that wraps the same npm descriptor; pinning \
+ that descriptor to the hosted tarball would change the other entry too, \
+ so the hosted redirect leaves {fname} untouched — use `scan --mode \
+ vendored` or `--mode agent` for this package",
+ dep.version
+ ),
+ });
+ continue;
+ }
+ if targets.len() > 1 {
+ result.warnings.push(RewriteWarning {
+ code: "redirect_yarn_berry_ambiguous_entry".into(),
+ detail: format!(
+ "yarn.lock holds {} separate entries resolving {fname}@{}; run `yarn \
+ install` once to dedupe the lock, then re-run",
+ targets.len(),
+ dep.version
+ ),
+ });
+ continue;
+ }
+ let Some((target_idx, key_ranges)) = targets.pop() else {
+ if !matched_any && !alias_skipped {
+ result.warnings.push(RewriteWarning {
+ code: "redirect_yarn_berry_entry_not_found".into(),
+ detail: format!("no npm: lock entry resolving {fname}@{}", dep.version),
+ });
+ }
+ continue;
+ };
+ if !yarn_berry_tarball_url_ok(&dep.artifact_url) {
+ result.warnings.push(RewriteWarning {
+ code: "redirect_yarn_berry_artifact_url_unsupported".into(),
+ detail: format!(
+ "{fname}@{}: hosted artifact URL {:?} is not an http(s) `.tgz` URL \
+ without a query or fragment, so yarn could not fetch it as a \
+ tarball locator; leaving the lock entry untouched",
+ dep.version, dep.artifact_url
+ ),
+ });
+ continue;
+ }
+ // Without a checksum only an entry already keyed by this artifact
+ // (an earlier run wrote its checksum) can be kept; any other needs
+ // the checksum written.
+ let already_keyed = blocks[target_idx]
+ .lines()
+ .next()
+ .is_some_and(|l| l == format!("\"{fname}@{}\":", dep.artifact_url));
+ if checksum.is_none() && !already_keyed {
+ result.warnings.push(RewriteWarning {
+ code: "redirect_yarn_berry_missing_checksum".into(),
+ detail: format!(
+ "{fname}@{} has no yarnBerry10c0 cache checksum",
+ dep.version
+ ),
+ });
+ continue;
+ }
+ let Some(manifest_obj) = manifest.as_mut().and_then(Value::as_object_mut) else {
+ result.warnings.push(RewriteWarning {
+ code: "redirect_yarn_berry_manifest_missing".into(),
+ detail: format!(
+ "the root package.json is missing or is not a JSON object; the hosted \
+ redirect pins {fname}@{} through its `resolutions` field, so the lock \
+ entry is left untouched",
+ dep.version
+ ),
+ });
+ continue;
+ };
+ let block = blocks[target_idx].clone();
+ // An entry keyed by its tarball URL (an earlier hosted run) recovers
+ // its ranges from the manifest selectors routed to that URL.
+ let current_url = if key_ranges.is_empty() {
+ block
+ .lines()
+ .next()
+ .and_then(|l| l.strip_suffix(':'))
+ .map(|k| k.trim_matches('"'))
+ .and_then(split_pattern)
+ .map(|(_, r)| r.to_string())
+ } else {
+ None
+ };
+ let pin = match berry_resolutions_pin(
+ manifest_obj,
+ &fname,
+ &dep.version,
+ &key_ranges,
+ current_url.as_deref(),
+ &dep.artifact_url,
+ ) {
+ Ok(pin) => pin,
+ Err(warning) => {
+ result.warnings.push(warning);
+ continue;
+ }
+ };
+ // The entry yarn writes for those resolutions: only the key and the
+ // resolution change (plus our checksum); every other line — version,
+ // dependencies, bin, languageName — carries over verbatim.
+ let new_key = format!("\"{fname}@{}\"", dep.artifact_url);
+ let resolution = format!("{fname}@{}", dep.artifact_url);
+ let body_lines = block.split_once('\n').map(|(_, rest)| rest).unwrap_or("");
+ let mut rewritten = format!("\n{new_key}:\n{body_lines}");
+ // `NoExpand`: the URL is literal text, and a `$` in it (a patch
+ // server path) must never be read as a capture-group reference.
+ rewritten = resolution_re
+ .replace(
+ &rewritten,
+ NoExpand(&format!("\n resolution: \"{resolution}\"")),
+ )
+ .to_string();
+ match &checksum {
+ Some(checksum) if checksum_re.is_match(&rewritten) => {
rewritten = checksum_re
- .replace(&rewritten, format!("\n checksum: {checksum}").as_str())
+ .replace(&rewritten, NoExpand(&format!("\n checksum: {checksum}")))
.to_string();
- } else {
+ }
+ Some(checksum) => {
rewritten = resolution_re
.replace(
&rewritten,
- format!("\n resolution: \"{resolution}\"\n checksum: {checksum}")
- .as_str(),
+ NoExpand(&format!(
+ "\n resolution: \"{resolution}\"\n checksum: {checksum}"
+ )),
)
.to_string();
}
- matched_any = true;
- if rewritten != *block {
- // The ledger records the lock's on-disk bytes (CRLF lines for
- // a CRLF lock), so every revert's byte-exact `replacen`
- // matches what the file really holds.
- result.edits.push(FileEdit {
- path: "yarn.lock".into(),
- kind: "redirect_yarn_berry_entry".into(),
- action: "rewritten".into(),
- key: Some(format!("{fname}@{}", dep.version)),
- original: Some(Value::String(eol.restore(block).into_owned())),
- new: Some(Value::String(eol.restore(&rewritten).into_owned())),
- });
- *block = rewritten;
- changed = true;
- }
+ None => {}
}
- if !matched_any && !alias_skipped {
- result.warnings.push(RewriteWarning {
- code: "redirect_yarn_berry_entry_not_found".into(),
- detail: format!("no npm: lock entry resolving {fname}@{}", dep.version),
+ let rewritten = rewritten[1..].to_string();
+ for (selector, original) in pin.apply(manifest_obj, &dep.artifact_url) {
+ manifest_changed = true;
+ result.edits.push(FileEdit {
+ path: BERRY_MANIFEST.into(),
+ kind: "redirect_yarn_berry_resolution".into(),
+ action: if original.is_some() { "rewritten" } else { "added" }.into(),
+ key: Some(selector),
+ original: original.map(Value::String),
+ new: Some(Value::String(dep.artifact_url.clone())),
});
}
+ if rewritten != block {
+ // The ledger records the lock's on-disk bytes (CRLF lines for a
+ // CRLF lock), so every revert's byte-exact `replacen` matches
+ // what the file really holds.
+ result.edits.push(FileEdit {
+ path: "yarn.lock".into(),
+ kind: "redirect_yarn_berry_entry".into(),
+ action: "rewritten".into(),
+ key: Some(format!("{fname}@{}", dep.version)),
+ original: Some(Value::String(eol.restore(&block).into_owned())),
+ new: Some(Value::String(eol.restore(&rewritten).into_owned())),
+ });
+ blocks[target_idx] = rewritten;
+ moved_keys.push(new_key);
+ changed = true;
+ }
+ result
+ .confirmed_yarn_berry_uuids
+ .insert(dep.patch_uuid.clone());
}
if changed {
- let out = blocks.join("\n\n");
+ berry_reposition_blocks(&mut blocks, &moved_keys, was_sorted);
+ let out = format!("{}{trailing_newlines}", blocks.join("\n\n"));
result
.files
.insert("yarn.lock".into(), format!("{bom}{}", eol.restore(&out)));
}
+ if manifest_changed {
+ if let (Some(text), Some(value)) = (manifest_text, manifest.as_ref()) {
+ match crate::vendor::common::JsonLayout::of(text)
+ .render(value)
+ .map(String::from_utf8)
+ {
+ Ok(Ok(rendered)) => {
+ result.files.insert(BERRY_MANIFEST.into(), rendered);
+ }
+ _ => {
+ // Unreachable for a parsed object; fail closed anyway: a
+ // lock pin without its resolutions is not installable.
+ result.files.remove("yarn.lock");
+ result.edits.retain(|e| {
+ e.kind != "redirect_yarn_berry_entry"
+ && e.kind != "redirect_yarn_berry_resolution"
+ });
+ for dep in &npm {
+ result.confirmed_yarn_berry_uuids.remove(&dep.patch_uuid);
+ }
+ result.warnings.push(RewriteWarning {
+ code: "redirect_yarn_berry_manifest_missing".into(),
+ detail: "the root package.json could not be re-serialized; nothing \
+ was redirected"
+ .into(),
+ });
+ }
+ }
+ }
+ }
+}
+
+/// A berry lock block's sort key: its unquoted key, or `None` for header
+/// comment blocks and `__metadata`.
+fn berry_sort_key(block: &str) -> Option<&str> {
+ let first = block.lines().next()?;
+ if first.starts_with([' ', '\t', '#']) || !first.ends_with(':') {
+ return None;
+ }
+ let key = first[..first.len() - 1].trim_matches('"');
+ (key != "__metadata").then_some(key)
+}
+
+/// Whether a berry lock's entries are in yarn's key order (see
+/// [`berry_reposition_blocks`]).
+pub(crate) fn berry_entries_sorted(blocks: &[String]) -> bool {
+ let keys: Vec<&str> = blocks.iter().filter_map(|b| berry_sort_key(b)).collect();
+ keys.windows(2).all(|w| w[0] <= w[1])
+}
+
+/// Whether an LF-normalized berry lock holds an entry for `name` at
+/// `version`, under any descriptor (npm, tarball, `patch:`, …).
+fn berry_lock_locks(content: &str, name: &str, version: &str) -> bool {
+ use crate::vendor::yarn_classic_lock::{split_berry_key_patterns, split_pattern};
+ let version_line = format!("\n version: {version}\n");
+ content.split("\n\n").any(|block| {
+ let Some(key) = block.lines().next().and_then(|l| l.strip_suffix(':')) else {
+ return false;
+ };
+ if key.starts_with([' ', '\t', '#']) || key == "__metadata" {
+ return false;
+ }
+ format!("{block}\n").contains(&version_line)
+ && split_berry_key_patterns(key).iter().any(|p| {
+ split_pattern(p).is_some_and(|(n, range)| {
+ n == name && berry_npm_alias_target(range).is_none_or(|real| real == name)
+ })
+ })
+ })
+}
+
+/// The package an `npm:@` alias range installs (`None` for a
+/// plain `npm:` or any other protocol).
+fn berry_npm_alias_target(range: &str) -> Option<&str> {
+ let body = range.strip_prefix("npm:")?;
+ crate::vendor::yarn_classic_lock::split_pattern(body).map(|(real, _)| real)
+}
+
+/// The root manifest the yarn berry hosted pin edits.
+const BERRY_MANIFEST: &str = "package.json";
+
+/// Whether `url` (a URL lock key or `resolutions` value) is a hosted
+/// tarball pin socket-patch wrote for `name` (at `version`, when given):
+/// this run's own artifact URL, or a URL on the same patch server (scheme,
+/// host and port of `artifact_url`) whose leaf names the package version.
+/// A user's own tarball for the package — a mirror, a fork — is on another
+/// origin and is never ours to rewrite.
+fn berry_hosted_pin_is_ours(
+ url: &str,
+ name: &str,
+ version: Option<&str>,
+ artifact_url: &str,
+) -> bool {
+ if url == artifact_url {
+ return true;
+ }
+ let names_it = match version {
+ Some(v) => hosted_url::hosted_url_names(url, name, v),
+ None => hosted_url::hosted_url_version(url, name).is_some(),
+ };
+ let same_origin = match (reqwest::Url::parse(url), reqwest::Url::parse(artifact_url)) {
+ (Ok(a), Ok(b)) => {
+ a.scheme() == b.scheme()
+ && a.host_str().is_some()
+ && a.host_str() == b.host_str()
+ && a.port_or_known_default() == b.port_or_known_default()
+ }
+ _ => false,
+ };
+ names_it && same_origin
}
+/// Whether `value` (a `resolutions` value) is a hosted tarball pin written
+/// for some version of `name` — ours to rewrite or drop (see
+/// [`berry_hosted_pin_is_ours`]).
+fn berry_resolution_is_ours(value: &Value, name: &str, artifact_url: &str) -> bool {
+ value
+ .as_str()
+ .is_some_and(|v| berry_hosted_pin_is_ours(v, name, None, artifact_url))
+}
+
+/// The manifest side of one berry hosted pin, computed by
+/// [`berry_resolutions_pin`] and written by [`BerryResolutionsPin::apply`].
+struct BerryResolutionsPin {
+ /// `@` selectors to route to the hosted tarball, one per
+ /// descriptor the lock entry carries (yarn's own `npm:` form).
+ selectors: Vec,
+ /// Our selectors left by an earlier pin of the same version that no
+ /// longer name a locked descriptor: dropped.
+ stale: Vec,
+}
+
+impl BerryResolutionsPin {
+ /// Write the pin into `manifest`: every selector routed to `url`, stale
+ /// ones dropped, and an emptied `resolutions` table removed. Returns
+ /// `(selector, previous value)` for each selector it added or changed.
+ fn apply(
+ &self,
+ manifest: &mut serde_json::Map,
+ url: &str,
+ ) -> Vec<(String, Option)> {
+ let table = manifest
+ .entry("resolutions".to_string())
+ .or_insert_with(|| Value::Object(serde_json::Map::new()));
+ let Some(table) = table.as_object_mut() else {
+ return Vec::new();
+ };
+ for selector in &self.stale {
+ table.shift_remove(selector);
+ }
+ let mut changed = Vec::new();
+ for selector in &self.selectors {
+ let previous = table.get(selector).and_then(Value::as_str).map(str::to_string);
+ if previous.as_deref() != Some(url) {
+ table.insert(selector.clone(), Value::String(url.to_string()));
+ changed.push((selector.clone(), previous));
+ }
+ }
+ if table.is_empty() {
+ manifest.shift_remove("resolutions");
+ }
+ changed
+ }
+}
+
+/// Plan the `resolutions` entries that route `name@version`'s locked
+/// descriptors to the hosted tarball. `key_ranges` are the lock entry's npm
+/// ranges (`npm:^1.3.0`); for an entry an earlier run already keyed by its
+/// tarball URL they are empty and recovered from our selectors routed to
+/// `current_url`.
+///
+/// Refuses (fail closed, nothing written) when the manifest already carries
+/// a user-authored `resolutions` entry for the package — any selector whose
+/// target is `name`, bare or scoped — since yarn would apply it alongside
+/// (or instead of) ours, and overwriting it would silently change what the
+/// user pinned.
+fn berry_resolutions_pin(
+ manifest: &serde_json::Map,
+ name: &str,
+ version: &str,
+ key_ranges: &[String],
+ current_url: Option<&str>,
+ artifact_url: &str,
+) -> Result {
+ use crate::vendor::yarn_berry_lock::resolution_selector_target;
+ let empty = serde_json::Map::new();
+ let table = match manifest.get("resolutions") {
+ None => &empty,
+ Some(Value::Object(table)) => table,
+ Some(_) => {
+ return Err(RewriteWarning {
+ code: "redirect_yarn_berry_resolutions_conflict".into(),
+ detail: "package.json `resolutions` is not an object; the hosted redirect \
+ will not rewrite it"
+ .into(),
+ })
+ }
+ };
+ let mut ours: Vec<(&String, &Value)> = Vec::new();
+ for (selector, value) in table {
+ if resolution_selector_target(selector) != Some(name) {
+ continue;
+ }
+ if berry_resolution_is_ours(value, name, artifact_url) {
+ ours.push((selector, value));
+ continue;
+ }
+ return Err(RewriteWarning {
+ code: "redirect_yarn_berry_resolutions_conflict".into(),
+ detail: format!(
+ "package.json already has a user-authored resolutions entry for `{selector}` \
+ ({value}); the hosted redirect pins {name}@{version} through `resolutions` \
+ and will not overwrite it — remove that entry (or use `scan --mode \
+ vendored`) and re-run"
+ ),
+ });
+ }
+ let selectors: Vec = if key_ranges.is_empty() {
+ ours.iter()
+ .filter(|(_, value)| value.as_str().is_some() && value.as_str() == current_url)
+ .map(|(selector, _)| (*selector).clone())
+ .collect()
+ } else {
+ key_ranges.iter().map(|r| format!("{name}@{r}")).collect()
+ };
+ if selectors.is_empty() {
+ return Err(RewriteWarning {
+ code: "redirect_yarn_berry_resolutions_conflict".into(),
+ detail: format!(
+ "yarn.lock pins {name}@{version} to a hosted tarball but package.json has no \
+ resolutions entry routing a descriptor to it, so the original descriptor is \
+ unknown and neither this run nor `socket-patch rollback` can rebuild the \
+ entry — restore yarn.lock and package.json from version control (or delete \
+ the entry and run `yarn install`), then re-run"
+ ),
+ });
+ }
+ let stale = ours
+ .iter()
+ .filter(|(selector, value)| {
+ !selectors.contains(selector)
+ && value
+ .as_str()
+ .is_some_and(|v| berry_hosted_pin_is_ours(v, name, Some(version), artifact_url))
+ })
+ .map(|(selector, _)| (*selector).clone())
+ .collect();
+ Ok(BerryResolutionsPin { selectors, stale })
+}
+
+/// Move each entry keyed `moved` to where yarn sorts it. Yarn writes lock
+/// entries sorted by their (unquoted) key — `__metadata` first — so an entry
+/// re-keyed from `name@npm:…` to `name@` can move past a sibling (e.g.
+/// `name@npm:7.0.0` now sorts after `name@https://…`); a lock in any other
+/// order is rewritten by yarn and fails `--immutable`. Header comment blocks
+/// and `__metadata` keep their place; the moved entry is inserted before the
+/// first entry whose key sorts after it. A lock that was not in yarn's
+/// order before the edit (`was_sorted`, from [`berry_entries_sorted`]; a
+/// hand-edited lock) keeps the entry in place, so a pin and its rollback
+/// still round-trip byte-exactly.
+pub(crate) fn berry_reposition_blocks(blocks: &mut Vec, moved: &[String], was_sorted: bool) {
+ if !was_sorted {
+ return;
+ }
+ for key in moved {
+ let line = format!("{key}:");
+ let Some(from) = blocks
+ .iter()
+ .position(|b| b.lines().next() == Some(line.as_str()))
+ else {
+ continue;
+ };
+ let block = blocks.remove(from);
+ let Some(own) = berry_sort_key(&block).map(str::to_string) else {
+ blocks.insert(from, block);
+ continue;
+ };
+ let to = blocks
+ .iter()
+ .position(|b| berry_sort_key(b).is_some_and(|k| k > own.as_str()))
+ .unwrap_or(blocks.len());
+ blocks.insert(to, block);
+ }
+}
+
+
// ── bun.lock (text lockfile) ─────────────────────────────────────────────────
// A registry 4-tuple `["name@version", "", {deps}, "sha512-…"]` is
// rewritten to a URL 3-tuple `["name@", {deps verbatim},
@@ -7502,6 +8026,12 @@ mod tests {
}
}
+ /// A minimal root manifest: the berry hosted pin writes its
+ /// `resolutions` into it.
+ fn berry_manifest() -> String {
+ "{\n \"name\": \"app\",\n \"version\": \"1.0.0\"\n}\n".to_string()
+ }
+
fn berry_lock(cache_key: &str) -> String {
format!(
"# header\n\n__metadata:\n version: 8\n cacheKey: {cache_key}\n\n\
@@ -7511,6 +8041,549 @@ mod tests {
)
}
+ /// Files for a berry hosted rewrite: the lock plus the root manifest.
+ fn berry_files(lock: String, manifest: String) -> BTreeMap {
+ let mut files = BTreeMap::new();
+ files.insert("yarn.lock".to_string(), lock);
+ files.insert("package.json".to_string(), manifest);
+ files
+ }
+
+ const BERRY_UUID: &str = "11111111-1111-4111-8111-111111111111";
+
+ fn berry_hosted_url(path_name: &str, leaf: &str, version: &str) -> String {
+ format!("https://patch.socket.dev/patch/npm/{path_name}/{version}/tok/{BERRY_UUID}/{leaf}-{version}.tgz")
+ }
+
+ /// #404: the hosted pin must never be an `npm:` locator. Yarn fetches an
+ /// `npm:` locator (`::__archiveUrl=` included) with its npm fetcher,
+ /// which attaches the npm registry's auth (`npmAuthToken`,
+ /// `YARN_NPM_AUTH_TOKEN`, `npmScopes`) for every scoped package and,
+ /// under `npmAlwaysAuth`, for every package — the registry token went to
+ /// the patch host. A tarball locator under the untouched `npm:` key is
+ /// rejected by yarn's hardened mode (YN0078, on by default for public PR
+ /// CI), so the pin is what yarn itself writes for a root `resolutions`
+ /// entry: a `@npm:` selector routed to the tarball, and the
+ /// lock entry re-keyed `@`, fetched with the tarball fetcher
+ /// (no registry auth, identical cache zip and `10c0` checksum).
+ #[test]
+ fn yarn_berry_hosted_pin_routes_resolutions_to_a_tarball_entry() {
+ let checksum = format!("10c0/{}", "7".repeat(128));
+ let scoped_url = berry_hosted_url("@isaacs/string-locale-compare", "string-locale-compare", "1.1.0");
+ let plain_url = berry_hosted_url("left-pad", "left-pad", "1.3.0");
+ let scoped = DepOverride {
+ namespace: Some("@isaacs".into()),
+ ..berry_override("string-locale-compare", "1.1.0", &scoped_url, &checksum)
+ };
+ let plain = berry_override("left-pad", "1.3.0", &plain_url, &checksum);
+ let lock = format!(
+ "{}\n\"@isaacs/string-locale-compare@npm:^1.1.0\":\n version: 1.1.0\n \
+ resolution: \"@isaacs/string-locale-compare@npm:1.1.0\"\n checksum: 10c0/{}\n \
+ languageName: node\n linkType: hard\n",
+ berry_lock("10c0"),
+ "4".repeat(128)
+ );
+ let files = berry_files(lock, berry_manifest());
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(&files, &[scoped, plain], &mut r);
+ assert!(r.warnings.is_empty(), "{:?}", r.warnings);
+ let out = &r.files["yarn.lock"];
+ assert!(
+ out.contains(&format!(
+ "\n\"@isaacs/string-locale-compare@{scoped_url}\":\n version: 1.1.0\n \
+ resolution: \"@isaacs/string-locale-compare@{scoped_url}\"\n checksum: {checksum}\n"
+ )),
+ "scoped entry re-keyed to its tarball: {out}"
+ );
+ assert!(
+ out.contains(&format!(
+ "\n\"left-pad@{plain_url}\":\n version: 1.3.0\n \
+ resolution: \"left-pad@{plain_url}\"\n checksum: {checksum}\n"
+ )),
+ "unscoped entry re-keyed to its tarball: {out}"
+ );
+ assert!(!out.contains("__archiveUrl") && !out.contains("@npm:"), "{out}");
+ assert!(out.ends_with("linkType: hard\n"), "trailing newline kept: {out:?}");
+ let manifest: Value = serde_json::from_str(&r.files["package.json"]).unwrap();
+ assert_eq!(
+ manifest["resolutions"],
+ json!({
+ "@isaacs/string-locale-compare@npm:^1.1.0": scoped_url,
+ "left-pad@npm:^1.3.0": plain_url,
+ }),
+ "{manifest}"
+ );
+ assert_eq!(manifest["name"], "app", "the rest of the manifest is kept");
+ assert_eq!(
+ r.edits.iter().filter(|e| e.kind == "redirect_yarn_berry_entry").count(),
+ 2
+ );
+ assert_eq!(
+ r.edits.iter().filter(|e| e.kind == "redirect_yarn_berry_resolution").count(),
+ 2
+ );
+ }
+
+ /// The selectors are descriptor-specific: another locked version of the
+ /// same package keeps its registry entry, a multi-range key gets one
+ /// selector per range, and the re-keyed entry moves to where yarn sorts
+ /// it (`is-number@https://…` sorts before `is-number@npm:7.0.0`), or
+ /// `yarn install --immutable` would rewrite the lock.
+ #[test]
+ fn yarn_berry_pin_is_descriptor_specific_and_resorted() {
+ let checksum = format!("10c0/{}", "7".repeat(128));
+ let url = berry_hosted_url("is-number", "is-number", "6.0.0");
+ let ovr = berry_override("is-number", "6.0.0", &url, &checksum);
+ let lock = format!(
+ "# header\n\n__metadata:\n version: 8\n cacheKey: 10c0\n\n\
+ \"is-number@npm:7.0.0\":\n version: 7.0.0\n resolution: \"is-number@npm:7.0.0\"\n \
+ checksum: 10c0/{a}\n languageName: node\n linkType: hard\n\n\
+ \"is-number@npm:^6.0.0, is-number@npm:~6.0.0\":\n version: 6.0.0\n \
+ resolution: \"is-number@npm:6.0.0\"\n checksum: 10c0/{b}\n languageName: node\n \
+ linkType: hard\n\n\
+ \"is-odd@npm:3.0.1\":\n version: 3.0.1\n resolution: \"is-odd@npm:3.0.1\"\n \
+ dependencies:\n is-number: \"npm:^6.0.0\"\n checksum: 10c0/{c}\n \
+ languageName: node\n linkType: hard\n",
+ a = "1".repeat(128),
+ b = "2".repeat(128),
+ c = "3".repeat(128)
+ );
+ let files = berry_files(lock, berry_manifest());
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r);
+ assert!(r.warnings.is_empty(), "{:?}", r.warnings);
+ let out = &r.files["yarn.lock"];
+ let keys: Vec<&str> = out
+ .lines()
+ .filter(|l| l.starts_with('"'))
+ .collect();
+ assert_eq!(
+ keys,
+ vec![
+ format!("\"is-number@{url}\":").as_str(),
+ "\"is-number@npm:7.0.0\":",
+ "\"is-odd@npm:3.0.1\":",
+ ],
+ "{out}"
+ );
+ assert!(
+ out.contains("resolution: \"is-number@npm:7.0.0\""),
+ "the other version is untouched: {out}"
+ );
+ assert!(
+ out.contains(" is-number: \"npm:^6.0.0\""),
+ "dependents keep their descriptors: {out}"
+ );
+ let manifest: Value = serde_json::from_str(&r.files["package.json"]).unwrap();
+ assert_eq!(
+ manifest["resolutions"],
+ json!({"is-number@npm:^6.0.0": url, "is-number@npm:~6.0.0": url}),
+ "{manifest}"
+ );
+ }
+
+ /// A repeat run over its own pin is a no-op; a superseding patch (new
+ /// uuid) re-pins the tarball-keyed entry and its selectors in place.
+ #[test]
+ fn yarn_berry_repeat_run_is_stable_and_a_new_uuid_repins() {
+ let checksum = format!("10c0/{}", "7".repeat(128));
+ let url = berry_hosted_url("left-pad", "left-pad", "1.3.0");
+ let ovr = berry_override("left-pad", "1.3.0", &url, &checksum);
+ let files = berry_files(berry_lock("10c0"), berry_manifest());
+ let mut first = RewriteResult::default();
+ rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut first);
+ let pinned = berry_files(
+ first.files["yarn.lock"].clone(),
+ first.files["package.json"].clone(),
+ );
+ assert!(first.confirmed_yarn_berry_uuids.contains(BERRY_UUID));
+ let mut again = RewriteResult::default();
+ rewrite_yarn_berry(&pinned, std::slice::from_ref(&ovr), &mut again);
+ assert!(again.warnings.is_empty(), "{:?}", again.warnings);
+ assert!(again.files.is_empty(), "repeat run rewrites nothing: {:?}", again.files);
+ // A pin already complete is confirmed without a write.
+ assert!(again.confirmed_yarn_berry_uuids.contains(BERRY_UUID));
+
+ let new_url = url.replace(BERRY_UUID, "22222222-2222-4222-8222-222222222222");
+ let newer = berry_override("left-pad", "1.3.0", &new_url, &checksum);
+ let mut repin = RewriteResult::default();
+ rewrite_yarn_berry(&pinned, std::slice::from_ref(&newer), &mut repin);
+ assert!(repin.warnings.is_empty(), "{:?}", repin.warnings);
+ let out = &repin.files["yarn.lock"];
+ assert!(out.contains(&format!("\"left-pad@{new_url}\":")), "{out}");
+ assert!(!out.contains(BERRY_UUID), "{out}");
+ let manifest: Value = serde_json::from_str(&repin.files["package.json"]).unwrap();
+ assert_eq!(manifest["resolutions"], json!({"left-pad@npm:^1.3.0": new_url}));
+ }
+
+ /// The URL-keyed lock entry alone is half a pin: with its manifest
+ /// `resolutions` routing removed, yarn installs nothing from it, so the
+ /// rewriter owns the dep yet never confirms it — a URL in the lock must
+ /// not let hosted confirmation (and the in-run VEX) attest the patch.
+ #[test]
+ fn yarn_berry_pin_without_its_routing_is_owned_but_never_confirmed() {
+ let checksum = format!("10c0/{}", "7".repeat(128));
+ let url = berry_hosted_url("left-pad", "left-pad", "1.3.0");
+ let ovr = berry_override("left-pad", "1.3.0", &url, &checksum);
+ let mut first = RewriteResult::default();
+ rewrite_yarn_berry(
+ &berry_files(berry_lock("10c0"), berry_manifest()),
+ std::slice::from_ref(&ovr),
+ &mut first,
+ );
+ let orphan = berry_files(first.files["yarn.lock"].clone(), berry_manifest());
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(&orphan, std::slice::from_ref(&ovr), &mut r);
+ assert!(r.files.is_empty(), "{:?}", r.files);
+ assert!(
+ r.warnings
+ .iter()
+ .any(|w| w.code == "redirect_yarn_berry_resolutions_conflict"),
+ "{:?}",
+ r.warnings
+ );
+ assert!(r.yarn_berry_uuids.contains(BERRY_UUID));
+ assert!(r.confirmed_yarn_berry_uuids.is_empty());
+
+ // A lock that does not lock the version hands the dep back.
+ let other = berry_override("left-pad", "9.9.9", &url, &checksum);
+ let mut none = RewriteResult::default();
+ rewrite_yarn_berry(
+ &berry_files(berry_lock("10c0"), berry_manifest()),
+ std::slice::from_ref(&other),
+ &mut none,
+ );
+ assert!(none.yarn_berry_uuids.is_empty());
+ assert!(none.confirmed_yarn_berry_uuids.is_empty());
+ // ... before any per-grant gate: a grant this rewriter cannot use
+ // (no checksum, an unfetchable URL) still leaves an unlocked
+ // package to the other lockfiles.
+ for bad in [
+ DepOverride {
+ integrity: Integrity::default(),
+ ..other.clone()
+ },
+ berry_override(
+ "left-pad",
+ "9.9.9",
+ "https://patch.socket.dev/x.zip",
+ &checksum,
+ ),
+ ] {
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(
+ &berry_files(berry_lock("10c0"), berry_manifest()),
+ std::slice::from_ref(&bad),
+ &mut r,
+ );
+ assert!(r.yarn_berry_uuids.is_empty(), "{:?}", r.warnings);
+ }
+ }
+
+ /// A berry lock the preflight refuses (here an unsupported cacheKey)
+ /// verifies nothing, so it confirms nothing — yet the deps it locks stay
+ /// the berry rewriter's: an earlier run's URL in the lock must not
+ /// confirm them through the hosted text probe.
+ #[test]
+ fn yarn_berry_preflight_refusal_owns_locked_deps_without_confirming() {
+ let checksum = format!("10c0/{}", "7".repeat(128));
+ let url = berry_hosted_url("left-pad", "left-pad", "1.3.0");
+ let ovr = berry_override("left-pad", "1.3.0", &url, &checksum);
+ let mut first = RewriteResult::default();
+ rewrite_yarn_berry(
+ &berry_files(berry_lock("10c0"), berry_manifest()),
+ std::slice::from_ref(&ovr),
+ &mut first,
+ );
+ let refused = berry_files(
+ first.files["yarn.lock"].replace("cacheKey: 10c0", "cacheKey: 8c0"),
+ berry_manifest(),
+ );
+ let unlocked = berry_override("right-pad", "1.0.0", &url, &checksum);
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(&refused, &[ovr, unlocked], &mut r);
+ assert_eq!(r.warnings[0].code, "redirect_yarn_berry_cache_unsupported");
+ assert!(r.files.is_empty());
+ assert_eq!(
+ r.yarn_berry_uuids.iter().collect::>(),
+ vec![BERRY_UUID],
+ "only the locked dep is owned"
+ );
+ assert!(r.confirmed_yarn_berry_uuids.is_empty());
+ }
+
+ /// A fork alias key (`left-pad@npm:other@^1.3.0`) installs `other`
+ /// under the `left-pad` name: even at the patched version it is not the
+ /// patched package, so it is never re-keyed nor makes the real entry
+ /// ambiguous.
+ #[test]
+ fn yarn_berry_fork_alias_entry_is_never_re_keyed() {
+ let checksum = format!("10c0/{}", "7".repeat(128));
+ let url = berry_hosted_url("left-pad", "left-pad", "1.3.0");
+ let ovr = berry_override("left-pad", "1.3.0", &url, &checksum);
+ let fork = format!(
+ "\"left-pad@npm:other@^1.3.0\":\n version: 1.3.0\n resolution: \"other@npm:1.3.0\"\n \
+ checksum: 10c0/{}\n languageName: node\n linkType: hard\n",
+ "4".repeat(128)
+ );
+ let fork_only =
+ format!("# header\n\n__metadata:\n version: 8\n cacheKey: 10c0\n\n{fork}");
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(
+ &berry_files(fork_only, berry_manifest()),
+ std::slice::from_ref(&ovr),
+ &mut r,
+ );
+ assert!(r.files.is_empty(), "{:?}", r.files);
+ assert_eq!(r.warnings[0].code, "redirect_yarn_berry_entry_not_found");
+ assert!(r.yarn_berry_uuids.is_empty());
+
+ // Beside the real entry: only the real one is pinned.
+ let both = format!("{}\n{fork}", berry_lock("10c0"));
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(
+ &berry_files(both, berry_manifest()),
+ std::slice::from_ref(&ovr),
+ &mut r,
+ );
+ assert!(r.warnings.is_empty(), "{:?}", r.warnings);
+ let out = &r.files["yarn.lock"];
+ assert!(out.contains(&format!("\"left-pad@{url}\":")), "{out}");
+ assert!(
+ out.contains(&fork),
+ "the fork entry is byte-identical: {out}"
+ );
+ assert!(r.confirmed_yarn_berry_uuids.contains(BERRY_UUID));
+ }
+
+ /// The artifact URL is spliced as literal text: a `$` in it (legal in a
+ /// URL path) must not be expanded as a regex capture reference.
+ #[test]
+ fn yarn_berry_artifact_url_dollar_is_written_literally() {
+ let checksum = format!("10c0/{}", "7".repeat(128));
+ let url = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/t$0k$1/u/left-pad-1.3.0.tgz";
+ let ovr = berry_override("left-pad", "1.3.0", url, &checksum);
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(
+ &berry_files(berry_lock("10c0"), berry_manifest()),
+ std::slice::from_ref(&ovr),
+ &mut r,
+ );
+ assert!(r.warnings.is_empty(), "{:?}", r.warnings);
+ let out = &r.files["yarn.lock"];
+ assert!(
+ out.contains(&format!(
+ "\"left-pad@{url}\":\n version: 1.3.0\n resolution: \"left-pad@{url}\"\n \
+ checksum: {checksum}\n"
+ )),
+ "{out}"
+ );
+ // ... and the checksum-line insertion path, for a lock without one.
+ let no_checksum_line =
+ berry_lock("10c0").replace(&format!(" checksum: 10c0/{}\n", "3".repeat(128)), "");
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(
+ &berry_files(no_checksum_line, berry_manifest()),
+ std::slice::from_ref(&ovr),
+ &mut r,
+ );
+ let out = &r.files["yarn.lock"];
+ assert!(
+ out.contains(&format!(
+ " resolution: \"left-pad@{url}\"\n checksum: {checksum}\n"
+ )),
+ "{out}"
+ );
+ }
+
+ /// A rescan whose grant lacks the `yarnBerry10c0` checksum still
+ /// confirms a pin an earlier run completed (the lock already holds the
+ /// checksum it was written with); an entry that would need the checksum
+ /// written is owned and refused, never confirmed.
+ #[test]
+ fn yarn_berry_checksumless_grant_keeps_a_complete_pin_only() {
+ let checksum = format!("10c0/{}", "7".repeat(128));
+ let url = berry_hosted_url("left-pad", "left-pad", "1.3.0");
+ let ovr = berry_override("left-pad", "1.3.0", &url, &checksum);
+ let mut first = RewriteResult::default();
+ rewrite_yarn_berry(
+ &berry_files(berry_lock("10c0"), berry_manifest()),
+ std::slice::from_ref(&ovr),
+ &mut first,
+ );
+ let pinned = berry_files(
+ first.files["yarn.lock"].clone(),
+ first.files["package.json"].clone(),
+ );
+ let checksumless = DepOverride {
+ integrity: Integrity::default(),
+ ..ovr.clone()
+ };
+ let mut again = RewriteResult::default();
+ rewrite_yarn_berry(&pinned, std::slice::from_ref(&checksumless), &mut again);
+ assert!(again.warnings.is_empty(), "{:?}", again.warnings);
+ assert!(again.files.is_empty(), "{:?}", again.files);
+ assert!(again.confirmed_yarn_berry_uuids.contains(BERRY_UUID));
+
+ let mut fresh = RewriteResult::default();
+ rewrite_yarn_berry(
+ &berry_files(berry_lock("10c0"), berry_manifest()),
+ std::slice::from_ref(&checksumless),
+ &mut fresh,
+ );
+ assert_eq!(
+ fresh.warnings[0].code,
+ "redirect_yarn_berry_missing_checksum"
+ );
+ assert!(fresh.files.is_empty());
+ assert!(fresh.yarn_berry_uuids.contains(BERRY_UUID));
+ assert!(fresh.confirmed_yarn_berry_uuids.is_empty());
+ }
+
+ /// A lock written by an earlier release carries the old
+ /// `npm:…::__archiveUrl=` pin; a repeat hosted run re-pins it (#404).
+ #[test]
+ fn yarn_berry_legacy_archive_url_pin_is_migrated() {
+ let checksum = format!("10c0/{}", "7".repeat(128));
+ let url = berry_hosted_url("left-pad", "left-pad", "1.3.0");
+ let ovr = berry_override("left-pad", "1.3.0", &url, &checksum);
+ let legacy = berry_lock("10c0").replace(
+ "resolution: \"left-pad@npm:1.3.0\"",
+ &format!(
+ "resolution: \"left-pad@npm:1.3.0::__archiveUrl={}\"",
+ crate::utils::uri::encode_uri_component(&url)
+ ),
+ );
+ let files = berry_files(legacy, berry_manifest());
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r);
+ assert!(r.warnings.is_empty(), "{:?}", r.warnings);
+ let out = &r.files["yarn.lock"];
+ assert!(
+ out.contains(&format!("\"left-pad@{url}\":\n version: 1.3.0\n resolution: \"left-pad@{url}\"\n")),
+ "{out}"
+ );
+ assert!(!out.contains("__archiveUrl"), "{out}");
+ assert!(r.files["package.json"].contains("\"left-pad@npm:^1.3.0\""));
+ }
+
+ /// The pin never overwrites a user-authored `resolutions` entry for the
+ /// package (bare, ranged or nested), never runs without a root
+ /// manifest, and leaves a package yarn also locks through a builtin
+ /// `patch:` entry alone (that entry wraps the same npm descriptor).
+ /// Each is a skip with a warning, nothing written.
+ #[test]
+ fn yarn_berry_pin_refusals_leave_everything_untouched() {
+ let checksum = format!("10c0/{}", "7".repeat(128));
+ let url = berry_hosted_url("left-pad", "left-pad", "1.3.0");
+ let ovr = berry_override("left-pad", "1.3.0", &url, &checksum);
+ for (label, selector) in [
+ ("bare", "left-pad"),
+ ("ranged", "left-pad@npm:^1.3.0"),
+ ("nested", "app/left-pad"),
+ ] {
+ let manifest = format!(
+ "{{\n \"name\": \"app\",\n \"resolutions\": {{\n \"{selector}\": \"1.3.0\"\n }}\n}}\n"
+ );
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(&berry_files(berry_lock("10c0"), manifest), std::slice::from_ref(&ovr), &mut r);
+ assert!(r.files.is_empty(), "{label}: {:?}", r.files);
+ assert_eq!(
+ r.warnings.iter().map(|w| w.code.as_str()).collect::>(),
+ vec!["redirect_yarn_berry_resolutions_conflict"],
+ "{label}"
+ );
+ }
+ // A user's own tarball for the package — same `-.tgz`
+ // leaf, another origin (a mirror, a fork) — is user-authored too.
+ let manifest = "{\n \"name\": \"app\",\n \"resolutions\": {\n \
+ \"left-pad@npm:^1.3.0\": \"https://mirror.example/left-pad-1.3.0.tgz\"\n }\n}\n";
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(
+ &berry_files(berry_lock("10c0"), manifest.into()),
+ std::slice::from_ref(&ovr),
+ &mut r,
+ );
+ assert!(r.files.is_empty(), "mirror tarball: {:?}", r.files);
+ assert_eq!(
+ r.warnings
+ .iter()
+ .map(|w| w.code.as_str())
+ .collect::>(),
+ vec!["redirect_yarn_berry_resolutions_conflict"],
+ "mirror tarball"
+ );
+ // An unrelated user entry is kept as-is next to ours.
+ let manifest = "{\n \"name\": \"app\",\n \"resolutions\": {\n \"other\": \"2.0.0\"\n }\n}\n";
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(&berry_files(berry_lock("10c0"), manifest.into()), std::slice::from_ref(&ovr), &mut r);
+ assert!(r.warnings.is_empty(), "{:?}", r.warnings);
+ let m: Value = serde_json::from_str(&r.files["package.json"]).unwrap();
+ assert_eq!(m["resolutions"], json!({"other": "2.0.0", "left-pad@npm:^1.3.0": url}));
+
+ let mut files = BTreeMap::new();
+ files.insert("yarn.lock".to_string(), berry_lock("10c0"));
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r);
+ assert!(r.files.is_empty(), "{:?}", r.files);
+ assert_eq!(
+ r.warnings.iter().map(|w| w.code.as_str()).collect::>(),
+ vec!["redirect_yarn_berry_manifest_missing"]
+ );
+
+ let with_patch = format!(
+ "{}\n\"left-pad@patch:left-pad@npm%3A^1.3.0#~builtin\":\n \
+ version: 1.3.0\n resolution: \"left-pad@patch:left-pad@npm%3A1.3.0#~builtin::version=1.3.0&hash=abc\"\n \
+ languageName: node\n linkType: hard\n",
+ berry_lock("10c0")
+ );
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(&berry_files(with_patch, berry_manifest()), std::slice::from_ref(&ovr), &mut r);
+ assert!(r.files.is_empty(), "{:?}", r.files);
+ let codes: Vec<&str> = r.warnings.iter().map(|w| w.code.as_str()).collect();
+ assert!(codes.contains(&"redirect_yarn_berry_shared_descriptor"), "{codes:?}");
+ }
+
+ /// Yarn routes a URL locator to its tarball fetcher only when it is an
+ /// `http(s)://` URL whose path ends in `.tgz`/`.tar.gz` with no query
+ /// (yarn's `TARBALL_REGEXP`). Any other artifact URL would make yarn
+ /// reject the lock, so the entry is refused and left byte-identical.
+ #[test]
+ fn yarn_berry_refuses_artifact_url_yarn_cannot_fetch_as_tarball() {
+ let checksum = format!("10c0/{}", "7".repeat(128));
+ for url in [
+ "https://p.test/left-pad-1.3.0.zip",
+ "https://p.test/left-pad-1.3.0.tgz?sig=1",
+ "https://p.test/left-pad-1.3.0.tgz#frag",
+ "ftp://p.test/left-pad-1.3.0.tgz",
+ "https://p.test/a b/left-pad-1.3.0.tgz",
+ "https://p.test/a\"b/left-pad-1.3.0.tgz",
+ ] {
+ let ovr = berry_override("left-pad", "1.3.0", url, &checksum);
+ let files = berry_files(berry_lock("10c0"), berry_manifest());
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r);
+ assert!(r.files.is_empty(), "{url}: nothing written");
+ assert!(r.edits.is_empty(), "{url}: {:?}", r.edits);
+ assert_eq!(
+ r.warnings.iter().map(|w| w.code.as_str()).collect::>(),
+ vec!["redirect_yarn_berry_artifact_url_unsupported"],
+ "{url}"
+ );
+ }
+ for url in [
+ "https://p.test/left-pad-1.3.0.tgz",
+ "http://127.0.0.1:8080/patch/npm/@s/n/1.0.0/t/u/n-1.0.0.tar.gz",
+ ] {
+ let ovr = berry_override("left-pad", "1.3.0", url, &checksum);
+ let files = berry_files(berry_lock("10c0"), berry_manifest());
+ let mut r = RewriteResult::default();
+ rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r);
+ assert!(r.warnings.is_empty(), "{url}: {:?}", r.warnings);
+ assert!(r.files.contains_key("yarn.lock"), "{url}");
+ }
+ }
+
/// yarn 4.0.x: a lock that spells its `10c0` checksums bare (yarn
/// 4.0.0–4.0.2) gets the hosted entry's checksum spelled bare — the
/// API's prefixed `yarnBerry10c0` would make `yarn install --immutable`
@@ -7533,10 +8606,14 @@ mod tests {
] {
let mut files = BTreeMap::new();
files.insert("yarn.lock".to_string(), lock.clone());
+ files.insert("package.json".to_string(), berry_manifest());
let mut r = RewriteResult::default();
rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r);
let out = &r.files["yarn.lock"];
- assert!(out.contains("::__archiveUrl="), "{out}");
+ assert!(
+ out.contains("resolution: \"left-pad@http://p.test/lp.tgz\""),
+ "{out}"
+ );
assert!(out.contains(&want), "want {want:?} in:\n{out}");
assert_eq!(out.matches("checksum:").count(), 1, "{out}");
}
@@ -14502,9 +15579,16 @@ packages:
let lf = berry_lock_two_entries();
let mut lf_files = BTreeMap::new();
lf_files.insert("yarn.lock".to_string(), lf.clone());
+ lf_files.insert("package.json".to_string(), berry_manifest());
let mut lf_result = RewriteResult::default();
rewrite_yarn_berry(&lf_files, std::slice::from_ref(&ovr), &mut lf_result);
let lf_out = lf_result.files["yarn.lock"].clone();
+ let lf_edit = lf_result
+ .edits
+ .iter()
+ .find(|e| e.path == "yarn.lock")
+ .expect("the LF lock edit")
+ .clone();
for (label, bom, crlf) in [
("crlf", "", true),
@@ -14522,6 +15606,7 @@ packages:
let input = respell(&lf);
let mut files = BTreeMap::new();
files.insert("yarn.lock".to_string(), input.clone());
+ files.insert("package.json".to_string(), berry_manifest());
let mut r = RewriteResult::default();
rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r);
assert!(r.warnings.is_empty(), "{label}: {:?}", r.warnings);
@@ -14544,13 +15629,14 @@ packages:
"{label}: BOM kept"
);
assert!(
- out.contains(&crate::utils::uri::encode_uri_component(url)),
+ out.contains(&format!("resolution: \"left-pad@{url}\"")),
"{label}: {out}"
);
// One edit; its fragments are the on-disk bytes of the entry.
- assert_eq!(r.edits.len(), 1, "{label}");
- let edit = &r.edits[0];
+ let lock_edits: Vec<&FileEdit> = r.edits.iter().filter(|e| e.path == "yarn.lock").collect();
+ assert_eq!(lock_edits.len(), 1, "{label}");
+ let edit = lock_edits[0];
let (orig, new) = (
edit.original.as_ref().and_then(Value::as_str).unwrap(),
edit.new.as_ref().and_then(Value::as_str).unwrap(),
@@ -14559,7 +15645,7 @@ packages:
(orig, new),
(
respell(
- lf_result.edits[0]
+ lf_edit
.original
.as_ref()
.unwrap()
@@ -14567,7 +15653,7 @@ packages:
.unwrap()
)
.trim_start_matches('\u{feff}'),
- respell(lf_result.edits[0].new.as_ref().unwrap().as_str().unwrap())
+ respell(lf_edit.new.as_ref().unwrap().as_str().unwrap())
.trim_start_matches('\u{feff}'),
),
"{label}: fragments in the lock's on-disk form"
@@ -14586,6 +15672,7 @@ packages:
// Re-run over the rewritten lock: nothing to do.
let mut files = BTreeMap::new();
files.insert("yarn.lock".to_string(), out.clone());
+ files.insert("package.json".to_string(), r.files["package.json"].clone());
let mut again = RewriteResult::default();
rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut again);
assert!(
@@ -14712,11 +15799,15 @@ packages:
assert!(is_berry_lock(&lock));
let mut files = BTreeMap::new();
files.insert("yarn.lock".to_string(), lock);
+ files.insert("package.json".to_string(), berry_manifest());
let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let out = &r.files["yarn.lock"];
assert!(out.starts_with("\u{feff}__metadata:"), "{out:?}");
- assert!(out.contains("::__archiveUrl="), "{out}");
+ assert!(
+ out.contains("resolution: \"left-pad@http://p.test/lp.tgz\""),
+ "{out}"
+ );
}
/// CRLF locks preserve their newline style through hosted rewriting.
@@ -16486,7 +17577,7 @@ packages:
/// An UNQUOTED single-descriptor berry key (yarn emits unquoted keys for
/// names that need no YAML quoting) whose entry has no `checksum:` line:
- /// the resolution gains `::__archiveUrl=` and a checksum line is INSERTED
+ /// the resolution becomes the tarball locator and a checksum line is INSERTED
/// after it.
#[test]
fn yarn_berry_unquoted_key_without_checksum_gains_inserted_line() {
@@ -16497,18 +17588,19 @@ packages:
resolution: \"left-pad@npm:1.3.0\"\n languageName: node\n linkType: hard\n";
let mut files = BTreeMap::new();
files.insert("yarn.lock".to_string(), lock.to_string());
+ files.insert("package.json".to_string(), berry_manifest());
let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr));
let out = r.files.get("yarn.lock").expect("lock rewritten");
assert!(
- out.contains("\n resolution: \"left-pad@npm:1.3.0::__archiveUrl="),
- "resolution gains the archiveUrl binding: {out}"
+ out.contains("\n resolution: \"left-pad@http://p.test/lp.tgz\""),
+ "resolution becomes the tarball locator: {out}"
);
assert!(
out.contains(&format!("\"\n checksum: {checksum}\n languageName: node")),
"checksum inserted right after the resolution: {out}"
);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
- assert_eq!(r.edits.len(), 1);
+ assert_eq!(r.edits.iter().filter(|e| e.path == "yarn.lock").count(), 1);
}
/// A bun URL 3-tuple already at the CURRENT artifact URL but with a stale
@@ -17529,6 +18621,7 @@ packages:
let ovr = berry_override("left-pad", "1.3.0", "http://p.test/lp.tgz", &checksum);
let mut files = BTreeMap::new();
files.insert("yarn.lock".to_string(), berry_lock("10c0"));
+ files.insert("package.json".to_string(), berry_manifest());
files.insert(
".yarnrc.yml".to_string(),
"compressionLevel: 0\n".to_string(),
@@ -17541,7 +18634,7 @@ packages:
.get("yarn.lock")
.expect("explicit level 0 must not refuse");
assert!(
- out.contains("__archiveUrl=") && out.contains(&checksum),
+ out.contains("left-pad@http://p.test/lp.tgz") && out.contains(&checksum),
"{out}"
);
}
@@ -17561,6 +18654,7 @@ packages:
);
let mut files = BTreeMap::new();
files.insert("yarn.lock".to_string(), lock);
+ files.insert("package.json".to_string(), berry_manifest());
let mut r = RewriteResult::default();
rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
@@ -17575,12 +18669,12 @@ packages:
"the rangeless key stays byte-identical: {out}"
);
assert_eq!(
- r.edits.len(),
+ r.edits.iter().filter(|e| e.path == "yarn.lock").count(),
1,
"only the real entry is edited: {:?}",
r.edits
);
- assert!(out.contains("__archiveUrl="), "{out}");
+ assert!(out.contains("left-pad@http://p.test/lp.tgz"), "{out}");
}
/// A descriptor with NO protocol at all (`left-pad@1.3.0`) is refused
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 65d590299..fe1938991 100644
--- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs
+++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs
@@ -372,16 +372,18 @@ async fn restore_berry(
ctx: &Ctx<'_>,
result: &mut FormatResult,
) {
+ use crate::vendor::yarn_berry_lock::resolution_selector_target;
use crate::vendor::yarn_classic_lock::{split_berry_key_patterns, split_pattern};
let (bom, body) = match raw.strip_prefix('\u{feff}') {
Some(rest) => ("\u{feff}", rest),
None => ("", raw),
};
- let yarnrc_rel = match rel.rsplit_once('/') {
- Some((dir, _)) => format!("{dir}/.yarnrc.yml"),
- None => ".yarnrc.yml".to_string(),
+ let dir_prefix = match rel.rsplit_once('/') {
+ Some((dir, _)) => format!("{dir}/"),
+ None => String::new(),
};
+ let yarnrc_rel = format!("{dir_prefix}.yarnrc.yml");
let yarnrc = view.read(&yarnrc_rel).await.ok().flatten();
if let Err(w) = super::super::preflight_yarn_berry_hosted(raw, yarnrc.as_deref()) {
refuse_all_in(pins, rel, result, w.detail);
@@ -389,7 +391,10 @@ async fn restore_berry(
}
let eol = LineEndings::of(body);
let content = to_lf(body).into_owned();
- let mut blocks: Vec = content.split("\n\n").map(String::from).collect();
+ let trimmed = content.trim_end_matches('\n');
+ let trailing_newlines = content[trimmed.len()..].to_string();
+ let mut blocks: Vec = trimmed.split("\n\n").map(String::from).collect();
+ let was_sorted = super::super::berry_entries_sorted(&blocks);
let resolution_re = Regex::new(r#"\n {2}resolution: "([^"]*)""#)
.expect("static resolution-line regex is valid");
let checksum_re =
@@ -397,15 +402,45 @@ async fn restore_berry(
let version_re =
Regex::new(r"\n {2}version: ([^\n]*)").expect("static version-line regex is valid");
- let mut hits: Vec<(usize, String, String, String)> = Vec::new();
+ // The root manifest: a hosted pin keyed by its tarball URL keeps the
+ // descriptors it replaced only as `resolutions` selectors routed there.
+ let pkg_rel = format!("{dir_prefix}package.json");
+ let pkg_text = view.read(&pkg_rel).await.ok().flatten();
+ let mut pkg: Option = pkg_text
+ .as_deref()
+ .and_then(|t| serde_json::from_str(t.strip_prefix('\u{feff}').unwrap_or(t)).ok())
+ .filter(serde_json::Value::is_object);
+ let mut pkg_changed = false;
+
+ // `(block index, uuid, name, version, restored key or None to keep it,
+ // selectors to drop)`.
+ struct Hit {
+ idx: usize,
+ uuid: String,
+ name: String,
+ version: String,
+ key: Option,
+ selectors: Vec,
+ }
+ let mut hits: Vec = Vec::new();
for (i, block) in blocks.iter().enumerate() {
let Some(resolution) = resolution_re.captures(block).map(|c| c[1].to_string()) else {
continue;
};
- let Some((_, archive)) = resolution.split_once("::__archiveUrl=") else {
+ // The hosted pin is the tarball-URL locator `name@`; locks
+ // pinned by releases up to 5.0 spell it as an `npm:` locator's
+ // percent-encoded `::__archiveUrl=` binding (#404).
+ let Some((_, reference)) = split_pattern(&resolution) else {
+ continue;
+ };
+ let url_pin = reference.starts_with("https://") || reference.starts_with("http://");
+ let archive = if url_pin {
+ reference
+ } else if let Some((_, binding)) = reference.split_once("::__archiveUrl=") {
+ binding.split('&').next().unwrap_or(binding)
+ } else {
continue;
};
- let archive = archive.split('&').next().unwrap_or(archive);
let Some(uuid) = ctx.hosted_uuid(archive) else {
continue;
};
@@ -425,16 +460,80 @@ async fn restore_berry(
let version = version_re
.captures(block)
.map(|c| c[1].trim().trim_matches('"').to_string());
- match (names.len(), names.into_iter().next(), version) {
- (1, Some(name), Some(version)) => hits.push((i, uuid, name.to_string(), version)),
- _ => result.refuse(
+ let (Some(name), Some(version), 1) = (names.iter().next(), version, names.len()) else {
+ result.refuse(
&uuid,
format!("a {rel} entry wiring it names no single package and version"),
- ),
- }
+ );
+ continue;
+ };
+ // An entry keyed by the tarball descriptor itself takes back the
+ // descriptors its `resolutions` selectors route to that URL.
+ let keyed_by_url = matches!(
+ patterns.as_slice(),
+ [only] if split_pattern(only).is_some_and(|(_, r)| r == reference)
+ );
+ let (restored_key, selectors) = if keyed_by_url {
+ let selectors: Vec = pkg
+ .as_ref()
+ .and_then(|p| p.get("resolutions"))
+ .and_then(serde_json::Value::as_object)
+ .map(|table| {
+ table
+ .iter()
+ .filter(|(sel, value)| {
+ resolution_selector_target(sel) == Some(name.as_str())
+ && value.as_str() == Some(reference)
+ })
+ .map(|(sel, _)| sel.clone())
+ .collect()
+ })
+ .unwrap_or_default();
+ let mut descriptors: Vec = selectors
+ .iter()
+ .filter(|sel| {
+ split_pattern(sel).is_some_and(|(n, r)| n == name && r.starts_with("npm:"))
+ })
+ .cloned()
+ .collect();
+ if descriptors.is_empty() {
+ result.refuse(
+ &uuid,
+ format!(
+ "{pkg_rel} has no resolutions entry routing a {name} descriptor to the \
+ hosted tarball, so the {rel} entry's original key cannot be rebuilt — \
+ restore {rel} and {pkg_rel} from version control (or delete the entry \
+ and run `yarn install`)"
+ ),
+ );
+ continue;
+ }
+ descriptors.sort();
+ descriptors.dedup();
+ (Some(format!("\"{}\"", descriptors.join(", "))), selectors)
+ } else {
+ (None, Vec::new())
+ };
+ hits.push(Hit {
+ idx: i,
+ uuid,
+ name: name.clone(),
+ version,
+ key: restored_key,
+ selectors,
+ });
}
let mut changed = false;
- for (i, uuid, name, version) in hits {
+ let mut moved: Vec = Vec::new();
+ for Hit {
+ idx,
+ uuid,
+ name,
+ version,
+ key,
+ selectors,
+ } in hits
+ {
if result.refused.contains_key(&uuid) {
continue;
}
@@ -459,20 +558,69 @@ async fn restore_berry(
};
let resolution = format!("\n resolution: \"{name}@npm:{version}\"").replace('$', "$$");
let mut block = resolution_re
- .replace(&blocks[i], resolution.as_str())
+ .replace(&blocks[idx], resolution.as_str())
.into_owned();
if checksum_re.is_match(&block) {
block = checksum_re
.replace(&block, format!("\n checksum: {checksum}").as_str())
.into_owned();
}
- blocks[i] = block;
+ if let Some(key) = key {
+ let body_lines = block
+ .split_once('\n')
+ .map(|(_, r)| r.to_string())
+ .unwrap_or_default();
+ block = format!("{key}:\n{body_lines}");
+ moved.push(key);
+ }
+ blocks[idx] = block;
+ if !selectors.is_empty() {
+ if let Some(table) = pkg
+ .as_mut()
+ .and_then(serde_json::Value::as_object_mut)
+ .and_then(|obj| obj.get_mut("resolutions"))
+ .and_then(serde_json::Value::as_object_mut)
+ {
+ for selector in &selectors {
+ table.shift_remove(selector);
+ }
+ pkg_changed = true;
+ }
+ }
result.handled.insert(uuid);
changed = true;
}
- if changed {
- view.write(rel, format!("{bom}{}", eol.restore(&blocks.join("\n\n"))));
+ if !changed {
+ return;
+ }
+ if pkg_changed {
+ if let (Some(text), Some(value)) = (pkg_text.as_deref(), pkg.as_mut()) {
+ if let Some(obj) = value.as_object_mut() {
+ if obj
+ .get("resolutions")
+ .and_then(serde_json::Value::as_object)
+ .is_some_and(serde_json::Map::is_empty)
+ {
+ obj.shift_remove("resolutions");
+ }
+ }
+ match crate::vendor::common::JsonLayout::of(text)
+ .render(value)
+ .map(String::from_utf8)
+ {
+ Ok(Ok(rendered)) => view.write(&pkg_rel, rendered),
+ _ => {
+ for uuid in pins.keys() {
+ result.refuse(uuid, format!("{pkg_rel} could not be re-serialized"));
+ }
+ return;
+ }
+ }
+ }
}
+ super::super::berry_reposition_blocks(&mut blocks, &moved, was_sorted);
+ let out = format!("{}{trailing_newlines}", blocks.join("\n\n"));
+ view.write(rel, format!("{bom}{}", eol.restore(&out)));
}
// ── pnpm-lock.yaml ───────────────────────────────────────────────────────────
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 e43f5d85a..e6392c30a 100644
--- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs
+++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs
@@ -998,6 +998,43 @@ async fn yarn_berry_registry_resolutions_inventory_with_checksums() {
assert!(!entries.iter().any(|e| e.name == "fixture"), "{entries:?}");
}
+/// #404: a hosted berry pin — keyed by its tarball descriptor (the
+/// `resolutions` pin) or, from an earlier release, under the untouched
+/// `npm:` key — is still the registry package, so lock-only discovery keeps
+/// inventorying it (and a later hosted scan can re-pin it). A user's own URL
+/// dependency whose tarball does not name a package version stays out.
+#[tokio::test]
+async fn yarn_berry_hosted_tarball_pin_stays_in_the_inventory() {
+ let tmp = tempfile::tempdir().unwrap();
+ let hosted = YARN_BERRY
+ .replace(
+ "resolution: \"left-pad@npm:1.3.0\"",
+ "resolution: \"left-pad@https://patch.socket.dev/patch/npm/left-pad/1.3.0/t/u/left-pad-1.3.0.tgz\"",
+ )
+ .replace(
+ "\"@scope/pkg@npm:^2.0.0\":\n version: 2.0.0\n resolution: \"@scope/pkg@npm:2.0.0\"",
+ "\"@scope/pkg@https://patch.socket.dev/patch/npm/@scope/pkg/2.0.0/t/u/pkg-2.0.0.tgz\":\n \
+ version: 2.0.0\n \
+ resolution: \"@scope/pkg@https://patch.socket.dev/patch/npm/@scope/pkg/2.0.0/t/u/pkg-2.0.0.tgz\"",
+ )
+ + "\n\"own@https://example.test/own-latest.tgz\":\n version: 1.0.0\n \
+ resolution: \"own@https://example.test/own-latest.tgz\"\n \
+ checksum: 10c0/own==\n languageName: node\n linkType: hard\n";
+ assert!(hosted.contains("\"@scope/pkg@https://"), "{hosted}");
+ write(tmp.path(), "yarn.lock", &hosted).await;
+
+ let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap();
+ assert_eq!(flavor, NpmLockFlavor::YarnBerry);
+ let lp = entry(&entries, "left-pad");
+ assert_eq!(lp.version, "1.3.0");
+ assert_eq!(
+ lp.integrity,
+ LockIntegrity::BerryChecksum("10c0/deadbeefcafe==".into())
+ );
+ assert_eq!(entry(&entries, "@scope/pkg").version, "2.0.0");
+ assert!(!entries.iter().any(|e| e.name == "own"), "{entries:?}");
+}
+
/// yarn berry writes a CRLF `yarn.lock` on Windows (a new lockfile gets
/// `os.EOL`), and editors add a BOM: the Windows spellings — header-less
/// too — inventory exactly like the LF lock, with no stray `\r` riding into
diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs
index 7f2ccc2b1..065214aa1 100644
--- a/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs
+++ b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs
@@ -187,6 +187,29 @@ pub(super) async fn inventory_yarn_berry_in(view: &ProjectView<'_>) -> Option bool {
+ use crate::vendor::yarn_classic_lock::split_pattern;
+ crate::patch::redirect::hosted_url::hosted_url_names(reference, name, version)
+ && !entry.patterns.is_empty()
+ && entry.patterns.iter().all(|p| {
+ split_pattern(p)
+ .is_some_and(|(_, range)| range.starts_with("npm:") || range == reference)
+ })
+}
+
fn berry_registry_view(text: &str) -> Vec {
let mut out = Vec::new();
for entry in berry_entries(text).entries {
@@ -194,16 +217,29 @@ fn berry_registry_view(text: &str) -> Vec {
continue;
}
// Registry resolutions are `name@npm:` (a `::binding`
- // suffix may follow). Anything else (workspace:/patch:/file:/link:)
- // is skipped — including our own vendored file: resolutions.
+ // suffix may follow). A Socket hosted pin is a tarball-URL locator
+ // (`name@https://…/-.tgz`, #404) — still the registry
+ // package, just fetched from the patch host (see
+ // [`berry_hosted_tarball_entry`]). Anything else (workspace:/patch:/
+ // file:/link:, a user's own URL dependency) is skipped — including
+ // our own vendored file: resolutions.
let Some(locator) = entry.locator() else {
continue;
};
- let Some((version_from_res, _)) = locator.npm() else {
- continue;
- };
let lines = &entry.block.lines;
- let version = berry_field(lines, "version").unwrap_or(version_from_res);
+ let version = match locator.npm() {
+ Some((version_from_res, _)) => {
+ berry_field(lines, "version").unwrap_or(version_from_res)
+ }
+ None => match berry_field(lines, "version") {
+ Some(v)
+ if berry_hosted_tarball_entry(&entry, locator.name, v, locator.reference) =>
+ {
+ v
+ }
+ _ => continue,
+ },
+ };
let integrity = berry_field(lines, "checksum")
.map(|c| LockIntegrity::BerryChecksum(c.to_string()))
.unwrap_or(LockIntegrity::None);
diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs
index c051e38cc..39c12a814 100644
--- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs
+++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs
@@ -1281,7 +1281,7 @@ fn carried_sections(lines: &[String]) -> Vec {
/// cache zip, but a `--immutable` install treats a respelled checksum as a
/// lockfile modification (YN0028: "The lockfile would have been modified by
/// this install") — so an entry Socket writes (the vendored `file:` entry,
-/// the hosted `__archiveUrl` rewrite) must follow the lock's own spelling or
+/// the hosted tarball-locator rewrite) must follow the lock's own spelling or
/// every CI install of a yarn 4.0.x project fails. A lock with no checksum
/// at all keeps the prefixed form (every yarn since 4.1).
pub(crate) fn lock_spells_bare_checksums(lock_text: &str) -> bool {
@@ -1392,7 +1392,8 @@ impl<'a> BerryLocator<'a> {
}
/// The `__archiveUrl=` binding of a registry locator (bindings are
- /// `&`-joined), still percent-encoded — what the hosted redirect writes.
+ /// `&`-joined), still percent-encoded — what hosted redirects up to 5.0
+ /// wrote (and what yarn itself writes for a custom registry).
pub(crate) fn archive_url(&self) -> Option<&'a str> {
self.npm()?
.1
diff --git a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs
index f2f7edf5f..dd11192c7 100644
--- a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs
+++ b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs
@@ -39,7 +39,6 @@ use crate::hash::git_sha256::compute_git_sha256_from_bytes;
use crate::manifest::schema::{PatchFileInfo, PatchRecord};
use crate::patch::apply::PatchSources;
use crate::patch::redirect::{rewrite_registry_redirect, DepOverride, Integrity};
-use crate::utils::uri::encode_uri_component;
use crate::vendor::lock_inventory::{inventory_npm_lock, LockIntegrity};
use crate::vendor::npm_flavor::NpmLockFlavor;
use crate::vendor::yarn_berry_lock::revert_yarn_berry;
@@ -907,11 +906,11 @@ async fn berry_vendoring_a_builtin_patched_package_refuses_fail_closed() {
}
/// Incident guard 4c: the hosted redirect rewriter on a berry lock rewrites
-/// ONLY the targeted npm: entry (gaining yarn's own `::__archiveUrl=`
-/// binding) and leaves the builtin patch: entries byte-identical.
+/// ONLY the targeted npm: entry (its resolution becoming the hosted
+/// tarball-URL locator) and leaves the builtin patch: entries byte-identical.
///
/// RED-verified: asserting BERRY_PATCH_COUNT+1 fails; asserting the fsevents
-/// entry gained an __archiveUrl fails.
+/// entry was redirected fails.
#[tokio::test]
async fn berry_hosted_redirect_leaves_builtin_patch_entries_untouched() {
let hosted_url = format!(
@@ -937,6 +936,7 @@ async fn berry_hosted_redirect_leaves_builtin_patch_entries_untouched() {
let mut files = BTreeMap::new();
files.insert("yarn.lock".to_string(), BERRY_BEFORE_LOCK.to_string());
files.insert(".yarnrc.yml".to_string(), BERRY_YARNRC.to_string());
+ files.insert("package.json".to_string(), BERRY_BEFORE_PKG.to_string());
let result = rewrite_registry_redirect(&files, &[dep]);
let text = result.files.get("yarn.lock").expect("yarn.lock rewritten");
@@ -948,11 +948,11 @@ async fn berry_hosted_redirect_leaves_builtin_patch_entries_untouched() {
assert_eq!(berry_edits.len(), 1, "{:?}", result.edits);
assert_eq!(berry_edits[0].key.as_deref(), Some("left-pad@1.3.0"));
- // The target gained yarn's own archive binding…
+ // The target is re-keyed to the hosted tarball (its `resolutions`
+ // selector rides package.json)…
assert!(
text.contains(&format!(
- " resolution: \"left-pad@npm:1.3.0::__archiveUrl={}\"",
- encode_uri_component(&hosted_url)
+ "\"left-pad@{hosted_url}\":\n version: 1.3.0\n resolution: \"left-pad@{hosted_url}\""
)),
"target entry redirected: {text}"
);
@@ -965,7 +965,7 @@ async fn berry_hosted_redirect_leaves_builtin_patch_entries_untouched() {
"redirect must not introduce or remove patch: strings"
);
assert!(
- !text.contains("fsevents@npm:2.3.2::__archiveUrl"),
+ !text.contains("fsevents@https://"),
"untargeted packages must not be redirected"
);
// left-pad has no builtin patch: entry, so the protocol gate (and every
@@ -984,16 +984,12 @@ async fn berry_hosted_redirect_leaves_builtin_patch_entries_untouched() {
/// Incident guard 4d: redirecting a package yarn ITSELF builtin-patches
/// (resolve — the fixture holds both the plain `resolve@npm:1.20.0` entry
/// and the builtin `resolve@patch:...#builtin` entry at the
-/// same version) rewrites ONLY the npm: entry and leaves the builtin
-/// `patch:` block byte-identical, warning about the block it refused to
-/// touch. Without the protocol gate the rewriter would splice an
-/// `npm:...::__archiveUrl=` resolution under the still-`patch:` key — a
-/// corrupted key/resolution protocol mismatch in the incident's exact error
-/// family, emitted as a silent second edit.
-///
-/// RED-verified: with the protocol gate removed from `rewrite_yarn_berry`,
-/// TWO `resolve@1.20.0` edits are emitted and the RESOLVE_PATCH_ENTRY
-/// verbatim assert fails (resolution rewritten under the patch: key).
+/// same version) leaves BOTH entries byte-identical. The builtin `patch:`
+/// descriptor wraps the npm one, so the `resolutions` pin the hosted
+/// redirect writes would move it too (#404's option C); the redirect skips
+/// the package, naming the patch: entry and why the npm entry is left too.
+/// The older incident — splicing a hosted resolution under the still-`patch:`
+/// key, a corrupted key/resolution protocol pairing — stays impossible.
#[tokio::test]
async fn berry_hosted_redirect_of_builtin_patched_package_skips_patch_entry() {
let hosted_url = format!(
@@ -1017,45 +1013,35 @@ async fn berry_hosted_redirect_of_builtin_patched_package_skips_patch_entry() {
let mut files = BTreeMap::new();
files.insert("yarn.lock".to_string(), BERRY_BEFORE_LOCK.to_string());
files.insert(".yarnrc.yml".to_string(), BERRY_YARNRC.to_string());
+ files.insert("package.json".to_string(), BERRY_BEFORE_PKG.to_string());
let result = rewrite_registry_redirect(&files, &[dep]);
- let text = result.files.get("yarn.lock").expect("yarn.lock rewritten");
-
- // Exactly ONE edit — the plain npm: entry. (The corruption shape was
- // two edits both keyed resolve@1.20.0, the second under the patch: key.)
- let berry_edits: Vec<_> = result
- .edits
- .iter()
- .filter(|e| e.kind == "redirect_yarn_berry_entry")
- .collect();
- assert_eq!(berry_edits.len(), 1, "{:?}", result.edits);
- assert_eq!(berry_edits[0].key.as_deref(), Some("resolve@1.20.0"));
-
- // The plain npm: entry gained yarn's archive binding…
+ // Nothing is written: the builtin `patch:` entry wraps the same npm
+ // descriptor a `resolutions` pin would move, so the whole package is
+ // left alone (both entries byte-identical) with both reasons named.
assert!(
- text.contains(&format!(
- " resolution: \"resolve@npm:1.20.0::__archiveUrl={}\"",
- encode_uri_component(&hosted_url)
- )),
- "plain npm: entry redirected: {text}"
+ result.files.get("yarn.lock").is_none(),
+ "{:?}",
+ result.files
);
- // …the builtin patch: entries survive byte-identically (key AND
- // resolution — the corruption kept the key but rewrote the resolution),
- // with the patch: count unchanged…
- assert!(text.contains(RESOLVE_PATCH_ENTRY), "{text}");
- assert!(text.contains(FSEVENTS_PATCH_ENTRY), "{text}");
- assert_eq!(
- text.matches("patch:").count(),
- BERRY_PATCH_COUNT,
- "redirect must not introduce or remove patch: strings"
+ assert!(
+ result.files.get("package.json").is_none(),
+ "{:?}",
+ result.files
);
- // …and the skip is loud, naming the un-ownable entry.
assert!(
- result.warnings.iter().any(|w| {
- w.code == "redirect_yarn_berry_unsupported_protocol"
- && w.detail.contains("builtin")
- }),
- "warning must name the skipped builtin patch: entry: {:?}",
- result.warnings
+ !result
+ .edits
+ .iter()
+ .any(|e| e.kind.starts_with("redirect_yarn_berry")),
+ "{:?}",
+ result.edits
+ );
+ let codes: Vec<&str> = result.warnings.iter().map(|w| w.code.as_str()).collect();
+ assert!(
+ codes.contains(&"redirect_yarn_berry_unsupported_protocol")
+ && codes.contains(&"redirect_yarn_berry_shared_descriptor"),
+ "{codes:?}"
);
+ assert!(BERRY_BEFORE_LOCK.contains(RESOLVE_PATCH_ENTRY));
}
diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs
index 1a73013ba..ea8fcef83 100644
--- a/crates/socket-patch-core/src/vex/discover/yarn.rs
+++ b/crates/socket-patch-core/src/vex/discover/yarn.rs
@@ -44,15 +44,17 @@
//! `version:` line. `workspace:`, `patch:`, `portal:`, `link:`, `exec:`,
//! `git` and plain registry locators are not ours and are skipped silently.
//!
-//! * **Hosted** (`patch::redirect::rewrite_yarn_berry`):
-//! `resolution: "name@npm:X::__archiveUrl="` —
-//! the `__archiveUrl` binding value (bindings are `&`-joined after `::`) is
-//! handed to [`DiscoverCtx::hosted_uuid`], which decodes a wholly
-//! percent-encoded url; a custom-registry `__archiveUrl` is not Socket's and
-//! is skipped. The locator's `npm:` version must equal `version:` (the
-//! rewriter writes both from the same coordinate). Hand-edit tolerance: a
-//! direct url locator `name@https://patch.socket.dev/…` (what a
-//! url-range dependency locks to) is accepted too. Pin: `checksum:`
+//! * **Hosted** (`patch::redirect::rewrite_yarn_berry`): the direct
+//! tarball-URL locator `resolution: "name@https://patch.socket.dev/…"`,
+//! whose reference is handed to [`DiscoverCtx::hosted_uuid`]. Locks pinned
+//! by releases up to 5.0 spell it
+//! `resolution: "name@npm:X::__archiveUrl="` (an
+//! `npm:` locator, whose fetcher sent registry auth to the patch host —
+//! #404); that `__archiveUrl` binding value (bindings are `&`-joined after
+//! `::`) is still recognized, decoded by the same helper; a
+//! custom-registry `__archiveUrl` is not Socket's and is skipped. That
+//! locator's `npm:` version must equal `version:` (the rewriter wrote both
+//! from the same coordinate). Pin: `checksum:`
//! (`10c0/` — the cache-zip checksum, [`LockIntegrity::BerryChecksum`];
//! yarn 4.0.x spells it as bare hex under `cacheKey: 10c0`, read as the
//! same pin); the rewriter always writes it, so `integrity_required = true`.
@@ -216,19 +218,107 @@ struct BerryVendored {
key: String,
}
+/// A berry hosted entry keyed by its own tarball descriptor (the
+/// `resolutions` pin, #404), awaiting its `package.json` confirmation.
+struct BerryHostedKeyed {
+ name: String,
+ version: String,
+ url: String,
+ wiring: Wired,
+ integrity: Option,
+ key: String,
+}
+
async fn extract_berry(ctx: &DiscoverCtx<'_>, lock: BerryLock, out: &mut Discovery) {
let mut vendored: Vec = Vec::new();
+ let mut hosted_keyed: Vec = Vec::new();
for entry in lock.entries.iter().filter(|e| e.live) {
- berry_block(ctx, entry, lock.cache_key.as_deref(), &mut vendored, out);
+ berry_block(
+ ctx,
+ entry,
+ lock.cache_key.as_deref(),
+ &mut vendored,
+ &mut hosted_keyed,
+ out,
+ );
}
+ confirm_berry_hosted_keyed(ctx, hosted_keyed, out).await;
confirm_berry_vendored(ctx, vendored, out).await;
}
+/// Emit each berry hosted entry keyed by its tarball descriptor only when
+/// the root `package.json` `resolutions` routes a descriptor of the package
+/// to that same URL: yarn reaches the entry through that selector alone, so
+/// without it the entry is orphaned (an `--immutable` install fails, a
+/// plain install re-resolves the registry package) and is diagnosed
+/// instead.
+async fn confirm_berry_hosted_keyed(
+ ctx: &DiscoverCtx<'_>,
+ hosted: Vec,
+ out: &mut Discovery,
+) {
+ if hosted.is_empty() {
+ return;
+ }
+ let routes: Vec<(String, String)> = match ctx.read_bytes(PACKAGE_JSON, out).await {
+ None => Vec::new(),
+ Some(bytes) => {
+ let bytes = bytes.strip_prefix(b"\xef\xbb\xbf").unwrap_or(&bytes);
+ match parse_json(PACKAGE_JSON, bytes) {
+ Ok(doc) => doc
+ .get("resolutions")
+ .and_then(Value::as_object)
+ .map(|res| {
+ res.iter()
+ .filter_map(|(selector, value)| {
+ let target = resolution_selector_target(selector)?;
+ Some((target.to_string(), value.as_str()?.to_string()))
+ })
+ .collect()
+ })
+ .unwrap_or_default(),
+ Err(detail) => {
+ out.diag(DIAG_LOCKFILE_UNPARSEABLE, PACKAGE_JSON, detail);
+ Vec::new()
+ }
+ }
+ }
+ };
+ for entry in hosted {
+ if routes
+ .iter()
+ .any(|(target, url)| *target == entry.name && *url == entry.url)
+ {
+ emit(
+ &entry.name,
+ &entry.version,
+ &entry.url,
+ entry.wiring,
+ entry.integrity,
+ &entry.key,
+ out,
+ );
+ } else {
+ out.diag(
+ DIAG_REF_INVALID,
+ YARN_LOCK,
+ format!(
+ "{YARN_LOCK}: hosted entry `{}` is orphaned: no {PACKAGE_JSON} \
+ `resolutions` entry routes a {} descriptor to {}, so yarn does not \
+ install it",
+ entry.key, entry.name, entry.url
+ ),
+ );
+ }
+ }
+}
+
fn berry_block(
ctx: &DiscoverCtx<'_>,
entry: &YarnEntry,
cache_key: Option<&str>,
vendored: &mut Vec,
+ hosted_keyed: &mut Vec,
out: &mut Discovery,
) {
let block = &entry.block;
@@ -312,6 +402,18 @@ fn berry_block(
key: block.key.clone(),
});
}
+ // Keyed by its own tarball descriptor: the `resolutions` pin, live
+ // only through its package.json selector.
+ hosted if entry.patterns.len() == 1 && entry.patterns[0] == format!("{name}@{spec}") => {
+ hosted_keyed.push(BerryHostedKeyed {
+ name: name.to_string(),
+ version: version.to_string(),
+ url: spec.to_string(),
+ wiring: hosted,
+ integrity,
+ key: block.key.clone(),
+ });
+ }
hosted => emit(name, version, spec, hosted, integrity, &block.key, out),
}
}
@@ -1114,27 +1216,54 @@ mod tests {
);
}
- /// Hand-edit tolerance: a direct url locator on the patch server (what a
- /// url-range dependency locks to) and CRLF line endings (a
- /// `core.autocrlf` checkout of an LF lock).
+ /// The `resolutions` pin (#404): an entry keyed by its own tarball
+ /// descriptor on the patch server, with CRLF line endings (a
+ /// `core.autocrlf` checkout of an LF lock), attests only while the root
+ /// `package.json` routes a descriptor of the package to that URL — yarn
+ /// reaches the entry through that selector alone. Without it (no
+ /// manifest, no selector, a selector to another URL) the entry is an
+ /// orphan: diagnosed, never attested.
#[tokio::test]
async fn berry_hosted_direct_url_locator_and_crlf() {
let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz");
+ let lock = berry(&[berry_block(
+ &format!("left-pad@{url}"),
+ "1.3.0",
+ &format!("left-pad@{url}"),
+ Some(CHECKSUM),
+ )])
+ .replace('\n', "\r\n");
let p = Project::new();
+ p.write("yarn.lock", &lock);
p.write(
- "yarn.lock",
- berry(&[berry_block(
- &format!("left-pad@{url}"),
- "1.3.0",
- &format!("left-pad@{url}"),
- Some(CHECKSUM),
- )])
- .replace('\n', "\r\n"),
+ "package.json",
+ serde_json::json!({"resolutions": {"left-pad@npm:^1.3.0": url}}).to_string(),
);
assert_refs(
&run(&p).await,
&[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)],
);
+ let other = hosted_url("npm", "left-pad", "1.3.0", UUID_B, "left-pad-1.3.0.tgz");
+ for pkg in [
+ None,
+ Some(serde_json::json!({"name": "app"}).to_string()),
+ Some(serde_json::json!({"resolutions": {"left-pad@npm:^1.3.0": other}}).to_string()),
+ ] {
+ let p = Project::new();
+ p.write("yarn.lock", &lock);
+ if let Some(pkg) = &pkg {
+ p.write("package.json", pkg);
+ }
+ let out = run(&p).await;
+ assert!(out.refs.is_empty(), "{pkg:?}: {:#?}", out.refs);
+ assert!(
+ out.diagnostics
+ .iter()
+ .any(|d| d.code == DIAG_REF_INVALID && d.detail.contains("orphaned")),
+ "{pkg:?}: {:#?}",
+ out.diagnostics
+ );
+ }
}
/// Negative berry shapes: an `__archiveUrl` on a foreign host carrying
diff --git a/crates/socket-patch-core/tests/equivalence/pdm_rewrite_shared_parse.golden b/crates/socket-patch-core/tests/equivalence/pdm_rewrite_shared_parse.golden
index 7b431b1fe..54dc20d26 100644
--- a/crates/socket-patch-core/tests/equivalence/pdm_rewrite_shared_parse.golden
+++ b/crates/socket-patch-core/tests/equivalence/pdm_rewrite_shared_parse.golden
@@ -1,122 +1,122 @@
# One grown pdm.lock and its url deps, rewritten, then re-run over the result.
#