From ad9e70d5414e22ef2452b84de862e3040ca51856 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 05:31:33 +0000 Subject: [PATCH 1/6] Start fix for #907 Assisted-by: Claude Code:claude-opus-5-5 From b416636af701b4501834d979772cf0a760c76ab7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 05:33:25 +0000 Subject: [PATCH 2/6] Warn on hosted yarn classic pins berry drops A yarn 2+ install migrates a classic (v1) yarn.lock and re-resolves every entry from the registry, so a hosted pin is silently dropped and the package installs unpatched. Vendored mode already warned about this; hosted mode said nothing. The hosted engine now reads package.json beside a classic yarn.lock, and the classic rewriter warns redirect_yarn_classic_berry_migration_risk once per run when the lock carries a hosted pin, unless package.json pins yarn 1 through packageManager. The yarn 1 check is shared with the vendored probe so both modes agree. Fixes #907 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/covgap_commands_scan_hosted.rs | 102 +++++++++++ crates/socket-patch-core/src/hosted/engine.rs | 14 +- .../src/patch/redirect/mod.rs | 161 ++++++++++++++++++ crates/socket-patch-core/src/vendor/mod.rs | 43 +++-- docs/ecosystems.md | 5 + 6 files changed, 305 insertions(+), 22 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index dff38f2c8..1cb187202 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. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). 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`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is refused BEFORE its revert, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. 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, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files). **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). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `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). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). **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, plus — read-only — a `.bundle/config` bundle path refused as a write root because it resolves outside the project, which takes the project-local remedy) 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, then `BUNDLE_CACHE_PATH:` in the global config (`bundle config set --global`: `$BUNDLE_CONFIG`, else `$BUNDLE_USER_CONFIG`, else `$BUNDLE_USER_HOME/config`, else `~/.bundle/config`), else `vendor/cache`; a relative value is read against the project root. The same global tier, below the app config and the environment, applies to `BUNDLE_GEMFILE:` and, for agent-mode install-root discovery, to `BUNDLE_PATH:`. A present local or environment `path`, `path.system`, or `disable_shared_gems` setting (including an empty string or false flag) shadows the global path tier, matching the tested Bundler 2.6/4 behavior; Bundler 1.x's legacy global-path shortcut is not modeled. An empty higher-tier `gemfile` setting also shadows the global value but leaves an existing nonempty `BUNDLE_GEMFILE` environment value in effect, or uses default manifest discovery when there is none. With `BUNDLE_IGNORE_CONFIG` set (any value) bundler reads no config file, so the app and global configs are 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. diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index 54ddf7441..15a052534 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -2607,3 +2607,105 @@ async fn vlt_decides_before_binary_bun_and_a_refused_uuid_is_never_confirmed() { let (confirmed, _tmp) = run(false); assert_eq!(confirmed["redirect"]["redirected"], 1, "{confirmed:#}"); } + +// ───────────────── yarn classic berry-migration advisory ───────────────── + +/// Yarn classic project: package.json (with `package_manager` when given) + +/// installed node_modules copy + a v1 yarn.lock resolving NAME upstream. +fn write_yarn_classic_project(root: &Path, package_manager: Option<&str>) { + let pm = package_manager + .map(|pm| format!(r#", "packageManager": "{pm}""#)) + .unwrap_or_default(); + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0"{pm}, "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + std::fs::write( + root.join("yarn.lock"), + format!( + "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ + # yarn lockfile v1\n\n\n\ + {NAME}@{VERSION}:\n version \"{VERSION}\"\n \ + resolved \"https://registry.yarnpkg.com/{NAME}/-/{NAME}-{VERSION}.tgz#bbbb\"\n \ + integrity {UPSTREAM_SHA512}\n" + ), + ) + .unwrap(); +} + +/// #907: a hosted pin in a classic yarn.lock is dropped by the next yarn 2+ +/// (berry) install exactly like vendored wiring, so `scan --mode hosted` +/// must warn `redirect_yarn_classic_berry_migration_risk` — on a dry run, on +/// the wet run and on an idempotent re-run — when package.json declares no +/// package manager or a berry one. +#[tokio::test] +async fn hosted_yarn_classic_pin_warns_berry_migration_risk() { + for package_manager in [None, Some("yarn@4.18.1")] { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + mock_view(&server, UUID, PURL).await; + let tmp = tempfile::tempdir().unwrap(); + write_yarn_classic_project(tmp.path(), package_manager); + + for (label, extra) in [ + ("dry run", &["--dry-run"][..]), + ("wet run", &[][..]), + ("re-run", &[][..]), + ] { + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), extra, &[]); + assert_eq!(code, 0, "{package_manager:?} {label}: {doc:#}"); + let codes = warning_codes(&doc); + assert_eq!( + codes + .iter() + .filter(|c| *c == "redirect_yarn_classic_berry_migration_risk") + .count(), + 1, + "{package_manager:?} {label}: exactly one advisory: {codes:?}" + ); + let detail = warning_detail(&doc, "redirect_yarn_classic_berry_migration_risk"); + assert!(detail.contains("yarn 2+"), "{detail}"); + } + let lock = std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(); + assert!( + lock.contains(HOSTED_URL), + "the warning never blocks the pin: {lock}" + ); + } +} + +/// #907: a corepack `packageManager: yarn@1…` pin makes a stray berry +/// install refuse instead of migrate, so hosted stays silent, as vendored +/// does. +#[tokio::test] +async fn hosted_yarn_classic_pin_with_yarn1_package_manager_stays_silent() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + mock_view(&server, UUID, PURL).await; + let tmp = tempfile::tempdir().unwrap(); + write_yarn_classic_project(tmp.path(), Some("yarn@1.22.22")); + + let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + let codes = warning_codes(&doc); + assert!( + !codes + .iter() + .any(|c| c == "redirect_yarn_classic_berry_migration_risk"), + "a yarn 1 pin suppresses the advisory: {codes:?}" + ); + let lock = std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(); + assert!(lock.contains(HOSTED_URL), "{lock}"); +} diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index f84f61b1a..343467b17 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -465,12 +465,16 @@ pub async fn read_candidate_files( { out.read(view, unreadable, "package.json").await; // Otherwise the root manifest's `overrides` decide which git / url / - // `file:` dependent specs npm really installs from (#490). Only the npm - // lock rewriter reads it, as advisory input: no rewriter edits it, so a - // link or an unreadable in-memory entry is left out (the rewriter then - // keeps its conservative reading) rather than refused. + // `file:` dependent specs npm really installs from (#490), and beside a + // classic `yarn.lock` its `packageManager` says whether a yarn 2+ install + // could migrate the lock and drop the hosted pins (#907). The npm lock + // and yarn classic rewriters read it as advisory input only: no + // rewriter edits it, so a link or an unreadable in-memory entry is left + // out (the rewriters then keep their conservative reading) rather than + // refused. } else if candidates.iter().any(|c| c.dep.ecosystem == "npm") - && NPM_LOCKS.iter().any(|lock| out.files.contains_key(*lock)) + && (NPM_LOCKS.iter().any(|lock| out.files.contains_key(*lock)) + || out.files.contains_key("yarn.lock")) { let rel = crate::hosted::memory::select::NPM_MANIFEST_REL; if let Some(text) = read_advisory(view, unreadable, rel).await { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 72c8b2967..7ba92d385 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3264,6 +3264,7 @@ fn rewrite_yarn_classic( let mut heads: Vec)>> = blocks.iter().map(|b| yarn_classic_block_head(b)).collect(); let mut changed = false; + let mut any_pinned = false; for dep in &npm { let fname = full_name(dep); let Some(sha512) = dep.integrity.sha512.clone() else { @@ -3396,6 +3397,25 @@ fn rewrite_yarn_classic( detail: format!("no yarn.lock entry resolving {fname}@{}", dep.version), }); } + any_pinned |= matched_any; + } + // Yarn 2+ (berry) migrates a classic lock on install and re-resolves + // every entry from the registry, dropping the hosted pins this lock now + // carries (rewritten this run or already pinned) — the packages then + // install unpatched with nothing printed. The vendored probe + // (`vendor::yarn_classic_berry_migration_risk`) warns about the same + // trap; warn here too, once per run, unless corepack pins yarn 1. + if any_pinned + && !crate::vendor::manifest_pins_yarn_classic(files.get("package.json").map(String::as_str)) + { + result.warnings.push(RewriteWarning { + code: "redirect_yarn_classic_berry_migration_risk".into(), + detail: "yarn.lock is yarn-classic (v1) with hosted pins: installing with yarn 2+ \ + (berry) migrates the lockfile and silently drops them — packages install \ + unpatched from the registry. Pin yarn classic (e.g. \"packageManager\": \ + \"yarn@1.22.22\" in package.json) so every install uses yarn 1." + .into(), + }); } if changed { let mut out = blocks.join("\n\n"); @@ -9626,6 +9646,147 @@ mod tests { .to_string() } + const BERRY_RISK: &str = "redirect_yarn_classic_berry_migration_risk"; + + fn classic_files(package_json: Option<&str>) -> BTreeMap { + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), classic_lock_two_entries()); + if let Some(manifest) = package_json { + files.insert("package.json".to_string(), manifest.to_string()); + } + files + } + + fn berry_risk_count(r: &RewriteResult) -> usize { + r.warnings.iter().filter(|w| w.code == BERRY_RISK).count() + } + + /// #907: a hosted pin in a classic lock is dropped by a yarn 2+ install + /// exactly like vendored wiring, so the hosted rewrite must warn the + /// way the vendored probe does — with no `packageManager` pin, with a + /// non-1 yarn declared (`yarn@10` must not prefix-match `yarn@1`), and + /// when the manifest is missing or unparseable (fail toward warning). + #[test] + fn yarn_classic_hosted_pin_warns_berry_migration_risk() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + for manifest in [ + Some(r#"{"name":"p","dependencies":{"left-pad":"^1.3.0"}}"#), + Some(r#"{"name":"p","packageManager":"yarn@4.18.1"}"#), + Some(r#"{"name":"p","packageManager":"yarn@10.0.0"}"#), + Some(r#"{"name":"p","packageManager":"pnpm@9.0.0"}"#), + Some("{not json"), + None, + ] { + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&classic_files(manifest), std::slice::from_ref(&ovr), &mut r); + assert!( + r.files.contains_key("yarn.lock"), + "{manifest:?}: pin must land" + ); + assert_eq!(berry_risk_count(&r), 1, "{manifest:?}: {:?}", r.warnings); + let w = r.warnings.iter().find(|w| w.code == BERRY_RISK).unwrap(); + assert!( + w.detail.contains("yarn 2+"), + "detail names the trap: {}", + w.detail + ); + assert!( + w.detail.contains("yarn@1"), + "detail names the remedy: {}", + w.detail + ); + } + } + + /// #907: a corepack `packageManager: yarn@1…` pin makes a stray berry + /// install refuse instead of migrate, so it suppresses the warning (as + /// it does the vendored one), including a pin with a corepack hash. + #[test] + fn yarn_classic_hosted_pin_yarn1_package_manager_suppresses_berry_risk() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + for pm in ["yarn@1.22.22", "yarn@1.7.0", "yarn@1.22.22+sha512.abc"] { + let manifest = format!(r#"{{"name":"p","packageManager":"{pm}"}}"#); + let mut r = RewriteResult::default(); + rewrite_yarn_classic( + &classic_files(Some(&manifest)), + std::slice::from_ref(&ovr), + &mut r, + ); + assert!(r.files.contains_key("yarn.lock")); + assert!(r.warnings.is_empty(), "{pm}: {:?}", r.warnings); + } + } + + /// #907: the warning is per run, not per package, and it fires on an + /// idempotent re-run too: the lock already carries the pin, so the next + /// berry install drops it just the same. + #[test] + fn yarn_classic_berry_risk_is_once_per_run_and_survives_rerun() { + let lp = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + let ab = npm_override("abbrev", "1.1.1", "http://p.test/ab.tgz", "sha512-AB=="); + let files = classic_files(Some(r#"{"name":"p"}"#)); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, &[lp.clone(), ab], &mut r); + assert_eq!(r.edits.len(), 2, "both entries pinned: {:?}", r.edits); + assert_eq!(berry_risk_count(&r), 1, "{:?}", r.warnings); + + let mut pinned = files.clone(); + pinned.insert("yarn.lock".into(), r.files["yarn.lock"].clone()); + let mut again = RewriteResult::default(); + rewrite_yarn_classic(&pinned, std::slice::from_ref(&lp), &mut again); + assert!( + again.edits.is_empty(), + "re-run is a no-op: {:?}", + again.edits + ); + assert_eq!(berry_risk_count(&again), 1, "{:?}", again.warnings); + } + + /// No pin in the lock means nothing for berry to drop: a dep with no + /// classic entry warns only `entry_not_found`, and a berry lock is not + /// the classic rewriter's at all. + #[test] + fn yarn_classic_berry_risk_silent_without_a_pin() { + let missing = npm_override("nope", "9.9.9", "http://p.test/n.tgz", "sha512-N=="); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&classic_files(Some(r#"{"name":"p"}"#)), &[missing], &mut r); + let codes: Vec<&str> = r.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!(codes, ["redirect_yarn_classic_entry_not_found"]); + + let lp = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + "__metadata:\n version: 8\n\n\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n \ + resolution: \"left-pad@npm:1.3.0\"\n" + .to_string(), + ); + files.insert("package.json".to_string(), r#"{"name":"p"}"#.to_string()); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, &[lp], &mut r); + assert_eq!(berry_risk_count(&r), 0, "{:?}", r.warnings); + } + /// A CRLF classic lock (Windows `core.autocrlf` checkout) must rewrite /// the TARGET entry, not whichever entry happens to come first, and every /// untouched line must keep its CRLF ending byte-exactly (see the CRLF diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 2c8adc2c5..19b20094a 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -183,22 +183,9 @@ pub fn yarn_classic_berry_migration_risk(project_root: &Path) -> Option(&pkg).ok()) - .and_then(|v| { - v.get("packageManager") - .and_then(|p| p.as_str().map(String::from)) - }) - { - let major = pm.trim().strip_prefix("yarn@").map(|rest| { - rest.chars() - .take_while(char::is_ascii_digit) - .collect::() - }); - if major.as_deref() == Some("1") { - return None; - } + let manifest = read_regular_to_string_sync(&project_root.join("package.json")).ok(); + if manifest_pins_yarn_classic(manifest.as_deref()) { + return None; } Some(VendorWarning::new( "yarn_classic_berry_migration_risk", @@ -209,6 +196,30 @@ pub fn yarn_classic_berry_migration_risk(project_root: &Path) -> Option) -> bool { + let Some(pm) = manifest + .and_then(|pkg| serde_json::from_str::(pkg).ok()) + .and_then(|v| { + v.get("packageManager") + .and_then(|p| p.as_str().map(String::from)) + }) + else { + return false; + }; + let major = pm.trim().strip_prefix("yarn@").map(|rest| { + rest.chars() + .take_while(char::is_ascii_digit) + .collect::() + }); + major.as_deref() == Some("1") +} + /// Vendoring acquires immutable artifacts from the patch service. /// `auto` remains a command-line compatibility alias for `service`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 14735578b..32d78222d 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -113,6 +113,11 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. unpatched artifact. The reverse shape — an alias of the patched NAME pointing at a different package (`"left-pad@npm:some-fork@^1.3.0"`, the fork-substitution idiom) — is never rewritten: it resolves a different package. +- **yarn classic and yarn 2+** — installing with yarn 2+ (berry) migrates a classic (v1) + `yarn.lock` and re-resolves every entry from the registry, dropping hosted pins and + vendored wiring alike, so the packages install unpatched. A run that leaves such a pin + warns (`redirect_yarn_classic_berry_migration_risk` / `yarn_classic_berry_migration_risk`) + unless `package.json` pins yarn classic through `"packageManager": "yarn@1…"`. - **yarn classic git dependencies** — yarn 1 fetches a git pattern (`git+https:`, `git+ssh:`, `git:`, `ssh:`, a `….git` url, or a bare `https://github.com//`) with git, using the lock entry's `resolved` as the remote, so a rewritten `resolved` From aa1d3c36f7264844fb78b52982c89e374d534b03 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 05:40:22 +0000 Subject: [PATCH 3/6] Skip berry warning when no root package.json A yarn.lock with no root package.json is not a project yarn installs from, so there is nothing to warn about. This also keeps the advisory off rewriter fixtures that carry only a lock. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 7ba92d385..c788a1374 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3404,10 +3404,11 @@ fn rewrite_yarn_classic( // carries (rewritten this run or already pinned) — the packages then // install unpatched with nothing printed. The vendored probe // (`vendor::yarn_classic_berry_migration_risk`) warns about the same - // trap; warn here too, once per run, unless corepack pins yarn 1. - if any_pinned - && !crate::vendor::manifest_pins_yarn_classic(files.get("package.json").map(String::as_str)) - { + // trap; warn here too, once per run, unless corepack pins yarn 1. The + // engine reads the root manifest beside a classic lock; with none there + // is no project for yarn to install, so nothing to warn about. + let manifest = files.get("package.json").map(String::as_str); + if any_pinned && manifest.is_some() && !crate::vendor::manifest_pins_yarn_classic(manifest) { result.warnings.push(RewriteWarning { code: "redirect_yarn_classic_berry_migration_risk".into(), detail: "yarn.lock is yarn-classic (v1) with hosted pins: installing with yarn 2+ \ @@ -9665,7 +9666,7 @@ mod tests { /// exactly like vendored wiring, so the hosted rewrite must warn the /// way the vendored probe does — with no `packageManager` pin, with a /// non-1 yarn declared (`yarn@10` must not prefix-match `yarn@1`), and - /// when the manifest is missing or unparseable (fail toward warning). + /// when the manifest is unparseable (fail toward warning). #[test] fn yarn_classic_hosted_pin_warns_berry_migration_risk() { let ovr = npm_override( @@ -9680,7 +9681,6 @@ mod tests { Some(r#"{"name":"p","packageManager":"yarn@10.0.0"}"#), Some(r#"{"name":"p","packageManager":"pnpm@9.0.0"}"#), Some("{not json"), - None, ] { let mut r = RewriteResult::default(); rewrite_yarn_classic(&classic_files(manifest), std::slice::from_ref(&ovr), &mut r); @@ -9768,6 +9768,18 @@ mod tests { let codes: Vec<&str> = r.warnings.iter().map(|w| w.code.as_str()).collect(); assert_eq!(codes, ["redirect_yarn_classic_entry_not_found"]); + // No root manifest: no project for yarn to install, nothing to warn. + let lp = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&classic_files(None), std::slice::from_ref(&lp), &mut r); + assert!(r.files.contains_key("yarn.lock")); + assert_eq!(berry_risk_count(&r), 0, "{:?}", r.warnings); + let lp = npm_override( "left-pad", "1.3.0", From 66009a0f83fb55aed29f68501fee7415b0c17adb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:24:21 +0000 Subject: [PATCH 4/6] Route Gradle digests through utils::digest main has failed socket-patch-core's lib tests since Gradle support (#646) and the digest helpers (#865) both landed. The guard test production_digests_go_through_the_helpers flags three files #646 added that still hash inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs. That breaks test, test-release and coverage on every open PR. Each inline sha1/sha256 call now goes through sha1_hex_of or sha256_hex_of, which compute the same lowercase hex. Behaviour is unchanged. Assisted-by: Claude Code:claude-opus-5-5 (cherry picked from commit 659ac2c24e5c5904e743b4bc98ea4645da2ed6a1) --- crates/socket-patch-core/src/crawlers/gradle_cache.rs | 9 ++++----- crates/socket-patch-core/src/patch/jvm_jar.rs | 7 ++----- crates/socket-patch-core/src/patch/sidecars/maven.rs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } From 13242189b758bbc408757424564940a456503b03 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 06:10:59 +0000 Subject: [PATCH 5/6] Pin yarn 1 in the yarn classic bench fixture The scan benchmark treats any warning as a failed scenario. Its yarn classic project declared no package manager, so a hosted scan now correctly warns that a yarn 2+ install would drop the pins. Declare packageManager yarn@1.22.22, as the berry fixture declares yarn 4 and as a real classic project would. The measured scan is unchanged. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-bench/src/fixtures/npm.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-bench/src/fixtures/npm.rs b/crates/socket-patch-bench/src/fixtures/npm.rs index f5da76325..da0d41ffa 100644 --- a/crates/socket-patch-bench/src/fixtures/npm.rs +++ b/crates/socket-patch-bench/src/fixtures/npm.rs @@ -537,7 +537,17 @@ pub fn yarn_classic_lock(g: &Graph) -> String { pub fn build_yarn_classic(t: &mut Tree, size: Size) -> std::io::Result { let g = graph("yarn-classic", size); - t.write("project/package.json", g.package_json())?; + // Pin yarn 1 as a real classic project does: without the pin a hosted + // scan rightly warns `redirect_yarn_classic_berry_migration_risk` + // (#907), which the scenario would count as an unexpected warning. + t.write( + "project/package.json", + g.package_json().replacen( + "\"private\": true", + "\"private\": true,\n \"packageManager\": \"yarn@1.22.22\"", + 1, + ), + )?; t.write("project/yarn.lock", yarn_classic_lock(&g))?; t.write( "project/node_modules/.yarn-integrity", From 494c3bfb078421fb6510c195c8bced620dad3393 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 13:01:52 +0000 Subject: [PATCH 6/6] Drop stale entries from the digest pending list Main's test jobs are red: production_digests_go_through_the_helpers fails because PENDING_INLINE_DIGESTS still names gradle_cache.rs, jvm_jar.rs and sidecars/maven.rs. #955 added them while those files computed digests inline, and #690 (sbt/Mill/scala-cli) then moved them onto utils::digest helpers; the two merged independently, so the ratchet now fails on stale entries on every platform. Remove the three entries so the list matches the production tree. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qq8uwZ9NTCZXZRygn7woy7 (cherry picked from commit 6cb46c0f3c0187379ff5ac4bd52fb2e68969a1a3) --- crates/socket-patch-core/src/utils/digest.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/socket-patch-core/src/utils/digest.rs b/crates/socket-patch-core/src/utils/digest.rs index 105225e5e..630adefa1 100644 --- a/crates/socket-patch-core/src/utils/digest.rs +++ b/crates/socket-patch-core/src/utils/digest.rs @@ -135,9 +135,6 @@ mod tests { /// when you move it onto the helpers above; the test fails on a stale /// entry as well as on a new inline copy. const PENDING_INLINE_DIGESTS: &[&str] = &[ - "crawlers/gradle_cache.rs", - "patch/jvm_jar.rs", - "patch/sidecars/maven.rs", "utils/group_commit.rs", "vendor/jvm/mod.rs", "vendor/maven_repo.rs",