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-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, 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