From 369133e32eb7c0338f04b28896b95129d77ef824 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:27:45 +0000 Subject: [PATCH 1/3] Start fix for #628, #629 Assisted-by: Claude Code:claude-opus-5-5 From 2409f1a81527fc33aa8f014985eb2c1a684273f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:04:01 +0000 Subject: [PATCH 2/3] Test hosted berry refusal of mixed package.json A berry project whose root package.json mixes CRLF and LF is refused by vendored mode, but hosted mode rewrites it in the majority ending. These tests cover a fresh hosted scan and the vendored-to-hosted takeover (#628). They fail until the gate is shared. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/in_process_redirect.rs | 46 +++++++++++++++++++ .../tests/in_process_vendor.rs | 9 ++++ 2 files changed, 55 insertions(+) diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 7ae650809..3917b0510 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -972,6 +972,52 @@ async fn scan_redirect_refuses_a_mixed_line_ending_yarn_berry_lock() { ); } +/// #628: the root `package.json` of a berry project is a file the hosted +/// rewrite edits (its `resolutions`), so a manifest mixing CRLF and LF is +/// refused like a mixed lock — the same decision vendored mode takes with +/// `vendor_yarn_berry_mixed_line_endings` — instead of being re-rendered in +/// its majority ending, which rewrote lines the user never touched and +/// left rollback no original bytes to restore. Nothing is written. +#[tokio::test] +#[serial] +async fn scan_redirect_refuses_a_mixed_line_ending_yarn_berry_manifest() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference_with_berry(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_berry_project_spelled(tmp.path(), |t| t.to_string()); + let pkg_path = tmp.path().join("package.json"); + std::fs::write( + &pkg_path, + format!( + "{{\r\n \"name\": \"consumer\",\n \"version\": \"0.0.0\",\r\n \ + \"dependencies\": {{ \"{NAME}\": \"^{VERSION}\" }}\r\n}}\r\n" + ), + ) + .unwrap(); + let lock_path = tmp.path().join("yarn.lock"); + let (pkg_before, lock_before) = ( + std::fs::read(&pkg_path).unwrap(), + std::fs::read(&lock_path).unwrap(), + ); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + let detail = redirect_warning_detail(&env, "redirect_yarn_berry_mixed_line_endings"); + assert!(detail.contains("package.json"), "names the file: {detail}"); + assert!(detail.contains("yarn install"), "remedy named: {detail}"); + assert_eq!(std::fs::read(&pkg_path).unwrap(), pkg_before, "untouched"); + assert_eq!(std::fs::read(&lock_path).unwrap(), lock_before, "untouched"); + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "no ledger for a refused rewrite" + ); +} + /// Classic (v1) yarn.lock with CRLF line endings (Windows `core.autocrlf` /// checkout): the full hosted chain must repoint the TARGET entry — not /// whichever entry sorts first — and keep every untouched line CRLF diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 1b6b410fc..437997cd5 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1503,6 +1503,15 @@ async fn berry_takeovers_refuse_before_reverting_the_old_mode() { "yarn.lock", "redirect_yarn_berry_mixed_line_endings", ), + // #628: hosted mode re-renders the root manifest (its + // `resolutions`), so a mixed one is refused before the revert, the + // same decision the hosted→vendored leg below takes. + ( + "mixed package.json", + mix, + "package.json", + "redirect_yarn_berry_mixed_line_endings", + ), ( "compressionLevel", compression, From 759933a850676e86aeb6a89742f68c926b9dcd1a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:04:01 +0000 Subject: [PATCH 3/3] Share yarn berry project gates across modes Hosted and vendored modes each carried their own copy of the yarn berry project refusals (mixed line endings, cacheKey, .yarnrc.yml compressionLevel), and the copies drifted: hosted mode never checked the root package.json, so it silently rewrote a mixed-line-ending manifest that vendored mode refuses (#628). The gates now live once in formats/yarn/berry_gates.rs. The vendored backend and its takeover preflight, the hosted rewriter, the vendored-to-hosted takeover and the hosted restore all call it and keep their existing codes. Hosted mode now refuses a mixed package.json with redirect_yarn_berry_mixed_line_endings before writing or reverting anything (#629). Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 5 + crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/scan/hosted.rs | 10 +- .../src/formats/yarn/berry_gates.rs | 374 ++++++++++++++++++ .../socket-patch-core/src/formats/yarn/mod.rs | 2 + .../src/patch/redirect/mod.rs | 134 ++++--- .../src/patch/redirect/upstream/npm.rs | 13 +- .../src/vendor/lock_inventory/yarn.rs | 8 +- .../src/vendor/yarn_berry_lock.rs | 291 +++++++------- docs/ecosystems.md | 5 +- 10 files changed, 601 insertions(+), 243 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/yarn/berry_gates.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3572a298c..4dc3ca65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,11 @@ limits, and required install commands. ### Fixed +- Hosted mode refuses a Yarn Berry project whose root `package.json` mixes CRLF + and LF line endings (`redirect_yarn_berry_mixed_line_endings`), as vendored + mode already did, instead of rewriting every minority line. Both modes now + share one set of berry project gates (line endings, `cacheKey`, + `compressionLevel`). - Global mode (`-g`) finds npm, yarn, pnpm, bun, RubyGems and Composer on Windows, where they install as `.cmd` / `.bat` shims, instead of reporting an empty scan. The yarn and npm-family global lookups no longer run from the diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 69fb09df9..53d83fbfb 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 **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`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`). 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`. 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). +`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`). 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`. 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). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. 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 `yarn.lock` or `package.json` 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` — 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). diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 97e6866ce..a79d4ff31 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1650,8 +1650,8 @@ async fn vendored_takeover( None }; // Yarn berry twin of the bun gate: the berry rewriter's project-level - // refusals (mixed line endings, cacheKey, `.yarnrc.yml` - // compressionLevel) must be known before the takeover reverts a + // refusals (mixed yarn.lock / package.json line endings, cacheKey, + // `.yarnrc.yml` compressionLevel) must be known before the takeover reverts a // vendored berry purl, or the revert strips the live vendored patch // and the rewriter then refuses the lock. Only entries the // vendor ledger wired through the yarn-berry backend are gated (the @@ -1673,8 +1673,14 @@ async fn vendored_takeover( ) .await .ok(); + let manifest = socket_patch_core::utils::fs::read_regular_to_string( + &common.cwd.join("package.json"), + ) + .await + .ok(); socket_patch_core::patch::redirect::preflight_yarn_berry_hosted( &lock, + manifest.as_deref(), yarnrc.as_deref(), ) .err() diff --git a/crates/socket-patch-core/src/formats/yarn/berry_gates.rs b/crates/socket-patch-core/src/formats/yarn/berry_gates.rs new file mode 100644 index 000000000..eff26ed8b --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/berry_gates.rs @@ -0,0 +1,374 @@ +//! The yarn berry project gates: the refusals that hold for a whole +//! project, whatever the patched package — a `yarn.lock` or root +//! `package.json` whose line endings are mixed, a lock `cacheKey` whose +//! cache checksum cannot be reproduced offline, and a `.yarnrc.yml` +//! `compressionLevel` (or an unreadable `.yarnrc.yml`) that changes it. +//! +//! Pure: callers read the files and map a [`BerryGate`] onto their own +//! code prefix (`vendor_yarn_berry_*` for the vendored backend and its +//! hosted→vendored takeover preflight, `redirect_yarn_berry_*` for the +//! hosted rewriter, its vendored→hosted takeover preflight and the hosted +//! restore). Both modes edit the same two files, so they must take the +//! same decision on them; the detail text lives here so it reads the same +//! in either mode. + +use crate::utils::line_endings::LineEndings; + +/// The lock file the gates read. +pub const YARN_LOCK: &str = "yarn.lock"; +/// The root manifest both modes edit (vendored `file:` wiring, hosted +/// `resolutions`). +pub const PACKAGE_JSON: &str = "package.json"; +/// The yarn config whose `compressionLevel` the gates read. +pub const YARNRC: &str = ".yarnrc.yml"; + +/// The only cache key whose checksum reproduces offline: yarn 4's internal +/// cache version `10` with compressionLevel 0 (`c0`, stored zip entries). +pub const SUPPORTED_CACHE_KEY: &str = "10c0"; + +/// What the caller could read of the project's `.yarnrc.yml`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Yarnrc<'a> { + /// No `.yarnrc.yml` (yarn's defaults apply). + Absent, + /// The file's text. + Text(&'a str), + /// The file exists but could not be read; the error text. + Unreadable(&'a str), +} + +impl<'a> Yarnrc<'a> { + /// `Text` for a read file, `Absent` for none. + pub fn from_option(text: Option<&'a str>) -> Self { + text.map_or(Self::Absent, Self::Text) + } +} + +/// Why a berry project is refused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BerryGate { + /// `file` mixes CRLF and LF line endings, or holds a bare CR. + MixedLineEndings { file: &'static str }, + /// The lock has no `__metadata:` block — not a berry lockfile. + NoMetadata, + /// The lock's `cacheKey` is not [`SUPPORTED_CACHE_KEY`]; `None` when + /// the `__metadata` block carries no `cacheKey` line. + CacheKey { found: Option }, + /// `.yarnrc.yml` sets a `compressionLevel` other than 0. + Compression { level: String }, + /// `.yarnrc.yml` exists but could not be read, so its + /// `compressionLevel` cannot be verified. + YarnrcUnreadable { error: String }, +} + +impl BerryGate { + /// The refusal's code suffix after the mode's `*_yarn_berry_` prefix: + /// `mixed_line_endings`, or `cache_unsupported` for every cache gate. + /// [`BerryGate::NoMetadata`] maps to `cache_unsupported` too; the + /// vendored backend reports it as `vendor_lockfile_version_unsupported`. + pub fn code_suffix(&self) -> &'static str { + match self { + Self::MixedLineEndings { .. } => "mixed_line_endings", + Self::NoMetadata + | Self::CacheKey { .. } + | Self::Compression { .. } + | Self::YarnrcUnreadable { .. } => "cache_unsupported", + } + } + + /// The refusal's human-readable detail, the same in both modes. + pub fn detail(&self) -> String { + match self { + Self::MixedLineEndings { file } => format!( + "{file} mixes CRLF and LF line endings (or holds a bare carriage return), so \ + no single line ending can be kept — yarn rewrites the file with one ending \ + on its next install and rejects a lockfile like this under `--immutable` \ + (YN0028); run `yarn install` once to normalize it, then re-run; leaving it \ + untouched" + ), + Self::NoMetadata => { + format!("{YARN_LOCK} has no `__metadata:` entry — not a yarn berry lockfile") + } + Self::CacheKey { found } => format!( + "{YARN_LOCK} cacheKey is `{}`; only `{SUPPORTED_CACHE_KEY}` (yarn 4 with \ + compressionLevel 0, the default) has an offline-reproducible cache checksum \ + — remove custom compression settings and re-run `yarn install`", + found.as_deref().unwrap_or("(missing)") + ), + Self::Compression { level } => format!( + "{YARNRC} sets `compressionLevel: {level}`, which changes berry's cache \ + checksums; only compressionLevel 0 (the yarn 4 default) is supported" + ), + Self::YarnrcUnreadable { error } => { + format!("cannot read {YARNRC} to verify the cache configuration: {error}") + } + } + } +} + +/// Every project gate, in the order both modes raise them: the lock's line +/// endings, its `cacheKey`, the `.yarnrc.yml` compressionLevel, then the +/// root manifest's line endings (`manifest` is `None` when the caller has +/// no manifest to edit). +pub fn check(lock: &str, manifest: Option<&str>, yarnrc: Yarnrc<'_>) -> Result<(), BerryGate> { + check_line_endings(YARN_LOCK, lock)?; + check_cache_key(lock)?; + check_yarnrc(yarnrc)?; + if let Some(manifest) = manifest { + check_line_endings(PACKAGE_JSON, manifest)?; + } + Ok(()) +} + +/// The line-ending gate for one file. yarn berry keeps ONE line ending per +/// file: a new file gets `os.EOL` (CRLF on Windows) and every later write +/// re-renders the whole file in its majority ending (`normalizeLineEndings` +/// in yarnpkg-fslib `FakeFS.ts`, used by `Project.persistLockfile` and +/// `Workspace.persistManifest`). A uniform CRLF or LF file is edited in its +/// own ending; a mixed one has no ending to keep — and `yarn install +/// --immutable` already rejects a mixed lock (YN0028), because the +/// re-render differs from the file. A leading BOM is not a line break. +pub fn check_line_endings(file: &'static str, text: &str) -> Result<(), BerryGate> { + if LineEndings::of(text) == LineEndings::Mixed { + return Err(BerryGate::MixedLineEndings { file }); + } + Ok(()) +} + +/// The `__metadata` / `cacheKey` gate: a berry checksum is the sha512 of +/// the cache archive, whose bytes depend on the cache format version and +/// compression; only [`SUPPORTED_CACHE_KEY`] is reproducible offline, and a +/// guessed `checksum:` bricks installs (YN0018). +pub fn check_cache_key(lock: &str) -> Result<(), BerryGate> { + let Some(mut fields) = metadata_fields(lock) else { + return Err(BerryGate::NoMetadata); + }; + let found = fields.find_map(|line| scalar_field(line, "cacheKey")); + if found == Some(SUPPORTED_CACHE_KEY) { + return Ok(()); + } + Err(BerryGate::CacheKey { + found: found.map(str::to_string), + }) +} + +/// The `.yarnrc.yml` gate: any compressionLevel but 0 changes berry's +/// cache checksums, and an unreadable file cannot be verified. +pub fn check_yarnrc(yarnrc: Yarnrc<'_>) -> Result<(), BerryGate> { + match yarnrc { + Yarnrc::Absent => Ok(()), + Yarnrc::Unreadable(error) => Err(BerryGate::YarnrcUnreadable { + error: error.to_string(), + }), + Yarnrc::Text(rc) => match yarnrc_compression_level(rc) { + Some(level) if level != "0" => Err(BerryGate::Compression { + level: level.to_string(), + }), + _ => Ok(()), + }, + } +} + +/// The lock's `cacheKey` (berry writes it unquoted: ` cacheKey: 10c0`), +/// `None` without a `__metadata` block or a `cacheKey` line in it. +pub fn cache_key(lock: &str) -> Option<&str> { + metadata_fields(lock)?.find_map(|line| scalar_field(line, "cacheKey")) +} + +/// The `.yarnrc.yml` `compressionLevel` value, when set. A flat line scan is +/// enough: yarn writes the knob as a top-level scalar, and any +/// value we cannot positively read as `0` makes the caller refuse. /// CRLF lines split like LF ones (`str::lines`), and a leading BOM is +/// skipped the way yarn's YAML parser skips it — otherwise a knob on the +/// first line of a BOM'd file would read as unset (the offline-reproducible +/// default) while yarn applies it and every install fails YN0018. +/// +/// The value is read as a YAML scalar: a quoted value ends at its closing +/// quote, and a plain value ends before a whitespace-separated `#` comment +/// (`compressionLevel: 0 # keep yarn default` is `0`, #370). A `#` with no +/// whitespace before it stays part of a plain value, as in YAML. +pub fn yarnrc_compression_level(rc: &str) -> Option<&str> { + let rc = rc.strip_prefix('\u{feff}').unwrap_or(rc); + rc.lines().find_map(|line| { + let rest = line.strip_prefix("compressionLevel:")?.trim(); + if let Some(quote) = rest.chars().next().filter(|c| matches!(c, '\'' | '"')) { + if let Some(end) = rest[1..].find(quote) { + return Some(&rest[1..1 + end]); + } + } + let value = rest + .char_indices() + .find(|&(i, c)| c == '#' && rest[..i].ends_with([' ', '\t'])) + .map_or(rest, |(i, _)| &rest[..i]); + Some(value.trim_end().trim_matches(['\'', '"'])) + }) +} + +/// The body lines of the lock's column-0 `__metadata:` block (CRLF and a +/// leading BOM tolerated), up to the next blank or column-0 line; `None` +/// when there is no such block. +fn metadata_fields(lock: &str) -> Option> { + let lock = lock.strip_prefix('\u{feff}').unwrap_or(lock); + let mut lines = lock.lines(); + lines.find(|line| line.trim_end() == "__metadata:")?; + Some(lines.take_while(|line| line.starts_with(' '))) +} + +/// A 2-space body field ` : ` (value possibly quoted). +/// Deeper sub-map lines are not body fields. +fn scalar_field<'a>(line: &'a str, field: &str) -> Option<&'a str> { + let rest = line.strip_prefix(" ")?; + if rest.starts_with(' ') { + return None; + } + let value = rest.strip_prefix(field)?.strip_prefix(':')?; + Some(value.trim().trim_matches('"')) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lock(cache_key: &str) -> String { + format!( + "# yarn lockfile\n\n__metadata:\n version: 8\n cacheKey: {cache_key}\n\n\ + \"left-pad@npm:^1.3.0\":\n version: 1.3.0\n languageName: node\n" + ) + } + + #[test] + fn a_supported_project_passes_in_any_uniform_ending() { + let lf = lock("10c0"); + let crlf = lf.replace('\n', "\r\n"); + let pkg = "{\r\n \"name\": \"app\"\r\n}\r\n"; + for text in [&lf, &crlf, &format!("\u{feff}{crlf}")] { + assert_eq!(check(text, Some(pkg), Yarnrc::Absent), Ok(()), "{text:?}"); + } + assert_eq!( + check(&lf, None, Yarnrc::Text("compressionLevel: 0 # default\n")), + Ok(()) + ); + assert_eq!(cache_key(&crlf), Some("10c0")); + } + + #[test] + fn every_gate_names_its_cause() { + let lf = lock("10c0"); + let mixed = format!("{{\r\n \"name\": \"app\",\n \"version\": \"1.0.0\"\r\n}}\r\n"); + assert_eq!( + check(&lf.replacen('\n', "\r\n", 1), None, Yarnrc::Absent), + Err(BerryGate::MixedLineEndings { file: YARN_LOCK }) + ); + assert_eq!( + check(&lf, Some(&mixed), Yarnrc::Absent), + Err(BerryGate::MixedLineEndings { file: PACKAGE_JSON }) + ); + assert_eq!( + check(&lock("8"), None, Yarnrc::Absent), + Err(BerryGate::CacheKey { + found: Some("8".into()) + }) + ); + assert_eq!( + check(&lf.replace(" cacheKey: 10c0\n", ""), None, Yarnrc::Absent), + Err(BerryGate::CacheKey { found: None }) + ); + assert_eq!( + check("\"a@npm:1\":\n version: 1\n", None, Yarnrc::Absent), + Err(BerryGate::NoMetadata) + ); + assert_eq!( + check(&lf, None, Yarnrc::Text("compressionLevel: mixed\n")), + Err(BerryGate::Compression { + level: "mixed".into() + }) + ); + assert_eq!( + check(&lf, None, Yarnrc::Unreadable("permission denied")), + Err(BerryGate::YarnrcUnreadable { + error: "permission denied".into() + }) + ); + // The lock gates win over the manifest one, so a refusal names the + // first file a yarn install would trip on. + assert_eq!( + check(&lock("8"), Some(&mixed), Yarnrc::Absent), + Err(BerryGate::CacheKey { + found: Some("8".into()) + }) + ); + } + + #[test] + fn details_name_the_file_the_value_and_the_remedy() { + let mixed = BerryGate::MixedLineEndings { file: PACKAGE_JSON }.detail(); + assert!(mixed.starts_with("package.json mixes") && mixed.contains("yarn install")); + assert!(BerryGate::CacheKey { found: None } + .detail() + .contains("`(missing)`")); + assert!(BerryGate::CacheKey { + found: Some("8".into()) + } + .detail() + .contains("cacheKey is `8`")); + assert_eq!( + BerryGate::Compression { level: "9".into() }.code_suffix(), + "cache_unsupported" + ); + } + + #[test] + fn a_sub_map_or_later_block_never_supplies_the_cache_key() { + let text = "__metadata:\n version: 8\n nested:\n cacheKey: 10c0\n\n\ + \"x@npm:1\":\n cacheKey: 10c0\n"; + assert_eq!(cache_key(text), None); + } + + /// A `.yarnrc.yml` saved with a BOM (and CRLF) still has its first-line + /// `compressionLevel` knob read — yarn applies it, so it must refuse. + #[test] + fn yarnrc_compression_level_reads_past_a_bom_and_crlf() { + assert_eq!( + yarnrc_compression_level("\u{feff}compressionLevel: mixed\r\nnodeLinker: pnp\r\n"), + Some("mixed") + ); + assert_eq!( + yarnrc_compression_level("nodeLinker: pnp\r\ncompressionLevel: 0\r\n"), + Some("0") + ); + assert_eq!( + yarnrc_compression_level("\u{feff}nodeLinker: pnp\r\n"), + None + ); + } + + /// A trailing YAML comment is not part of the scalar (#370): yarn reads + /// `compressionLevel: 0 # keep yarn default` as `0`, quoted or not. + #[test] + fn yarnrc_compression_level_drops_a_trailing_comment() { + for (rc, level) in [ + ("compressionLevel: 0 # keep yarn default\n", "0"), + ("compressionLevel: 0\t# tab-separated\r\n", "0"), + ("compressionLevel: 0 #\n", "0"), + ("compressionLevel: \"0\" # quoted\n", "0"), + ("compressionLevel: '0'# quoted, no gap\n", "0"), + ("compressionLevel: mixed # not the default\n", "mixed"), + ("compressionLevel: 9 #\r\n", "9"), + ] { + assert_eq!(yarnrc_compression_level(rc), Some(level), "{rc:?}"); + } + } + + /// A `#` with no whitespace before it is part of a plain scalar in YAML, + /// so `0#x` is not the default and must still refuse (fail closed). + #[test] + fn yarnrc_compression_level_keeps_an_unseparated_hash() { + assert_eq!( + yarnrc_compression_level("compressionLevel: 0#x\n"), + Some("0#x") + ); + assert_eq!( + yarnrc_compression_level("compressionLevel: \"0 # in quotes\"\n"), + Some("0 # in quotes") + ); + } +} diff --git a/crates/socket-patch-core/src/formats/yarn/mod.rs b/crates/socket-patch-core/src/formats/yarn/mod.rs index c7f51d5b2..87032372a 100644 --- a/crates/socket-patch-core/src/formats/yarn/mod.rs +++ b/crates/socket-patch-core/src/formats/yarn/mod.rs @@ -8,6 +8,8 @@ //! probe, the lock-inventory view, repair's reference flavor, both hosted //! rewriters and lockfile discovery cannot disagree on it. +pub mod berry_gates; + /// Which grammar a `yarn.lock` head declares. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum YarnLockGrammar { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index c5b565053..aa081d06b 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -23,11 +23,11 @@ use regex::{NoExpand, Regex}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use crate::formats::yarn::berry_gates::{self, Yarnrc}; use crate::utils::digest::is_hex64_lower; use crate::utils::line_endings::{to_lf, LineEndings}; use crate::vendor::common::{parse_json_text, JsonLayout}; use crate::vendor::npm_origin::{legacy_packages_key, npm_non_registry_entries, NpmOverrides}; -use crate::vendor::yarn_berry_lock::yarnrc_compression_level; mod bun_binary; pub use bun_binary::{preflight_bun_binary, rewrite_bun_binary}; @@ -3284,31 +3284,15 @@ fn yarn_berry_tarball_url_ok(url: &str) -> bool { && (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`. -const YARN_BERRY_SUPPORTED_CACHE_KEY: &str = "10c0"; - -/// The `cacheKey:` value from the `__metadata` block (berry writes it unquoted: -/// ` cacheKey: 10c0`), mirroring the vendored backend's `berry_field`. -fn berry_cache_key(content: &str) -> Option { - let meta = content.split("\n\n").find(|b| { - b.lines() - .next() - .is_some_and(|l| l.trim_end() == "__metadata:") - })?; - for line in meta.lines().skip(1) { - if let Some(rest) = line.strip_prefix(" cacheKey:") { - return Some(rest.trim().trim_matches('"').to_string()); - } - } - None -} - /// The project-level refusals of the yarn berry hosted rewriter — the gates /// that hold for every dep of the lock, whatever the overrides: a MIXED -/// line-ending lock, an unsupported `cacheKey`, and a `.yarnrc.yml` -/// `compressionLevel` other than 0. `Ok` for a lock that is not berry (the -/// classic rewriter owns those). +/// line-ending lock or root `package.json` (`manifest`, the file the +/// rewriter's `resolutions` land in), an unsupported `cacheKey`, and a +/// `.yarnrc.yml` `compressionLevel` other than 0. The same +/// [`berry_gates::check`] the vendored backend raises, under this mode's +/// `redirect_yarn_berry_*` codes, so the two modes take one decision on the +/// files both edit (#628). `Ok` for a lock that is not berry (the classic +/// rewriter owns those). /// /// Exposed so the vendored→hosted mode takeover (`scan`/`get --mode hosted` /// over a vendored berry purl) can refuse BEFORE it reverts the vendored @@ -3354,46 +3338,18 @@ pub fn preflight_yarn_berry_hosted_dep(dep: &DepOverride) -> Result<(), RewriteW }) } -pub fn preflight_yarn_berry_hosted(lock: &str, yarnrc: Option<&str>) -> Result<(), RewriteWarning> { +pub fn preflight_yarn_berry_hosted( + lock: &str, + manifest: Option<&str>, + yarnrc: Option<&str>, +) -> Result<(), RewriteWarning> { if !is_berry_lock(lock) { return Ok(()); } - let body = lock.strip_prefix('\u{feff}').unwrap_or(lock); - if LineEndings::of(body) == LineEndings::Mixed { - return Err(RewriteWarning { - code: "redirect_yarn_berry_mixed_line_endings".into(), - detail: "yarn.lock mixes CRLF and LF line endings (or holds a bare carriage \ - return), so no single line ending can be kept, and yarn itself \ - rejects it under `--immutable` (YN0028) — run `yarn install` once to \ - normalize the lock, then re-run; leaving it untouched" - .into(), - }); - } - // Refuse any lock whose cache checksum we can't reproduce - // offline. A guessed `checksum:` bricks installs (YN0018). - let key = berry_cache_key(&to_lf(body)); - if key.as_deref() != Some(YARN_BERRY_SUPPORTED_CACHE_KEY) { - return Err(RewriteWarning { - code: "redirect_yarn_berry_cache_unsupported".into(), - detail: format!( - "yarn.lock cacheKey is `{}`; only `{YARN_BERRY_SUPPORTED_CACHE_KEY}` \ - (yarn 4, compressionLevel 0 default) has an offline-reproducible cache checksum", - key.as_deref().unwrap_or("(missing)") - ), - }); - } - if let Some(level) = yarnrc.and_then(yarnrc_compression_level) { - if level != "0" { - return Err(RewriteWarning { - code: "redirect_yarn_berry_cache_unsupported".into(), - detail: format!( - ".yarnrc.yml sets `compressionLevel: {level}`, which changes berry's \ - cache checksums; only compressionLevel 0 (the yarn 4 default) is supported" - ), - }); - } - } - Ok(()) + berry_gates::check(lock, manifest, Yarnrc::from_option(yarnrc)).map_err(|gate| RewriteWarning { + code: format!("redirect_yarn_berry_{}", gate.code_suffix()), + detail: gate.detail(), + }) } fn rewrite_yarn_berry( @@ -3423,12 +3379,14 @@ fn rewrite_yarn_berry( Some(rest) => ("\u{feff}", rest), None => ("", raw.as_str()), }; - // Project-level gates (line endings, cacheKey, compressionLevel), shared - // with the vendored→hosted takeover preflight so a takeover never + // Project-level gates (lock and manifest line endings, cacheKey, + // compressionLevel), shared with the vendored→hosted takeover preflight so a takeover never // reverts vendored wiring this rewriter then refuses. - if let Err(warning) = - preflight_yarn_berry_hosted(raw, files.get(".yarnrc.yml").map(String::as_str)) - { + if let Err(warning) = preflight_yarn_berry_hosted( + raw, + files.get(BERRY_MANIFEST).map(String::as_str), + 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 @@ -8691,6 +8649,46 @@ mod tests { } } + /// #628: a root `package.json` mixing CRLF and LF is refused untouched, + /// like a mixed lock and like vendored mode (`JsonLayout` would re-render + /// every minority line in the majority ending). A uniform CRLF manifest + /// is still rewritten in its own ending. + #[test] + fn berry_mixed_root_manifest_is_refused_untouched() { + let checksum = format!("10c0/{}", "7".repeat(128)); + 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(), + "{\r\n \"name\": \"app\",\n \"version\": \"1.0.0\"\r\n}\r\n".to_string(), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty(), "nothing written: {:?}", r.files.keys()); + assert_eq!( + r.warnings.first().map(|w| w.code.as_str()), + Some("redirect_yarn_berry_mixed_line_endings"), + "{:?}", + r.warnings + ); + assert!(r.warnings[0].detail.contains("package.json")); + assert!(r.confirmed_yarn_berry_uuids.is_empty()); + + files.insert( + "package.json".to_string(), + "{\r\n \"name\": \"app\",\r\n \"version\": \"1.0.0\"\r\n}\r\n".to_string(), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r); + let manifest = r.files.get("package.json").expect("uniform CRLF rewritten"); + assert_eq!( + LineEndings::of(manifest), + LineEndings::Crlf, + "kept CRLF: {manifest:?}" + ); + } + #[test] fn yarn_berry_warning_branches() { let checksum = format!("10c0/{}", "7".repeat(128)); @@ -15928,13 +15926,13 @@ packages: classic_lock_two_entries().replacen("\n", "\r\n", 1), ] { assert_eq!( - preflight_yarn_berry_hosted(&ok, None).map_err(|w| w.code), + preflight_yarn_berry_hosted(&ok, None, None).map_err(|w| w.code), Ok(()), "{ok:?}" ); } let code = |lock: &str, rc: Option<&str>| { - preflight_yarn_berry_hosted(lock, rc).map_err(|w| w.code) + preflight_yarn_berry_hosted(lock, None, rc).map_err(|w| w.code) }; assert_eq!( code(&crlf.replacen("\r\n", "\n", 1), None), 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 fe1938991..ccfa93e77 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -385,7 +385,14 @@ async fn restore_berry( }; 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()) { + // The root manifest: a hosted pin keyed by its tarball URL keeps the + // descriptors it replaced only as `resolutions` selectors routed there. + // Read before the gates: a mixed one is refused like a mixed lock. + let pkg_rel = format!("{dir_prefix}package.json"); + let pkg_text = view.read(&pkg_rel).await.ok().flatten(); + if let Err(w) = + super::super::preflight_yarn_berry_hosted(raw, pkg_text.as_deref(), yarnrc.as_deref()) + { refuse_all_in(pins, rel, result, w.detail); return; } @@ -402,10 +409,6 @@ async fn restore_berry( let version_re = Regex::new(r"\n {2}version: ([^\n]*)").expect("static version-line regex is valid"); - // 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()) 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 065214aa1..9a41f7401 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs @@ -5,9 +5,7 @@ use std::path::Path; use crate::utils::digest::is_hex; -use crate::vendor::yarn_berry_lock::{ - berry_field, berry_metadata, parse_berry_locator, BerryLocator, -}; +use crate::vendor::yarn_berry_lock::{berry_field, parse_berry_locator, BerryLocator}; use crate::vendor::yarn_classic_lock::{ self, classic_field, live_blocks, scan_blocks, split_berry_key_patterns, split_key_patterns, split_resolved_sha1, LockBlock, @@ -79,9 +77,7 @@ pub(crate) struct BerryLock { /// discovery share (see [`classic_entries`]). pub(crate) fn berry_entries(text: &str) -> BerryLock { let blocks = scan_blocks(text); - let cache_key = berry_metadata(&blocks) - .and_then(|meta| berry_field(&meta.lines, "cacheKey")) - .map(str::to_string); + let cache_key = crate::formats::yarn::berry_gates::cache_key(text).map(str::to_string); let mut entries = yarn_entries(blocks, split_berry_key_patterns); entries.retain(|e| e.block.key != "__metadata"); BerryLock { cache_key, entries } 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 5721d2a51..497038dde 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -44,12 +44,12 @@ use serde_json::Value; use sha2::{Digest, Sha256, Sha512}; use crate::constants::SOCKET_DIR; +use crate::formats::yarn::berry_gates::{self, BerryGate, Yarnrc, SUPPORTED_CACHE_KEY}; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{normalize_file_path, PatchSources}; use crate::utils::fs::{ atomic_write_bytes_preserving_mode, read_regular_to_bytes, read_regular_to_string, }; -use crate::utils::line_endings::LineEndings; use crate::utils::socket_dir::remove_tree_and_prune; use crate::utils::uri::encode_uri_component; @@ -84,10 +84,6 @@ static PKG_JSON_MEMO: ParseMemo = ParseMemo::new(); const KIND_RESOLUTION: &str = "yarn_berry_resolution"; const KIND_LOCK_ENTRY: &str = "yarn_berry_lock_entry"; -/// The only cache key the offline checksum recipe reproduces (yarn 4's -/// internal CACHE_VERSION `10` + compressionLevel 0 → `c0`). -const SUPPORTED_CACHE_KEY: &str = "10c0"; - /// Vendor one installed npm package into a yarn-berry (4.x, cacheKey 10c0) /// project. Same contract as [`super::npm_lock::vendor_npm`]: refuse-early, /// wire-last; `entry` is `None` for dry runs and the in-sync re-run. @@ -129,7 +125,7 @@ pub async fn vendor_yarn_berry<'a>( return outcome; } let blocks = scan_blocks_shared(&lock_text); - if let Some(outcome) = refuse_unsupported_cache(&blocks) { + if let Some(outcome) = refuse_unsupported_cache(&lock_text) { return outcome; } @@ -612,7 +608,7 @@ pub(super) async fn read_project(project_root: &Path) -> Result Option { - (LineEndings::of(text) == LineEndings::Mixed).then(|| { - refused( - "vendor_yarn_berry_mixed_line_endings", - format!( - "{file} mixes CRLF and LF line endings (or holds a bare carriage return), so \ - no single line ending can be kept — yarn rewrites the file with one ending \ - on its next install and rejects a lockfile like this under `--immutable` \ - (YN0028); run `yarn install` once to normalize it, then re-run" - ), - ) - }) +/// [`berry_gates::check_line_endings`] as this backend's refusal. A +/// uniformly CRLF or LF file is spliced (yarn.lock) or re-serialized +/// (package.json) in its own ending; `yarn install` normalizes a mixed one, +/// after which vendoring proceeds. +fn refuse_mixed_line_endings(file: &'static str, text: &str) -> Option { + berry_gates::check_line_endings(file, text) + .err() + .map(gate_refusal) } -/// The `__metadata` / `cacheKey` gate: the checksum is sha512 of the cache -/// archive, whose bytes depend on the cache format version + compression; -/// only 10c0 (stored entries) is reproducible offline. Emitting a guess would -/// brick installs with YN0018, so refuse. -fn refuse_unsupported_cache(blocks: &[LockBlock]) -> Option { - let Some(meta) = berry_metadata(blocks) else { - return Some(refused( - "vendor_lockfile_version_unsupported", - "yarn.lock has no `__metadata:` entry — not a yarn berry lockfile".to_string(), - )); - }; - let cache_key = berry_field(&meta.lines, "cacheKey").unwrap_or(""); - (cache_key != SUPPORTED_CACHE_KEY).then(|| { - refused( - "vendor_yarn_berry_cache_unsupported", - format!( - "yarn.lock cacheKey is `{cache_key}`; only `{SUPPORTED_CACHE_KEY}` (yarn 4 \ - with compressionLevel 0, the default) has an offline-reproducible cache \ - checksum — remove custom compression settings and re-run `yarn install`" - ), - ) - }) +/// [`berry_gates::check_cache_key`] as this backend's refusal. +fn refuse_unsupported_cache(lock_text: &str) -> Option { + berry_gates::check_cache_key(lock_text) + .err() + .map(gate_refusal) } -/// The `.yarnrc.yml` `compressionLevel` gate: any level but 0 -/// changes berry's cache checksums. +/// [`berry_gates::check_yarnrc`] over the project's `.yarnrc.yml`: any +/// compressionLevel but 0 changes berry's cache checksums, and an +/// unreadable file cannot be verified. async fn refuse_unsupported_compression(project_root: &Path) -> Option { - match read_regular_to_string(&project_root.join(YARNRC)).await { - Ok(rc) => yarnrc_compression_level(&rc) - .filter(|level| *level != "0") - .map(|level| { - refused( - "vendor_yarn_berry_cache_unsupported", - format!( - "{YARNRC} sets `compressionLevel: {level}`, which changes berry's \ - cache checksums; only compressionLevel 0 (the yarn 4 default) is \ - supported" - ), - ) - }), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, - Err(e) => Some(refused( - "vendor_yarn_berry_cache_unsupported", - format!("cannot read {YARNRC} to verify the cache configuration: {e}"), - )), - } + let read = read_regular_to_string(&project_root.join(YARNRC)).await; + let error; + let yarnrc = match &read { + Ok(rc) => Yarnrc::Text(rc), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Yarnrc::Absent, + Err(e) => { + error = e.to_string(); + Yarnrc::Unreadable(&error) + } + }; + berry_gates::check_yarnrc(yarnrc).err().map(gate_refusal) +} + +/// A shared project gate as this backend's refusal: the +/// `vendor_yarn_berry_*` code, and `vendor_lockfile_version_unsupported` +/// for a lock that is not berry at all. +fn gate_refusal(gate: BerryGate) -> VendorOutcome { + let code = match (&gate, gate.code_suffix()) { + (BerryGate::NoMetadata, _) => "vendor_lockfile_version_unsupported", + (_, "mixed_line_endings") => "vendor_yarn_berry_mixed_line_endings", + _ => "vendor_yarn_berry_cache_unsupported", + }; + refused(code, gate.detail()) } /// The project-level refusals [`vendor_yarn_berry`] raises before any @@ -1090,7 +1058,7 @@ pub async fn yarn_berry_vendor_preflight(project_root: &Path) -> Option<(&'stati if let Some(outcome) = refuse_mixed_line_endings(YARN_LOCK, &lock_text) { return into_pair(outcome); } - if let Some(outcome) = refuse_unsupported_cache(&scan_blocks(&lock_text)) { + if let Some(outcome) = refuse_unsupported_cache(&lock_text) { return into_pair(outcome); } if let Some(outcome) = refuse_unsupported_compression(project_root).await { @@ -1398,41 +1366,6 @@ fn root_workspace_name(blocks: &[LockBlock]) -> Option { None } -/// The `.yarnrc.yml` `compressionLevel` value, when set. A flat line scan is -/// enough: yarn writes the knob as a top-level scalar, and any -/// value we cannot positively read as `0` makes the caller refuse. Shared -/// with the hosted-redirect rewriter, whose cache-checksum gate is identical. -/// CRLF lines split like LF ones (`str::lines`), and a leading BOM is -/// skipped the way yarn's YAML parser skips it — otherwise a knob on the -/// first line of a BOM'd file would read as unset (the offline-reproducible -/// default) while yarn applies it and every install fails YN0018. -/// -/// The value is read as a YAML scalar: a quoted value ends at its closing -/// quote, and a plain value ends before a whitespace-separated `#` comment -/// (`compressionLevel: 0 # keep yarn default` is `0`, #370). A `#` with no -/// whitespace before it stays part of a plain value, as in YAML. -pub(crate) fn yarnrc_compression_level(rc: &str) -> Option<&str> { - let rc = rc.strip_prefix('\u{feff}').unwrap_or(rc); - rc.lines().find_map(|line| { - let rest = line.strip_prefix("compressionLevel:")?.trim(); - if let Some(quote) = rest.chars().next().filter(|c| matches!(c, '\'' | '"')) { - if let Some(end) = rest[1..].find(quote) { - return Some(&rest[1..1 + end]); - } - } - let value = rest - .char_indices() - .find(|&(i, c)| c == '#' && rest[..i].ends_with([' ', '\t'])) - .map_or(rest, |(i, _)| &rest[..i]); - Some(value.trim_end().trim_matches(['\'', '"'])) - }) -} - -/// The lock's exact `__metadata` block (its `version` / `cacheKey` header). -pub(crate) fn berry_metadata(blocks: &[LockBlock]) -> Option<&LockBlock> { - blocks.iter().find(|b| b.key == "__metadata") -} - /// A berry `resolution:` locator `name@`, split at the first `@` /// past a leading `@scope/` marker ([`split_pattern`]). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -3997,6 +3930,95 @@ __metadata: } } + /// #629: both modes run ONE set of berry project gates, so the same + /// project gets the same decision — the same gate, under each mode's + /// code prefix, with the same detail — from the vendored preflight and + /// the hosted rewriter's preflight (the vendored→hosted takeover's). + #[tokio::test] + async fn both_modes_take_the_same_project_gate_decision() { + let half = |t: &str| { + let c = crlf(t); + let at = c.rfind("\r\n").unwrap(); + format!("{}\n{}", &c[..at], &c[at + 2..]) + }; + let lf_lock = B3_BEFORE_LOCK.to_string(); + let lf_pkg = B3_BEFORE_PKG.to_string(); + let no_key = lf_lock.replace(" cacheKey: 10c0\n", ""); + assert_ne!(no_key, lf_lock, "fixture carries a cacheKey line"); + for (label, pkg, lock, yarnrc, suffix) in [ + ("supported", lf_pkg.clone(), lf_lock.clone(), None, None), + ( + "bom crlf", + crlf(B3_BEFORE_PKG), + format!("\u{feff}{}", crlf(B3_BEFORE_LOCK)), + None, + None, + ), + ( + "cacheKey 10", + lf_pkg.clone(), + lf_lock.replace("cacheKey: 10c0", "cacheKey: 10"), + None, + Some("cache_unsupported"), + ), + ( + "no cacheKey", + lf_pkg.clone(), + no_key, + None, + Some("cache_unsupported"), + ), + ( + "compressionLevel mixed", + lf_pkg.clone(), + lf_lock.clone(), + Some("compressionLevel: mixed\n"), + Some("cache_unsupported"), + ), + ( + "mixed lock", + crlf(B3_BEFORE_PKG), + half(B3_BEFORE_LOCK), + None, + Some("mixed_line_endings"), + ), + ( + "mixed package.json", + half(B3_BEFORE_PKG), + crlf(B3_BEFORE_LOCK), + None, + Some("mixed_line_endings"), + ), + ] { + let fx = fixture_with(&pkg, &lock).await; + if let Some(rc) = yarnrc { + tokio::fs::write(fx.root().join(YARNRC), rc).await.unwrap(); + } + let vendored = yarn_berry_vendor_preflight(fx.root()).await; + let hosted = + crate::patch::redirect::preflight_yarn_berry_hosted(&lock, Some(&pkg), yarnrc) + .err(); + match suffix { + None => { + assert_eq!(vendored, None, "{label}: vendored passes"); + assert!(hosted.is_none(), "{label}: hosted passes: {hosted:?}"); + } + Some(suffix) => { + let (code, detail) = + vendored.unwrap_or_else(|| panic!("{label}: vendored refuses")); + let hosted = hosted.unwrap_or_else(|| panic!("{label}: hosted refuses")); + assert_eq!(code, format!("vendor_yarn_berry_{suffix}"), "{label}"); + assert_eq!( + hosted.code, + format!("redirect_yarn_berry_{suffix}"), + "{label}" + ); + assert_eq!(hosted.detail, detail, "{label}: one detail text"); + } + } + } + } + /// Revert never refuses on line endings. A lock mixed AFTER vendoring /// (an editor saving one line LF into a CRLF lock) restores the entry in /// the terminator of the block it replaces, every other byte kept; a @@ -4033,55 +4055,6 @@ __metadata: ); } - /// A `.yarnrc.yml` saved with a BOM (and CRLF) still has its first-line - /// `compressionLevel` knob read — yarn applies it, so it must refuse. - #[test] - fn yarnrc_compression_level_reads_past_a_bom_and_crlf() { - assert_eq!( - yarnrc_compression_level("\u{feff}compressionLevel: mixed\r\nnodeLinker: pnp\r\n"), - Some("mixed") - ); - assert_eq!( - yarnrc_compression_level("nodeLinker: pnp\r\ncompressionLevel: 0\r\n"), - Some("0") - ); - assert_eq!( - yarnrc_compression_level("\u{feff}nodeLinker: pnp\r\n"), - None - ); - } - - /// A trailing YAML comment is not part of the scalar (#370): yarn reads - /// `compressionLevel: 0 # keep yarn default` as `0`, quoted or not. - #[test] - fn yarnrc_compression_level_drops_a_trailing_comment() { - for (rc, level) in [ - ("compressionLevel: 0 # keep yarn default\n", "0"), - ("compressionLevel: 0\t# tab-separated\r\n", "0"), - ("compressionLevel: 0 #\n", "0"), - ("compressionLevel: \"0\" # quoted\n", "0"), - ("compressionLevel: '0'# quoted, no gap\n", "0"), - ("compressionLevel: mixed # not the default\n", "mixed"), - ("compressionLevel: 9 #\r\n", "9"), - ] { - assert_eq!(yarnrc_compression_level(rc), Some(level), "{rc:?}"); - } - } - - /// A `#` with no whitespace before it is part of a plain scalar in YAML, - /// so `0#x` is not the default and must still refuse (fail closed). - #[test] - fn yarnrc_compression_level_keeps_an_unseparated_hash() { - assert_eq!( - yarnrc_compression_level("compressionLevel: 0#x\n"), - Some("0#x") - ); - assert_eq!( - yarnrc_compression_level("compressionLevel: \"0 # in quotes\"\n"), - Some("0 # in quotes") - ); - } - /// yarn 4.0.x spells `10c0` checksums bare, 4.1+ prefixed: a written /// entry follows the lock (an `--immutable` install rejects a respelled /// checksum with YN0028). A lock with no checksum keeps the prefix. diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 1beb7b91d..300c3bf1b 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -85,8 +85,9 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. 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 - line ending (a BOM is kept); a lock mixing CRLF and LF is refused - (`redirect_yarn_berry_mixed_line_endings`) until `yarn install` normalizes it. See + line ending (a BOM is kept); a lock or root `package.json` mixing CRLF and LF is + refused (`redirect_yarn_berry_mixed_line_endings`, the same decision vendored mode + takes) until `yarn install` normalizes it. See [yarn berry compatibility](testing/yarn-berry-compatibility.md). - **yarn `npm:` aliases (classic & berry)** — a lock entry that consumes the patched package only through an alias descriptor (`"safe-pad@npm:left-pad@^1.3.0"`) is left