From e26a1e862af50971deb987ad11c4e07e3cf0db35 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 12:29:34 +0000 Subject: [PATCH 01/12] Start fix for #404 Assisted-by: Claude Code:claude-opus-5-5 From e20d139dcb1ceb9a792dd361639af7d92ef1a995 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 12:48:19 +0000 Subject: [PATCH 02/12] Stop yarn berry pins leaking npm auth tokens Hosted mode pinned a patched yarn berry package as an npm: locator (npm:::__archiveUrl=). Yarn fetches npm: locators with its npm fetcher, which attaches the configured registry token (npmAuthToken, YARN_NPM_AUTH_TOKEN, npmScopes) to every scoped package request, and to every request under npmAlwaysAuth, so the token was sent to the patch server on each cold install. The lock now pins a plain tarball-URL locator (@). Yarn fetches it with its tarball fetcher, which sends no registry auth and builds the same cache zip, so the 10c0 checksum is unchanged and --immutable still passes. Rollback, VEX and the mode takeovers keep recognizing the old form, and the next hosted scan re-pins it. An artifact URL yarn could not fetch as a tarball is refused instead of written. Fixes #404 Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 5 + crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../socket-patch-cli/src/commands/scan/mod.rs | 26 +++ .../tests/e2e_hosted_production.rs | 10 +- .../tests/e2e_redirect_yarn_berry_build.rs | 87 +++++-- .../tests/e2e_yarn4_pnpm_linker_build.rs | 11 +- .../tests/e2e_yarn4_workspaces_build.rs | 13 +- .../tests/in_process_redirect.rs | 95 +++++++- .../tests/mode_migration_npm.rs | 5 +- .../tests/yarn_berry_common/mod.rs | 2 +- crates/socket-patch-core/src/hosted/engine.rs | 5 +- .../src/patch/redirect/mod.rs | 215 ++++++++++++++++-- .../src/patch/redirect/upstream/npm.rs | 13 +- .../src/vendor/yarn_berry_lock.rs | 5 +- .../src/vendor/yarn_layering_tests.rs | 27 +-- .../src/vex/discover/yarn.rs | 20 +- docs/ecosystems.md | 3 +- docs/testing/yarn-berry-compatibility.md | 23 +- 18 files changed, 467 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a92c2aea..f4a6deba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,11 @@ 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 lock now pins a plain tarball URL instead of an `npm:` + locator, which made yarn attach `npmAuthToken` / `YARN_NPM_AUTH_TOKEN` to + scoped packages (and to every package under `npmAlwaysAuth`). 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 bcbf0fa68..d7d1a423f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -161,7 +161,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `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_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`); 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** (`yarn.lock` entry only — `resolution:` becomes the tarball-URL locator `@` + `yarnBerry10c0` checksum, never an `npm:` locator, whose fetcher would send npm registry auth to the patch host (an older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run); an artifact URL yarn cannot fetch as a tarball is refused `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`); 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 `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten. 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. @@ -358,7 +358,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) | `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 `resolution: @` (older releases: `…::__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 26a1282d7..de49ac5fe 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -4309,6 +4309,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 1ef761175..c58e0b506 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -1471,12 +1471,12 @@ fn yarn_berry_hosted_install_proof() { 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!( 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..4f88818ca 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. //! @@ -286,7 +289,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, @@ -549,12 +552,17 @@ 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}" + ); + 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), @@ -598,7 +606,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 +631,57 @@ 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"), ], ); (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 @@ -698,6 +756,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 +765,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 +799,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..def2166e8 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,13 @@ 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}" + ); + 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), 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..2f9fd2f90 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,13 @@ 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}" + ); + 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), diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index c58dcabaa..70b5d27a9 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,12 +717,13 @@ 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), @@ -770,9 +773,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 +793,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:?}" ); @@ -845,6 +846,78 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto } } +/// #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/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 2d9173b9a..5e8d72946 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -672,8 +672,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(); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 59d148cdf..427e014f9 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3139,15 +3139,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`. @@ -3300,6 +3328,18 @@ fn rewrite_yarn_berry( }); 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; + } // Berry versions are UNQUOTED (` version: 1.3.0`). let version_re = Regex::new(&(String::from(r"\n {2}version: ") + ®ex::escape(&dep.version) + "\n")) @@ -3453,13 +3493,12 @@ 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) - ); + // Rewrite the resolution wholesale as a tarball-URL locator (see + // the section header: never `npm:`, whose fetcher sends registry + // auth) — handles a pre-existing `::__archiveUrl=` (a + // custom-registry lock, or an older release's hosted pin) and a + // stale hosted URL for free. + let resolution = format!("{fname}@{}", dep.artifact_url); let mut rewritten = resolution_re .replace(block, format!("\n resolution: \"{resolution}\"").as_str()) .to_string(); @@ -7153,6 +7192,132 @@ mod tests { ) } + /// #404: the hosted pin must be a plain tarball-URL locator, never an + /// `npm:` one. 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 — so + /// the registry token went to the patch host. A `name@https://…tgz` + /// locator goes through yarn's tarball fetcher, which sends no registry + /// auth, and builds the same cache zip (same `10c0` checksum). + #[test] + fn yarn_berry_hosted_pin_is_a_tarball_locator_not_npm() { + let checksum = format!("10c0/{}", "7".repeat(128)); + let scoped_url = "https://patch.socket.dev/patch/npm/@isaacs/string-locale-compare/1.1.0/\ + tok/11111111-1111-4111-8111-111111111111/string-locale-compare-1.1.0.tgz"; + let plain_url = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/\ + tok/11111111-1111-4111-8111-111111111111/left-pad-1.3.0.tgz"; + 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 mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), lock); + 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 resolution: \"@isaacs/string-locale-compare@{scoped_url}\"\n checksum: {checksum}\n" + )), + "scoped pin is a tarball locator: {out}" + ); + assert!( + out.contains(&format!( + "\n resolution: \"left-pad@{plain_url}\"\n checksum: {checksum}\n" + )), + "unscoped pin is a tarball locator: {out}" + ); + assert!(!out.contains("__archiveUrl"), "{out}"); + assert!(!out.contains("resolution: \"left-pad@npm:"), "{out}"); + // The descriptor keys (what package.json ranges match) stay npm:. + assert!(out.contains("\n\"left-pad@npm:^1.3.0\":\n"), "{out}"); + assert_eq!(r.edits.len(), 2, "{:?}", r.edits); + } + + /// A lock written by an earlier release carries the old + /// `npm:…::__archiveUrl=` pin; a repeat hosted run re-pins that entry + /// to the tarball locator (#404), so upgrading stops the leak. + #[test] + fn yarn_berry_legacy_archive_url_pin_is_migrated() { + let checksum = format!("10c0/{}", "7".repeat(128)); + let url = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/tok/\ + 11111111-1111-4111-8111-111111111111/left-pad-1.3.0.tgz"; + 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 mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), legacy); + 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!("\n resolution: \"left-pad@{url}\"\n")), + "{out}" + ); + assert!(!out.contains("__archiveUrl"), "{out}"); + } + + /// 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 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.contains_key("yarn.lock"), "{url}: lock untouched"); + 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 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.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` @@ -7178,7 +7343,10 @@ mod tests { 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}"); } @@ -13792,7 +13960,7 @@ packages: "{label}: BOM kept" ); assert!( - out.contains(&crate::utils::uri::encode_uri_component(url)), + out.contains(&format!("resolution: \"left-pad@{url}\"")), "{label}: {out}" ); @@ -13955,7 +14123,10 @@ packages: 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. @@ -15725,7 +15896,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() { @@ -15739,8 +15910,8 @@ packages: 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")), @@ -16780,7 +16951,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}" ); } @@ -16819,7 +16990,7 @@ packages: "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 379726b25..54d1d952d 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -400,10 +400,19 @@ async fn restore_berry( 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 archive = if reference.starts_with("https://") || reference.starts_with("http://") { + 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; }; 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 dcd692c95..38353fe10 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 { @@ -1378,7 +1378,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..f3791f59a 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!( @@ -948,12 +947,9 @@ 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 now resolves to the hosted tarball locator… assert!( - text.contains(&format!( - " resolution: \"left-pad@npm:1.3.0::__archiveUrl={}\"", - encode_uri_component(&hosted_url) - )), + text.contains(&format!(" resolution: \"left-pad@{hosted_url}\"")), "target entry redirected: {text}" ); // …and the builtin patch: entries are byte-identical, count unchanged. @@ -965,7 +961,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 @@ -986,8 +982,8 @@ async fn berry_hosted_redirect_leaves_builtin_patch_entries_untouched() { /// 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 +/// touch. Without the protocol gate the rewriter would splice a hosted +/// tarball 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. /// @@ -1031,12 +1027,9 @@ async fn berry_hosted_redirect_of_builtin_patched_package_skips_patch_entry() { 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… + // The plain npm: entry now resolves to the hosted tarball locator… assert!( - text.contains(&format!( - " resolution: \"resolve@npm:1.20.0::__archiveUrl={}\"", - encode_uri_component(&hosted_url) - )), + text.contains(&format!(" resolution: \"resolve@{hosted_url}\"")), "plain npm: entry redirected: {text}" ); // …the builtin patch: entries survive byte-identically (key AND diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index 1a73013ba..6f70fb9ba 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`. diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 7a475455a..b1a9f167c 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -76,7 +76,8 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. a reliable substitute. Run `socket-patch vex` after installation to verify the patched files. See the [compatibility matrix and workflow](testing/pnpm-compatibility.md). - **yarn berry** — the redirect edits the `yarn.lock` entry only (cacheKey `10c0` / - yarn 4), and `.yarnrc.yml`'s `compressionLevel` must stay 0. The node-modules linker + yarn 4), pinning a plain tarball-URL locator so yarn never sends npm registry + credentials to the patch server, and `.yarnrc.yml`'s `compressionLevel` must stay 0. The node-modules linker is e2e-covered; PnP is untested for hosted — the lock rewrite fires, but PnP's `.yarn/cache` resolution isn't exercised. CRLF locks — what yarn writes on Windows, and what a `core.autocrlf` checkout produces anywhere — are rewritten in their own diff --git a/docs/testing/yarn-berry-compatibility.md b/docs/testing/yarn-berry-compatibility.md index 5bd45eee3..1d13a484b 100644 --- a/docs/testing/yarn-berry-compatibility.md +++ b/docs/testing/yarn-berry-compatibility.md @@ -2,8 +2,9 @@ socket-patch supports yarn berry at cacheKey `10c0` — yarn 4 with the default `compressionLevel: 0`, the one cache-zip checksum recipe it can reproduce -offline — in both modes: hosted (`scan --mode hosted` rewrites the lock entry -to the hosted `::__archiveUrl=`) and vendored (`vendor` wires the root +offline — in both modes: hosted (`scan --mode hosted` rewrites the lock entry's +`resolution:` to the hosted tarball-URL locator `@https://…/-.tgz`) +and vendored (`vendor` wires the root `package.json` `resolutions` plus the lock's `file:` entry). yarn 2 and 3 (cacheKeys `7` / `8`) are refused by both modes. The node-modules and pnpm linkers are covered end to end; Plug'n'Play keeps packages inside @@ -11,6 +12,24 @@ linkers are covered end to end; Plug'n'Play keeps packages inside and so does `apply` (`yarn_pnp_unsupported`), while standalone `vex` still attests a hosted lock's `checksum:` pin. +## Registry credentials + +The hosted pin is a plain tarball-URL locator, never an `npm:` one. Yarn +fetches an `npm:` locator — including the `npm:::__archiveUrl=` form +releases up to 5.0 wrote — with its npm fetcher, which attaches the configured +registry auth (`npmAuthToken`, `YARN_NPM_AUTH_TOKEN`, `npmScopes..npmAuthToken`) +to every scoped package's request, and to every request under +`npmAlwaysAuth: true`, whatever host the URL names (#404). The tarball fetcher +sends no registry auth and builds the identical cache zip, so the `10c0` +checksum is unchanged. Measured on yarn 4.12.0 with a fresh checkout and a cold +cache: the old form sent `Authorization: Bearer ` to the patch host in all +three configurations, the tarball locator sent none, and `yarn install +--immutable` left the lock untouched. A lock carrying the old form keeps being +recognized by `vex`, rollback and the mode takeovers, and the next hosted `scan` +re-pins it. An artifact URL yarn could not fetch as a tarball (not `http(s)`, not +ending in `.tgz`/`.tar.gz`, or carrying a query or fragment) is refused with +`redirect_yarn_berry_artifact_url_unsupported`, leaving the entry untouched. + ## Test matrix The `yarn-berry-e2e` job in `.github/workflows/ci.yml` runs From 52290526476298f32acd5a2ec56052307aac0f5a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:15:30 +0000 Subject: [PATCH 03/12] Keep hosted berry pins in the lock inventory Lock-only discovery skipped a berry entry whose resolution is a tarball URL, so a package already pinned by hosted mode dropped out of the inventory. Treat a tarball-URL resolution under npm: descriptor keys as the registry package. Also refresh the redirect and VEX goldens and the vendor takeover test for the new pin form. WIP: yarn's hardened mode rejects this pin form (YN0078), see #465. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/in_process_vendor.rs | 3 +- .../src/vendor/lock_inventory/tests.rs | 34 ++++++++++++++++++ .../src/vendor/lock_inventory/yarn.rs | 36 +++++++++++++++---- .../npm/yarn-berry/basic/expected-edits.json | 2 +- .../npm/yarn-berry/basic/expected/yarn.lock | 2 +- .../existing-archive-url/expected-edits.json | 2 +- .../existing-archive-url/expected/yarn.lock | 2 +- .../multi-descriptor-key/expected-edits.json | 2 +- .../multi-descriptor-key/expected/yarn.lock | 2 +- .../multiple-versions/expected-edits.json | 2 +- .../multiple-versions/expected/yarn.lock | 2 +- .../npm/yarn-berry/rerun-noop/input/yarn.lock | 2 +- .../scoped-package/expected-edits.json | 2 +- .../scoped-package/expected/yarn.lock | 2 +- .../vex-discover-golden/redirect-npm.json | 12 +++---- 15 files changed, 83 insertions(+), 24 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 461b6d41a..7e923357b 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), 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 6fa1f698b..60a844b0b 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,40 @@ async fn yarn_berry_registry_resolutions_inventory_with_checksums() { assert!(!entries.iter().any(|e| e.name == "fixture"), "{entries:?}"); } +/// #404: a hosted berry pin is a tarball-URL locator under the untouched +/// `npm:` descriptor key — 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 (its key names the URL) is not a registry +/// package and 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( + "resolution: \"@scope/pkg@npm:2.0.0\"", + "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-1.0.0.tgz\":\n version: 1.0.0\n \ + resolution: \"own@https://example.test/own-1.0.0.tgz\"\n \ + checksum: 10c0/own==\n languageName: node\n linkType: hard\n"; + 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..e0d623c88 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,18 @@ pub(super) async fn inventory_yarn_berry_in(view: &ProjectView<'_>) -> Option bool { + use crate::vendor::yarn_classic_lock::split_pattern; + (reference.starts_with("https://") || reference.starts_with("http://")) + && !entry.patterns.is_empty() + && entry.patterns.iter().all(|p| { + split_pattern(p).is_some_and(|(_, range)| range.starts_with("npm:")) + }) +} + fn berry_registry_view(text: &str) -> Vec { let mut out = Vec::new(); for entry in berry_entries(text).entries { @@ -194,16 +206,28 @@ 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) under descriptor keys that all stay + // `npm:` — still the registry package, just fetched from the patch + // host. Anything else (workspace:/patch:/file:/link:, or a user's + // own URL dependency, whose key names the URL) 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 if berry_hosted_tarball_entry(&entry, locator.reference) => { + match berry_field(lines, "version") { + Some(v) => v, + None => continue, + } + } + None => 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/tests/fixtures/redirect/npm/yarn-berry/basic/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected-edits.json index fbda0655c..b2dc1bb6d 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected-edits.json +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected-edits.json @@ -5,6 +5,6 @@ "action": "rewritten", "key": "left-pad@1.3.0", "original": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955\n languageName: node\n linkType: hard", - "new": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0::__archiveUrl=https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" + "new": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" } ] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected/yarn.lock index 755d86f29..487015381 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected/yarn.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected/yarn.lock @@ -7,7 +7,7 @@ __metadata: "left-pad@npm:^1.3.0": version: 1.3.0 - resolution: "left-pad@npm:1.3.0::__archiveUrl=https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz" + resolution: "left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 languageName: node linkType: hard diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected-edits.json index b27c51d32..63c1e9052 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected-edits.json +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected-edits.json @@ -5,6 +5,6 @@ "action": "rewritten", "key": "left-pad@1.3.0", "original": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0::__archiveUrl=https%3A%2F%2Fregistry.corp.example%2Fleft-pad-1.3.0.tgz\"\n checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955\n languageName: node\n linkType: hard", - "new": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0::__archiveUrl=https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" + "new": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" } ] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected/yarn.lock index 755d86f29..487015381 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected/yarn.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected/yarn.lock @@ -7,7 +7,7 @@ __metadata: "left-pad@npm:^1.3.0": version: 1.3.0 - resolution: "left-pad@npm:1.3.0::__archiveUrl=https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz" + resolution: "left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 languageName: node linkType: hard diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected-edits.json index e89ed4d26..6031b96e9 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected-edits.json +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected-edits.json @@ -5,6 +5,6 @@ "action": "rewritten", "key": "left-pad@1.3.0", "original": "\"left-pad@npm:^1.0.0, left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955\n languageName: node\n linkType: hard", - "new": "\"left-pad@npm:^1.0.0, left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0::__archiveUrl=https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" + "new": "\"left-pad@npm:^1.0.0, left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" } ] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected/yarn.lock index 8ab2423d9..3f7b7cf6b 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected/yarn.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected/yarn.lock @@ -7,7 +7,7 @@ __metadata: "left-pad@npm:^1.0.0, left-pad@npm:^1.3.0": version: 1.3.0 - resolution: "left-pad@npm:1.3.0::__archiveUrl=https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz" + resolution: "left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 languageName: node linkType: hard diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected-edits.json index fbda0655c..b2dc1bb6d 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected-edits.json +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected-edits.json @@ -5,6 +5,6 @@ "action": "rewritten", "key": "left-pad@1.3.0", "original": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955\n languageName: node\n linkType: hard", - "new": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0::__archiveUrl=https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" + "new": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" } ] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected/yarn.lock index 473795b55..08d4ba60b 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected/yarn.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected/yarn.lock @@ -14,7 +14,7 @@ __metadata: "left-pad@npm:^1.3.0": version: 1.3.0 - resolution: "left-pad@npm:1.3.0::__archiveUrl=https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz" + resolution: "left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 languageName: node linkType: hard diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/rerun-noop/input/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/rerun-noop/input/yarn.lock index 755d86f29..487015381 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/rerun-noop/input/yarn.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/rerun-noop/input/yarn.lock @@ -7,7 +7,7 @@ __metadata: "left-pad@npm:^1.3.0": version: 1.3.0 - resolution: "left-pad@npm:1.3.0::__archiveUrl=https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz" + resolution: "left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 languageName: node linkType: hard diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected-edits.json index 825155ac4..47c9542a9 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected-edits.json +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected-edits.json @@ -5,6 +5,6 @@ "action": "rewritten", "key": "@babel/core@7.0.0", "original": "\"@babel/core@npm:^7.0.0\":\n version: 7.0.0\n resolution: \"@babel/core@npm:7.0.0\"\n checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955\n languageName: node\n linkType: hard", - "new": "\"@babel/core@npm:^7.0.0\":\n version: 7.0.0\n resolution: \"@babel/core@npm:7.0.0::__archiveUrl=https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" + "new": "\"@babel/core@npm:^7.0.0\":\n version: 7.0.0\n resolution: \"@babel/core@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" } ] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected/yarn.lock index 77cf3b868..c6e93b548 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected/yarn.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected/yarn.lock @@ -7,7 +7,7 @@ __metadata: "@babel/core@npm:^7.0.0": version: 7.0.0 - resolution: "@babel/core@npm:7.0.0::__archiveUrl=https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz" + resolution: "@babel/core@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 languageName: node linkType: hard diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json index 4b37774b7..34a1677e9 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json @@ -6409,7 +6409,7 @@ "artifact_rel": null, "locked_integrity": "BerryChecksum(\"10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\")", "integrity_required": true, - "url": "https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz", + "url": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz", "lockfile_basis_ok": true } ], @@ -6472,7 +6472,7 @@ "artifact_rel": null, "locked_integrity": "BerryChecksum(\"10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\")", "integrity_required": true, - "url": "https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz", + "url": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz", "lockfile_basis_ok": true } ], @@ -6535,7 +6535,7 @@ "artifact_rel": null, "locked_integrity": "BerryChecksum(\"10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\")", "integrity_required": true, - "url": "https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz", + "url": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz", "lockfile_basis_ok": true } ], @@ -6585,7 +6585,7 @@ "artifact_rel": null, "locked_integrity": "BerryChecksum(\"10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\")", "integrity_required": true, - "url": "https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz", + "url": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz", "lockfile_basis_ok": true } ], @@ -6644,7 +6644,7 @@ "artifact_rel": null, "locked_integrity": "BerryChecksum(\"10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\")", "integrity_required": true, - "url": "https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz", + "url": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz", "lockfile_basis_ok": true } ], @@ -6681,7 +6681,7 @@ "artifact_rel": null, "locked_integrity": "BerryChecksum(\"10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\")", "integrity_required": true, - "url": "https%3A%2F%2Fpatch.socket.dev%2Fpatch%2Fnpm%2F11111111-1111-1111-1111-111111111111%2F77777777-7777-7777-7777-777777777777%2Fleft-pad-1.3.0.tgz", + "url": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz", "lockfile_basis_ok": true } ], From 4a477817d9d9255516415b7bd6d296fb19bb58ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:33:12 +0000 Subject: [PATCH 04/12] Pin yarn berry patches through resolutions The tarball-URL pin stopped the registry token leak, but yarn's hardened mode (on by default for public pull request CI) rejects a tarball resolution under an npm: lock key (YN0078), breaking installs. Hosted mode now pins a yarn berry package the way yarn itself does for a root resolutions entry: package.json routes each locked descriptor (name@npm:) to the hosted tarball, and the lock entry is re-keyed name@, moved to yarn's sort order. Only that package's descriptors move; other versions and other files stay untouched. Rollback rebuilds the original key from those selectors and removes them. A user-authored resolutions entry for the package, a missing manifest, or a builtin patch: entry wrapping the same descriptor refuse the pin instead of overwriting anything. Other npm flavors are unchanged; package.json is only read beside a yarn berry lock. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_redirect_yarn_berry_build.rs | 30 +- .../tests/e2e_yarn4_pnpm_linker_build.rs | 30 +- .../tests/e2e_yarn4_workspaces_build.rs | 31 +- .../tests/in_process_redirect.rs | 20 +- crates/socket-patch-core/src/hosted/engine.rs | 12 + .../src/hosted/memory/select.rs | 6 + .../src/patch/redirect/mod.rs | 728 +++++++++++++++--- .../src/patch/redirect/upstream/npm.rs | 166 +++- .../src/vendor/yarn_layering_tests.rs | 73 +- 9 files changed, 929 insertions(+), 167 deletions(-) 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 4f88818ca..277e209ba 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 @@ -270,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, } @@ -350,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" @@ -560,6 +564,21 @@ async fn berry_hosted_project( 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.contains(&format!("\n\"{DEP}@{hosted_url}\":\n")), + "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}" @@ -587,6 +606,7 @@ async fn berry_hosted_project( patched, host, registry_lock, + registry_pkg, _server: server, }) } @@ -633,6 +653,11 @@ fn fresh_checkout_yarn_install(fx: &BerryRedirectFixture) -> (PathBuf, Output) { ("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) @@ -689,7 +714,10 @@ async fn assert_patch_host_got_no_auth(fx: &BerryRedirectFixture) { /// 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(); 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 def2166e8..209da5bdc 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 @@ -561,6 +561,21 @@ async fn yarn4_pnpm_linker_hosted_redirect_fresh_checkout_installs_patched_bytes 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.contains(&format!("\n\"{DEP}@{hosted_url}\":\n")), + "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}" @@ -574,11 +589,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 @@ -606,7 +624,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 2f9fd2f90..80111fa01 100644 --- a/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs +++ b/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs @@ -556,6 +556,21 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() { 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.contains(&format!("\n\"{DEP}@{hosted_url}\":\n")), + "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}" @@ -573,11 +588,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, @@ -610,7 +629,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 70b5d27a9..d947532dd 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -729,9 +729,18 @@ async fn scan_redirect_rewrites_yarn_berry_lock() { 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()); @@ -842,6 +851,13 @@ 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()); } } diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 5e8d72946..9a86024ef 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -402,6 +402,18 @@ pub async fn read_candidate_files( out.read(view, unreadable, name).await; } + // A yarn berry lock is pinned through the root manifest's `resolutions` + // (see `patch::redirect::rewrite_yarn_berry`): read `package.json` only + // then, so no other npm flavor ever reads or writes it. + 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; + } + // Cargo workspace members (and in-root path dependencies) declare // dependencies of their own: a member's direct `cfg-if = "1"` must be // pinned alongside the root's, or the redirected lock entry is diff --git a/crates/socket-patch-core/src/hosted/memory/select.rs b/crates/socket-patch-core/src/hosted/memory/select.rs index fb4dfc832..6b05a6c3c 100644 --- a/crates/socket-patch-core/src/hosted/memory/select.rs +++ b/crates/socket-patch-core/src/hosted/memory/select.rs @@ -142,6 +142,11 @@ fn classify(rel: &str, root_files: &BTreeSet<&str>) -> Option { if REDIRECT_CANDIDATE_FILES.contains(&rel) || EXTRA_TEXT_FILES.contains(&rel) { return Some(Need::Text); } + // The yarn berry hosted pin routes through the root manifest's + // `resolutions`; the engine reads it only beside a `yarn.lock`. + if rel == "package.json" && root_files.contains("yarn.lock") { + return Some(Need::Text); + } if PNP_MARKERS.contains(&rel) || rel == "rush.json" { return Some(Need::Present); } @@ -423,6 +428,7 @@ pub fn candidate_files() -> Vec { "common/config/subspaces/*/pnpm-lock.yaml", "*.py.lock", "*.py (beside *.py.lock)", + "package.json (beside yarn.lock)", "pylock.toml", "pylock.*.toml", "**/Cargo.toml (Cargo workspaces)", diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 8b880377a..84eaa6a35 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3343,7 +3343,24 @@ fn rewrite_yarn_berry( 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 = @@ -3386,7 +3403,15 @@ fn rewrite_yarn_berry( .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 { @@ -3461,6 +3486,16 @@ 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). + // An entry an earlier hosted run already keyed by its tarball + // descriptor (`"@"`): ours to re-pin. + if let [Some((_, range))] = parsed.as_slice() { + if *range == dep.artifact_url + || hosted_url::hosted_url_names(range, &fname, &dep.version) + { + targets.push((block_idx, Vec::new())); + continue; + } + } if !parsed.iter().all(|p| { p.expect("every pattern parsed — None-bearing keys are skipped above") .1 @@ -3519,6 +3554,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!( @@ -3533,60 +3569,376 @@ fn rewrite_yarn_berry( }); continue; } - // Rewrite the resolution wholesale as a tarball-URL locator (see - // the section header: never `npm:`, whose fetcher sends registry - // auth) — handles a pre-existing `::__archiveUrl=` (a - // custom-registry lock, or an older release's hosted pin) and a - // stale hosted URL for free. - let resolution = format!("{fname}@{}", dep.artifact_url); - let mut rewritten = resolution_re - .replace(block, format!("\n resolution: \"{resolution}\"").as_str()) - .to_string(); - if checksum_re.is_match(&rewritten) { - rewritten = checksum_re - .replace(&rewritten, format!("\n checksum: {checksum}").as_str()) - .to_string(); - } else { - rewritten = resolution_re - .replace( - &rewritten, - format!("\n resolution: \"{resolution}\"\n checksum: {checksum}") - .as_str(), - ) - .to_string(); - } + 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; - 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())), + } + 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), }); - *block = rewritten; - changed = true; } - } - if !matched_any && !alias_skipped { + continue; + }; + let Some(manifest_obj) = manifest.as_mut().and_then(Value::as_object_mut) else { result.warnings.push(RewriteWarning { - code: "redirect_yarn_berry_entry_not_found".into(), - detail: format!("no npm: lock entry resolving {fname}@{}", dep.version), + 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}"); + rewritten = resolution_re + .replace(&rewritten, format!("\n resolution: \"{resolution}\"").as_str()) + .to_string(); + if checksum_re.is_match(&rewritten) { + rewritten = checksum_re + .replace(&rewritten, format!("\n checksum: {checksum}").as_str()) + .to_string(); + } else { + rewritten = resolution_re + .replace( + &rewritten, + format!("\n resolution: \"{resolution}\"\n checksum: {checksum}").as_str(), + ) + .to_string(); + } + 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; } } 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" + }); + 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]) +} + +/// The root manifest the yarn berry hosted pin edits. +const BERRY_MANIFEST: &str = "package.json"; + +/// Whether `value` (a `resolutions` value) is a hosted tarball pin written +/// for some version of `name` (or this run's own artifact URL) — ours to +/// rewrite or drop. +fn berry_resolution_is_ours(value: &Value, name: &str, artifact_url: &str) -> bool { + value + .as_str() + .is_some_and(|v| v == artifact_url || hosted_url::hosted_url_version(v, name).is_some()) +} + +/// 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; run `socket-patch rollback` \ + (or restore both files from version control) and re-run" + ), + }); + } + let stale = ours + .iter() + .filter(|(selector, value)| { + !selectors.contains(selector) + && value.as_str().is_some_and(|v| { + v == artifact_url || hosted_url::hosted_url_names(v, name, version) + }) + }) + .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}, @@ -7454,6 +7806,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\ @@ -7463,26 +7821,41 @@ mod tests { ) } - /// #404: the hosted pin must be a plain tarball-URL locator, never an - /// `npm:` one. 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 — so - /// the registry token went to the patch host. A `name@https://…tgz` - /// locator goes through yarn's tarball fetcher, which sends no registry - /// auth, and builds the same cache zip (same `10c0` checksum). + /// 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_is_a_tarball_locator_not_npm() { + fn yarn_berry_hosted_pin_routes_resolutions_to_a_tarball_entry() { let checksum = format!("10c0/{}", "7".repeat(128)); - let scoped_url = "https://patch.socket.dev/patch/npm/@isaacs/string-locale-compare/1.1.0/\ - tok/11111111-1111-4111-8111-111111111111/string-locale-compare-1.1.0.tgz"; - let plain_url = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/\ - tok/11111111-1111-4111-8111-111111111111/left-pad-1.3.0.tgz"; + 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) + ..berry_override("string-locale-compare", "1.1.0", &scoped_url, &checksum) }; - let plain = berry_override("left-pad", "1.3.0", plain_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 \ @@ -7490,58 +7863,219 @@ mod tests { berry_lock("10c0"), "4".repeat(128) ); - let mut files = BTreeMap::new(); - files.insert("yarn.lock".to_string(), lock); + 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 resolution: \"@isaacs/string-locale-compare@{scoped_url}\"\n checksum: {checksum}\n" + "\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 pin is a tarball locator: {out}" + "scoped entry re-keyed to its tarball: {out}" ); assert!( out.contains(&format!( - "\n resolution: \"left-pad@{plain_url}\"\n checksum: {checksum}\n" + "\n\"left-pad@{plain_url}\":\n version: 1.3.0\n \ + resolution: \"left-pad@{plain_url}\"\n checksum: {checksum}\n" )), - "unscoped pin is a tarball locator: {out}" + "unscoped entry re-keyed to its tarball: {out}" ); - assert!(!out.contains("__archiveUrl"), "{out}"); - assert!(!out.contains("resolution: \"left-pad@npm:"), "{out}"); - // The descriptor keys (what package.json ranges match) stay npm:. - assert!(out.contains("\n\"left-pad@npm:^1.3.0\":\n"), "{out}"); - assert_eq!(r.edits.len(), 2, "{:?}", r.edits); + 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(), + ); + 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); + + 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})); } /// A lock written by an earlier release carries the old - /// `npm:…::__archiveUrl=` pin; a repeat hosted run re-pins that entry - /// to the tarball locator (#404), so upgrading stops the leak. + /// `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 = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/tok/\ - 11111111-1111-4111-8111-111111111111/left-pad-1.3.0.tgz"; - let ovr = berry_override("left-pad", "1.3.0", url, &checksum); + 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) + crate::utils::uri::encode_uri_component(&url) ), ); - let mut files = BTreeMap::new(); - files.insert("yarn.lock".to_string(), legacy); + 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!("\n resolution: \"left-pad@{url}\"\n")), + 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}" + ); + } + // 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 @@ -7560,17 +8094,13 @@ mod tests { "https://p.test/a\"b/left-pad-1.3.0.tgz", ] { let ovr = berry_override("left-pad", "1.3.0", url, &checksum); - let mut files = BTreeMap::new(); - files.insert("yarn.lock".to_string(), berry_lock("10c0")); + 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.contains_key("yarn.lock"), "{url}: lock untouched"); + 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::>(), + r.warnings.iter().map(|w| w.code.as_str()).collect::>(), vec!["redirect_yarn_berry_artifact_url_unsupported"], "{url}" ); @@ -7580,8 +8110,7 @@ mod tests { "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 mut files = BTreeMap::new(); - files.insert("yarn.lock".to_string(), berry_lock("10c0")); + 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); @@ -7611,6 +8140,7 @@ 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"]; @@ -14382,9 +14912,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), @@ -14402,6 +14939,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); @@ -14429,8 +14967,9 @@ packages: ); // 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(), @@ -14439,7 +14978,7 @@ packages: (orig, new), ( respell( - lf_result.edits[0] + lf_edit .original .as_ref() .unwrap() @@ -14447,7 +14986,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" @@ -14466,6 +15005,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!( @@ -14583,6 +15123,7 @@ 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"]; @@ -16371,6 +16912,7 @@ 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!( @@ -16382,7 +16924,7 @@ packages: "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 @@ -17403,6 +17945,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(), @@ -17435,6 +17978,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); @@ -17449,7 +17993,7 @@ 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 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 54d1d952d..b3dccbdff 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -370,16 +370,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); @@ -387,7 +389,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 = @@ -395,7 +400,27 @@ 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; @@ -406,7 +431,8 @@ async fn restore_berry( let Some((_, reference)) = split_pattern(&resolution) else { continue; }; - let archive = if reference.starts_with("https://") || reference.starts_with("http://") { + 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) @@ -432,16 +458,78 @@ 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" + ), + ); + 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; } @@ -466,20 +554,66 @@ 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/yarn_layering_tests.rs b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs index f3791f59a..1f0c04ef8 100644 --- a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs +++ b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs @@ -936,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"); @@ -947,9 +948,12 @@ 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 now resolves to the hosted tarball locator… + // The target is re-keyed to the hosted tarball (its `resolutions` + // selector rides package.json)… assert!( - text.contains(&format!(" resolution: \"left-pad@{hosted_url}\"")), + text.contains(&format!( + "\"left-pad@{hosted_url}\":\n version: 1.3.0\n resolution: \"left-pad@{hosted_url}\"" + )), "target entry redirected: {text}" ); // …and the builtin patch: entries are byte-identical, count unchanged. @@ -980,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 a hosted -/// tarball 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!( @@ -1013,42 +1013,27 @@ 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 now resolves to the hosted tarball locator… + // 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!(result.files.get("yarn.lock").is_none(), "{:?}", result.files); + assert!(result.files.get("package.json").is_none(), "{:?}", result.files); assert!( - text.contains(&format!(" resolution: \"resolve@{hosted_url}\"")), - "plain npm: entry redirected: {text}" - ); - // …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" + !result + .edits + .iter() + .any(|e| e.kind.starts_with("redirect_yarn_berry")), + "{:?}", + result.edits ); - // …and the skip is loud, naming the un-ownable entry. + let codes: Vec<&str> = result.warnings.iter().map(|w| w.code.as_str()).collect(); 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 + codes.contains(&"redirect_yarn_berry_unsupported_protocol") + && codes.contains(&"redirect_yarn_berry_shared_descriptor"), + "{codes:?}" ); + assert!(BERRY_BEFORE_LOCK.contains(RESOLVE_PATCH_ENTRY)); } From e0b225acda2298d908c4788edeecf746169dac7f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:47:57 +0000 Subject: [PATCH 05/12] Cover the resolutions pin in goldens and docs Lock-only inventory now keeps a berry entry keyed by its hosted tarball, so already-pinned packages stay visible to later scans. The yarn berry redirect goldens gain a root package.json and expect the resolutions selectors plus the re-keyed entry; the VEX discovery golden, the vendor takeover test and the docs, CLI contract and changelog describe the new pin shape and its refusals. Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 10 ++- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../tests/in_process_vendor.rs | 17 +++- .../src/patch/redirect/mod.rs | 2 +- .../src/patch/redirect/upstream/npm.rs | 5 +- .../src/vendor/lock_inventory/tests.rs | 21 ++--- .../src/vendor/lock_inventory/yarn.rs | 46 +++++++---- .../src/vendor/yarn_layering_tests.rs | 12 ++- .../npm/yarn-berry/basic/expected-edits.json | 9 ++- .../yarn-berry/basic/expected/package.json | 11 +++ .../npm/yarn-berry/basic/expected/yarn.lock | 2 +- .../npm/yarn-berry/basic/input/package.json | 8 ++ .../input/package.json | 8 ++ .../existing-archive-url/expected-edits.json | 9 ++- .../expected/package.json | 11 +++ .../existing-archive-url/expected/yarn.lock | 2 +- .../existing-archive-url/input/package.json | 8 ++ .../missing-berry-checksum/input/package.json | 8 ++ .../multi-descriptor-key/expected-edits.json | 16 +++- .../expected/package.json | 12 +++ .../multi-descriptor-key/expected/yarn.lock | 2 +- .../multi-descriptor-key/input/package.json | 8 ++ .../multiple-versions/expected-edits.json | 9 ++- .../multiple-versions/expected/package.json | 11 +++ .../multiple-versions/expected/yarn.lock | 2 +- .../multiple-versions/input/package.json | 8 ++ .../yarn-berry/rerun-noop/input/package.json | 11 +++ .../npm/yarn-berry/rerun-noop/input/yarn.lock | 2 +- .../scoped-package/expected-edits.json | 9 ++- .../scoped-package/expected/package.json | 11 +++ .../scoped-package/expected/yarn.lock | 2 +- .../scoped-package/input/package.json | 8 ++ .../input/package.json | 8 ++ .../vex-discover-golden/redirect-npm.json | 60 ++++++++++++++ docs/ecosystems.md | 10 ++- docs/testing/yarn-berry-compatibility.md | 78 ++++++++++++++----- 36 files changed, 387 insertions(+), 73 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/input/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/cachekey-mismatch-refusal/input/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/input/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/missing-berry-checksum/input/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/input/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/input/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/rerun-noop/input/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/input/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/yarnrc-compression-refusal/input/package.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 04d1942eb..b632f23de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,9 +143,13 @@ limits, and required install commands. - 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 lock now pins a plain tarball URL instead of an `npm:` - locator, which made yarn attach `npmAuthToken` / `YARN_NPM_AUTH_TOKEN` to - scoped packages (and to every package under `npmAlwaysAuth`). Locks pinned by + 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 diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 9d8ff9e36..994764761 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -161,7 +161,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `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_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:` becomes the tarball-URL locator `@` + `yarnBerry10c0` checksum, never an `npm:` locator, whose fetcher would send npm registry auth to the patch host (an older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run); an artifact URL yarn cannot fetch as a tarball is refused `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). +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` — read 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 `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten. 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. @@ -358,7 +358,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: @` (older releases: `…::__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/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 7e923357b..41d9734ab 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1348,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-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 84eaa6a35..3ce5c5ca6 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -64,7 +64,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; 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 b3dccbdff..e7bb31b1b 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -562,7 +562,10 @@ async fn restore_berry( .into_owned(); } if let Some(key) = key { - let body_lines = block.split_once('\n').map(|(_, r)| r.to_string()).unwrap_or_default(); + let body_lines = block + .split_once('\n') + .map(|(_, r)| r.to_string()) + .unwrap_or_default(); block = format!("{key}:\n{body_lines}"); moved.push(key); } 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 60a844b0b..84664ef8f 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -998,11 +998,11 @@ async fn yarn_berry_registry_resolutions_inventory_with_checksums() { assert!(!entries.iter().any(|e| e.name == "fixture"), "{entries:?}"); } -/// #404: a hosted berry pin is a tarball-URL locator under the untouched -/// `npm:` descriptor key — 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 (its key names the URL) is not a registry -/// package and stays out. +/// #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(); @@ -1012,12 +1012,15 @@ async fn yarn_berry_hosted_tarball_pin_stays_in_the_inventory() { "resolution: \"left-pad@https://patch.socket.dev/patch/npm/left-pad/1.3.0/t/u/left-pad-1.3.0.tgz\"", ) .replace( - "resolution: \"@scope/pkg@npm:2.0.0\"", - "resolution: \"@scope/pkg@https://patch.socket.dev/patch/npm/@scope/pkg/2.0.0/t/u/pkg-2.0.0.tgz\"", + "\"@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-1.0.0.tgz\":\n version: 1.0.0\n \ - resolution: \"own@https://example.test/own-1.0.0.tgz\"\n \ + + "\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(); 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 e0d623c88..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,15 +187,26 @@ pub(super) async fn inventory_yarn_berry_in(view: &ProjectView<'_>) -> Option bool { +/// Whether a berry entry resolving to `reference` (with `version:` +/// `version`) is a hosted redirect's tarball pin of a registry package: +/// an `http(s)://` locator whose leaf names the package version (the +/// hosted artifact layout), either keyed by that same tarball descriptor — +/// what the `resolutions` pin writes (#404) — or under `npm:` descriptor +/// keys (an earlier release's pin). A user's own URL dependency keeps a URL +/// whose leaf need not name the version; one that does is the same package +/// version anyway. +fn berry_hosted_tarball_entry( + entry: &YarnEntry, + name: &str, + version: &str, + reference: &str, +) -> bool { use crate::vendor::yarn_classic_lock::split_pattern; - (reference.starts_with("https://") || reference.starts_with("http://")) + 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:")) + split_pattern(p) + .is_some_and(|(_, range)| range.starts_with("npm:") || range == reference) }) } @@ -207,11 +218,11 @@ fn berry_registry_view(text: &str) -> Vec { } // Registry resolutions are `name@npm:` (a `::binding` // suffix may follow). A Socket hosted pin is a tarball-URL locator - // (`name@https://…tgz`, #404) under descriptor keys that all stay - // `npm:` — still the registry package, just fetched from the patch - // host. Anything else (workspace:/patch:/file:/link:, or a user's - // own URL dependency, whose key names the URL) is skipped — - // including our own vendored file: resolutions. + // (`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; }; @@ -220,13 +231,14 @@ fn berry_registry_view(text: &str) -> Vec { Some((version_from_res, _)) => { berry_field(lines, "version").unwrap_or(version_from_res) } - None if berry_hosted_tarball_entry(&entry, locator.reference) => { - match berry_field(lines, "version") { - Some(v) => v, - None => continue, + None => match berry_field(lines, "version") { + Some(v) + if berry_hosted_tarball_entry(&entry, locator.name, v, locator.reference) => + { + v } - } - None => continue, + _ => continue, + }, }; let integrity = berry_field(lines, "checksum") .map(|c| LockIntegrity::BerryChecksum(c.to_string())) 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 1f0c04ef8..dd11192c7 100644 --- a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs +++ b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs @@ -1019,8 +1019,16 @@ async fn berry_hosted_redirect_of_builtin_patched_package_skips_patch_entry() { // 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!(result.files.get("yarn.lock").is_none(), "{:?}", result.files); - assert!(result.files.get("package.json").is_none(), "{:?}", result.files); + assert!( + result.files.get("yarn.lock").is_none(), + "{:?}", + result.files + ); + assert!( + result.files.get("package.json").is_none(), + "{:?}", + result.files + ); assert!( !result .edits diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected-edits.json index b2dc1bb6d..fbb1d5546 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected-edits.json +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected-edits.json @@ -1,10 +1,17 @@ [ + { + "path": "package.json", + "kind": "redirect_yarn_berry_resolution", + "action": "added", + "key": "left-pad@npm:^1.3.0", + "new": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" + }, { "path": "yarn.lock", "kind": "redirect_yarn_berry_entry", "action": "rewritten", "key": "left-pad@1.3.0", "original": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955\n languageName: node\n linkType: hard", - "new": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" + "new": "\"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\":\n version: 1.3.0\n resolution: \"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" } ] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected/package.json new file mode 100644 index 000000000..4e9c10438 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected/package.json @@ -0,0 +1,11 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + }, + "resolutions": { + "left-pad@npm:^1.3.0": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected/yarn.lock index 487015381..ed6ea3260 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected/yarn.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/expected/yarn.lock @@ -5,7 +5,7 @@ __metadata: version: 8 cacheKey: 10c0 -"left-pad@npm:^1.3.0": +"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz": version: 1.3.0 resolution: "left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/input/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/input/package.json new file mode 100644 index 000000000..8761f8284 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/basic/input/package.json @@ -0,0 +1,8 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/cachekey-mismatch-refusal/input/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/cachekey-mismatch-refusal/input/package.json new file mode 100644 index 000000000..8761f8284 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/cachekey-mismatch-refusal/input/package.json @@ -0,0 +1,8 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected-edits.json index 63c1e9052..d6961ca0b 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected-edits.json +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected-edits.json @@ -1,10 +1,17 @@ [ + { + "path": "package.json", + "kind": "redirect_yarn_berry_resolution", + "action": "added", + "key": "left-pad@npm:^1.3.0", + "new": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" + }, { "path": "yarn.lock", "kind": "redirect_yarn_berry_entry", "action": "rewritten", "key": "left-pad@1.3.0", "original": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0::__archiveUrl=https%3A%2F%2Fregistry.corp.example%2Fleft-pad-1.3.0.tgz\"\n checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955\n languageName: node\n linkType: hard", - "new": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" + "new": "\"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\":\n version: 1.3.0\n resolution: \"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" } ] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected/package.json new file mode 100644 index 000000000..4e9c10438 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected/package.json @@ -0,0 +1,11 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + }, + "resolutions": { + "left-pad@npm:^1.3.0": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected/yarn.lock index 487015381..ed6ea3260 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected/yarn.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/expected/yarn.lock @@ -5,7 +5,7 @@ __metadata: version: 8 cacheKey: 10c0 -"left-pad@npm:^1.3.0": +"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz": version: 1.3.0 resolution: "left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/input/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/input/package.json new file mode 100644 index 000000000..8761f8284 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/existing-archive-url/input/package.json @@ -0,0 +1,8 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/missing-berry-checksum/input/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/missing-berry-checksum/input/package.json new file mode 100644 index 000000000..8761f8284 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/missing-berry-checksum/input/package.json @@ -0,0 +1,8 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected-edits.json index 6031b96e9..2407eb8a0 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected-edits.json +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected-edits.json @@ -1,10 +1,24 @@ [ + { + "path": "package.json", + "kind": "redirect_yarn_berry_resolution", + "action": "added", + "key": "left-pad@npm:^1.0.0", + "new": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" + }, + { + "path": "package.json", + "kind": "redirect_yarn_berry_resolution", + "action": "added", + "key": "left-pad@npm:^1.3.0", + "new": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" + }, { "path": "yarn.lock", "kind": "redirect_yarn_berry_entry", "action": "rewritten", "key": "left-pad@1.3.0", "original": "\"left-pad@npm:^1.0.0, left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955\n languageName: node\n linkType: hard", - "new": "\"left-pad@npm:^1.0.0, left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" + "new": "\"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\":\n version: 1.3.0\n resolution: \"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" } ] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected/package.json new file mode 100644 index 000000000..6835dfba5 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected/package.json @@ -0,0 +1,12 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + }, + "resolutions": { + "left-pad@npm:^1.0.0": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz", + "left-pad@npm:^1.3.0": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected/yarn.lock index 3f7b7cf6b..ed6ea3260 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected/yarn.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/expected/yarn.lock @@ -5,7 +5,7 @@ __metadata: version: 8 cacheKey: 10c0 -"left-pad@npm:^1.0.0, left-pad@npm:^1.3.0": +"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz": version: 1.3.0 resolution: "left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/input/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/input/package.json new file mode 100644 index 000000000..8761f8284 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multi-descriptor-key/input/package.json @@ -0,0 +1,8 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected-edits.json index b2dc1bb6d..fbb1d5546 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected-edits.json +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected-edits.json @@ -1,10 +1,17 @@ [ + { + "path": "package.json", + "kind": "redirect_yarn_berry_resolution", + "action": "added", + "key": "left-pad@npm:^1.3.0", + "new": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" + }, { "path": "yarn.lock", "kind": "redirect_yarn_berry_entry", "action": "rewritten", "key": "left-pad@1.3.0", "original": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955\n languageName: node\n linkType: hard", - "new": "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" + "new": "\"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\":\n version: 1.3.0\n resolution: \"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" } ] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected/package.json new file mode 100644 index 000000000..4e9c10438 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected/package.json @@ -0,0 +1,11 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + }, + "resolutions": { + "left-pad@npm:^1.3.0": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected/yarn.lock index 08d4ba60b..585da446a 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected/yarn.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/expected/yarn.lock @@ -12,7 +12,7 @@ __metadata: languageName: node linkType: hard -"left-pad@npm:^1.3.0": +"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz": version: 1.3.0 resolution: "left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/input/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/input/package.json new file mode 100644 index 000000000..8761f8284 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/multiple-versions/input/package.json @@ -0,0 +1,8 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/rerun-noop/input/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/rerun-noop/input/package.json new file mode 100644 index 000000000..4e9c10438 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/rerun-noop/input/package.json @@ -0,0 +1,11 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + }, + "resolutions": { + "left-pad@npm:^1.3.0": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/rerun-noop/input/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/rerun-noop/input/yarn.lock index 487015381..ed6ea3260 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/rerun-noop/input/yarn.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/rerun-noop/input/yarn.lock @@ -5,7 +5,7 @@ __metadata: version: 8 cacheKey: 10c0 -"left-pad@npm:^1.3.0": +"left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz": version: 1.3.0 resolution: "left-pad@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected-edits.json index 47c9542a9..20665c51d 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected-edits.json +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected-edits.json @@ -1,10 +1,17 @@ [ + { + "path": "package.json", + "kind": "redirect_yarn_berry_resolution", + "action": "added", + "key": "@babel/core@npm:^7.0.0", + "new": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" + }, { "path": "yarn.lock", "kind": "redirect_yarn_berry_entry", "action": "rewritten", "key": "@babel/core@7.0.0", "original": "\"@babel/core@npm:^7.0.0\":\n version: 7.0.0\n resolution: \"@babel/core@npm:7.0.0\"\n checksum: 10c0/3fb59c76e281a2f5c810ad71dbbb8eba8b10c6cf94733dc7f27b8c516a5376cacea53543e76f6ae477d866c8954b27f1e15ca349424c2542474eb5bb1d2b6955\n languageName: node\n linkType: hard", - "new": "\"@babel/core@npm:^7.0.0\":\n version: 7.0.0\n resolution: \"@babel/core@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" + "new": "\"@babel/core@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\":\n version: 7.0.0\n resolution: \"@babel/core@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz\"\n checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3\n languageName: node\n linkType: hard" } ] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected/package.json new file mode 100644 index 000000000..509abe827 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected/package.json @@ -0,0 +1,11 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + }, + "resolutions": { + "@babel/core@npm:^7.0.0": "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected/yarn.lock index c6e93b548..e93309e14 100644 --- a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected/yarn.lock +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/expected/yarn.lock @@ -5,7 +5,7 @@ __metadata: version: 8 cacheKey: 10c0 -"@babel/core@npm:^7.0.0": +"@babel/core@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz": version: 7.0.0 resolution: "@babel/core@https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/left-pad-1.3.0.tgz" checksum: 10c0/7785879d9a7dc9bee6730ec55926a0ab9ed6bfe0eaee0cbcbcf00841d42488fddda51265c73eeddd54c5deca87d131e846ff66d27d890ef73f12720b458d7ca3 diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/input/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/input/package.json new file mode 100644 index 000000000..8761f8284 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/scoped-package/input/package.json @@ -0,0 +1,8 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/yarnrc-compression-refusal/input/package.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/yarnrc-compression-refusal/input/package.json new file mode 100644 index 000000000..8761f8284 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-berry/yarnrc-compression-refusal/input/package.json @@ -0,0 +1,8 @@ +{ + "name": "consumer", + "version": "0.0.0", + "private": true, + "dependencies": { + "left-pad": "^1.3.0" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json index 34a1677e9..2130028cb 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json @@ -6415,11 +6415,21 @@ ], "diagnostics": [], "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "package.json" + }, { "uuid": "11111111-1111-1111-1111-111111111111", "mode": "hosted", "file": "yarn.lock" }, + { + "uuid": "77777777-7777-7777-7777-777777777777", + "mode": "hosted", + "file": "package.json" + }, { "uuid": "77777777-7777-7777-7777-777777777777", "mode": "hosted", @@ -6478,11 +6488,21 @@ ], "diagnostics": [], "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "package.json" + }, { "uuid": "11111111-1111-1111-1111-111111111111", "mode": "hosted", "file": "yarn.lock" }, + { + "uuid": "77777777-7777-7777-7777-777777777777", + "mode": "hosted", + "file": "package.json" + }, { "uuid": "77777777-7777-7777-7777-777777777777", "mode": "hosted", @@ -6541,11 +6561,21 @@ ], "diagnostics": [], "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "package.json" + }, { "uuid": "11111111-1111-1111-1111-111111111111", "mode": "hosted", "file": "yarn.lock" }, + { + "uuid": "77777777-7777-7777-7777-777777777777", + "mode": "hosted", + "file": "package.json" + }, { "uuid": "77777777-7777-7777-7777-777777777777", "mode": "hosted", @@ -6591,11 +6621,21 @@ ], "diagnostics": [], "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "package.json" + }, { "uuid": "11111111-1111-1111-1111-111111111111", "mode": "hosted", "file": "yarn.lock" }, + { + "uuid": "77777777-7777-7777-7777-777777777777", + "mode": "hosted", + "file": "package.json" + }, { "uuid": "77777777-7777-7777-7777-777777777777", "mode": "hosted", @@ -6650,11 +6690,21 @@ ], "diagnostics": [], "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "package.json" + }, { "uuid": "11111111-1111-1111-1111-111111111111", "mode": "hosted", "file": "yarn.lock" }, + { + "uuid": "77777777-7777-7777-7777-777777777777", + "mode": "hosted", + "file": "package.json" + }, { "uuid": "77777777-7777-7777-7777-777777777777", "mode": "hosted", @@ -6687,11 +6737,21 @@ ], "diagnostics": [], "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "package.json" + }, { "uuid": "11111111-1111-1111-1111-111111111111", "mode": "hosted", "file": "yarn.lock" }, + { + "uuid": "77777777-7777-7777-7777-777777777777", + "mode": "hosted", + "file": "package.json" + }, { "uuid": "77777777-7777-7777-7777-777777777777", "mode": "hosted", diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 36d9d94c0..e5af5b93f 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -75,9 +75,13 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. upstream bytes. Use a clean install tree and an empty store; `--force` is not a reliable substitute. Run `socket-patch vex` after installation to verify the patched files. See the [compatibility matrix and workflow](testing/pnpm-compatibility.md). -- **yarn berry** — the redirect edits the `yarn.lock` entry only (cacheKey `10c0` / - yarn 4), pinning a plain tarball-URL locator so yarn never sends npm registry - credentials to the patch server, and `.yarnrc.yml`'s `compressionLevel` must stay 0. The node-modules linker +- **yarn berry** — the redirect pins the way yarn does for a root `resolutions` + entry (cacheKey `10c0` / yarn 4): `package.json` routes the locked descriptor + (`"left-pad@npm:^1.3.0"`) to the hosted tarball and only that `yarn.lock` entry + is re-keyed by it. Yarn then fetches it without npm registry credentials and + hardened mode accepts it. A user-authored `resolutions` entry for the package + is never overwritten (`redirect_yarn_berry_resolutions_conflict`), and + `.yarnrc.yml`'s `compressionLevel` must stay 0. The node-modules linker is e2e-covered; PnP is untested for hosted — the lock rewrite fires, but PnP's `.yarn/cache` resolution isn't exercised. CRLF locks — what yarn writes on Windows, and what a `core.autocrlf` checkout produces anywhere — are rewritten in their own diff --git a/docs/testing/yarn-berry-compatibility.md b/docs/testing/yarn-berry-compatibility.md index 1d13a484b..4ea792d22 100644 --- a/docs/testing/yarn-berry-compatibility.md +++ b/docs/testing/yarn-berry-compatibility.md @@ -2,9 +2,10 @@ socket-patch supports yarn berry at cacheKey `10c0` — yarn 4 with the default `compressionLevel: 0`, the one cache-zip checksum recipe it can reproduce -offline — in both modes: hosted (`scan --mode hosted` rewrites the lock entry's -`resolution:` to the hosted tarball-URL locator `@https://…/-.tgz`) -and vendored (`vendor` wires the root +offline — in both modes: hosted (`scan --mode hosted` routes the locked +descriptors to the hosted tarball through root `package.json` `resolutions` and +re-keys the lock entry `@https://…/-.tgz`, see below) and +vendored (`vendor` wires the root `package.json` `resolutions` plus the lock's `file:` entry). yarn 2 and 3 (cacheKeys `7` / `8`) are refused by both modes. The node-modules and pnpm linkers are covered end to end; Plug'n'Play keeps packages inside @@ -12,23 +13,60 @@ linkers are covered end to end; Plug'n'Play keeps packages inside and so does `apply` (`yarn_pnp_unsupported`), while standalone `vex` still attests a hosted lock's `checksum:` pin. -## Registry credentials - -The hosted pin is a plain tarball-URL locator, never an `npm:` one. Yarn -fetches an `npm:` locator — including the `npm:::__archiveUrl=` form -releases up to 5.0 wrote — with its npm fetcher, which attaches the configured -registry auth (`npmAuthToken`, `YARN_NPM_AUTH_TOKEN`, `npmScopes..npmAuthToken`) -to every scoped package's request, and to every request under -`npmAlwaysAuth: true`, whatever host the URL names (#404). The tarball fetcher -sends no registry auth and builds the identical cache zip, so the `10c0` -checksum is unchanged. Measured on yarn 4.12.0 with a fresh checkout and a cold -cache: the old form sent `Authorization: Bearer ` to the patch host in all -three configurations, the tarball locator sent none, and `yarn install ---immutable` left the lock untouched. A lock carrying the old form keeps being -recognized by `vex`, rollback and the mode takeovers, and the next hosted `scan` -re-pins it. An artifact URL yarn could not fetch as a tarball (not `http(s)`, not -ending in `.tgz`/`.tar.gz`, or carrying a query or fragment) is refused with -`redirect_yarn_berry_artifact_url_unsupported`, leaving the entry untouched. +## Hosted pin shape and registry credentials + +The hosted pin is what yarn itself writes for a root `resolutions` entry: + +- `package.json` gains one descriptor-specific selector per range the lock + entry carries, routed to the hosted tarball: + `"resolutions": {"left-pad@npm:^1.3.0": "https://patch.socket.dev/…/left-pad-1.3.0.tgz"}`; +- `yarn.lock` re-keys only that entry, `"left-pad@https://…/left-pad-1.3.0.tgz":`, + with the same URL as its `resolution:` and the patched `10c0` checksum. Its + `version:`, `dependencies:` and every dependent's descriptors stay + byte-identical, and the entry moves to where yarn sorts it. + +Why this shape (#404): + +- An `npm:` locator — including the `npm:::__archiveUrl=` pin releases + up to 5.0 wrote — is fetched with yarn's npm fetcher, which attaches the + configured registry auth (`npmAuthToken`, `YARN_NPM_AUTH_TOKEN`, + `npmScopes..npmAuthToken`) to every scoped package's request, and to + every request under `npmAlwaysAuth: true`, whatever host the URL names. The + tarball fetcher sends none. +- A tarball locator under the untouched `npm:` key is rejected by yarn's + hardened mode (`YN0078: Invalid resolution`), which yarn turns on by itself + for CI runs on public pull requests and `enableHardenedMode: true` turns on + anywhere. The `resolutions` pin passes it, so hosted mode assumes every + berry project may run hardened. + +Measured on yarn 4.12.0 (fresh checkout, cold cache, `YARN_NPM_AUTH_TOKEN` + +`npmAlwaysAuth`, hardened mode): `yarn install --immutable --check-cache` +passes for direct and transitive packages, leaves the lock untouched, and the +patch host receives no `Authorization` header; another locked version of the +same package keeps its registry entry. The real-yarn capstone +(`e2e_redirect_yarn_berry_build`) runs that fresh install in hardened mode with +a registry token configured and asserts the patch host received none. + +Only yarn berry projects are touched this way: `package.json` is read beside a +berry `yarn.lock` only, and yarn classic, npm, pnpm, bun and vlt pins are +unchanged. `rollback` / `remove` rebuild the original lock key from the +selectors and drop them (an emptied `resolutions` table is removed); a lock +pinned by an older release (`::__archiveUrl=`) is still recognized by `vex`, +rollback and the mode takeovers, and the next hosted `scan` re-pins it. + +Refusals (nothing written, the warning names the cause): + +- `redirect_yarn_berry_resolutions_conflict` — `package.json` already has a + user-authored `resolutions` entry for the package (bare, ranged or nested); + hosted mode never overwrites it. +- `redirect_yarn_berry_manifest_missing` — no root `package.json` object. +- `redirect_yarn_berry_shared_descriptor` — the package is also locked through + a non-npm entry (yarn's builtin `patch:` compatibility entries for + `resolve`, `typescript`, `fsevents`), which wraps the same descriptor a pin + would move. +- `redirect_yarn_berry_artifact_url_unsupported` — an artifact URL yarn could + not fetch as a tarball (not `http(s)`, not ending in `.tgz`/`.tar.gz`, or + carrying a query or fragment). ## Test matrix From 80e47523af77a740bbbc085cae362708d81c741b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:21:28 +0000 Subject: [PATCH 06/12] Address review findings on berry pin A yarn.lock entry already keyed by a tarball URL is now only treated as ours when it points at the patch host or names the exact artifact. A mirror tarball of the same version is left alone and refused as a user resolution instead of being re-pinned. VEX discovery no longer reports a hosted patch from a URL-keyed lock entry unless package.json still routes the package there. A lock that lost its resolutions entry is flagged as orphaned rather than counted as patched, and a resolutions value on its own does not confirm a redirect either. The orphan refusal now tells users to restore yarn.lock and package.json from version control, or delete the entry and reinstall. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_hosted_production.rs | 16 +- crates/socket-patch-core/src/hosted/engine.rs | 62 ++++++- .../src/patch/redirect/mod.rs | 72 +++++++-- .../src/patch/redirect/upstream/npm.rs | 4 +- .../src/vex/discover/yarn.rs | 151 ++++++++++++++++-- 5 files changed, 277 insertions(+), 28 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index 656045590..69098cbb6 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -1467,6 +1467,9 @@ 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"); @@ -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-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 5d47dfa49..bc0e8bac0 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -1318,15 +1318,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(); @@ -1656,6 +1661,61 @@ 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:?}"); + } + fn gem_candidate() -> Candidate { use crate::patch::redirect::{Integrity, RegistryOverride, RegistryOverrideIdentifiers}; Candidate { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index c70d64355..4ce20f5dd 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3504,9 +3504,7 @@ fn rewrite_yarn_berry( // An entry an earlier hosted run already keyed by its tarball // descriptor (`"@"`): ours to re-pin. if let [Some((_, range))] = parsed.as_slice() { - if *range == dep.artifact_url - || hosted_url::hosted_url_names(range, &fname, &dep.version) - { + if berry_hosted_pin_is_ours(range, &fname, Some(&dep.version), &dep.artifact_url) { targets.push((block_idx, Vec::new())); continue; } @@ -3782,13 +3780,44 @@ pub(crate) fn berry_entries_sorted(blocks: &[String]) -> bool { /// 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` (or this run's own artifact URL) — ours to -/// rewrite or drop. +/// 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| v == artifact_url || hosted_url::hosted_url_version(v, name).is_some()) + .is_some_and(|v| berry_hosted_pin_is_ours(v, name, None, artifact_url)) } /// The manifest side of one berry hosted pin, computed by @@ -3900,8 +3929,10 @@ fn berry_resolutions_pin( 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; run `socket-patch rollback` \ - (or restore both files from version control) and re-run" + 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" ), }); } @@ -3909,9 +3940,9 @@ fn berry_resolutions_pin( .iter() .filter(|(selector, value)| { !selectors.contains(selector) - && value.as_str().is_some_and(|v| { - v == artifact_url || hosted_url::hosted_url_names(v, name, version) - }) + && value + .as_str() + .is_some_and(|v| berry_hosted_pin_is_ours(v, name, Some(version), artifact_url)) }) .map(|(selector, _)| (*selector).clone()) .collect(); @@ -8085,6 +8116,25 @@ mod tests { "{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(); 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 5e8b3bf07..fe1938991 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -501,7 +501,9 @@ async fn restore_berry( &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" + 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; diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index 6f70fb9ba..ea8fcef83 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -218,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; @@ -314,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), } } @@ -1116,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 From f44ebc6e8bd721ea6277b2abad7a45f43fcb66d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:56:14 +0000 Subject: [PATCH 07/12] Match berry lock keys on CRLF locks too The real-yarn berry suites run on CRLF files on Windows, as yarn writes them there. The new check that the lock entry is keyed by the hosted tarball expected an LF right after the key, so it failed on every Windows run even though the lock was rewritten correctly. The check now compares whole lines with the CR stripped. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs | 3 ++- crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs | 3 ++- crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) 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 277e209ba..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 @@ -567,7 +567,8 @@ async fn berry_hosted_project( // #404 option C: the entry is keyed by the tarball descriptor, and the // root package.json routes the locked descriptor there. assert!( - lock.contains(&format!("\n\"{DEP}@{hosted_url}\":\n")), + 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(); 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 209da5bdc..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 @@ -564,7 +564,8 @@ async fn yarn4_pnpm_linker_hosted_redirect_fresh_checkout_installs_patched_bytes // #404 option C: the entry is keyed by the tarball descriptor, and the // root package.json routes the locked descriptor there. assert!( - lock.contains(&format!("\n\"{DEP}@{hosted_url}\":\n")), + 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(); 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 80111fa01..d2aec0078 100644 --- a/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs +++ b/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs @@ -559,7 +559,8 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() { // #404 option C: the entry is keyed by the tarball descriptor, and the // root package.json routes the locked descriptor there. assert!( - lock.contains(&format!("\n\"{DEP}@{hosted_url}\":\n")), + 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(); From 86bd24624d7b5c5a4ac47004054d86cc5c6c4670 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:52:48 +0000 Subject: [PATCH 08/12] Confirm berry pins only with manifest routing A hosted yarn berry pin is two edits: the lock entry keyed by the patch tarball and the package.json resolutions entry that routes the package to it. If the resolutions entry is removed, yarn no longer installs the patch, but a rescan still counted the package as redirected because the tarball URL was in yarn.lock. That fed the in-run VEX, which then attested not_affected for an unpatched package. The berry rewriter now reports which packages its lock pins and which of those pins are complete, and hosted confirmation trusts only that report for them. An orphaned lock entry is refused as before and is no longer counted or attested. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/hosted/engine.rs | 89 +++++++ .../src/patch/redirect/mod.rs | 81 ++++++ .../pdm_rewrite_shared_parse.golden | 240 +++++++++--------- .../tests/equivalence/poetry_rewrite.golden | 240 +++++++++--------- 4 files changed, 410 insertions(+), 240 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index bc0e8bac0..c53b6099a 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -1396,6 +1396,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 — @@ -1716,6 +1723,88 @@ mod tests { 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 gem_candidate() -> Candidate { use crate::patch::redirect::{Integrity, RegistryOverride, RegistryOverrideIdentifiers}; Candidate { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 4ce20f5dd..f4df1b90f 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -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, @@ -527,6 +545,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, @@ -552,6 +572,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 @@ -3383,6 +3407,10 @@ fn rewrite_yarn_berry( let mut changed = false; for dep in &npm { let fname = full_name(dep); + // This rewriter alone decides the dep from here on (see + // [`RewriteResult::yarn_berry_uuids`]); only a lock that does not + // lock its version at all hands it back to the other lockfiles. + result.yarn_berry_uuids.insert(dep.patch_uuid.clone()); // 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 @@ -3630,6 +3658,9 @@ fn rewrite_yarn_berry( detail: format!("no npm: lock entry resolving {fname}@{}", dep.version), }); } + if !matched_any && !shared_descriptor { + result.yarn_berry_uuids.remove(&dep.patch_uuid); + } continue; }; let Some(manifest_obj) = manifest.as_mut().and_then(Value::as_object_mut) else { @@ -3722,6 +3753,9 @@ fn rewrite_yarn_berry( moved_keys.push(new_key); changed = true; } + result + .confirmed_yarn_berry_uuids + .insert(dep.patch_uuid.clone()); } if changed { berry_reposition_blocks(&mut blocks, &moved_keys, was_sorted); @@ -3747,6 +3781,9 @@ fn rewrite_yarn_berry( 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 \ @@ -8045,10 +8082,13 @@ mod tests { 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); @@ -8062,6 +8102,47 @@ mod tests { 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()); + } + /// A lock written by an earlier release carries the old /// `npm:…::__archiveUrl=` pin; a repeat hosted run re-pins it (#404). #[test] 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 b6fa49d60..74a750368 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. # -0.12.3-extras.lock/extra=0/crlf=false 21655a1176272fba 08240ba723af2d0c -0.12.3-extras.lock/extra=0/crlf=false/re-run 9991f30c983a9730 122633e9e87893b7 -0.12.3-extras.lock/extra=0/crlf=true 5900533590840a46 661d3eab379eec41 -0.12.3-extras.lock/extra=0/crlf=true/re-run 881424448f72e65f 38321d58c8d79860 -0.12.3-extras.lock/extra=3/crlf=false ce9fcf172c1ebe53 835d9c46aa027db3 -0.12.3-extras.lock/extra=3/crlf=false/re-run 3255b7f165a3f1d3 18db90f07be0748b -0.12.3-extras.lock/extra=3/crlf=true edf41cc43e312ff7 fa72e7e13439bf56 -0.12.3-extras.lock/extra=3/crlf=true/re-run 2c3c4e5915d2acb2 f10bc01b62db6995 -0.12.3.lock/extra=0/crlf=false bb6882fa6e308227 3fcae84c661312d8 -0.12.3.lock/extra=0/crlf=false/re-run 4968389f75327559 a56f9fcdfd15ef59 -0.12.3.lock/extra=0/crlf=true 28fa02d135110df5 bfeb1aefb1e1cb94 -0.12.3.lock/extra=0/crlf=true/re-run d4862ba4b6fc43c9 9a814aa9e74e2b1f -0.12.3.lock/extra=3/crlf=false 875655d84a273615 7874edd9e47717a8 -0.12.3.lock/extra=3/crlf=false/re-run 3c2b2fe1fa3f2817 b68d461fc7fe171a -0.12.3.lock/extra=3/crlf=true 5b7bdf5d23ba4480 f2933dc828bfeb6a -0.12.3.lock/extra=3/crlf=true/re-run b6e20136704fd19e 630aedbd31b3a8aa -1.15.5.lock/extra=0/crlf=false 4d4f13130b2f5ca6 0ccbe59baa9f0fbe -1.15.5.lock/extra=0/crlf=false/re-run 4d4f13130b2f5ca6 0ccbe59baa9f0fbe -1.15.5.lock/extra=0/crlf=true 738ea560b4bac5e9 0ccbe59baa9f0fbe -1.15.5.lock/extra=0/crlf=true/re-run 738ea560b4bac5e9 0ccbe59baa9f0fbe -1.15.5.lock/extra=3/crlf=false 05076616697b9f90 5159a2ff08da0af0 -1.15.5.lock/extra=3/crlf=false/re-run 05076616697b9f90 5159a2ff08da0af0 -1.15.5.lock/extra=3/crlf=true 61a0a8be9a863e05 5159a2ff08da0af0 -1.15.5.lock/extra=3/crlf=true/re-run 61a0a8be9a863e05 5159a2ff08da0af0 -2.0.3.lock/extra=0/crlf=false f85fd6eef847aaaf c783b4deb1169551 -2.0.3.lock/extra=0/crlf=false/re-run f85fd6eef847aaaf c783b4deb1169551 -2.0.3.lock/extra=0/crlf=true 5d41e681e269208b c783b4deb1169551 -2.0.3.lock/extra=0/crlf=true/re-run 5d41e681e269208b c783b4deb1169551 -2.0.3.lock/extra=3/crlf=false 4d3cfc88ac5ffece b191be10baefa6d7 -2.0.3.lock/extra=3/crlf=false/re-run 4d3cfc88ac5ffece b191be10baefa6d7 -2.0.3.lock/extra=3/crlf=true 337957cda423fdbf b191be10baefa6d7 -2.0.3.lock/extra=3/crlf=true/re-run 337957cda423fdbf b191be10baefa6d7 -2.1.5.lock/extra=0/crlf=false f85fd6eef847aaaf c783b4deb1169551 -2.1.5.lock/extra=0/crlf=false/re-run f85fd6eef847aaaf c783b4deb1169551 -2.1.5.lock/extra=0/crlf=true 5d41e681e269208b c783b4deb1169551 -2.1.5.lock/extra=0/crlf=true/re-run 5d41e681e269208b c783b4deb1169551 -2.1.5.lock/extra=3/crlf=false 4d3cfc88ac5ffece b191be10baefa6d7 -2.1.5.lock/extra=3/crlf=false/re-run 4d3cfc88ac5ffece b191be10baefa6d7 -2.1.5.lock/extra=3/crlf=true 337957cda423fdbf b191be10baefa6d7 -2.1.5.lock/extra=3/crlf=true/re-run 337957cda423fdbf b191be10baefa6d7 -2.10.4.lock/extra=0/crlf=false 172e58243f676d64 dc874a7a22e7924c -2.10.4.lock/extra=0/crlf=false/re-run 52c4734e1adb2848 ba6b6361916544f2 -2.10.4.lock/extra=0/crlf=true 84b3284978be45b7 eb97cb41d74e0b09 -2.10.4.lock/extra=0/crlf=true/re-run 7d030b203da8acd0 ff6d620f94840245 -2.10.4.lock/extra=3/crlf=false 9c9557ecc3467229 f87505b8e76d1664 -2.10.4.lock/extra=3/crlf=false/re-run cc827f83fff579f2 460733ce80caa2a2 -2.10.4.lock/extra=3/crlf=true 001be1702fcbb10b 1f33ff52cab24fad -2.10.4.lock/extra=3/crlf=true/re-run e1337b373345aad8 25f71becb57e2c44 -2.11.2.lock/extra=0/crlf=false 6b4f6ea534403ce1 86e6d1a046779d57 -2.11.2.lock/extra=0/crlf=false/re-run 02e7df4fcb62c310 55ca2409ae480dbf -2.11.2.lock/extra=0/crlf=true c626206c4024fe46 e5530caff4c2f936 -2.11.2.lock/extra=0/crlf=true/re-run 87f02f8297dce1ec c2f007bdb811f273 -2.11.2.lock/extra=3/crlf=false 71024d43303c242f 390e37055a77d5bf -2.11.2.lock/extra=3/crlf=false/re-run 4c92b86de4ae77eb 24c66b7c114ad749 -2.11.2.lock/extra=3/crlf=true 986a91c5cd4511b7 8691357982ab4f25 -2.11.2.lock/extra=3/crlf=true/re-run 601377db18458ff3 5abbebea7456d39a -2.17.3.lock/extra=0/crlf=false 98c5c38de2f9a8e1 e5fede4b116a45fe -2.17.3.lock/extra=0/crlf=false/re-run bea465fa3cc73663 55ca2409ae480dbf -2.17.3.lock/extra=0/crlf=true a0c9bb9ed37191ef 74da760c19111918 -2.17.3.lock/extra=0/crlf=true/re-run ea711090534018d5 c2f007bdb811f273 -2.17.3.lock/extra=3/crlf=false 093ae70e2482288c 00f5c85e81a1402a -2.17.3.lock/extra=3/crlf=false/re-run b73d20dc2ea08fa5 24c66b7c114ad749 -2.17.3.lock/extra=3/crlf=true d21eb6a0c09c524c ec6b9c2e5ffd80bd -2.17.3.lock/extra=3/crlf=true/re-run 15971e9f41f1f52e 5abbebea7456d39a -2.29.2-extras.lock/extra=0/crlf=false eb78aecb54bd098d 7d28c3f85178a304 -2.29.2-extras.lock/extra=0/crlf=false/re-run 42d93b5c405df78d 41b2b3b16308685b -2.29.2-extras.lock/extra=0/crlf=true a74183ac5e064afc 99963c06c57709c7 -2.29.2-extras.lock/extra=0/crlf=true/re-run 9c29d9c8a18cf39b a8281c40211c9564 -2.29.2-extras.lock/extra=3/crlf=false 6485ead297972881 0046bbcdd56d9888 -2.29.2-extras.lock/extra=3/crlf=false/re-run a522a0262db8df40 a3d92a64c9b9161a -2.29.2-extras.lock/extra=3/crlf=true 53718730bd1cda51 f3460c6de1231dd2 -2.29.2-extras.lock/extra=3/crlf=true/re-run 8b5ab3312a7b27a3 30bc0feb4cf3430e -2.29.2.lock/extra=0/crlf=false 0afc5c9cc3e3929d 87aafe287ffa1b2d -2.29.2.lock/extra=0/crlf=false/re-run 95943761aa62a765 55ca2409ae480dbf -2.29.2.lock/extra=0/crlf=true 36eebecfb1bfccd1 b66b619ce85adfae -2.29.2.lock/extra=0/crlf=true/re-run 02f20956ed8b4483 c2f007bdb811f273 -2.29.2.lock/extra=3/crlf=false ef194e4c3f23be69 a08e0b96fa59510d -2.29.2.lock/extra=3/crlf=false/re-run 771462e9c51554c5 24c66b7c114ad749 -2.29.2.lock/extra=3/crlf=true cbaa4943320bfe44 fb06e91706beee85 -2.29.2.lock/extra=3/crlf=true/re-run 35b9ad6159d5458d 5abbebea7456d39a -2.3.4.lock/extra=0/crlf=false 1edd8acc82a5ddcc 1b22a21fde747f10 -2.3.4.lock/extra=0/crlf=false/re-run 1edd8acc82a5ddcc 1b22a21fde747f10 -2.3.4.lock/extra=0/crlf=true 817bd86e9a710e96 1b22a21fde747f10 -2.3.4.lock/extra=0/crlf=true/re-run 817bd86e9a710e96 1b22a21fde747f10 -2.3.4.lock/extra=3/crlf=false 26d0cc1acb4d1e66 bae07a9d9d86a2dc -2.3.4.lock/extra=3/crlf=false/re-run 26d0cc1acb4d1e66 bae07a9d9d86a2dc -2.3.4.lock/extra=3/crlf=true 9e6c6abc2b3b36da bae07a9d9d86a2dc -2.3.4.lock/extra=3/crlf=true/re-run 9e6c6abc2b3b36da bae07a9d9d86a2dc -2.6.1.lock/extra=0/crlf=false a1cf90463aec548e 2eb0674ae44aafe5 -2.6.1.lock/extra=0/crlf=false/re-run a1cf90463aec548e 2eb0674ae44aafe5 -2.6.1.lock/extra=0/crlf=true b4f2fc1d2ad04aff 2eb0674ae44aafe5 -2.6.1.lock/extra=0/crlf=true/re-run b4f2fc1d2ad04aff 2eb0674ae44aafe5 -2.6.1.lock/extra=3/crlf=false 0a39d27410f0a359 94669d0d54dd0a72 -2.6.1.lock/extra=3/crlf=false/re-run 0a39d27410f0a359 94669d0d54dd0a72 -2.6.1.lock/extra=3/crlf=true 028a8e1f1bf050a5 94669d0d54dd0a72 -2.6.1.lock/extra=3/crlf=true/re-run 028a8e1f1bf050a5 94669d0d54dd0a72 -2.7.4.lock/extra=0/crlf=false a1cf90463aec548e 2eb0674ae44aafe5 -2.7.4.lock/extra=0/crlf=false/re-run a1cf90463aec548e 2eb0674ae44aafe5 -2.7.4.lock/extra=0/crlf=true b4f2fc1d2ad04aff 2eb0674ae44aafe5 -2.7.4.lock/extra=0/crlf=true/re-run b4f2fc1d2ad04aff 2eb0674ae44aafe5 -2.7.4.lock/extra=3/crlf=false 0a39d27410f0a359 94669d0d54dd0a72 -2.7.4.lock/extra=3/crlf=false/re-run 0a39d27410f0a359 94669d0d54dd0a72 -2.7.4.lock/extra=3/crlf=true 028a8e1f1bf050a5 94669d0d54dd0a72 -2.7.4.lock/extra=3/crlf=true/re-run 028a8e1f1bf050a5 94669d0d54dd0a72 -2.8.2.lock/extra=0/crlf=false 55e31f21f6b597b6 5f6c14896258259e -2.8.2.lock/extra=0/crlf=false/re-run bdf788b9e0b21711 4ec68a9020622cd5 -2.8.2.lock/extra=0/crlf=true 8860e7f81b315e97 f66e02a7d1d3b103 -2.8.2.lock/extra=0/crlf=true/re-run 152ed911da843927 b19163993a46fe62 -2.8.2.lock/extra=3/crlf=false aa2044473027360e 794fbf9141bfe2b5 -2.8.2.lock/extra=3/crlf=false/re-run a0c3c1cf672c0825 8ee5d959e60359db -2.8.2.lock/extra=3/crlf=true 59da76de16052042 388cb4ac81e75f84 -2.8.2.lock/extra=3/crlf=true/re-run 37089aac3445a20c 0c38b85d219ec85c -2.9.3.lock/extra=0/crlf=false 55e31f21f6b597b6 5f6c14896258259e -2.9.3.lock/extra=0/crlf=false/re-run bdf788b9e0b21711 4ec68a9020622cd5 -2.9.3.lock/extra=0/crlf=true 8860e7f81b315e97 f66e02a7d1d3b103 -2.9.3.lock/extra=0/crlf=true/re-run 152ed911da843927 b19163993a46fe62 -2.9.3.lock/extra=3/crlf=false aa2044473027360e 794fbf9141bfe2b5 -2.9.3.lock/extra=3/crlf=false/re-run a0c3c1cf672c0825 8ee5d959e60359db -2.9.3.lock/extra=3/crlf=true 59da76de16052042 388cb4ac81e75f84 -2.9.3.lock/extra=3/crlf=true/re-run 37089aac3445a20c 0c38b85d219ec85c +0.12.3-extras.lock/extra=0/crlf=false 21655a1176272fba 8deb69369a513794 +0.12.3-extras.lock/extra=0/crlf=false/re-run 9991f30c983a9730 813e0af73f5fce41 +0.12.3-extras.lock/extra=0/crlf=true 5900533590840a46 fa1e042120e3fbc3 +0.12.3-extras.lock/extra=0/crlf=true/re-run 881424448f72e65f 070f7ee484eb741e +0.12.3-extras.lock/extra=3/crlf=false ce9fcf172c1ebe53 afdc8398cac55aa3 +0.12.3-extras.lock/extra=3/crlf=false/re-run 3255b7f165a3f1d3 2f4f8d2a9e9713ee +0.12.3-extras.lock/extra=3/crlf=true edf41cc43e312ff7 7db75a0daccb71a0 +0.12.3-extras.lock/extra=3/crlf=true/re-run 2c3c4e5915d2acb2 e9e8783207679b5c +0.12.3.lock/extra=0/crlf=false bb6882fa6e308227 dfc876cc82697195 +0.12.3.lock/extra=0/crlf=false/re-run 4968389f75327559 bd2136c31ba52d03 +0.12.3.lock/extra=0/crlf=true 28fa02d135110df5 611a1e202b6e0a2c +0.12.3.lock/extra=0/crlf=true/re-run d4862ba4b6fc43c9 35557bf89774726a +0.12.3.lock/extra=3/crlf=false 875655d84a273615 7f61f7270d2aa7e6 +0.12.3.lock/extra=3/crlf=false/re-run 3c2b2fe1fa3f2817 d0074471667e0dd0 +0.12.3.lock/extra=3/crlf=true 5b7bdf5d23ba4480 a7da592b38564e15 +0.12.3.lock/extra=3/crlf=true/re-run b6e20136704fd19e f2f05a965679eb7c +1.15.5.lock/extra=0/crlf=false 4d4f13130b2f5ca6 fa90f4211ad91091 +1.15.5.lock/extra=0/crlf=false/re-run 4d4f13130b2f5ca6 fa90f4211ad91091 +1.15.5.lock/extra=0/crlf=true 738ea560b4bac5e9 fa90f4211ad91091 +1.15.5.lock/extra=0/crlf=true/re-run 738ea560b4bac5e9 fa90f4211ad91091 +1.15.5.lock/extra=3/crlf=false 05076616697b9f90 340fd9b46b2a9d62 +1.15.5.lock/extra=3/crlf=false/re-run 05076616697b9f90 340fd9b46b2a9d62 +1.15.5.lock/extra=3/crlf=true 61a0a8be9a863e05 340fd9b46b2a9d62 +1.15.5.lock/extra=3/crlf=true/re-run 61a0a8be9a863e05 340fd9b46b2a9d62 +2.0.3.lock/extra=0/crlf=false f85fd6eef847aaaf 5689c531c25ae9be +2.0.3.lock/extra=0/crlf=false/re-run f85fd6eef847aaaf 5689c531c25ae9be +2.0.3.lock/extra=0/crlf=true 5d41e681e269208b 5689c531c25ae9be +2.0.3.lock/extra=0/crlf=true/re-run 5d41e681e269208b 5689c531c25ae9be +2.0.3.lock/extra=3/crlf=false 4d3cfc88ac5ffece f63ca0ac09ce8d28 +2.0.3.lock/extra=3/crlf=false/re-run 4d3cfc88ac5ffece f63ca0ac09ce8d28 +2.0.3.lock/extra=3/crlf=true 337957cda423fdbf f63ca0ac09ce8d28 +2.0.3.lock/extra=3/crlf=true/re-run 337957cda423fdbf f63ca0ac09ce8d28 +2.1.5.lock/extra=0/crlf=false f85fd6eef847aaaf 5689c531c25ae9be +2.1.5.lock/extra=0/crlf=false/re-run f85fd6eef847aaaf 5689c531c25ae9be +2.1.5.lock/extra=0/crlf=true 5d41e681e269208b 5689c531c25ae9be +2.1.5.lock/extra=0/crlf=true/re-run 5d41e681e269208b 5689c531c25ae9be +2.1.5.lock/extra=3/crlf=false 4d3cfc88ac5ffece f63ca0ac09ce8d28 +2.1.5.lock/extra=3/crlf=false/re-run 4d3cfc88ac5ffece f63ca0ac09ce8d28 +2.1.5.lock/extra=3/crlf=true 337957cda423fdbf f63ca0ac09ce8d28 +2.1.5.lock/extra=3/crlf=true/re-run 337957cda423fdbf f63ca0ac09ce8d28 +2.10.4.lock/extra=0/crlf=false 172e58243f676d64 4c5d29781aa9d364 +2.10.4.lock/extra=0/crlf=false/re-run 52c4734e1adb2848 394167cd3b4c6932 +2.10.4.lock/extra=0/crlf=true 84b3284978be45b7 d4b9d9891bcffd5e +2.10.4.lock/extra=0/crlf=true/re-run 7d030b203da8acd0 7b312593d6e39ed0 +2.10.4.lock/extra=3/crlf=false 9c9557ecc3467229 5bcf0a811f668264 +2.10.4.lock/extra=3/crlf=false/re-run cc827f83fff579f2 2b5c743061e81829 +2.10.4.lock/extra=3/crlf=true 001be1702fcbb10b ed0c1aad03af731e +2.10.4.lock/extra=3/crlf=true/re-run e1337b373345aad8 b8ce2604477408e8 +2.11.2.lock/extra=0/crlf=false 6b4f6ea534403ce1 da2d89f56541bbf4 +2.11.2.lock/extra=0/crlf=false/re-run 02e7df4fcb62c310 3ffd7e4f79a17003 +2.11.2.lock/extra=0/crlf=true c626206c4024fe46 7df92da14dc64ad9 +2.11.2.lock/extra=0/crlf=true/re-run 87f02f8297dce1ec 5c45c36fbd45899d +2.11.2.lock/extra=3/crlf=false 71024d43303c242f dd3f2286e69b1f0d +2.11.2.lock/extra=3/crlf=false/re-run 4c92b86de4ae77eb 653c7cb9abf6f79b +2.11.2.lock/extra=3/crlf=true 986a91c5cd4511b7 c88b940ee56b564e +2.11.2.lock/extra=3/crlf=true/re-run 601377db18458ff3 0ef8b0153b7e10d5 +2.17.3.lock/extra=0/crlf=false 98c5c38de2f9a8e1 876e3728e63d6b1f +2.17.3.lock/extra=0/crlf=false/re-run bea465fa3cc73663 3ffd7e4f79a17003 +2.17.3.lock/extra=0/crlf=true a0c9bb9ed37191ef dda94c3977cdbd7b +2.17.3.lock/extra=0/crlf=true/re-run ea711090534018d5 5c45c36fbd45899d +2.17.3.lock/extra=3/crlf=false 093ae70e2482288c 39abe9d8678ba0cb +2.17.3.lock/extra=3/crlf=false/re-run b73d20dc2ea08fa5 653c7cb9abf6f79b +2.17.3.lock/extra=3/crlf=true d21eb6a0c09c524c e60bb8f27f72f538 +2.17.3.lock/extra=3/crlf=true/re-run 15971e9f41f1f52e 0ef8b0153b7e10d5 +2.29.2-extras.lock/extra=0/crlf=false eb78aecb54bd098d 3d01c9bfd88f980b +2.29.2-extras.lock/extra=0/crlf=false/re-run 42d93b5c405df78d 559d7d045a5815a9 +2.29.2-extras.lock/extra=0/crlf=true a74183ac5e064afc a4d0f36931fca8f8 +2.29.2-extras.lock/extra=0/crlf=true/re-run 9c29d9c8a18cf39b 8277c0a2c684885a +2.29.2-extras.lock/extra=3/crlf=false 6485ead297972881 9057b4c5df84decf +2.29.2-extras.lock/extra=3/crlf=false/re-run a522a0262db8df40 dff73fdf4454358c +2.29.2-extras.lock/extra=3/crlf=true 53718730bd1cda51 267633e7e9978a79 +2.29.2-extras.lock/extra=3/crlf=true/re-run 8b5ab3312a7b27a3 41ce1e03a04684d6 +2.29.2.lock/extra=0/crlf=false 0afc5c9cc3e3929d 276354199939792e +2.29.2.lock/extra=0/crlf=false/re-run 95943761aa62a765 3ffd7e4f79a17003 +2.29.2.lock/extra=0/crlf=true 36eebecfb1bfccd1 590c1f1288d7af0b +2.29.2.lock/extra=0/crlf=true/re-run 02f20956ed8b4483 5c45c36fbd45899d +2.29.2.lock/extra=3/crlf=false ef194e4c3f23be69 7ac375db5bedb19b +2.29.2.lock/extra=3/crlf=false/re-run 771462e9c51554c5 653c7cb9abf6f79b +2.29.2.lock/extra=3/crlf=true cbaa4943320bfe44 34ada2d0a985541a +2.29.2.lock/extra=3/crlf=true/re-run 35b9ad6159d5458d 0ef8b0153b7e10d5 +2.3.4.lock/extra=0/crlf=false 1edd8acc82a5ddcc ad77f63b294735c7 +2.3.4.lock/extra=0/crlf=false/re-run 1edd8acc82a5ddcc ad77f63b294735c7 +2.3.4.lock/extra=0/crlf=true 817bd86e9a710e96 ad77f63b294735c7 +2.3.4.lock/extra=0/crlf=true/re-run 817bd86e9a710e96 ad77f63b294735c7 +2.3.4.lock/extra=3/crlf=false 26d0cc1acb4d1e66 8fff8bd4b18685eb +2.3.4.lock/extra=3/crlf=false/re-run 26d0cc1acb4d1e66 8fff8bd4b18685eb +2.3.4.lock/extra=3/crlf=true 9e6c6abc2b3b36da 8fff8bd4b18685eb +2.3.4.lock/extra=3/crlf=true/re-run 9e6c6abc2b3b36da 8fff8bd4b18685eb +2.6.1.lock/extra=0/crlf=false a1cf90463aec548e c26d9df86f0adc07 +2.6.1.lock/extra=0/crlf=false/re-run a1cf90463aec548e c26d9df86f0adc07 +2.6.1.lock/extra=0/crlf=true b4f2fc1d2ad04aff c26d9df86f0adc07 +2.6.1.lock/extra=0/crlf=true/re-run b4f2fc1d2ad04aff c26d9df86f0adc07 +2.6.1.lock/extra=3/crlf=false 0a39d27410f0a359 8e9d70398f3f644e +2.6.1.lock/extra=3/crlf=false/re-run 0a39d27410f0a359 8e9d70398f3f644e +2.6.1.lock/extra=3/crlf=true 028a8e1f1bf050a5 8e9d70398f3f644e +2.6.1.lock/extra=3/crlf=true/re-run 028a8e1f1bf050a5 8e9d70398f3f644e +2.7.4.lock/extra=0/crlf=false a1cf90463aec548e c26d9df86f0adc07 +2.7.4.lock/extra=0/crlf=false/re-run a1cf90463aec548e c26d9df86f0adc07 +2.7.4.lock/extra=0/crlf=true b4f2fc1d2ad04aff c26d9df86f0adc07 +2.7.4.lock/extra=0/crlf=true/re-run b4f2fc1d2ad04aff c26d9df86f0adc07 +2.7.4.lock/extra=3/crlf=false 0a39d27410f0a359 8e9d70398f3f644e +2.7.4.lock/extra=3/crlf=false/re-run 0a39d27410f0a359 8e9d70398f3f644e +2.7.4.lock/extra=3/crlf=true 028a8e1f1bf050a5 8e9d70398f3f644e +2.7.4.lock/extra=3/crlf=true/re-run 028a8e1f1bf050a5 8e9d70398f3f644e +2.8.2.lock/extra=0/crlf=false 55e31f21f6b597b6 ba05e7f98ac104a6 +2.8.2.lock/extra=0/crlf=false/re-run bdf788b9e0b21711 0cfa60de6096961a +2.8.2.lock/extra=0/crlf=true 8860e7f81b315e97 a0aed934037da0ae +2.8.2.lock/extra=0/crlf=true/re-run 152ed911da843927 c4ba8fd2675df70b +2.8.2.lock/extra=3/crlf=false aa2044473027360e ff792a05804ef43d +2.8.2.lock/extra=3/crlf=false/re-run a0c3c1cf672c0825 2deaca68111ef5f7 +2.8.2.lock/extra=3/crlf=true 59da76de16052042 d97295c31df62f01 +2.8.2.lock/extra=3/crlf=true/re-run 37089aac3445a20c b78eda5a0bdf96e1 +2.9.3.lock/extra=0/crlf=false 55e31f21f6b597b6 ba05e7f98ac104a6 +2.9.3.lock/extra=0/crlf=false/re-run bdf788b9e0b21711 0cfa60de6096961a +2.9.3.lock/extra=0/crlf=true 8860e7f81b315e97 a0aed934037da0ae +2.9.3.lock/extra=0/crlf=true/re-run 152ed911da843927 c4ba8fd2675df70b +2.9.3.lock/extra=3/crlf=false aa2044473027360e ff792a05804ef43d +2.9.3.lock/extra=3/crlf=false/re-run a0c3c1cf672c0825 2deaca68111ef5f7 +2.9.3.lock/extra=3/crlf=true 59da76de16052042 d97295c31df62f01 +2.9.3.lock/extra=3/crlf=true/re-run 37089aac3445a20c b78eda5a0bdf96e1 diff --git a/crates/socket-patch-core/tests/equivalence/poetry_rewrite.golden b/crates/socket-patch-core/tests/equivalence/poetry_rewrite.golden index f98b498a5..72b9a4ad5 100644 --- a/crates/socket-patch-core/tests/equivalence/poetry_rewrite.golden +++ b/crates/socket-patch-core/tests/equivalence/poetry_rewrite.golden @@ -1,122 +1,122 @@ # One grown poetry.lock pair and its deps, rewritten, then re-run over the result. # -0.12.17_extra=0_crlf=false d2b80f8d058298a0 7f509240352ad7a8 -0.12.17_extra=0_crlf=false/re-run d2b80f8d058298a0 7f509240352ad7a8 -0.12.17_extra=0_crlf=true 4b3a0e23f3264df8 7f509240352ad7a8 -0.12.17_extra=0_crlf=true/re-run 4b3a0e23f3264df8 7f509240352ad7a8 -0.12.17_extra=3_crlf=false a8bee4606ba5b760 d0a6ea16d4cc417e -0.12.17_extra=3_crlf=false/re-run a8bee4606ba5b760 d0a6ea16d4cc417e -0.12.17_extra=3_crlf=true 5ae487fb99db0745 d0a6ea16d4cc417e -0.12.17_extra=3_crlf=true/re-run 5ae487fb99db0745 d0a6ea16d4cc417e -1.0.10_extra=0_crlf=false 06d7816556307b33 68d9a638c38495be -1.0.10_extra=0_crlf=false/re-run 613ac93e03589fac 33bbc0c4a6aa1d37 -1.0.10_extra=0_crlf=true 38755c44d8811d7b ad4fd40d36c0ad94 -1.0.10_extra=0_crlf=true/re-run a039bf51c09a676d 33bbc0c4a6aa1d37 -1.0.10_extra=3_crlf=false cd980d8f38e065ee 4430fc9529674206 -1.0.10_extra=3_crlf=false/re-run 885d4754947c0ec7 058fe94fee294277 -1.0.10_extra=3_crlf=true 684566844b37fcd0 ba84db1bf1aa9ae8 -1.0.10_extra=3_crlf=true/re-run 4fd1416655a91e78 058fe94fee294277 -1.1.15_extra=0_crlf=false d0f26c272489ebe3 4f4f6683942816f7 -1.1.15_extra=0_crlf=false/re-run bc82912108fd25ce 33bbc0c4a6aa1d37 -1.1.15_extra=0_crlf=true 6c566dc6e3aeba50 ce637cba6490743c -1.1.15_extra=0_crlf=true/re-run dea1745a49ef026f 33bbc0c4a6aa1d37 -1.1.15_extra=3_crlf=false 8870f129976dee0d 21ee9a93167e1565 -1.1.15_extra=3_crlf=false/re-run 8551e339b8b54ddf 058fe94fee294277 -1.1.15_extra=3_crlf=true 4d055246679dee4c d50b3dcc3db91f50 -1.1.15_extra=3_crlf=true/re-run 53fe14f551e02333 058fe94fee294277 -1.2.2_extra=0_crlf=false 4b75722f3771f21c f4162004a278ad59 -1.2.2_extra=0_crlf=false/re-run 133b23c3dfc4cce1 33bbc0c4a6aa1d37 -1.2.2_extra=0_crlf=true 1813b308432ea034 1e67c45e616e507e -1.2.2_extra=0_crlf=true/re-run 004001fb1c25e747 33bbc0c4a6aa1d37 -1.2.2_extra=3_crlf=false 812817b968306e99 0b39f87964356f2c -1.2.2_extra=3_crlf=false/re-run f81a1c4a49505bce a465f4f09bd7a1cb -1.2.2_extra=3_crlf=true 6051efc48c2d3992 f2af39ddf70ee7d4 -1.2.2_extra=3_crlf=true/re-run e3ec0a6371139d8b a465f4f09bd7a1cb -1.3.2_extra=0_crlf=false 86cda4c82eee7e8b 4bbfd63a2999d619 -1.3.2_extra=0_crlf=false/re-run a9df9acaedd7db22 33bbc0c4a6aa1d37 -1.3.2_extra=0_crlf=true e92e1cd1beeb0002 2066c6ae7e285570 -1.3.2_extra=0_crlf=true/re-run f13a599ff9511fe7 33bbc0c4a6aa1d37 -1.3.2_extra=3_crlf=false fe9f2817767e5612 93587980e1c249f3 -1.3.2_extra=3_crlf=false/re-run d9728f0057845cbb 058fe94fee294277 -1.3.2_extra=3_crlf=true 253448255457216e 62ff09de389cc26e -1.3.2_extra=3_crlf=true/re-run 71d0a8d8ab2ecc77 058fe94fee294277 -1.4.2_extra=0_crlf=false 00a7bc0a3c89c49b 36b408b703f1d8aa -1.4.2_extra=0_crlf=false/re-run 4aa5eedcf2dd0b02 33bbc0c4a6aa1d37 -1.4.2_extra=0_crlf=true 44de4691acb61dba 0a9305875ef23cab -1.4.2_extra=0_crlf=true/re-run 47bf0330a4e93507 33bbc0c4a6aa1d37 -1.4.2_extra=3_crlf=false a3fb1d70a1207ea7 9ce5033d5fe06c49 -1.4.2_extra=3_crlf=false/re-run 60023b576de9ad30 058fe94fee294277 -1.4.2_extra=3_crlf=true bd8911bca06c1262 cb69eada9836807d -1.4.2_extra=3_crlf=true/re-run a5f5292e17ecb5f4 058fe94fee294277 -1.5.1_extra=0_crlf=false a1795ca286fa80ce 4e26992d707a9185 -1.5.1_extra=0_crlf=false/re-run f19230652767bb30 33bbc0c4a6aa1d37 -1.5.1_extra=0_crlf=true 5871c60c9d63aeee db8424880d0e4a52 -1.5.1_extra=0_crlf=true/re-run 6dab15e1eab2f462 33bbc0c4a6aa1d37 -1.5.1_extra=3_crlf=false 0b33aee716ad5a55 2c4b5cb224ddc8d0 -1.5.1_extra=3_crlf=false/re-run e26d96dd76e1b368 058fe94fee294277 -1.5.1_extra=3_crlf=true 161846bd33f1d508 0554981aff8135c6 -1.5.1_extra=3_crlf=true/re-run 3e4cf8de3fdcb6ea 058fe94fee294277 -1.6.1_extra=0_crlf=false d1e0442566c6ce49 fe6855296498d9fe -1.6.1_extra=0_crlf=false/re-run ea7966d58c9f2aab 33bbc0c4a6aa1d37 -1.6.1_extra=0_crlf=true db20a2c4edcafbf0 d61e14cff7784574 -1.6.1_extra=0_crlf=true/re-run 8f7c22713b6f70b6 33bbc0c4a6aa1d37 -1.6.1_extra=3_crlf=false afd5d875780cdf40 2db96bfd93c1e9cf -1.6.1_extra=3_crlf=false/re-run 647ef4eb652f1ba6 058fe94fee294277 -1.6.1_extra=3_crlf=true b37fcda617275f4b 26a0a64c5793d8ac -1.6.1_extra=3_crlf=true/re-run 1a4219f03efad32a 058fe94fee294277 -1.7.1_extra=0_crlf=false 6600136814fb4134 6e2694ad356a0756 -1.7.1_extra=0_crlf=false/re-run abdbbfe42c8b4d7e 33bbc0c4a6aa1d37 -1.7.1_extra=0_crlf=true 6a85a6a27c99cd2b 8369450b092cf661 -1.7.1_extra=0_crlf=true/re-run ac9d84b451f9c128 33bbc0c4a6aa1d37 -1.7.1_extra=3_crlf=false 020fa829b6a870bc 22176abf3ed1e1f3 -1.7.1_extra=3_crlf=false/re-run 31133977b68135e7 058fe94fee294277 -1.7.1_extra=3_crlf=true bcb1fd00c2d4d5bf e5be1777f605bf60 -1.7.1_extra=3_crlf=true/re-run ac38704d471666ab 058fe94fee294277 -1.8.5_extra=0_crlf=false 7b0c488a965c4f7b fdba5e6b6f532877 -1.8.5_extra=0_crlf=false/re-run 60ce065c6b04b5b1 33bbc0c4a6aa1d37 -1.8.5_extra=0_crlf=true 2094d208abbeabc2 bd0e4b35f46b1016 -1.8.5_extra=0_crlf=true/re-run 5903de75cc2030e8 33bbc0c4a6aa1d37 -1.8.5_extra=3_crlf=false e22381008c314e3d 79c02c70931e0527 -1.8.5_extra=3_crlf=false/re-run 147043d9e02c7a08 058fe94fee294277 -1.8.5_extra=3_crlf=true e42675d2895de9fb b319402713da11d4 -1.8.5_extra=3_crlf=true/re-run 9e97459a7ac1ffb1 058fe94fee294277 -2.0.1_extra=0_crlf=false 2775b8f1411fbadb 5e2d1de5fe694063 -2.0.1_extra=0_crlf=false/re-run 5a19ccf0764fac47 33bbc0c4a6aa1d37 -2.0.1_extra=0_crlf=true 17ebf61a05b76816 438f96aa4087c670 -2.0.1_extra=0_crlf=true/re-run 97eff076ab7188a2 33bbc0c4a6aa1d37 -2.0.1_extra=3_crlf=false bc38767ba9dcc6e6 f8b147dc42d7aebe -2.0.1_extra=3_crlf=false/re-run 44cce38f20f8f16f 058fe94fee294277 -2.0.1_extra=3_crlf=true b68b1e02f151ef48 84bd0d184fd4841d -2.0.1_extra=3_crlf=true/re-run 054b4ddcb4dcfafa 058fe94fee294277 -2.1.4_extra=0_crlf=false 6f62ddaa4adc50a4 757a21041de4bafe -2.1.4_extra=0_crlf=false/re-run 5b7e62f966b8d271 33bbc0c4a6aa1d37 -2.1.4_extra=0_crlf=true 8a8d93a2b2127129 6480c31767f7cbce -2.1.4_extra=0_crlf=true/re-run d1cd3eeea0692da9 33bbc0c4a6aa1d37 -2.1.4_extra=3_crlf=false 65364c0f4e7aa0e7 466d6a49990d9d78 -2.1.4_extra=3_crlf=false/re-run a3676e7eddbccf4a 058fe94fee294277 -2.1.4_extra=3_crlf=true 821fdbf37b56026b 1ca37ca1cf0d8978 -2.1.4_extra=3_crlf=true/re-run 25f65d16b322be1c 058fe94fee294277 -2.2.1_extra=0_crlf=false 7a500b5022ac388c 7220a5bd51319fbc -2.2.1_extra=0_crlf=false/re-run b484d2ecc12a5edb 33bbc0c4a6aa1d37 -2.2.1_extra=0_crlf=true 11b39909d694a4f5 545be68d50053014 -2.2.1_extra=0_crlf=true/re-run f73c0184185b55c8 33bbc0c4a6aa1d37 -2.2.1_extra=3_crlf=false 10b0bcde3632669b 81a4c77f7f4e49e1 -2.2.1_extra=3_crlf=false/re-run 679acf31e13b71a0 058fe94fee294277 -2.2.1_extra=3_crlf=true feaa6416a4b168a3 0e8c1642cda6ab31 -2.2.1_extra=3_crlf=true/re-run 4b8aa165ed772485 058fe94fee294277 -2.3.4_extra=0_crlf=false 1ad17cac9704b1ce fffc310706b37104 -2.3.4_extra=0_crlf=false/re-run 398fea1a1ac5d77b 33bbc0c4a6aa1d37 -2.3.4_extra=0_crlf=true d4578a811e547b2c 7df856e191dcd834 -2.3.4_extra=0_crlf=true/re-run 5f79a7161605a083 33bbc0c4a6aa1d37 -2.3.4_extra=3_crlf=false d38430f6b06c87f3 0a52b8d5379a1ab6 -2.3.4_extra=3_crlf=false/re-run ee7114aaad11f4f0 058fe94fee294277 -2.3.4_extra=3_crlf=true 89e72b13eccda257 351bb2773426b174 -2.3.4_extra=3_crlf=true/re-run 04c0dd27743659d0 058fe94fee294277 -2.4.3_extra=0_crlf=false 70d3006a3e404efc 1bfcdd185fb8ff92 -2.4.3_extra=0_crlf=false/re-run cb0289ac6716caab 33bbc0c4a6aa1d37 -2.4.3_extra=0_crlf=true ebe555cb2a5fb1f2 c52420d9986dd40d -2.4.3_extra=0_crlf=true/re-run 3f6277fd5f2ef21b 33bbc0c4a6aa1d37 -2.4.3_extra=3_crlf=false 7bcb0eb7f4f1150a 63289bf331a1b40c -2.4.3_extra=3_crlf=false/re-run c7b4e742d1f0f79b 058fe94fee294277 -2.4.3_extra=3_crlf=true f1ba10a6e2549703 9e944e2c50375cbc -2.4.3_extra=3_crlf=true/re-run 6c2fb99f914a5767 058fe94fee294277 +0.12.17_extra=0_crlf=false d2b80f8d058298a0 b9e4650800083820 +0.12.17_extra=0_crlf=false/re-run d2b80f8d058298a0 b9e4650800083820 +0.12.17_extra=0_crlf=true 4b3a0e23f3264df8 b9e4650800083820 +0.12.17_extra=0_crlf=true/re-run 4b3a0e23f3264df8 b9e4650800083820 +0.12.17_extra=3_crlf=false a8bee4606ba5b760 617ee848cbd6df88 +0.12.17_extra=3_crlf=false/re-run a8bee4606ba5b760 617ee848cbd6df88 +0.12.17_extra=3_crlf=true 5ae487fb99db0745 617ee848cbd6df88 +0.12.17_extra=3_crlf=true/re-run 5ae487fb99db0745 617ee848cbd6df88 +1.0.10_extra=0_crlf=false 06d7816556307b33 484fe3a475bdc7b1 +1.0.10_extra=0_crlf=false/re-run 613ac93e03589fac e04a67e0369c9327 +1.0.10_extra=0_crlf=true 38755c44d8811d7b edf5ed765caeec06 +1.0.10_extra=0_crlf=true/re-run a039bf51c09a676d e04a67e0369c9327 +1.0.10_extra=3_crlf=false cd980d8f38e065ee c318cf603b2d28a2 +1.0.10_extra=3_crlf=false/re-run 885d4754947c0ec7 47efe39e0895de81 +1.0.10_extra=3_crlf=true 684566844b37fcd0 a3f7e955bf4db4ad +1.0.10_extra=3_crlf=true/re-run 4fd1416655a91e78 47efe39e0895de81 +1.1.15_extra=0_crlf=false d0f26c272489ebe3 106818e588c1fde4 +1.1.15_extra=0_crlf=false/re-run bc82912108fd25ce e04a67e0369c9327 +1.1.15_extra=0_crlf=true 6c566dc6e3aeba50 16fa4f162eb5dfa9 +1.1.15_extra=0_crlf=true/re-run dea1745a49ef026f e04a67e0369c9327 +1.1.15_extra=3_crlf=false 8870f129976dee0d e009e7cc6cf8013f +1.1.15_extra=3_crlf=false/re-run 8551e339b8b54ddf 47efe39e0895de81 +1.1.15_extra=3_crlf=true 4d055246679dee4c d39ca517cc610287 +1.1.15_extra=3_crlf=true/re-run 53fe14f551e02333 47efe39e0895de81 +1.2.2_extra=0_crlf=false 4b75722f3771f21c 700bb44d8d61bd37 +1.2.2_extra=0_crlf=false/re-run 133b23c3dfc4cce1 e04a67e0369c9327 +1.2.2_extra=0_crlf=true 1813b308432ea034 616b9a2fef40a4c0 +1.2.2_extra=0_crlf=true/re-run 004001fb1c25e747 e04a67e0369c9327 +1.2.2_extra=3_crlf=false 812817b968306e99 1a142761ae480336 +1.2.2_extra=3_crlf=false/re-run f81a1c4a49505bce f2a3b8f494eed542 +1.2.2_extra=3_crlf=true 6051efc48c2d3992 676f6c4a485fa7de +1.2.2_extra=3_crlf=true/re-run e3ec0a6371139d8b f2a3b8f494eed542 +1.3.2_extra=0_crlf=false 86cda4c82eee7e8b af9a5aad99a92f7e +1.3.2_extra=0_crlf=false/re-run a9df9acaedd7db22 e04a67e0369c9327 +1.3.2_extra=0_crlf=true e92e1cd1beeb0002 fde61bc0d67f0d56 +1.3.2_extra=0_crlf=true/re-run f13a599ff9511fe7 e04a67e0369c9327 +1.3.2_extra=3_crlf=false fe9f2817767e5612 2c6031f24b988251 +1.3.2_extra=3_crlf=false/re-run d9728f0057845cbb 47efe39e0895de81 +1.3.2_extra=3_crlf=true 253448255457216e 80e0be48107bd8f4 +1.3.2_extra=3_crlf=true/re-run 71d0a8d8ab2ecc77 47efe39e0895de81 +1.4.2_extra=0_crlf=false 00a7bc0a3c89c49b 082a492e6afe04a8 +1.4.2_extra=0_crlf=false/re-run 4aa5eedcf2dd0b02 e04a67e0369c9327 +1.4.2_extra=0_crlf=true 44de4691acb61dba 4a8205f4a36231ac +1.4.2_extra=0_crlf=true/re-run 47bf0330a4e93507 e04a67e0369c9327 +1.4.2_extra=3_crlf=false a3fb1d70a1207ea7 3859704662628986 +1.4.2_extra=3_crlf=false/re-run 60023b576de9ad30 47efe39e0895de81 +1.4.2_extra=3_crlf=true bd8911bca06c1262 1e5c5e7b4b37b48c +1.4.2_extra=3_crlf=true/re-run a5f5292e17ecb5f4 47efe39e0895de81 +1.5.1_extra=0_crlf=false a1795ca286fa80ce 0b8a439faee7fbbf +1.5.1_extra=0_crlf=false/re-run f19230652767bb30 e04a67e0369c9327 +1.5.1_extra=0_crlf=true 5871c60c9d63aeee 38ae1d9d6bfd76a2 +1.5.1_extra=0_crlf=true/re-run 6dab15e1eab2f462 e04a67e0369c9327 +1.5.1_extra=3_crlf=false 0b33aee716ad5a55 9925f9a6ef06e967 +1.5.1_extra=3_crlf=false/re-run e26d96dd76e1b368 47efe39e0895de81 +1.5.1_extra=3_crlf=true 161846bd33f1d508 8049421f81d4421f +1.5.1_extra=3_crlf=true/re-run 3e4cf8de3fdcb6ea 47efe39e0895de81 +1.6.1_extra=0_crlf=false d1e0442566c6ce49 b582122f11dc3d3b +1.6.1_extra=0_crlf=false/re-run ea7966d58c9f2aab e04a67e0369c9327 +1.6.1_extra=0_crlf=true db20a2c4edcafbf0 dac7be1a90f7c41f +1.6.1_extra=0_crlf=true/re-run 8f7c22713b6f70b6 e04a67e0369c9327 +1.6.1_extra=3_crlf=false afd5d875780cdf40 1d773624b88301f6 +1.6.1_extra=3_crlf=false/re-run 647ef4eb652f1ba6 47efe39e0895de81 +1.6.1_extra=3_crlf=true b37fcda617275f4b 809d3e18486cfdb6 +1.6.1_extra=3_crlf=true/re-run 1a4219f03efad32a 47efe39e0895de81 +1.7.1_extra=0_crlf=false 6600136814fb4134 8abb2fc52c3ba659 +1.7.1_extra=0_crlf=false/re-run abdbbfe42c8b4d7e e04a67e0369c9327 +1.7.1_extra=0_crlf=true 6a85a6a27c99cd2b 141b87d57540f8d5 +1.7.1_extra=0_crlf=true/re-run ac9d84b451f9c128 e04a67e0369c9327 +1.7.1_extra=3_crlf=false 020fa829b6a870bc fef245c9f6cf74ce +1.7.1_extra=3_crlf=false/re-run 31133977b68135e7 47efe39e0895de81 +1.7.1_extra=3_crlf=true bcb1fd00c2d4d5bf d0fb3a01af130572 +1.7.1_extra=3_crlf=true/re-run ac38704d471666ab 47efe39e0895de81 +1.8.5_extra=0_crlf=false 7b0c488a965c4f7b d3d0f2005c6eba75 +1.8.5_extra=0_crlf=false/re-run 60ce065c6b04b5b1 e04a67e0369c9327 +1.8.5_extra=0_crlf=true 2094d208abbeabc2 7355dcc07008bdd9 +1.8.5_extra=0_crlf=true/re-run 5903de75cc2030e8 e04a67e0369c9327 +1.8.5_extra=3_crlf=false e22381008c314e3d 310e89fa54524112 +1.8.5_extra=3_crlf=false/re-run 147043d9e02c7a08 47efe39e0895de81 +1.8.5_extra=3_crlf=true e42675d2895de9fb f6b5c6bab1fd4d66 +1.8.5_extra=3_crlf=true/re-run 9e97459a7ac1ffb1 47efe39e0895de81 +2.0.1_extra=0_crlf=false 2775b8f1411fbadb 8a5a1f9f229b918d +2.0.1_extra=0_crlf=false/re-run 5a19ccf0764fac47 e04a67e0369c9327 +2.0.1_extra=0_crlf=true 17ebf61a05b76816 26f80ba1fcd6798f +2.0.1_extra=0_crlf=true/re-run 97eff076ab7188a2 e04a67e0369c9327 +2.0.1_extra=3_crlf=false bc38767ba9dcc6e6 045b183ce065ecbe +2.0.1_extra=3_crlf=false/re-run 44cce38f20f8f16f 47efe39e0895de81 +2.0.1_extra=3_crlf=true b68b1e02f151ef48 071ef2ca4ce60cbc +2.0.1_extra=3_crlf=true/re-run 054b4ddcb4dcfafa 47efe39e0895de81 +2.1.4_extra=0_crlf=false 6f62ddaa4adc50a4 de6216f6455c89b5 +2.1.4_extra=0_crlf=false/re-run 5b7e62f966b8d271 e04a67e0369c9327 +2.1.4_extra=0_crlf=true 8a8d93a2b2127129 c7bebe0c6ae7585f +2.1.4_extra=0_crlf=true/re-run d1cd3eeea0692da9 e04a67e0369c9327 +2.1.4_extra=3_crlf=false 65364c0f4e7aa0e7 7c682a748ae52445 +2.1.4_extra=3_crlf=false/re-run a3676e7eddbccf4a 47efe39e0895de81 +2.1.4_extra=3_crlf=true 821fdbf37b56026b d70edca8dcbd4297 +2.1.4_extra=3_crlf=true/re-run 25f65d16b322be1c 47efe39e0895de81 +2.2.1_extra=0_crlf=false 7a500b5022ac388c c7fe1321a7cbaa66 +2.2.1_extra=0_crlf=false/re-run b484d2ecc12a5edb e04a67e0369c9327 +2.2.1_extra=0_crlf=true 11b39909d694a4f5 95adf7d92ccaab10 +2.2.1_extra=0_crlf=true/re-run f73c0184185b55c8 e04a67e0369c9327 +2.2.1_extra=3_crlf=false 10b0bcde3632669b 0a15ab32536efc8b +2.2.1_extra=3_crlf=false/re-run 679acf31e13b71a0 47efe39e0895de81 +2.2.1_extra=3_crlf=true feaa6416a4b168a3 8622d3b17da11939 +2.2.1_extra=3_crlf=true/re-run 4b8aa165ed772485 47efe39e0895de81 +2.3.4_extra=0_crlf=false 1ad17cac9704b1ce eb5749be95fe039d +2.3.4_extra=0_crlf=false/re-run 398fea1a1ac5d77b e04a67e0369c9327 +2.3.4_extra=0_crlf=true d4578a811e547b2c 59f5c3d95e44ca10 +2.3.4_extra=0_crlf=true/re-run 5f79a7161605a083 e04a67e0369c9327 +2.3.4_extra=3_crlf=false d38430f6b06c87f3 f9b7e52943c568bd +2.3.4_extra=3_crlf=false/re-run ee7114aaad11f4f0 47efe39e0895de81 +2.3.4_extra=3_crlf=true 89e72b13eccda257 9af3c0dd8ba7b11c +2.3.4_extra=3_crlf=true/re-run 04c0dd27743659d0 47efe39e0895de81 +2.4.3_extra=0_crlf=false 70d3006a3e404efc 36a66ed9aa6e1c95 +2.4.3_extra=0_crlf=false/re-run cb0289ac6716caab e04a67e0369c9327 +2.4.3_extra=0_crlf=true ebe555cb2a5fb1f2 68737b77ef429fb8 +2.4.3_extra=0_crlf=true/re-run 3f6277fd5f2ef21b e04a67e0369c9327 +2.4.3_extra=3_crlf=false 7bcb0eb7f4f1150a 78d3331b6d8aaaed +2.4.3_extra=3_crlf=false/re-run c7b4e742d1f0f79b 47efe39e0895de81 +2.4.3_extra=3_crlf=true f1ba10a6e2549703 9d6eb26cb8f7b142 +2.4.3_extra=3_crlf=true/re-run 6c2fb99f914a5767 47efe39e0895de81 From 044096201e97261ee4a5d6dcbf9f36790e166852 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:08:56 +0000 Subject: [PATCH 09/12] Own berry deps only when the lock pins them The berry rewriter claimed every npm patch as its own before looking at yarn.lock. A grant without a yarnBerry10c0 checksum, or with a URL yarn cannot fetch as a tarball, was then left owned but unconfirmed, so hosted mode stopped counting a package the lock does not pin (and that another lockfile may have rewritten). Ownership is now taken only once the lock is known to pin the package version. The checksum is required only when the entry has to be rewritten, so a rescan with a checksumless grant still confirms a pin an earlier run completed. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 164 +++++++++++++----- 1 file changed, 122 insertions(+), 42 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index f4df1b90f..d02ffd15e 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3407,39 +3407,15 @@ fn rewrite_yarn_berry( let mut changed = false; for dep in &npm { let fname = full_name(dep); - // This rewriter alone decides the dep from here on (see - // [`RewriteResult::yarn_berry_uuids`]); only a lock that does not - // lock its version at all hands it back to the other lockfiles. - result.yarn_berry_uuids.insert(dep.patch_uuid.clone()); // 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; - }; - 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; - } + .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")) @@ -3625,6 +3601,12 @@ fn rewrite_yarn_berry( 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(), @@ -3658,11 +3640,37 @@ fn rewrite_yarn_berry( detail: format!("no npm: lock entry resolving {fname}@{}", dep.version), }); } - if !matched_any && !shared_descriptor { - result.yarn_berry_uuids.remove(&dep.patch_uuid); - } 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(), @@ -3713,17 +3721,22 @@ fn rewrite_yarn_berry( rewritten = resolution_re .replace(&rewritten, format!("\n resolution: \"{resolution}\"").as_str()) .to_string(); - if checksum_re.is_match(&rewritten) { - rewritten = checksum_re - .replace(&rewritten, format!("\n checksum: {checksum}").as_str()) - .to_string(); - } else { - rewritten = resolution_re - .replace( - &rewritten, - format!("\n resolution: \"{resolution}\"\n checksum: {checksum}").as_str(), - ) - .to_string(); + match &checksum { + Some(checksum) if checksum_re.is_match(&rewritten) => { + rewritten = checksum_re + .replace(&rewritten, format!("\n checksum: {checksum}").as_str()) + .to_string(); + } + Some(checksum) => { + rewritten = resolution_re + .replace( + &rewritten, + format!("\n resolution: \"{resolution}\"\n checksum: {checksum}") + .as_str(), + ) + .to_string(); + } + None => {} } let rewritten = rewritten[1..].to_string(); for (selector, original) in pin.apply(manifest_obj, &dep.artifact_url) { @@ -8141,6 +8154,73 @@ mod tests { ); 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 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 From 68547b15d35465b7eef951328e4f883b7939f1e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:25:33 +0000 Subject: [PATCH 10/12] Harden berry pin writes and refused locks A patch server URL containing "$" was expanded as a regex capture reference when written into the berry lock entry, corrupting its resolution and checksum lines. The URL is now written literally. When the berry preflight refuses a lock (mixed line endings, an unsupported cacheKey), packages that lock pins are still decided by the berry rewriter, and left unconfirmed. Before, a URL from an earlier run in such a lock could confirm the package through the hosted text probe. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 116 +++++++++++++++++- 1 file changed, 111 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d62991782..f0ae7c7a6 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}; @@ -3389,6 +3389,15 @@ 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); @@ -3731,21 +3740,27 @@ fn rewrite_yarn_berry( 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, format!("\n resolution: \"{resolution}\"").as_str()) + .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(); } 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(); } @@ -3840,6 +3855,25 @@ pub(crate) fn berry_entries_sorted(blocks: &[String]) -> bool { 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, _)| n == name)) + }) +} + /// The root manifest the yarn berry hosted pin edits. const BERRY_MANIFEST: &str = "package.json"; @@ -8214,6 +8248,78 @@ mod tests { } } + /// 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()); + } + + /// 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 From dc9330f4c6371b209a9993a0a54a18dc51e3c764 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:50:40 +0000 Subject: [PATCH 11/12] Clarify when the berry pin edits package.json The npm lock rewriter now also reads the root package.json (#490), so the contract says the berry pin is the one that edits it, not the only reader. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index cb3f57ad8..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** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — read 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). +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. From 8d52ba6d5cc02624234d2c5335de9c4c777977d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 17:09:22 +0000 Subject: [PATCH 12/12] Leave yarn berry fork aliases untouched A lock entry keyed `left-pad@npm:other@^1.3.0` installs the package `other` under the left-pad name. If that fork happened to be at the patched version, the hosted rewrite re-keyed it to the left-pad tarball, replacing the user's fork with the patched package. Fork aliases are now skipped and do not make the real entry ambiguous. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 68 ++++++++++++++++++- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 76de43498..38ce4b433 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3541,6 +3541,15 @@ 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() { @@ -3882,12 +3891,21 @@ fn berry_lock_locks(content: &str, name: &str, version: &str) -> bool { return false; } format!("{block}\n").contains(&version_line) - && split_berry_key_patterns(key) - .iter() - .any(|p| split_pattern(p).is_some_and(|(n, _)| n == name)) + && 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"; @@ -8294,6 +8312,50 @@ mod tests { 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]