diff --git a/crates/socket-patch-bench/src/fixtures/npm.rs b/crates/socket-patch-bench/src/fixtures/npm.rs index da0d41ffa..bc5bd44c2 100644 --- a/crates/socket-patch-bench/src/fixtures/npm.rs +++ b/crates/socket-patch-bench/src/fixtures/npm.rs @@ -478,9 +478,11 @@ pub fn build_pnpm(t: &mut Tree, size: Size) -> std::io::Result { &format!("project/node_modules/{}", p.name), )?; } + // pnpm >= 11, so the scan still writes the `trustLockfile` scaffold (a + // pnpm 9.0-10.4 install record skips it, #734). t.write( "project/node_modules/.modules.yaml", - "layoutVersion: 5\nnodeLinker: isolated\npackageManager: pnpm@9.15.0\n", + "layoutVersion: 5\nnodeLinker: isolated\npackageManager: pnpm@11.0.0\n", )?; t.write("project/node_modules/.pnpm/lock.yaml", pnpm_lock(&g))?; t.mkdir("home")?; diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 5532ae820..95a3771f7 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -112,15 +112,19 @@ Beyond the globals above, each subcommand defines a small set of local arguments Each matching package instance is spliced, including scoped, quoted and nested-peer keys, one `redirect_pnpm_resolution` edit per changed instance (`rollback` / `remove` restore each from the npm registry — see "Hosted unwind coverage"). LF/CRLF and unrelated lock bytes are preserved. Unsupported matching instances refuse that dependency across the lockfile set; an already-hosted URL elsewhere cannot confirm a partial rewrite. -For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with a root-only `packages:` scaffold, or appended while preserving user bytes). pnpm >=11 requires this to accept hosted URLs; it disables registry re-verification for the whole lock, while sha512 tarball integrity remains enforced. The write (edit kind `redirect_pnpm_workspace_trust`) respects `--dry-run`, skips legacy locks and Rush repos, preserves explicit user settings (an existing top-level key in any YAML spelling: quoted, `trustLockfile :`, with a trailing comment), and is disabled by `--no-trust-lockfile-config`. The key goes inside the document (before a `...` end marker); a file a line append would corrupt (a flow-style root, an indented root, several documents) is left untouched and the warning gives the manual recoveries. The vendored `overrides:` mirror in `pnpm-workspace.yaml` reads keys the same way and refuses those shapes before writing. The `redirect_pnpm_trust_lockfile` warning explains manual configuration when required and clean reinstall guidance for all pnpm versions. Existing installs and warm stores can retain upstream files; use a clean install tree and empty store, then verify installed files with `socket-patch vex`. Neither a successful install nor a local VEX export guarantees hosted SBOM recognition or changes dashboard alert actions/counts. +For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with a root-only `packages:` scaffold, or appended while preserving user bytes). A project with no `pnpm-workspace.yaml` whose every pnpm pin — the installed `node_modules/.modules.yaml` `packageManager`, and package.json `packageManager`, `devEngines.packageManager` and `engines.pnpm` (at least one present) — names pnpm 9.0–10.4 gets no file (#734): those releases never read the key, and a created root-only workspace makes `pnpm add ` fail with ERR_PNPM_ADDING_TO_ROOT there; the warning names the pins and says to re-run after upgrading to pnpm >=11 (or install with `--trust-lockfile`). A pin naming a later pnpm, a range reaching past 10.4, an unparseable pin, or no pin keeps the scaffold, whose warning notes that pnpm 9.0–10.4 then need `pnpm add -w ` and that pinning that pnpm in package.json `packageManager` (then deleting the file and re-running) avoids it — provided every other pin names 9.0–10.4 too (the "every pin" rule above). **Known residual (#734)**: an unpinned project with no install record (no `node_modules/.modules.yaml`, no package.json pnpm pin — e.g. a fresh clone) still gets the root-only scaffold, since nothing shows its pnpm cannot read it; on pnpm 9.0–10.4 such a project needs `pnpm add -w ` to add dependencies, or a `packageManager` pin naming that pnpm so the scaffold is never created. pnpm >=11 requires this to accept hosted URLs; it disables registry re-verification for the whole lock, while sha512 tarball integrity remains enforced. The write (edit kind `redirect_pnpm_workspace_trust`) respects `--dry-run`, skips legacy locks and Rush repos, preserves explicit user settings (an existing top-level key in any YAML spelling: quoted, `trustLockfile :`, with a trailing comment), and is disabled by `--no-trust-lockfile-config`. The key goes inside the document (before a `...` end marker); a file a line append would corrupt (a flow-style root, an indented root, several documents) is left untouched and the warning gives the manual recoveries. The vendored `overrides:` mirror in `pnpm-workspace.yaml` reads keys the same way and refuses those shapes before writing. The `redirect_pnpm_trust_lockfile` warning explains manual configuration when required and clean reinstall guidance for all pnpm versions. When every lock the run spliced is a Rush lock, the same warning code carries the Rush remedy instead (rush runs pnpm in `common/temp`, so neither the repo-root key nor `--trust-lockfile` reaches it): `pnpm_config_trust_lockfile=true rush install`, plus `"usePnpmFrozenLockfileForRushInstall": true` in `common/config/rush/experiments.json` on pnpm 11 (whose default Rush install otherwise re-resolves the hosted entries to upstream with exit 0), and `rush purge` before reinstalling. A re-run over a 9.0 Rush lock an earlier run already redirected (nothing spliced) re-issues the same Rush remedy; a run that also spliced a non-Rush pnpm lock keeps the generic text plus a Rush note. Existing installs and warm stores can retain upstream files; use a clean install tree and empty store, then verify installed files with `socket-patch vex`. Neither a successful install nor a local VEX export guarantees hosted SBOM recognition or changes dashboard alert actions/counts. **npm hosted-mode `allow-remote` contract**: npm >=12 defaults `allow-remote=none` and refuses (EALLOWREMOTE) every lockfile entry whose `resolved` tarball is not served by the configured registry — exactly what a hosted redirect writes into `package-lock.json` / `npm-shrinkwrap.json`. Whenever a run leaves a ROOT npm lock carrying a granted hosted artifact URL (spliced this run, or already redirected by an earlier one — a missed config heals on re-run), the CLI ensures `allow-remote=all` in the project-root `.npmrc`: the file is created holding exactly `allow-remote=all\n` when absent, otherwise one `allow-remote=all` line is spliced in after the last non-empty top-level line (before any ini `[section]` header), in the file's own line ending, with the BOM, CRLF and trailing-newline shape preserved. The write lands in `redirect.rewrittenFiles` (edit kind `redirect_npmrc_allow_remote`), respects `--dry-run` (nothing written; the warning says what would be — including for a vendored → hosted takeover the dry run only previews), and is disabled by `--no-npm-allow-remote-config` / `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG`. The `.npmrc` grammar is npm's own `ini` parser's (cross-checked against it): lines split on any run of `\r` / `\n` (a bare `\r` ends a line), only the exact key `allow-remote` counts after ini unquoting (npm ignores `allow_remote` / `ALLOW-REMOTE` in a `.npmrc`; such a line is left alone and the real key appended), comment lines are ignored, a `[section]` header is recognized only as npm does — on the UNTRIMMED line (an indented or BOM-prefixed `[sec]` is a plain top-level key) — and ends the top-level scope, quotes and inline comments are stripped, the LAST top-level assignment wins, and the value is case-sensitive. An explicit other value (`allow-remote=none` / `root` / anything but `all`) is RESPECTED and never rewritten — the pnpm `trustLockfile: false` precedent — in the project `.npmrc` AND in every other npm config layer npm would consult: an `npm_config_allow_remote` environment variable (any spelling npm normalizes; it beats every `.npmrc`, so a project write could not take effect), and — when the project file sets nothing — the user (`npm_config_userconfig` / `~/.npmrc`), global (`npm_config_globalconfig` / `/etc/npmrc`, prefix from `npm_config_prefix`, the user/builtin config, `PREFIX` or the `node` binary's install root) and builtin (npm's own `npmrc` beside the `node` binary: `/lib/node_modules/npm/npmrc`, `\node_modules\npm\npmrc` on Windows) config files — path values `${VAR}`-expanded and `~`-expanded like npm, env names case-insensitive on Windows, where a committed project line would silently override a machine / org policy. A symlinked, non-regular or unreadable `.npmrc`, or one with bare-`\r` line endings (npm splits on them, the line splice does not), is left untouched. Every variant emits the `redirect_npm_allow_remote` warning (written / would write / already set / explicit value respected — naming the project file, the env var, or the user/global/builtin config path — / opted out / unreadable or unsupported), always with the tradeoff: `allow-remote=all` lets npm install ANY url-resolved dependency, not just Socket's patched ones, while the per-entry sha512 integrity pins stay enforced; the remedy for the non-writing variants is `allow-remote=all` in `.npmrc` or `npm ci --allow-remote=all`. npm <=11 is unaffected (11 defaults to `all`, <=10 has no such setting). **Unwind (v5.0: no ledger)**: once `rollback`, `remove` or a hosted → vendored takeover has restored the last hosted entry of the root `package-lock.json` / `npm-shrinkwrap.json` to its upstream registry entry (see "Hosted unwind coverage"), a project `.npmrc` holding exactly `allow-remote=all\n` (the file hosted mode creates) is deleted; any other `.npmrc` that still has a top-level `allow-remote=all` line is left untouched and the `npm_allow_remote_left` warning says the line may be removed if nothing else needs it (v5 keeps no record of whether hosted mode added it, so it is never removed behind the user's back). The rewrite's stage file is created with the `.npmrc`'s own permission bits (a 0600 token-bearing file is never staged world-readable). **Vendored mode is unaffected**: its `file:.socket/vendor/…` resolutions are npm `file` specs, which npm gates by `allow-file` (default `all`), never `allow-remote` — verified by the real npm 12 vendored matrix. +**pnpm per-member locks (#492)**: a workspace whose root `pnpm-workspace.yaml` sets `sharedWorkspaceLockfile: false` (or, when the YAML leaves the key unset, whose root `.npmrc` sets `shared-workspace-lockfile=false`; on disk, when neither file sets it, `pnpm_config_shared_workspace_lockfile` / `npm_config_shared_workspace_lockfile`; booleans in any letter case, as pnpm's YAML reader takes them) installs each member from the member's own `pnpm-lock.yaml`. Run from the workspace root, hosted mode reads every member lock the `packages:` globs name (quoted `!` negations applied; `node_modules`, `bower_components`, `test` and `tests` trees and symlinked directories skipped) beside the root lock, which may be absent (pnpm 7): each is rewritten like the root lock, a 9.0 member lock engages the `trustLockfile: true` auto-config in the ROOT `pnpm-workspace.yaml` (heal-on-rerun included), and discovery (`list`, `vex`, `rollback`, `remove`) reads the same member locks. A root lock whose `importers:` lists a member is a shared lock, so member locks beside it are stale and never read or attested. When the members' locks cannot be listed or read (no `packages:` key, whose pnpm <= 8 default finds projects in every directory; a `packages:` value that is not a plain list of globs; no member lock and no root lock, or a member lock that exists but is not readable text), every pnpm lock (the root's included) is left untouched and the run warns `redirect_pnpm_member_locks_unresolved` instead of `redirect_pnpm_no_lockfile`; no pnpm pin is confirmed. The lockfile inventory reads the same member locks, so their dependencies are discovered without an installed tree. The in-memory engine gives the same result as a disk run from the workspace root: a directory whose `pnpm-lock.yaml` has no `pnpm-workspace.yaml` beside it is a workspace member, never a root of its own, when (with `npm` among the `ecosystems`) the nearest ancestor `pnpm-workspace.yaml` is readable and its `packages:` globs (read as above, no braces, classes or extglobs) list it, it holds a `package.json` / `package.yaml` / `package.json5`, the workspace root installs it from its own lock (`sharedWorkspaceLockfile: false` with the member list resolved) or from a root lock beside which its lock is a stale leftover, and socket.yml admits the workspace root. Its lock is then demoted into that workspace root, which becomes a root even with no lock of its own (pnpm 7), the way a Cargo member's lock is demoted; the member stays a root only when it holds another lock marker, without its pnpm lock. The workspace root then pins (or refuses) its member locks as above, trusts them in its own file, refuses a member's branch lock (#556) and leaves a member lock beside a shared root lock unread. A directory that fails any of those tests keeps its lock as a root of its own (pinned like any root, or refused with `redirect_pnpm_settings_elsewhere` while the trust auto-config is on and the ancestor file may list it). Path selection adds the workspace root above every candidate to `roots`, fetches it, and fetches the member locks, branch locks and manifests below it in every directory holding a package manifest; given those `roots` as `projectRoots`, the session drops a confirmed member named beside its workspace root (unless it holds another lock) and a lockless workspace root that pins no member, so it runs the roots it would detect. A confirmed member named in `projectRoots` without its workspace root is not pinned: its lock is dropped and the run-level `pnpm_member_lock_ignored` warning names the workspace root to scan. + +**pnpm per-branch locks (#556)**: with `gitBranchLockfile` on (a top-level `gitBranchLockfile: true` in the root `pnpm-workspace.yaml`; when the YAML leaves the key unset, `git-branch-lockfile=true` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `npm_config_git_branch_lockfile` / `pnpm_config_git_branch_lockfile` set to `true`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm installs a git branch from its own `pnpm-lock..yaml` whenever that lock exists, in every directory it installs a lock from: the root, and under `sharedWorkspaceLockfile: false` each member's (#492). When the setting is on AND at least one such `pnpm-lock..yaml` exists (at the root, or in a member that installs from its own lock), hosted mode leaves every root pnpm lock (`pnpm-lock.yaml`, `shrinkwrap.yaml`) and `pnpm-workspace.yaml` untouched (every member lock too), confirms no npm pin from them and warns `redirect_pnpm_git_branch_lockfile` (in place of `redirect_pnpm_no_lockfile` when the branch lock is the only one); the detail names the setting and the branch lock(s), and the remedy is to turn `gitBranchLockfile` off and fold the branch locks into `pnpm-lock.yaml` with `pnpm install --merge-git-branch-lockfiles`, then re-run. A vendored→hosted takeover of a pnpm-vendored purl is refused with the same code (the vendored wiring and ledger stay byte-for-byte). Vendored mode refuses the same projects with `vendor_pnpm_git_branch_lockfile`, before any write and in the pre-flight. The setting with no branch lock (main, or a branch whose dependencies never changed) installs from `pnpm-lock.yaml`, which both modes wire as usual; a stray branch lock with the setting off is ignored. The in-memory engine fetches root `pnpm-lock.*.yaml` names presence-only and refuses the same way. + `redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers. **vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. `rollback` / `remove` restore each hosted node's slots [2] and [3] from the version document of the registry the node resolves against (slot [3] is its `dist.tarball`, as vlt writes it; `upstream_registry_fallback` when that registry can't be read), following the lock's own slot-[3] convention (see "Hosted unwind coverage"). Tested releases: `docs/testing/vlt-compatibility.md`. -**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). **A takeover the vendored backend does not carry through keeps the hosted pin (#853, #944)**: the wet run holds the restore in its group commit, and when the backend then refuses the purl — whatever the code: a pnpm `catalog:` dependency (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` (`vendor_lockfile_crlf_unsupported`), a workspace exact-pin override (`vendor_override_conflict`), a uv inline `[tool.uv] sources` table, a prebuilt download that fails, … — or its apply fails with nothing recorded, the restore is rolled back before anything reaches disk: the purl is reported `failed ` with the backend's own code and detail, neither `vendor_takeover_reverted_redirect` nor the restore's advisories are recorded for it, and the hosted wiring stays byte-for-byte (exit 1 / `partial_failure`), so the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed ` by running the backend's dry run over the restored project staged in memory (nothing written). A restore that writes a file outside the group commit's captured set (`.socket/gradle/hosted-index.tsv`) is not rolled back. The `scan` / `get --mode vendored --dry-run` preview does not model the takeover yet (it still lists such a purl `would_vendor`), except for the gem preflight below. **Gem preflight before the takeover**: `scan` / `get --mode vendored` ask the gem vendored backend's own refusals BEFORE the upstream restore — the manifest gate (`gemfile_not_loaded`: a `gems.rb` twin or a `BUNDLE_GEMFILE`-configured manifest) and the Gemfile declaration gate evaluated on the Gemfile and Gemfile.lock text the restore would leave (`gemfile_declaration_not_editable`: a declaration inside a `group` / `platforms` / conditional block, a parenthesized or duplicate declaration, …) — so a hosted gem vendored mode cannot wire is reported `failed ` with the hosted `Gemfile` / `Gemfile.lock` byte-untouched (exit 1 / `partial_failure`), and their `--dry-run` preview reports that gem as `would_refuse` with the same `errorCode` (exit 0, like the Bun / vlt `would_refuse` rows), never `would_vendor`. Pinned against real Bundler by `tests/e2e_redirect_gem_build.rs`. `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. +**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). **A takeover the vendored backend does not carry through keeps the hosted pin (#853, #944)**: the wet run holds the restore in its group commit, and when the backend then refuses the purl — whatever the code: a pnpm `catalog:` dependency (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` (`vendor_lockfile_crlf_unsupported`), a conflicting `pnpm-workspace.yaml` override — a range, another version or a `>` selector (`vendor_override_conflict`), a uv inline `[tool.uv] sources` table, a prebuilt download that fails, … — or its apply fails with nothing recorded, the restore is rolled back before anything reaches disk: the purl is reported `failed ` with the backend's own code and detail, neither `vendor_takeover_reverted_redirect` nor the restore's advisories are recorded for it, and the hosted wiring stays byte-for-byte (exit 1 / `partial_failure`), so the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed ` by running the backend's dry run over the restored project staged in memory (nothing written). A restore that writes a file outside the group commit's captured set (`.socket/gradle/hosted-index.tsv`) is not rolled back. The `scan` / `get --mode vendored --dry-run` preview, which stages no restore (it never touches the registry), predicts the pnpm (lockfileVersion 9) part of this (#853): a hosted pin the pnpm vendored backend refuses on lock or manifest text (a `catalog:` dependency, a CRLF `pnpm-lock.yaml` / `pnpm-workspace.yaml`, a conflicting override, …) is listed `would_refuse` with the wet run's `errorCode` and the backend's own `error` detail, evaluated on the still-hosted project (the pnpm restore only rewrites the entry's `resolution:`, which none of those gates reads); a hosted pin of any other lock flavor or ecosystem — a legacy pnpm 7/8 lock (`lockfileVersion` 5.4 / 6.0) included, whose CRLF, override-conflict and entry refusals are not lock-text gated — still previews `would_vendor` even when the wet takeover refuses it (the wet refusal keeps the hosted pin), except for the gem preflight below. **Gem preflight before the takeover**: `scan` / `get --mode vendored` ask the gem vendored backend's own refusals BEFORE the upstream restore — the manifest gate (`gemfile_not_loaded`: a `gems.rb` twin or a `BUNDLE_GEMFILE`-configured manifest) and the Gemfile declaration gate evaluated on the Gemfile and Gemfile.lock text the restore would leave (`gemfile_declaration_not_editable`: a declaration inside a `group` / `platforms` / conditional block, a parenthesized or duplicate declaration, …) — so a hosted gem vendored mode cannot wire is reported `failed ` with the hosted `Gemfile` / `Gemfile.lock` byte-untouched (exit 1 / `partial_failure`), and their `--dry-run` preview reports that gem as `would_refuse` with the same `errorCode` (exit 0, like the Bun / vlt `would_refuse` rows), never `would_vendor`. Pinned against real Bundler by `tests/e2e_redirect_gem_build.rs`. `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. ### Scan modes (v5.0) @@ -128,6 +132,8 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning). The standalone `vendor` command acts only on the project, so every form of it (plain, `--revert`, `--check`) is a usage error under global scope: exit 2, human `Error: cannot be used with vendor[ --revert| --check]: global installs have no project lockfile to …`, JSON `{status: "error", error: {code: "global_scope_unsupported", message}}`, checked before the project is read or locked (#498). +**pnpm 11+ global installs are one root each.** pnpm 11+ gives every `pnpm add -g` its own install, `$PNPM_HOME/global/v//node_modules` (each with its own `.pnpm`), and `pnpm root -g` prints their parent. `--global` and a `--global-prefix` naming such a directory (a `v` dir that is not itself a `node_modules` and has at least one child install carrying pnpm's `.modules.yaml`) crawl every child `node_modules` as a separate root, so `apply`, `rollback`, `scan` and `vex` see every install's copy of a package, not just the first install's (#435). One physical copy two installs both link is still visited once. Any other prefix (pnpm <= 10's `global/5/node_modules`, npm's) is crawled as the single root it names. + **scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts. **Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--apply`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the project's lockfiles pin ≥ 1 hosted patch: `{ mode, records: [{purl, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning. `mode` is the constant `"hosted"`. **v5.0 (MAJOR shape change)**: hosted mode keeps no ledger, so `records` lists the hosted pins lockfile discovery finds (one per `(purl, uuid)`, the same discovery `vex` uses: a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` origin), and the v4 `ledger` and `records[].ledgerKey` keys are gone. Each record's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the join is a plain string compare. `wiringLive` is the subset of those pins among this run's *counted* purls (post-`--ecosystems`-filter) — computed once per run, the same set that feeds `hosted_wiring_retained`. A record missing from `wiringLive` is still wired; it just was not crawled/queried this run (an `--ecosystems` filter, a zero discovery). The key is omitted when no lockfile pins a hosted patch (and under `--global`), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A pre-v5 `.socket/vendor/redirect-state.json` is not read. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result), and vendored-mode runs carry the takeover warnings (their takeovers may restore pins mid-run) — neither duplicates a pre-run snapshot that could go stale. @@ -155,15 +161,15 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Path-scoped scans (`scan [PATHS]...`, v5.0)**: what a PATH means depends on the mode. * **Hosted and vendored mode (bare `scan` included) — project directories** (`run_project_dirs`). Each PATH is a directory, or a glob (`*?[`) matching directories, relative to `--cwd`; the set is sorted and deduplicated, and each directory is scanned on its own exactly as if it were `--cwd` (its own lockfiles, ledgers and `.socket/`). With more than one directory, each run is headed `== ==` on stdout (unless `--silent`), and the exit code is the worst of the runs. Usage errors (exit 2, stderr only, before any scan): a PATH that is not a directory (`` `X` is not a directory``), a glob matching no directory (`` `X` matches no directory``), an invalid glob, and `--json` with more than one directory (`--json takes one project directory (N given); run one scan per directory`), so stdout stays one document. Likewise `--vex` with more than one directory (`--vex takes one project directory (N given); run one scan per directory`): the one output path would be overwritten by each run. -* **Agent mode (and a mode-less `--prune`/`--global` report) — installed-path globs** scoping DISCOVERY at the **purl level**: a package is in scope iff ANY of its crawled installed copies sits under a matching path, and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` combine with `--apply`/`--sync`/`--prune`/`--global`. Every scan JSON shape (success, zero-package, and error alike) carries an always-present `paths` key echoing the patterns verbatim (empty array when unscoped; a hosted/vendored per-directory run is unscoped, so it is `[]`). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on an agent-mode `scan` (exit 0)**. +* **Agent mode (and a mode-less `--prune`/`--global` report) — installed-path globs** scoping DISCOVERY at the **purl level**: a package is in scope iff ANY of its crawled installed copies sits under a matching path (every copy, enumerated the way `rollback`'s path targets enumerate them: a pnpm workspace member's link into the root `.pnpm` store and a nested member copy count, not just the one copy the crawl records per purl), and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` combine with `--apply`/`--sync`/`--prune`/`--global`. Every scan JSON shape (success, zero-package, and error alike) carries an always-present `paths` key echoing the patterns verbatim (empty array when unscoped; a hosted/vendored per-directory run is unscoped, so it is `[]`). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on an agent-mode `scan` (exit 0)**. -`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and redownloads committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). The vendor-ledger discovery supplement (the fresh-clone rule: a ledger entry with no installed copy stays discoverable because its committed artifact IS the dependency) holds only while the lockfile still resolves through that artifact: an entry the lockfile in-use probe (the one `--prune` reverts by) proves unwired, because the dependency was upgraded or removed, is NOT discovered and so is never re-vendored. A run without a non-hosted `--prune` reports it through the run-level `vendor_ledger_entry_unwired` warning; a `--prune` run reverts it in its GC and exits 0. That GC runs even when the crawl found no packages, as its vendored half alone (the manifest prune stays skipped there). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). JSON output gains a `download` sub-object — the detached download envelope `{found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (no `applied` field — nothing is applied in place; `detached: true` is pinned and always present; a `downloaded` record whose purl the ledger already holds at another uuid carries the additive `oldUuid` — the re-vendor the vendor step then performs — and its human `[fetch]` line reads ` (replacing )`) — and a `vendor` sub-object (a full vendor Envelope). Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). +`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and redownloads committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). The vendor-ledger discovery supplement (the fresh-clone rule: a ledger entry with no installed copy stays discoverable because its committed artifact IS the dependency) holds only while the lockfile still resolves through that artifact: an entry the lockfile in-use probe (the one `--prune` reverts by) proves unwired, because the dependency was upgraded or removed, is NOT discovered and so is never re-vendored. A run without a non-hosted `--prune` reports it through the run-level `vendor_ledger_entry_unwired` warning; a `--prune` run reverts it in its GC and exits 0. That GC runs even when the crawl found no packages, as its vendored half alone (the manifest prune stays skipped there). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). JSON output gains a `download` sub-object — the detached download envelope `{found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (no `applied` field — nothing is applied in place; `detached: true` is pinned and always present; a `downloaded` record whose purl the ledger already holds at another uuid carries the additive `oldUuid` — the re-vendor the vendor step then performs — and its human `[fetch]` line reads ` (replacing )`) — and a `vendor` sub-object (a full vendor Envelope). Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse, or whose hosted pnpm pin the takeover would be refused on (see "Takeover reconciliation") — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). **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. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and it is redirected in the same commit. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is kept vendored, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are covered the same way: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is skipped (`redirect.skipped[].reason`) with the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `redirected` count and warnings are the wet run's. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. 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` before its first wet write (the staged takeover 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, patches, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). `patches` (additive, v5.0) is the per-purl outcome of every selected patch, sorted by purl: `{purl, uuid, action}` with `action` `pinned` (`would_pin` under `--dry-run`; `redirected` counts these), `skipped` (`errorCode` = the `skipped[]` reason, `error` = its detail when it has one), or `unpinned` (`errorCode: redirect_unconfirmed` — the patch was granted but no lockfile entry pinning it could be rewritten; the human output's `Not hosted : …` line). An `unpinned` or `skipped` row does not change `status` or the exit code (the hosted exit policy is an open decision, #704). 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_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `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 additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten). 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 of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). **Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`). @@ -187,7 +193,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm * **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` records (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, which remains pinned to the fuzzy package-name path. PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `not_installed` — consumers dispatch on the per-record `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `(release_narrowing)`-prefixed strings in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found N patches:` listing shows only the patches whose package version survived the narrowing (the narrowing is judged over every result, so an installed package's paid fix a free user cannot download still lists as `[PAID] (no access)`, while skip records and counts cover only accessible patches), sorted by PURL in natural version order (`4.17.2` before `4.17.10`); the narrowed-out ones are summarized on stderr in one line per reason (`Skipped N patches for M package versions not installed here (use --all-releases to include them).`), and `--verbose` adds one `[skip] ()` line per skipped version after that summary, in natural version order. When the candidates hold more patches than were selected and the pick was made without a menu (a paid user's auto-pick, `--yes`, a non-TTY run), a `Selected:` block names the patch (purl, tier, short uuid, advisories) that will be installed before the prompt. Machine output (the prompt count, the JSON envelope) uses the kept set, unchanged. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines (and before an agent-mode `--dry-run` preview, so the preview names only the variants a wet run would fetch). * **Deliberate divergences from scan** (documented, not drift): agent-mode get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan and, v5.0, hosted/vendored get auto-pick); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--prune`; get does not run scan's pre-vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.0): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `{status:"success", dryRun:true, found, downloaded:0, skipped, applied:0, patches:[{purl, uuid, action:"would_add"|"would_update"(+oldUuid)|"skipped"}, ], warnings?}`, exit 0. -`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.0) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted upstream restore (every pin is resolved exactly like a wet run — registry lookups included, so a pin the wet run would refuse is previewed as that refusal — and nothing is flushed to disk), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. +`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.0) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse (or, see "Takeover reconciliation", whose hosted pnpm pin the takeover would be refused on): an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted upstream restore (every pin is resolved exactly like a wet run — registry lookups included, so a pin the wet run would refuse is previewed as that refusal — and nothing is flushed to disk), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. @@ -723,7 +729,7 @@ to **six flavors**. | npm (package-lock) | deterministic patched tarball `[@scope/]-.tgz`, plus `/.gitignore` (re-includes the tarball against the project's ignores, such as Node.gitignore's `*.tgz`) and `/.gitattributes` (`-text`); every tarball flavor below writes the same pair and refuses `vendor_artifact_gitignored` when git would still drop the tarball | `package-lock.json` only (`npm-shrinkwrap.json` wins when present): every entry matching name+version gets `resolved: "file:…"` + recomputed `integrity`. `package.json` untouched | `npm ci` (integrity-verified). Plain `npm install` preserves the entry; `npm update ` re-resolves and drops it | | npm / yarn classic | (same tarball) | `yarn.lock` only: matching blocks get `resolved "file:./…#"` + `integrity` (both checksums recomputed; merged-key & `npm:`-alias blocks covered) | `yarn install --frozen-lockfile --offline` (sha1 fragment + sha512 SRI both enforced; byte-stable lock) | | npm / yarn berry (node-modules linker) | (same tarball) | root `package.json` `resolutions` + `yarn.lock` entry with `checksum: 10c0/` of the berry cache-zip (reproduced from the tarball offline). **PnP is refused** (`.pnp.*` → different artifact pipeline) | `yarn install --immutable --check-cache`, cold cache. Refused if `__metadata.cacheKey ≠ 10c0` or a non-default `compressionLevel`. Both files keep their own layout — a CRLF lock (yarn's output on Windows) is spliced in CRLF, `package.json` is re-serialized with its BOM, indent, line ending and trailing-newline shape — so vendor + `--revert` round-trip byte-exactly; a lock or `package.json` MIXING CRLF and LF is refused before any write (`vendor_yarn_berry_mixed_line_endings`) | -| npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) | `pnpm install --frozen-lockfile --offline`, cold store (integrity-verified; byte-stable on pnpm 9 & 10). Other lockfileVersions: 5.4/6.0 route to the legacy backend below; anything else refused | +| npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) **+** the same override in `pnpm-workspace.yaml` (pnpm >= 10.5 reads it there; created with a root-only `packages:` scaffold when absent). A project with no `pnpm-workspace.yaml` pinned to pnpm 9.0–10.4 (every pin, read as for the hosted trust config) gets no file: those read package.json, and a root-only workspace makes `pnpm add` fail there (#734); a later vendor on pnpm >= 10.5 adds it. Residual: an unpinned project with no install record still gets the scaffold, so on pnpm 9.0–10.4 it needs `pnpm add -w ` or a `packageManager` pin | `pnpm install --frozen-lockfile --offline`, cold store (integrity-verified; byte-stable on pnpm 9 & 10). Other lockfileVersions: 5.4/6.0 route to the legacy backend below; anything else refused | | npm / pnpm LEGACY (lockfileVersion 5.4 = pnpm 7, 6.0 = pnpm 8; flavor `pnpm-legacy`) | (same tarball) | root `package.json` `pnpm.overrides` **+** legacy lock surgery (overrides / root dep + specifiers / packages rekey to a bare `file:` key with recomputed integrity / in-package dep refs). **No `pnpm-workspace.yaml` is written** (pnpm ≤ 8 reads overrides only from package.json). The lock's SPECIFIER is machine-ABSOLUTE — pnpm ≤ 8 absolutizes `file:` overrides itself — surfaced as `vendor_pnpm_legacy_absolute_specifier`. Legacy WORKSPACE locks (`importers:`) refused | same-path `pnpm install --frozen-lockfile --offline`, cold store (byte-stable on pnpm 7.33.5 / 8.15.9). A checkout at a DIFFERENT path fails the frozen check (path-bound specifier) and must run `pnpm install --offline --no-frozen-lockfile` once (the flag matters on CI, where pnpm defaults frozen on), which installs the vendored tarball and re-resolves only the specifier line | | npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` redownloads on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. Native binary support is described in the next row. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet). Bun 1.1.39–1.3.9 re-save the local tuple WITHOUT its `sha512` on any later lock re-save (`bun add`, `bun install` after a manifest change); the digest-less 2-tuple is recognised as the same wiring — an in-sync re-run stays `already_vendored` and re-pins the digest on disk (no new wiring record) when the committed artifact still holds the bytes the lock was written from — otherwise, as for any stale tuple of ours, the line is re-pinned and the fresh entry carries the new fingerprint — `repair` redownloads through it, and `vendor --revert` / `rollback` restore the registry line over it (a 2-tuple at ANOTHER uuid is still `vendor_lock_entry_drifted`) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | | npm / bun binary (`bun.lockb`, native binary format 1, 2 or 3) | (same tarball) | Rewrite matching binary package resolutions and integrity in place; preserve topology and unrelated metadata, update binary offsets and the package metadata hash. Text `bun.lock` takes precedence. `bun_lockb_package` wiring snapshots recover pristine registry metadata for repair and support per-package revert and hosted ↔ vendored migration. Binary discovery and rewrites require no installed Bun runtime. Malformed or unsupported content refuses `vendor_bun_lockb_invalid` before download or takeover. | Frozen installs with the original compatible Bun reader; see `docs/testing/bun-compatibility.md` for the release matrix and historical runtime integrity limits. | @@ -942,7 +948,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem * **Scope.** The hosted pins are what lockfile discovery finds — `(purl, patch uuid, files wiring it)`, recognized only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A scoped rollback (paths / identifiers / `--ecosystems`) restores exactly the pins in scope; each pin restores or refuses on its own (there is no whole-ledger replay, and a pre-v5 ledger's edits are never replayed). A pin discovery cannot see is out of reach: a lockless cargo `registry = "socket-patch-"` pin, a nuget exact-id mapping with no `packages.lock.json`, a gem wired only in the `Gemfile` (pre-bundler-2.6 mixed state) — restore those files from version control. * **What a restore does.** Every file wiring the pin is rewritten back to the DEFAULT UPSTREAM registry entry for `name@version`, re-resolving whatever the entry pins (tarball URL, integrity, checksum, hashes) from the public registry; only the hosted entries change and every other byte stays the file's own. A pin is **all-or-nothing**: refused in one of its files, it is restored in none of them, so no pin is left half hosted. Nothing reaches disk until every pin has resolved, and `--dry-run` resolves exactly like a wet run — registry lookups included — and skips only the write. Per format: - * **npm family** — `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / `shrinkwrap.yaml`, `bun.lock`: resolution + integrity (+ shasum where recorded) from the npm registry's version document (`SOCKET_NPM_REGISTRY`); a yarn berry lock whose `.yarnrc.yml` names another `npmRegistryServer` reads that registry's document instead, so a mirror's off-path `dist.tarball` keeps its `::__archiveUrl=` binding (falling back to the default registry, with `upstream_registry_fallback`, when the mirror can't be read). Side settings: a project `.npmrc` that is exactly `allow-remote=all\n` is deleted once no root npm lock entry is hosted, otherwise a remaining top-level `allow-remote=all` warns `npm_allow_remote_left`; a `pnpm-workspace.yaml` that is exactly the scaffold hosted mode creates is deleted once `pnpm-lock.yaml` is no longer hosted, otherwise a remaining `trustLockfile: true` warns `pnpm_trust_lockfile_left`. **`bun.lockb` (binary)**: `rollback` and `remove` refuse it (the checkout remedy). The hosted → vendored takeover and the eject DO restore it, since the vendor ledger then records the rebuilt record as its pre-vendor original: the native codec turns each hosted remote-tarball record back into Bun's npm registry record for `name@version` (the registry's `dist.tarball` + `dist.integrity`, the package metadata hash re-derived, the hosted URL string dropped from the string pool). The hosted rewrite keeps the registry record's inactive bytes (padding, semver) in the tarball record, so a lock it wrote comes back byte for byte — early writers' uninitialized padding included; a record without them (an older socket-patch or a Bun re-save) is rebuilt the way Bun writes one, and refused for a prerelease/build version. A lock the hosted rewrite had to normalize is marked in the root package's resolution value bytes (which no Bun reader reads): a binary format 1 lock it promoted to format 2 is demoted back to its exact format-1 bytes (verified by promoting it again, otherwise refused), and a lock whose workspace dependency behaviors it normalized is refused with the `git checkout -- bun.lockb` remedy. + * **npm family** — `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / `shrinkwrap.yaml`, `bun.lock`: resolution + integrity (+ shasum where recorded) from the npm registry's version document (`SOCKET_NPM_REGISTRY`); a yarn berry lock whose `.yarnrc.yml` names another `npmRegistryServer` reads that registry's document instead, so a mirror's off-path `dist.tarball` keeps its `::__archiveUrl=` binding, and a pnpm lock whose sibling settings name a registry reads that registry's document — `.npmrc` `registry` (or, for a scoped name, `@scope:registry`); on pnpm 10 a pnpm-workspace.yaml `registries` map instead when present; on pnpm 11+ (or an unknown major) pnpm-workspace.yaml `registries."@scope"` / `registry` / `registries.default` too, ahead of the matching `.npmrc` key — so a mirror's `tarball:` comes back as pnpm recorded it and a URL conventional under that registry stays derived (falling back to the default registry, with `upstream_registry_fallback`, when the mirror can't be read; a value holding an unexpanded `${VAR}` is read as unset). Whether a restored pnpm entry gets its `tarball:` back follows `lockfileIncludeTarballUrl` as the pnpm that wrote the lock read it (#902), from the strongest evidence available: (1) the lock's own unpinned registry resolutions (a bare one proves it off; a URL pnpm could have derived proves it on; pnpm 11+'s env lockfile document does not count); else (2) the settings file the installed pnpm major reads (`node_modules/.modules.yaml` `packageManager`, else package.json `packageManager`; a pre-9 lock or shrinkwrap means pnpm <= 8): `.npmrc` `lockfile-include-tarball-url` on pnpm <= 9, pnpm-workspace.yaml `lockfileIncludeTarballUrl` on pnpm >= 11 (also assumed for a lock carrying an env lockfile document), the workspace file then `.npmrc` on pnpm 10; else (3) pnpm 10's reading. A Rush lock (`common/config/rush/pnpm-lock.yaml` or a subspace lock, with `rush.json` at the Rush root) takes its pnpm major from rush.json `pnpmVersion` instead of tier 2's install record and package.json pin. A 9.0 lock may come from pnpm 9, 10 or 11+, so when tier 3's reading differs from pnpm 9's (`.npmrc` only) or pnpm >= 11's (pnpm-workspace.yaml only) — e.g. `.npmrc` on with the workspace file silent, or the workspace file setting it with `.npmrc` silent or disagreeing — the restore follows pnpm 10 but warns `upstream_pnpm_tarball_setting_guessed` (once per lock, naming the entries); a URL pnpm records anyway (not derivable from the registry) never warns. Side settings: a project `.npmrc` that is exactly `allow-remote=all\n` is deleted once no root npm lock entry is hosted, otherwise a remaining top-level `allow-remote=all` warns `npm_allow_remote_left`; a `pnpm-workspace.yaml` that is exactly the scaffold hosted mode creates is deleted once `pnpm-lock.yaml` is no longer hosted, otherwise a remaining `trustLockfile: true` warns `pnpm_trust_lockfile_left`. **`bun.lockb` (binary)**: `rollback` and `remove` refuse it (the checkout remedy). The hosted → vendored takeover and the eject DO restore it, since the vendor ledger then records the rebuilt record as its pre-vendor original: the native codec turns each hosted remote-tarball record back into Bun's npm registry record for `name@version` (the registry's `dist.tarball` + `dist.integrity`, the package metadata hash re-derived, the hosted URL string dropped from the string pool). The hosted rewrite keeps the registry record's inactive bytes (padding, semver) in the tarball record, so a lock it wrote comes back byte for byte — early writers' uninitialized padding included; a record without them (an older socket-patch or a Bun re-save) is rebuilt the way Bun writes one, and refused for a prerelease/build version. A lock the hosted rewrite had to normalize is marked in the root package's resolution value bytes (which no Bun reader reads): a binary format 1 lock it promoted to format 2 is demoted back to its exact format-1 bytes (verified by promoting it again, otherwise refused), and a lock whose workspace dependency behaviors it normalized is refused with the `git checkout -- bun.lockb` remedy. * **vlt** — `vlt-lock.json`: slot [2] from the registry's `dist.integrity`, slot [3] per the lock's own convention (see the vlt hosted-mode contract); every hosted instance of the pin together. * **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`); every `Cargo.toml` declaration loses its `registry = "socket-patch-"` pin (the shorthand the rewriter produced collapses back); every `[registries.socket-patch-]` block no manifest or lock still references leaves the project cargo config — including a superseded patch generation's block an earlier re-pin left behind (#864). A declaration it cannot unpin refuses. * **golang** — the hosted `replace` and the socket module's go.sum lines go; the upstream module's two go.sum lines come back, hashed from the module proxy (`SOCKET_GOPROXY`, else `GOPROXY` / `GONOPROXY` / `GOPRIVATE` as go reads them) and cross-checked against the checksum database (`SOCKET_GOSUMDB_URL`, else `sum.golang.org` unless `GOSUMDB=off` / `GONOSUMDB` / `GOPRIVATE` say go would not ask it). A `replace` the user had before the hosted run is not recorded anywhere, so the restore lands on the plain upstream module. @@ -961,7 +967,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem | Key | Shape | Meaning | |---|---|---| -| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `legacy_redirect_ledger_kept`, the upstream-restore advisories (`npm_allow_remote_left`, `pnpm_trust_lockfile_left`, `maven_trusted_checksums_left`, `nuget_default_config_left`, `upstream_uv_override_removed`, `upstream_registry_fallback`), `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), `rollback_record_superseded` (a manifest record superseded by a live hosted pin, left to the hosted leg — see Manifest cleanup), plus vendored/hosted leg advisories. New codes are additive (MINOR) | +| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `legacy_redirect_ledger_kept`, the upstream-restore advisories (`npm_allow_remote_left`, `pnpm_trust_lockfile_left`, `maven_trusted_checksums_left`, `nuget_default_config_left`, `upstream_uv_override_removed`, `upstream_registry_fallback`, `upstream_pnpm_tarball_setting_guessed`), `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), `rollback_record_superseded` (a manifest record superseded by a live hosted pin, left to the hosted leg — see Manifest cleanup), plus vendored/hosted leg advisories. New codes are additive (MINOR) | | `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. | | `vendoredReverted` | `[purl]` | Ledger entries cleanly reverted this run (unwired + artifact deleted + entry dropped; previewed on dry-run) | | `vendoredPreserved` | `[purl]` | `--preserve-state`: unwired with artifact + ledger entry kept | @@ -1124,7 +1130,7 @@ Env-only knobs used for hosted upstream restoration and JVM metadata verificatio | Env var | Default | Notes | |---|---|---| -| `SOCKET_NPM_REGISTRY` | `https://registry.npmjs.org` | Base for npm version documents (`//`, a scoped name's `/` as `%2f`; `dist.tarball` / `integrity` / `shasum`) the npm-family and vlt upstream restore reads, unless the project names another registry (yarn berry `npmRegistryServer`, vlt's node registry), whose document is read first. | +| `SOCKET_NPM_REGISTRY` | `https://registry.npmjs.org` | Base for npm version documents (`//`, a scoped name's `/` as `%2f`; `dist.tarball` / `integrity` / `shasum`) the npm-family and vlt upstream restore reads, unless the project names another registry (yarn berry `npmRegistryServer`, pnpm's lock-sibling `.npmrc` `registry` / `@scope:registry` or pnpm-workspace.yaml `registry` / `registries` as the pnpm major reads them, vlt's node registry), whose document is read first. | | `SOCKET_GOPROXY` | `https://proxy.golang.org` | Go module proxy used for upstream restoration, honoring `GOPROXY`, `GONOPROXY` and `GOPRIVATE`. | | `SOCKET_MAVEN_REGISTRY` | `https://repo1.maven.org/maven2` | maven2 base for the fallback upstream-pom download. | | `SOCKET_CRATES_INDEX` | `https://index.crates.io` | v5.0 upstream restore: the crates.io sparse index whose `checksum` a restored `Cargo.lock` entry gets back. | @@ -1209,7 +1215,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified } ``` -`files[].path` is the manifest file key (`package/index.js`). A pnpm or vlt package installed as more than one peer-variant store copy is patched (and rolled back) in every copy; a file of a copy other than the one the package resolved to is listed under that copy's on-disk path (`…/.pnpm/foo@1.0.0_react@18.3.1/node_modules/foo/index.js`). So a run that wrote only such a copy reports `applied` with that file, not `already_patched`, and rollback's `filesRolledBack` / `filesVerified` carry the same paths (it counts the package in `rolledBack`, not `alreadyOriginal`). +`files[].path` is the manifest file key (`package/index.js`). A pnpm or vlt package installed as more than one peer-variant store copy is patched (and rolled back) in every copy; a file of a copy other than the one the package resolved to is listed under that copy's on-disk path (`…/.pnpm/foo@1.0.0_react@18.3.1/node_modules/foo/index.js`). So a run that wrote only such a copy reports `applied` with that file, not `already_patched`, and rollback's `filesRolledBack` / `filesVerified` carry the same paths (it counts the package in `rolledBack`, not `alreadyOriginal`). Copies are counted by real location: a pnpm / Bun workspace member's `packages/a/node_modules/foo` link into the root store is the same copy as the store entry, so `apply` / `rollback` visit it once (one event, no phantom `already_patched` / `alreadyOriginal`), while path targets still match it through the member's link (#633). `details` is intentionally schemaless — different subcommands attach different keys. Consumers MUST treat unknown keys as best-effort metadata and must not break on absence. @@ -1261,7 +1267,8 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (`manifest.removedEntries: []`) and the run exits `partial_failure` 1. | | `npm_allow_remote_left` / `pnpm_trust_lockfile_left` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): no npm-family lock entry is hosted any more, but the project `.npmrc` keeps a top-level `allow-remote=all` (resp. `pnpm-workspace.yaml` keeps `trustLockfile: true`) in a file that is not exactly what hosted mode creates; the file is left untouched (v5 records no provenance), remove the line if nothing else needs it. A file that is exactly hosted mode's own is deleted silently. | | `maven_trusted_checksums_left` / `nuget_default_config_left` / `upstream_uv_override_removed` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): `.mvn` config keeps the trusted-checksums resolver lines because it holds more than hosted mode writes; `nuget.config` now holds only the nuget.org source (delete it if hosted mode created it); a transitive `override-dependencies` entry hosted mode added to `pyproject.toml` was removed. | -| `upstream_registry_fallback` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore: a yarn berry or vlt entry is restored from the version document of the registry the project resolves it against (`.yarnrc.yml` `npmRegistryServer`, vlt's node registry); that registry could not be read (e.g. it needs credentials), so the default registry's document was used and the restored tarball URL may not be the mirror's. | +| `upstream_registry_fallback` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore: a yarn berry, pnpm or vlt entry is restored from the version document of the registry the project resolves it against (`.yarnrc.yml` `npmRegistryServer`, the pnpm lock's sibling `.npmrc` `registry` / `@scope:registry` or pnpm-workspace.yaml `registry` / `registries`, vlt's node registry); that registry could not be read (e.g. it needs credentials), so the default registry's document was used and the restored tarball URL may not be the mirror's. | +| `upstream_pnpm_tarball_setting_guessed` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (#902): nothing showed which pnpm wrote a hosted `pnpm-lock.yaml` (no unpinned registry entry that shows the setting, no `node_modules/.modules.yaml` install record, no package.json `packageManager` pin; for a Rush lock, no rush.json `pnpmVersion`), so its entries were restored with or without `tarball:` by pnpm 10's reading of `lockfileIncludeTarballUrl` (pnpm-workspace.yaml, else `.npmrc` `lockfile-include-tarball-url`), and pnpm 9 (which reads only `.npmrc`) or pnpm >= 11 (which reads only pnpm-workspace.yaml) would have read it the other way. The detail names the lock, the setting followed, the pnpm that disagrees and the entries. Remedy: pin the pnpm (package.json `packageManager`, or reinstall so the install record names it; rush.json `pnpmVersion` for Rush), or give the two files the same value so every pnpm reads it alike; a rollback or remove can then be redone by restoring the lock from version control and re-running. Not raised when evidence decided, when both files read the same on every pnpm, or when the tarball is one pnpm records regardless. | | `legacy_redirect_ledger_kept` | rollback `warnings[]` (+ remove stderr) | v5.0: a pre-v5 `.socket/vendor/redirect-state.json` could not be deleted once no hosted pin was left; the file is inert (never read for planning). Never flips the exit. | | `vendor_stale_artifact_removed` | `removed` | vendor / scan `--vendor`: re-vendor under a newer patch uuid removed the previous uuid's orphaned artifact dir. | | `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (jsr). | @@ -1286,7 +1293,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_bun_workspace_unsupported` | `failed` | vendor / scan / get `--mode vendored` (bun): the text lock holds `workspace:` packages and its `lockfileVersion` is below 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the member; a committed version-2 lock is the proof every consumer runs Bun ≥ 1.4 (deliberate over-approximation: root-only declared packages would install on version 1 too). Detail names the version integer and a version-specific remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing version) — then, for a version-1 lock, "or use `--mode hosted`, which accepts version-1 workspace locks"; for a version-0 lock, "or delete `bun.lock`, re-lock with Bun ≥ 1.2 (which writes lockfileVersion 1) and use `--mode hosted`" (hosted refuses version-0 workspace locks, so a bare hosted pointer would send the user into a second refusal). Refused before any write — in the pre-download preflight on `get`/`scan` (see `vendor_bun_lockb_invalid` for the placements); in the shared preflight that `vendor` and the vendor step run BEFORE a hosted → vendored takeover's revert (a hosted-redirected purl stays hosted-wired, ledger and lock untouched; `vendor --dry-run` previews the same `failed` code); and in the engine when the run would write a NEW local tuple. Exempt: purls the vendor ledger wires at the selected uuid, purls whose every `bun.lock` instance is already a `.socket/vendor/npm/` tuple (any uuid), in-sync re-runs and `repair` redownloads. | | `vendor_lockfile_missing` / `vendor_lockfile_version_unsupported` (bun preflight placement) | `failed` | scan / get `--mode vendored` (bun): the pre-download preflight found `bun.lock` unreadable / at a `lockfileVersion` other than 0, 1 or 2 (a newer version: update socket-patch; no integer: re-lock with Bun ≥ 1.2 — the same text as hosted's `redirect_bun_lock_unsupported`) or outside bun's single-line `packages` grammar. Same placements as `vendor_bun_lockb_invalid`; nothing fetched, no patch record. An unreadable `.socket/vendor/state.json` met by the same preflight is `vendor_state_unreadable` (see that row), never one of these. | | `bun_lockb_invalid` | scan `warnings[]` (run-level) | scan (every mode): the native binary inventory could not parse or read `bun.lockb`; detail names the format or filesystem error. Also printed as `Warning: …` on stderr. Exit and status remain unchanged. The warning is retained on empty and non-empty scans; valid binary locks are inventoried normally without a runtime or install. | -| `would_refuse` | dry-run preview action (`vendor.patches[]`) | scan `--mode vendored --dry-run` / get `--mode vendored --dry-run`: the wet run's Bun preflight would refuse this npm purl; the record carries `errorCode` (one of the four Bun lock codes above, or `vendor_state_unreadable` for an unreadable vendor ledger) + `error`. Exit 0 / `status: "success"`, nothing written. | +| `would_refuse` | dry-run preview action (`vendor.patches[]`) | scan `--mode vendored --dry-run` / get `--mode vendored --dry-run`: the wet run's Bun preflight would refuse this npm purl (`errorCode` one of the four Bun lock codes above, or `vendor_state_unreadable` for an unreadable vendor ledger), or the pnpm backend would refuse the takeover of its hosted pin (`errorCode` that backend's lock-text code, #853); the record carries `errorCode` + `error`. Exit 0 / `status: "success"`, nothing written. | | `cargo_wiring_migrated` | `skipped` (advisory note) | vendor / scan / get `--mode vendored` / repair (v5.0): a pre-v5 `.cargo/config.toml` / `.cargo/config` vendored `[patch.crates-io]` entry was moved into the workspace-root `Cargo.toml` (dry run: "would move"); the ledger entry is rewritten to name `Cargo.toml` (lock originals kept). A vendor re-run that migrates reports the package `applied`, not `already_vendored`. | | `cargo_legacy_wiring_kept` | vendor: `failed`; repair: `skipped` (warning) | vendor / scan / get `--mode vendored` (v5.0): the pre-v5 config entry could not be removed after the manifest took the wiring — the run is unwound (manifest, lock and copy as before) and the package fails, since a kept entry would double-wire the crate and, on a uuid bump, point at a copy the stale sweep deletes; the code prefixes the error detail. repair: the move was refused (e.g. an unparseable `Cargo.toml`, a user entry for the crate, or an unremovable legacy entry — the manifest edit is unwound); left in place. | | `cargo_version_tagged` | `skipped` (advisory note) | vendor / scan / get `--mode vendored` / repair (v5.0): a vendored copy and its detached Cargo.lock entry were (re)tagged `+socket.` — a copy vendored before tagged versions, or a lock entry tagged for another uuid while the wiring points at this copy (dry run: "would tag"). A vendor re-run that tags reports the package `applied`. | @@ -1299,18 +1306,21 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | | `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | | `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; a directory whose only locks are `package-lock.json` / `npm-shrinkwrap.json` is refused the same way when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json`, because npm never reads a lock inside a workspace member (#1094; vendored refuses it with `vendor_lockfile_missing`)) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | -| `redirect_pnpm_settings_elsewhere` | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from the nearest ancestor `pnpm-workspace.yaml`, which pnpm reads alone (a member's own file is ignored). When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. Disk runs only. | +| `redirect_pnpm_settings_elsewhere` | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member (listed by the `packages:` globs of the nearest ancestor `pnpm-workspace.yaml`) with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from that ancestor file, which pnpm reads alone (a member's own file is ignored). A directory those globs do not list (no `packages:`, an empty list, a non-matching or `!`-excluded path) is a standalone project on pnpm 11.28+/12 that reads only its own file: it is pinned and gets its own `pnpm-workspace.yaml` like any single project. A root file that does not parse, or whose patterns use braces, classes or extglobs, counts as listing the project. When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. In memory, a member whose lock is demoted into its workspace root (#492) is never refused; one whose lock is not (the workspace root's files do not confirm it pins or ignores that lock, or socket.yml leaves the root out) is refused with this code as its project error, nothing written for it, whenever its lock is v9, the trust auto-config is on and that file may list it (listed, unreadable, or not readable as globs), whatever it says about `trustLockfile`. | | `eject_refused` | top-level `errorCode` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. | | `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. | | `eject_rolled_back` | warning | vendor eject (v5.0): a package failed after the restore began; every touched file was put back from the pre-eject snapshot, so the project is still hosted; `partial_failure`, exit 1. | | `eject_rollback_failed` | top-level `errorCode` | vendor eject (v5.0): putting the pre-eject snapshot back failed; the detail names the files to `git checkout --`; exit 1. | | `offline_eject_unavailable` | top-level `errorCode` | vendor eject under `--offline` / `SOCKET_OFFLINE` (v5.0): records and registry entries cannot be fetched offline; zero network requests, nothing touched, exit 1. | | `hosted_wiring_contested` | top-level `errorCode` (list: warning when it can still list) | rollback / remove / vendor eject / list (v5.0): a lockfile mentions a recognized hosted patch uuid that discovery rejected (or a pin with no lockfile), so the hosted set is not known exactly; refused with nothing touched, exit 1. Remedy: fix or `git checkout` the named lockfile. | -| `vendor_pnpm_settings_elsewhere` | `failed` | vendor / scan / get `--mode vendored` (pnpm, v9 lock): the project directory is a pnpm workspace member with its own `pnpm-lock.yaml` and no `pnpm-workspace.yaml` of its own; pnpm reads `overrides:` only from the nearest ancestor `pnpm-workspace.yaml`, so an override wired into the member (its `package.json` or a nested workspace file) would be ignored, failing frozen installs on pnpm >= 11 and silently reinstalling the unpatched package on a plain install. Refused before any write (the pre-download preflight and `--dry-run` included); the detail names the governing file; remedy: `--mode hosted`. | +| `vendor_pnpm_settings_elsewhere` | `failed` | vendor / scan / get `--mode vendored` (pnpm, v9 lock): the project directory is a pnpm workspace member (listed by the `packages:` globs of the nearest ancestor `pnpm-workspace.yaml`, read as for `redirect_pnpm_settings_elsewhere`) with its own `pnpm-lock.yaml` and no `pnpm-workspace.yaml` of its own; pnpm reads `overrides:` only from the nearest ancestor `pnpm-workspace.yaml`, so an override wired into the member (its `package.json` or a nested workspace file) would be ignored, failing frozen installs on pnpm >= 11 and silently reinstalling the unpatched package on a plain install. Refused before any write (the pre-download preflight and `--dry-run` included); the detail names the governing file; remedy: `--mode hosted`. A directory the globs do not list is a standalone project: the override is wired into a new `pnpm-workspace.yaml` of its own. | +| `vendor_pnpm_lock_multi_document` | `failed` | vendor / scan / get `--mode vendored` (pnpm, v9 lock): `pnpm-lock.yaml` has several YAML documents but is not pnpm's two-document layout — a `---`, an env document whose importers carry only `configDependencies` / `packageManagerDependencies` (pnpm >= 11 with config dependencies, pnpm 12 with `packageManager` set), a `---`, then the project lock with its own `lockfileVersion: '9.0'`. The vendored backend edits only that project document (the env document is written back byte-for-byte, #466) and will not guess for any other layout (three or more documents, a `...` end marker, a byte-order mark before a separator, an unrecognised first document). Refused before any write (the pre-download preflight and `--dry-run` included); remedy: `--mode hosted`. A vendored pnpm revert over such a lock (`vendor --revert`, `rollback`, the vendored → hosted takeover) fails as a revert failure whose detail names the lock, dry run included, before touching any surface, rather than reverting `package.json` / `pnpm-workspace.yaml` around a lock left wired to the artifact. | +| `vendor_config_dependency_unpatched` | `skipped` (warning) | vendor / scan / get `--mode vendored` (pnpm, v9 lock): the vendored package is also a pnpm config dependency (its registry key is in the lock's env document). Vendoring wires the project's copy only; the copy pnpm installs under `node_modules/.pnpm-config` keeps the registry bytes. Hosted mode pins both. | +| `vendor_pnpm_git_branch_lockfile` | `failed` | vendor / scan / get `--mode vendored` (pnpm, every lockfile version): `gitBranchLockfile` is on and a `pnpm-lock..yaml` exists at the root (or in a workspace member that installs from its own lock under `sharedWorkspaceLockfile: false`), so pnpm installs the branch from that lock while vendoring wires `pnpm-lock.yaml` only (every `--frozen-lockfile` install would then fail on the overrides mismatch). Refused before any write, by the pre-flight too; also raised in place of `vendor_lockfile_missing` when the branch lock is the only pnpm lock. Remedy: turn the setting off and run `pnpm install --merge-git-branch-lockfiles`. Hosted mode's twin is the `redirect_pnpm_git_branch_lockfile` warning (#556). | | `vendor_dir_symlink_unsupported` | `failed` | vendor / scan / get `--mode vendored` (every ecosystem): `.socket` itself (#887), `.socket/vendor`, `.socket/vendor/` or the patch's `` dir is a symlink or junction. socket-patch creates those directories itself and never writes links, so a linked one is not ours; its target may be another project's vendor store. Refused before any write. The vendored revert (`vendor --revert`, `rollback`, `remove`, the vendored → hosted takeover) fails on the same check with the same detail before it edits a lock or deletes anything, so it never deletes another project's artifacts through the link. The detail names the linked path. Remedy: replace the link with a real directory and re-run. | | `vendor_yarn_berry_cache_unsupported` | `failed` | vendor (yarn berry): lock `cacheKey ≠ 10c0` or non-default `.yarnrc.yml` `compressionLevel` — the cache-zip checksum is not reproducible. | | `vendor_yarn_berry_mixed_line_endings` | `failed` | vendor (yarn berry): `yarn.lock` or the root `package.json` mixes CRLF and LF line endings (or holds a bare CR) — no single ending can be kept, and yarn rewrites such a file wholesale on its next install (a mixed lock also fails `--immutable`, YN0028). Refused before any write; `yarn install` normalizes the files. A uniformly CRLF pair is vendored in CRLF. A hosted→vendored takeover (`vendor`, `scan`/`get --mode vendored`) raises this — and the berry `vendor_yarn_berry_cache_unsupported` gates — BEFORE restoring the hosted pin's upstream entry (dry run too), so a refused purl stays hosted. | -| `vendor_override_conflict` | `failed` | vendor (pnpm/yarn-berry): a user-authored override/resolution for the package already exists. | +| `vendor_override_conflict` | `failed` | vendor (pnpm/yarn-berry): a user-authored override/resolution for the package already exists. pnpm exception: a user exact pin equal to the vendored version, under the bare `` or the `@` key, in package.json `pnpm.overrides` or pnpm-workspace.yaml `overrides:` (#854; there a quoted `'1.3.0'` or a trailing `# comment` is the same pin), is taken over in place (its value becomes the vendored `file:` spec under the user's own key on every override surface) and `vendor --revert` restores it byte-for-byte. A range, another version, a `>` selector, several same-name keys, or package.json and pnpm-workspace.yaml pinning under different keys still refuse; the detail names the file that carries the override. | | `vendor_integrity_unverified` | `skipped` (warning) | vendor (pipenv): the lockfile format does not hash-check file entries; the committed wheel bytes are the protection. | | `vendor_content_mismatch_overwritten` | `skipped` (warning) | vendor: a staged file matched NEITHER beforeHash nor afterHash (patch built against different bytes, or local edits); the stage was overwritten with the verified patched content and the vendor succeeded. | | `vendor_vlt_transitive_unsupported` | `failed` | vendor (vlt): the target has an inbound edge from another package in `vlt-lock.json` (the detail names it); vendored mode rewires only direct dependencies of the root or a workspace member, because vlt silently reverts transitive lock surgery. Remedy: `--mode hosted`. Refused before any download or write, dry runs included (`would_refuse`). | diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index d9d43a07a..332b3debe 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -798,17 +798,8 @@ pub(crate) async fn lock_text_refusals_for( let cwd = params.cwd.as_path(); let origins = crate::commands::hosted_unwind::patch_server_origins_of(params.patch_server_url.as_deref()); - let pins = socket_patch_core::patch::redirect::upstream::HostedPin::all( - &socket_patch_core::vex::discover_patched_refs_with( - cwd, - &socket_patch_core::vex::DiscoverOptions { - patch_server_origins: origins.clone(), - }, - ) - .await, - ); - let claimed: std::collections::HashSet = - pins.iter().map(|pin| PurlKey::new(&pin.purl)).collect(); + let pins = hosted_pins(cwd, origins.clone()).await; + let claimed = claimed_purls(&pins); let fetchable: Vec<&PatchSearchResult> = selected .iter() .filter(|sr| bun_refusal.filter(|r| r.applies_to(&sr.purl)).is_none()) @@ -854,6 +845,42 @@ pub(crate) async fn lock_text_refusals_for( refusals } +/// The purl keys the project's lockfiles pin hosted (a patch-server +/// tarball, Socket's own or one of `origins`): the purls whose vendored +/// run is a hosted → vendored takeover. +/// The fetch-phase gate ([`lock_text_refusals_for`]) and the scan preview +/// (`scan/vendor_flow.rs`) both read the takeovers through here, so the dry +/// run and the wet run agree on which purls they are. +pub(crate) async fn hosted_claimed_purls( + cwd: &Path, + origins: Vec, +) -> std::collections::HashSet { + claimed_purls(&hosted_pins(cwd, origins).await) +} + +/// The project's hosted pins (see [`hosted_claimed_purls`]). +async fn hosted_pins( + cwd: &Path, + origins: Vec, +) -> Vec { + socket_patch_core::patch::redirect::upstream::HostedPin::all( + &socket_patch_core::vex::discover_patched_refs_with( + cwd, + &socket_patch_core::vex::DiscoverOptions { + patch_server_origins: origins, + }, + ) + .await, + ) +} + +/// The purl keys of `pins`. +fn claimed_purls( + pins: &[socket_patch_core::patch::redirect::upstream::HostedPin], +) -> std::collections::HashSet { + pins.iter().map(|pin| PurlKey::new(&pin.purl)).collect() +} + /// The record a detached ledger entry already carries for `purl` at /// exactly `uuid` — the ledger store's idempotency skip (no view fetch). /// Always `None` for the manifest store. diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index cf6a905a8..03d1c8ddc 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -28,7 +28,10 @@ use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vex::{ generate_vex_from_manifest_path, generate_vex_without_manifest, ManifestlessVex, VexEmbedArgs, }; -use crate::ecosystem_dispatch::{find_all_packages_for_purls, partition_purls, JvmScope}; +use crate::ecosystem_dispatch::{ + distinct_install_dirs, distinct_npm_copies, find_all_packages_for_purls, partition_purls, + JvmScope, +}; use crate::json_envelope::{ AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, RunWarning, Status, VexSummary, @@ -892,25 +895,6 @@ pub(crate) fn variant_matches_installed(first_file_status: Option<&VerifyStatus> } } -/// `paths` in order with every path that resolves to an already-listed -/// directory dropped: two discovered site-packages paths can name ONE -/// directory (a `lib64 -> lib` symlink, a symlinked venv), and patching it -/// twice would report the second pass `already_patched`. A path that can't -/// be canonicalized is kept as-is. -async fn distinct_install_dirs(paths: &[PathBuf]) -> Vec { - let mut seen: HashSet = HashSet::new(); - let mut out = Vec::with_capacity(paths.len()); - for path in paths { - let key = tokio::fs::canonicalize(path) - .await - .unwrap_or_else(|_| path.clone()); - if seen.insert(key) { - out.push(path.clone()); - } - } - out -} - /// The file whose verify status decides whether a release variant /// describes the installed distribution (fed to /// [`variant_matches_installed`]). @@ -2217,12 +2201,15 @@ async fn apply_patches_inner( // physical copy per PURL. Patching only one would leave a live, // vulnerable copy while reporting success (the multi-copy silent // partial). The apply loop below iterates every copy. - let all_packages = find_all_packages_for_purls( + let mut all_packages = find_all_packages_for_purls( &partitioned, &crawler_options, args.common.silent || args.common.json, ) .await; + // One visit per physical copy: a pnpm workspace member's link into + // the root store is the same copy the root walk found (#633). + distinct_npm_copies(&mut all_packages).await; if all_packages.is_empty() { // Vendored purls are already accounted for (synthesized Skipped/ diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index ac67bbaf7..2b071a134 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -2151,7 +2151,13 @@ async fn run_get_vendored( selected.iter().map(|p| p.purl.as_str()), ) .await; - let preview = super::scan::preview_vendor_json(&args.common.cwd, selected, &takeover).await; + let preview = super::scan::preview_vendor_json( + &args.common.cwd, + selected, + &super::hosted_unwind::patch_server_origins(&args.common), + &takeover, + ) + .await; if args.common.json { let mut result = serde_json::json!({ "status": "success", diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index e61f03d3d..6ea6027e8 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -25,7 +25,9 @@ use crate::args::{apply_env_toggles, is_local_go, parse_bool_flag, GlobalArgs}; use crate::commands::hosted_unwind::run_hosted_leg; use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend}; -use crate::ecosystem_dispatch::{find_all_packages_for_rollback, partition_purls, JvmScope}; +use crate::ecosystem_dispatch::{ + distinct_npm_copies, find_all_packages_for_rollback, partition_purls, JvmScope, +}; use crate::json_envelope::Command as EnvelopeCommand; use crate::looks_like_uuid; use crate::ui::{plural, StatusLine}; @@ -1992,12 +1994,14 @@ pub(crate) async fn rollback_patches_inner( // one would leave the other copy still patched (silently divergent from // the manifest's rolled-back state). The rollback loop below restores // every copy. - let all_packages_multi = find_all_packages_for_rollback( + let mut all_packages_multi = find_all_packages_for_rollback( &partitioned, &crawler_options, common.silent || common.json, ) .await; + // One restore per physical copy, as apply patches them (#633). + distinct_npm_copies(&mut all_packages_multi).await; // One representative path per PURL for the "is it installed" checks and // the abort envelope's path display. The before-blob gate and the diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 1b2382e49..bfa3517d0 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -30,7 +30,10 @@ use std::path::{Path, PathBuf}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; -use crate::ecosystem_dispatch::{crawl_ecosystems, crawl_ecosystems_with_npm}; +use crate::ecosystem_dispatch::{ + crawl_ecosystems, crawl_ecosystems_with_npm, find_all_packages_for_rollback_reusing, + partition_purls, +}; use crate::ui::{self, plural, print_json, StatusLine}; use crate::commands::agent_download::{ @@ -1784,7 +1787,11 @@ async fn run_scan( // Crawl packages. Vendored mode keeps the npm half for its engine to // reuse; hosted mode keeps it only for an embedded `--vex` (skipped // under `--dry-run`). No other run pays for copying the snapshot. - let keep_npm = vendor || (hosted && args.vex.vex.is_some() && !args.common.dry_run); + // A path-scoped (agent or mode-less) run keeps it to resolve every + // installed copy for the scope filter below. + let keep_npm = vendor + || (hosted && args.vex.vex.is_some() && !args.common.dry_run) + || !path_scope.is_empty(); let (mut all_crawled, mut eco_counts, skipped_bundle_config_path, npm_crawl) = if keep_npm { crawl_ecosystems_with_npm(&crawler_options, crawl_scope).await } else { @@ -1929,7 +1936,10 @@ async fn run_scan( // PATH scoping, strictly AFTER the `scanned_purls` capture. A purl is // in scope when ANY genuinely-crawled copy of it sits under a matching - // path. + // path. The crawl keeps one record per purl (for npm, the first copy + // the walk meets: a pnpm workspace's root `.pnpm` store entry, not the + // member's link to it), so a purl whose record misses is resolved to + // every installed copy the way `rollback`'s path targets are. let filtered_crawled: Vec<_> = if path_scope.is_empty() { filtered_crawled } else { @@ -1956,12 +1966,35 @@ async fn run_scan( )); } let scope = path_scope.bind(&args.common.cwd); - let in_scope: HashSet = filtered_crawled + let mut in_scope: HashSet = filtered_crawled .iter() .filter(|pkg| !supplement_purls.contains(&pkg.purl)) .filter(|pkg| scope.matches(&pkg.path)) .map(|pkg| pkg.purl.clone()) .collect(); + let mut unresolved: Vec = filtered_crawled + .iter() + .filter(|pkg| !supplement_purls.contains(&pkg.purl) && !in_scope.contains(&pkg.purl)) + .map(|pkg| pkg.purl.clone()) + .collect(); + unresolved.sort(); + unresolved.dedup(); + if !unresolved.is_empty() { + let partitioned = partition_purls(&unresolved, args.common.ecosystems.as_deref()); + let copies = find_all_packages_for_rollback_reusing( + &partitioned, + &crawler_options, + true, + npm_crawl.as_ref(), + ) + .await; + in_scope.extend( + copies + .into_iter() + .filter(|(_, paths)| paths.iter().any(|p| scope.matches(p))) + .map(|(purl, _)| purl), + ); + } filtered_crawled .into_iter() .filter(|pkg| in_scope.contains(&pkg.purl)) @@ -3077,7 +3110,15 @@ async fn run_scan( selected.iter().map(|p| p.purl.as_str()), ) .await; - Some(preview_vendor_json(&args.common.cwd, &selected, &takeover).await) + Some( + preview_vendor_json( + &args.common.cwd, + &selected, + &crate::commands::hosted_unwind::patch_server_origins(&args.common), + &takeover, + ) + .await, + ) } else { None }; diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 70303bed9..731729f8d 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -76,7 +76,9 @@ type VendorStepResult = Result<(bool, Envelope), VendorStepError>; /// package-lock preflight /// ([`crate::commands::bun_preflight::BunVendorRefusal`], /// [`crate::commands::vlt_preflight`], [`npm_lock_refusal`]) would refuse -/// before any download. +/// before any download, or whose hosted pnpm pin the takeover would fail +/// to replace ([`hosted_pnpm_refusals`]: the pnpm backend's lock-text +/// refusal, #853). `origins` are the run's `--patch-server-url` origins. /// The preview stays a ledger classification otherwise (engine refusals /// outside the preflights are not predicted), and `would_refuse` never /// flips the run's status or exit code. The preflights (the only disk @@ -88,6 +90,7 @@ type VendorStepResult = Result<(bool, Envelope), VendorStepError>; pub(crate) async fn preview_vendor_json( cwd: &Path, selected: &[PatchSearchResult], + origins: &[String], takeover_refusals: &HashMap, ) -> serde_json::Value { // The ledger load outcome reaches the preflight AS a result, so an @@ -100,6 +103,7 @@ pub(crate) async fn preview_vendor_json( vlt_vendor_preflight_selected(cwd, selected, state.as_ref().map(|s| &s.entries)).await; let npm_lock_refusal = npm_lock_refusal(cwd, selected).await; let state = state.unwrap_or_default(); + let pnpm_refusals = hosted_pnpm_refusals(cwd, selected, origins, &state).await; let mut patches: Vec = selected .iter() .map(|p| match lookup_entry(&state.entries, &p.purl) { @@ -119,8 +123,8 @@ pub(crate) async fn preview_vendor_json( "errorCode": r.code, "error": r.detail, }) } - _ if takeover_refusals.contains_key(&p.purl) => { - let (code, detail) = &takeover_refusals[&p.purl]; + _ if pnpm_refusals.contains_key(&p.purl) => { + let (code, detail) = &pnpm_refusals[&p.purl]; serde_json::json!({ "purl": p.purl, "uuid": p.uuid, "action": "would_refuse", "errorCode": code, "error": detail, @@ -133,6 +137,13 @@ pub(crate) async fn preview_vendor_json( "errorCode": code, "error": detail, }) } + _ if takeover_refusals.contains_key(&p.purl) => { + let (code, detail) = &takeover_refusals[&p.purl]; + serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, "action": "would_refuse", + "errorCode": code, "error": detail, + }) + } Some(e) if e.uuid == p.uuid => serde_json::json!({ "purl": p.purl, "uuid": p.uuid, "action": "already_vendored", }), @@ -172,9 +183,10 @@ fn with_symlink_warnings(cwd: &Path, purl: &str, mut row: serde_json::Value) -> } /// The purls of `selected` the wet run's Bun, vlt or npm package-lock -/// preflight, or the gem hosted→vendored takeover gate, would refuse before -/// any download (the `would_refuse` rows of [`preview_vendor_json`]): the -/// vendored planning pass, so a refused NEW patch holds no rollout slot. +/// preflight, the hosted pnpm takeover's lock-text gates, or the gem +/// hosted→vendored takeover gate, would refuse before any download (the +/// `would_refuse` rows of [`preview_vendor_json`]): the vendored planning +/// pass, so a refused NEW patch holds no rollout slot. pub(super) async fn preflight_refused_purls( common: &GlobalArgs, selected: &[PatchSearchResult], @@ -186,6 +198,9 @@ pub(super) async fn preflight_refused_purls( let vlt_refusals = vlt_vendor_preflight_selected(cwd, selected, state.as_ref().map(|s| &s.entries)).await; let npm_lock_refusal = npm_lock_refusal(cwd, selected).await; + let origins = crate::commands::hosted_unwind::patch_server_origins(common); + let pnpm_refusals = + hosted_pnpm_refusals(cwd, selected, &origins, &state.unwrap_or_default()).await; let gem_refusals = crate::commands::vendor::gem_takeover_preview_refusals( common, selected.iter().map(|p| p.purl.as_str()), @@ -197,6 +212,7 @@ pub(super) async fn preflight_refused_purls( refusal.as_ref().is_some_and(|r| r.applies_to(&p.purl)) || vlt_refusal_for(&vlt_refusals, &p.purl).is_some() || (p.purl.starts_with("pkg:npm/") && npm_lock_refusal.is_some()) + || pnpm_refusals.contains_key(&p.purl) || gem_refusals.contains_key(&p.purl) }) .map(|p| p.purl.clone()) @@ -217,6 +233,44 @@ async fn npm_lock_refusal( socket_patch_core::vendor::npm_lock_vendor_preflight(cwd).await } +/// The lock-text refusals a hosted → vendored takeover of `selected` meets +/// in a pnpm project (#853), keyed by the selected purl: each npm purl the +/// lockfiles pin hosted (see [`crate::commands::agent_download::hosted_claimed_purls`]) +/// and the ledger does not already hold at this uuid, refused by the pnpm +/// backend on the project's lock and manifest text +/// ([`socket_patch_core::vendor::pnpm_takeover_lock_text_refusals`]). The +/// wet takeover reaches the same refusal after its restore and rolls the +/// restore back, so the hosted pin stays. Discovery runs only when the +/// selection holds an npm purl and the project has a pnpm lock. +async fn hosted_pnpm_refusals( + cwd: &Path, + selected: &[PatchSearchResult], + origins: &[String], + state: &VendorState, +) -> HashMap { + let npm = |p: &&PatchSearchResult| p.purl.starts_with("pkg:npm/"); + if !selected.iter().any(|p| npm(&p)) + || tokio::fs::metadata(cwd.join("pnpm-lock.yaml")) + .await + .is_err() + { + return HashMap::new(); + } + let claimed = + crate::commands::agent_download::hosted_claimed_purls(cwd, origins.to_vec()).await; + let candidates: Vec<(&str, &str)> = selected + .iter() + .filter(npm) + .filter(|p| claimed.contains(&PurlKey::new(&p.purl))) + .filter(|p| lookup_entry(&state.entries, &p.purl).is_none_or(|e| e.uuid != p.uuid)) + .map(|p| (p.purl.as_str(), p.uuid.as_str())) + .collect(); + if candidates.is_empty() { + return HashMap::new(); + } + socket_patch_core::vendor::pnpm_takeover_lock_text_refusals(cwd, &candidates).await +} + /// Human rendering of the vendored dry-run preview's `would_refuse` records /// (see [`preview_vendor_json`]): the count line above it still says /// "would download and vendor", so name what the wet run would refuse and @@ -589,6 +643,7 @@ async fn run_vendor_json_path( // The planning pass: a patch the preflight refuses holds no slot (it // still reaches the engine, which reports the refusal). let writers = writers_of(&rows); + let origins = crate::commands::hosted_unwind::patch_server_origins(&args.common); let refused = preflight_refused_purls(&args.common, &writers).await; stage.plan(&rows, |r| !refused.contains(&r.writer.purl)); let deferred = stage.deferred_keys(); @@ -606,7 +661,8 @@ async fn run_vendor_json_path( selected.iter().map(|p| p.purl.as_str()), ) .await; - result["vendor"] = preview_vendor_json(&args.common.cwd, &selected, &takeover).await; + result["vendor"] = + preview_vendor_json(&args.common.cwd, &selected, &origins, &takeover).await; if prune { result["gc"] = gc_json( &args.common, @@ -1331,7 +1387,8 @@ mod preview_tests { #[tokio::test] async fn preview_without_bun_lock_is_plain_would_vendor() { let tmp = tempfile::tempdir().unwrap(); - let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)], &HashMap::new()).await; + let preview = + preview_vendor_json(tmp.path(), &[sel(UUID, NPM)], &[], &HashMap::new()).await; assert_eq!( preview, serde_json::json!({ @@ -1359,6 +1416,7 @@ mod preview_tests { let preview = preview_vendor_json( tmp.path(), &[sel(UUID, NPM), sel(UUID, PYPI)], + &[], &HashMap::new(), ) .await; @@ -1389,6 +1447,7 @@ mod preview_tests { let preview = preview_vendor_json( tmp.path(), &[sel(UUID, NPM), sel(UUID, PYPI)], + &[], &HashMap::new(), ) .await; @@ -1423,7 +1482,8 @@ mod preview_tests { std::fs::write(tmp.path().join("bun.lock"), V1_WORKSPACE_LOCK).unwrap(); seed_entry(tmp.path(), NPM, UUID); - let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)], &HashMap::new()).await; + let preview = + preview_vendor_json(tmp.path(), &[sel(UUID, NPM)], &[], &HashMap::new()).await; assert_eq!( action_of(&preview, NPM)["action"], "would_refuse", @@ -1431,7 +1491,8 @@ mod preview_tests { ); seed_entry(tmp.path(), NPM, OLD_UUID); - let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)], &HashMap::new()).await; + let preview = + preview_vendor_json(tmp.path(), &[sel(UUID, NPM)], &[], &HashMap::new()).await; let rec = action_of(&preview, NPM); assert_eq!(rec["action"], "would_refuse", "{preview}"); assert!( @@ -1445,7 +1506,8 @@ mod preview_tests { ); std::fs::write(tmp.path().join("bun.lock"), wired).unwrap(); seed_entry(tmp.path(), NPM, UUID); - let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)], &HashMap::new()).await; + let preview = + preview_vendor_json(tmp.path(), &[sel(UUID, NPM)], &[], &HashMap::new()).await; assert_eq!( action_of(&preview, NPM)["action"], "already_vendored", @@ -1462,8 +1524,13 @@ mod preview_tests { GEM.to_string(), ("gemfile_declaration_not_editable", "indented".to_string()), )]); - let preview = - preview_vendor_json(tmp.path(), &[sel(UUID, GEM), sel(UUID, NPM)], &refusals).await; + let preview = preview_vendor_json( + tmp.path(), + &[sel(UUID, GEM), sel(UUID, NPM)], + &[], + &refusals, + ) + .await; let gem = action_of(&preview, GEM); assert_eq!(gem["action"], "would_refuse", "{preview}"); assert_eq!(gem["errorCode"], "gemfile_declaration_not_editable"); @@ -1475,13 +1542,108 @@ mod preview_tests { async fn preview_marks_malformed_lockb_would_refuse() { let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); - let preview = preview_vendor_json(tmp.path(), &[sel(UUID, NPM)], &HashMap::new()).await; + let preview = + preview_vendor_json(tmp.path(), &[sel(UUID, NPM)], &[], &HashMap::new()).await; assert_eq!( action_of(&preview, NPM)["errorCode"], "vendor_bun_lockb_invalid", "{preview}" ); } + + const PNPM: &str = "pkg:npm/left-pad@1.3.0"; + + /// A pnpm v9 project with `left-pad@1.3.0` resolved from `tarball`, + /// declared through the default catalog when `catalog` is set. + fn pnpm_project(root: &Path, tarball: &str, catalog: bool) { + let spec = if catalog { "catalog:" } else { "1.3.0" }; + std::fs::write( + root.join("package.json"), + format!(r#"{{"name":"c","version":"1.0.0","dependencies":{{"left-pad":"{spec}"}}}}"#), + ) + .unwrap(); + let mut lock = String::from("lockfileVersion: '9.0'\n\n"); + if catalog { + std::fs::write( + root.join("pnpm-workspace.yaml"), + "packages:\n - .\ncatalog:\n left-pad: 1.3.0\n", + ) + .unwrap(); + lock.push_str( + "catalogs:\n default:\n left-pad:\n specifier: 1.3.0\n version: 1.3.0\n\n", + ); + } + let specifier = if catalog { "'catalog:'" } else { "1.3.0" }; + lock.push_str(&format!( + "importers:\n\n .:\n dependencies:\n left-pad:\n specifier: {specifier}\n version: 1.3.0\n\n\ + packages:\n\n left-pad@1.3.0:\n resolution: {{integrity: sha512-x==, tarball: {tarball}}}\n\n\ + snapshots:\n\n left-pad@1.3.0: {{}}\n" + )); + std::fs::write(root.join("pnpm-lock.yaml"), lock).unwrap(); + } + + fn hosted_tarball(origin: &str) -> String { + format!("{origin}/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/{UUID}/left-pad-1.3.0.tgz") + } + + /// #853: a hosted pnpm pin the vendored backend refuses on lock text + /// (here a `catalog:` dependency) previews `would_refuse` with the wet + /// takeover's code, not `would_vendor`. + #[tokio::test] + async fn preview_refuses_hosted_pnpm_catalog_pin() { + let tmp = tempfile::tempdir().unwrap(); + pnpm_project( + tmp.path(), + &hosted_tarball("https://patch.socket.dev"), + true, + ); + let preview = + preview_vendor_json(tmp.path(), &[sel(UUID, PNPM)], &[], &HashMap::new()).await; + let row = action_of(&preview, PNPM); + assert_eq!(row["action"], "would_refuse", "{preview}"); + assert_eq!( + row["errorCode"], "vendor_lock_entry_unsupported", + "{preview}" + ); + } + + /// A pin served from the operator's `--patch-server-url` is hosted too. + #[tokio::test] + async fn preview_refuses_hosted_pnpm_pin_from_custom_patch_server() { + let tmp = tempfile::tempdir().unwrap(); + let origin = "https://patches.example.com"; + pnpm_project(tmp.path(), &hosted_tarball(origin), true); + let preview = preview_vendor_json( + tmp.path(), + &[sel(UUID, PNPM)], + &[origin.to_string()], + &HashMap::new(), + ) + .await; + assert_eq!( + action_of(&preview, PNPM)["action"], + "would_refuse", + "{preview}" + ); + } + + /// A plain hosted pnpm pin the takeover replaces is not over-refused. + #[tokio::test] + async fn preview_plain_hosted_pnpm_pin_is_would_vendor() { + let tmp = tempfile::tempdir().unwrap(); + pnpm_project( + tmp.path(), + &hosted_tarball("https://patch.socket.dev"), + false, + ); + let preview = + preview_vendor_json(tmp.path(), &[sel(UUID, PNPM)], &[], &HashMap::new()).await; + assert_eq!( + action_of(&preview, PNPM)["action"], + "would_vendor", + "{preview}" + ); + } } #[cfg(test)] diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 072f4c4f0..c95fd440f 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -3627,7 +3627,12 @@ pub(crate) async fn vendor_records_reusing( // package.json `pnpm.overrides` mirror is ignored), so pnpm-wired // runs must name that file among the committables: a checkout // that loses it silently unvendors on the next install. - let commit = commit_hint(&wired_flavors); + // A project pinned to pnpm 9.0–10.4 gets no pnpm-workspace.yaml + // (#734), so the file is named only when it is there. + let commit = commit_hint( + &wired_flavors, + common.cwd.join("pnpm-workspace.yaml").exists(), + ); let mut installs: Vec<&str> = wired_flavors .iter() .filter_map(|f| flavor_install_command(f)) @@ -3703,13 +3708,20 @@ fn jvm_wiring_tool(wiring: &[vendor::state::WiringRecord]) -> Option<&'static st /// The "Commit …" next step for the flavors a run wired. sbt and scala-cli /// wire through a generated root file, never a lockfile: committing only /// `.socket/` would leave CI resolving the unpatched upstream silently. -fn commit_hint(wired: &HashSet) -> String { - if wired.contains("pnpm") { +fn commit_hint(wired: &HashSet, pnpm_workspace: bool) -> String { + if wired.contains("pnpm") && pnpm_workspace { return ".socket/vendor/, package.json, pnpm-lock.yaml, and pnpm-workspace.yaml to make \ the patches portable (pnpm >=11 reads the vendored override only from \ pnpm-workspace.yaml)" .to_string(); } + if wired.contains("pnpm") { + return ".socket/vendor/, package.json, and pnpm-lock.yaml to make the patches \ + portable (the project's pnpm 9.0–10.4 reads the override from package.json; \ + after upgrading to pnpm >=11, re-run vendor so it also writes \ + pnpm-workspace.yaml)" + .to_string(); + } if wired.contains("vlt") { return VLT_COMMIT_HINT.to_string(); } @@ -6206,26 +6218,35 @@ mod scope_and_hint_tests { #[test] fn jvm_commit_hint_names_the_generated_root_file() { let set = |fs: &[&str]| fs.iter().map(|f| f.to_string()).collect::>(); - let sbt = commit_hint(&set(&["sbt"])); + let sbt = commit_hint(&set(&["sbt"]), false); assert!( sbt.starts_with("socket-patch-vendor.sbt and .socket/vendor/"), "{sbt}" ); assert!(!sbt.contains("lockfiles"), "{sbt}"); - let cli = commit_hint(&set(&["scala-cli"])); + let cli = commit_hint(&set(&["scala-cli"]), false); assert!( cli.starts_with("socket-patch.scala and .socket/vendor/"), "{cli}" ); - let both = commit_hint(&set(&["sbt", "package-lock"])); + let both = commit_hint(&set(&["sbt", "package-lock"]), false); assert!( both.contains("socket-patch-vendor.sbt") && both.contains("lockfiles"), "{both}" ); assert_eq!( - commit_hint(&set(&[])), + commit_hint(&set(&[]), false), ".socket/vendor/ and the updated lockfiles to make the patches portable" ); + // pnpm names pnpm-workspace.yaml only when the run left one (#734). + let pnpm = commit_hint(&set(&["pnpm"]), true); + assert!(pnpm.contains("and pnpm-workspace.yaml"), "{pnpm}"); + let pnpm = commit_hint(&set(&["pnpm"]), false); + assert!( + pnpm.starts_with(".socket/vendor/, package.json, and pnpm-lock.yaml") + && pnpm.contains("re-run vendor"), + "{pnpm}" + ); let wiring = |kind: &str| { vec![vendor::state::WiringRecord { file: "x".into(), diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index c0fa50176..c2402e781 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -199,6 +199,45 @@ fn merge_npm_copies( } } +/// `paths` in order with every path that resolves to an already-listed +/// directory dropped: two discovered site-packages paths can name ONE +/// directory (a `lib64 -> lib` symlink, a symlinked venv), as can two npm +/// copies (see [`distinct_npm_copies`]), and patching it twice would report +/// the second pass `already_patched`. A path that can't be canonicalized is +/// kept as-is. +pub(crate) async fn distinct_install_dirs(paths: &[PathBuf]) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut out = Vec::with_capacity(paths.len()); + for path in paths { + let key = tokio::fs::canonicalize(path) + .await + .unwrap_or_else(|_| path.clone()); + if seen.insert(key) { + out.push(path.clone()); + } + } + out +} + +/// Collapse each npm PURL's copies to distinct physical directories, in +/// discovery order (the first spelling of each directory wins). The +/// resolver walks every `node_modules` root, so a pnpm / Bun isolated +/// workspace yields one copy twice: as the root's `.pnpm` store entry and +/// as a member's `packages/a/node_modules/` link to it. Visiting both +/// patched (or restored) it once and reported the second visit as a +/// phantom `already_patched` / already-original event (#633). The map +/// itself keeps every spelling, because path targets (`scan` / `rollback +/// packages/a`) select a copy by the member's link; only the commands that +/// act per copy collapse it. Genuinely distinct copies (nested duplicates, +/// store peer variants) have distinct real paths and are all kept. +pub(crate) async fn distinct_npm_copies(map: &mut HashMap>) { + for (purl, paths) in map.iter_mut() { + if paths.len() > 1 && Ecosystem::from_purl(purl) == Some(Ecosystem::Npm) { + *paths = distinct_install_dirs(paths).await; + } + } +} + /// Release-variant merge: the crawler is queried with base PURLs (no /// `?qualifiers`); fan the resulting paths back out to every qualified /// caller-supplied PURL that strips to the same base. Used for the @@ -439,7 +478,23 @@ pub async fn find_all_packages_for_rollback( options: &CrawlerOptions, silent: bool, ) -> HashMap> { - dispatch_find(partitioned, options, silent, merge_qualified, None).await + find_all_packages_for_rollback_reusing(partitioned, options, silent, None).await +} + +/// [`find_all_packages_for_rollback`], taking the npm `node_modules` roots +/// from `prior` as [`find_packages_for_rollback_reusing`] does. Used by +/// `scan`'s path scoping, which must see the same copies `rollback`'s path +/// targets do. +pub async fn find_all_packages_for_rollback_reusing( + partitioned: &HashMap>, + options: &CrawlerOptions, + silent: bool, + prior: Option<&NpmCrawlSnapshot>, +) -> HashMap> { + let npm_roots = prior + .filter(|p| p.taken_with(options)) + .map(|p| p.roots.as_slice()); + dispatch_find(partitioned, options, silent, merge_qualified, npm_roots).await } /// Qualified-aware PURL resolution for rollback, vendor, repair and @@ -1060,6 +1115,34 @@ mod tests { // ---- merge_npm_copies ------------------------------------------------- + /// #633: a pnpm workspace member's link into the root store is the + /// copy the root walk already found, so it collapses to the first + /// spelling; a genuine second copy, a missing path and a non-npm PURL + /// are kept as they are. + #[cfg(unix)] + #[tokio::test] + async fn distinct_npm_copies_drops_a_second_spelling_of_one_dir() { + let tmp = tempfile::tempdir().unwrap(); + let store = tmp.path().join("node_modules/.pnpm/a@1.0.0/node_modules/a"); + let nested = tmp.path().join("node_modules/b/node_modules/a"); + std::fs::create_dir_all(&store).unwrap(); + std::fs::create_dir_all(&nested).unwrap(); + let member_nm = tmp.path().join("packages/m/node_modules"); + std::fs::create_dir_all(&member_nm).unwrap(); + let link = member_nm.join("a"); + std::os::unix::fs::symlink(&store, &link).unwrap(); + let missing = tmp.path().join("gone"); + let mut map: HashMap> = HashMap::new(); + map.insert( + "pkg:npm/a@1.0.0".into(), + vec![store.clone(), link.clone(), nested.clone(), missing.clone()], + ); + map.insert("pkg:gem/a@1.0.0".into(), vec![store.clone(), link.clone()]); + distinct_npm_copies(&mut map).await; + assert_eq!(map["pkg:npm/a@1.0.0"], vec![store.clone(), nested, missing]); + assert_eq!(map["pkg:gem/a@1.0.0"], vec![store, link]); + } + #[test] fn merge_npm_copies_carries_every_copy_per_purl() { // The npm crawler returns EVERY physical copy of a PURL; the merge @@ -1837,6 +1920,18 @@ mod tests { find_packages_for_rollback_reusing(&partitioned, &options, true, Some(&snapshot)).await; assert_eq!(reused, crawled); assert!(crawled.contains_key("pkg:npm/baz@3.0.0"), "{crawled:?}"); + // The multi-copy twin keeps every copy, the member's nested one too. + let all_crawled = find_all_packages_for_rollback(&partitioned, &options, true).await; + let all_reused = + find_all_packages_for_rollback_reusing(&partitioned, &options, true, Some(&snapshot)) + .await; + assert_eq!(all_reused, all_crawled); + assert!( + all_crawled + .get("pkg:npm/foo@0.9.0") + .is_some_and(|paths| paths.len() == 2), + "{all_crawled:?}" + ); // The resolver finds the alias install itself (#356). let left_pad = "pkg:npm/left-pad@1.3.0".to_string(); assert_eq!(crawled.get(&left_pad), Some(&root.join("node_modules/lp"))); @@ -1863,6 +1958,17 @@ mod tests { find_packages_for_rollback(&app_partitioned, &elsewhere, true).await, "a snapshot of another root must not answer for this one" ); + assert_eq!( + find_all_packages_for_rollback_reusing( + &app_partitioned, + &elsewhere, + true, + Some(&snapshot) + ) + .await, + find_all_packages_for_rollback(&app_partitioned, &elsewhere, true).await, + "a snapshot of another root must not answer for this one" + ); } /// Stage one installed package for EVERY ecosystem under `root`, the diff --git a/crates/socket-patch-cli/tests/apply/in_process_npm_multicopy.rs b/crates/socket-patch-cli/tests/apply/in_process_npm_multicopy.rs index 724b6e55f..289287c6f 100644 --- a/crates/socket-patch-cli/tests/apply/in_process_npm_multicopy.rs +++ b/crates/socket-patch-cli/tests/apply/in_process_npm_multicopy.rs @@ -12,6 +12,7 @@ //! assert EVERY physical copy is patched (and later restored) AND that the //! JSON summary counts every copy. +use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::process::Command; @@ -130,6 +131,11 @@ fn build_two_copy_tree(tmp: &Path) -> (PathBuf, PathBuf, PathBuf, String, String } fn run_apply(root: &Path) -> (i32, serde_json::Value) { + run_apply_with(root, &[]) +} + +/// `run_apply` plus `extra` arguments (flags or path targets). +fn run_apply_with(root: &Path, extra: &[&OsStr]) -> (i32, serde_json::Value) { let out = Command::new(binary()) .args([ "apply", @@ -138,8 +144,9 @@ fn run_apply(root: &Path) -> (i32, serde_json::Value) { "--ecosystems", "npm", "--cwd", - root.to_str().unwrap(), ]) + .arg(root) + .args(extra) .output() .expect("run apply"); let code = out.status.code().unwrap_or(-1); @@ -324,6 +331,11 @@ fn build_vlt_peer_variant_tree(tmp: &Path, link_importer: bool) -> (PathBuf, Pat } fn run_rollback(root: &Path) -> (i32, serde_json::Value) { + run_rollback_with(root, &[]) +} + +/// `run_rollback` plus `extra` arguments (flags or path targets). +fn run_rollback_with(root: &Path, extra: &[&OsStr]) -> (i32, serde_json::Value) { let out = Command::new(binary()) .args([ "rollback", @@ -333,8 +345,9 @@ fn run_rollback(root: &Path) -> (i32, serde_json::Value) { "--ecosystems", "npm", "--cwd", - root.to_str().unwrap(), ]) + .arg(root) + .args(extra) .output() .expect("run rollback"); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); @@ -685,3 +698,220 @@ fn apply_and_rollback_refuse_a_node_modules_link_to_first_party_source() { "rollback wrote through the link" ); } + +/// #633: in a pnpm workspace on the isolated linker, a member's +/// `packages/a/node_modules/dupvuln` is a link to the root store entry +/// `node_modules/.pnpm/dupvuln@1.0.0/node_modules/dupvuln`. The root and +/// the member are separate `node_modules` roots, so the resolver sees the +/// one physical copy under two spellings; apply and rollback must still +/// visit it once (they used to report a phantom `already_patched` / +/// already-original second event on every run). Path targets keep seeing +/// the member's spelling, so `rollback packages/a` still selects it. +#[cfg(unix)] +#[test] +fn apply_and_rollback_visit_a_pnpm_workspace_member_link_once() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let name = "dupvuln"; + let purl = "pkg:npm/dupvuln@1.0.0"; + let original = b"module.exports = function(){ return 'VULNERABLE'; };\n"; + let mut patched = original.to_vec(); + patched.extend_from_slice(b"// SOCKET-PATCHED-MULTICOPY\n"); + std::fs::write( + root.join("package.json"), + r#"{ "name": "root", "version": "1.0.0", "private": true }"#, + ) + .unwrap(); + std::fs::write( + root.join("pnpm-workspace.yaml"), + "packages:\n - 'packages/*'\n", + ) + .unwrap(); + let store_copy = write_copy( + &root + .join("node_modules/.pnpm/dupvuln@1.0.0/node_modules") + .join(name), + name, + "1.0.0", + original, + ); + let member = root.join("packages").join("a"); + std::fs::create_dir_all(member.join("node_modules")).unwrap(); + std::fs::write( + member.join("package.json"), + r#"{ "name": "a", "version": "1.0.0", "dependencies": { "dupvuln": "1.0.0" } }"#, + ) + .unwrap(); + std::os::unix::fs::symlink( + "../../../node_modules/.pnpm/dupvuln@1.0.0/node_modules/dupvuln", + member.join("node_modules").join(name), + ) + .unwrap(); + stage_manifest_and_blob( + root, + purl, + &git_sha256(original), + &git_sha256(&patched), + &patched, + ); + std::fs::write( + root.join(".socket") + .join("blobs") + .join(git_sha256(original)), + original, + ) + .unwrap(); + + let (code, v) = run_apply(root); + assert_eq!(code, 0, "first apply; envelope={v}"); + assert_eq!(std::fs::read(&store_copy).unwrap(), patched); + assert_eq!(v["summary"]["applied"], 1, "first apply; envelope={v}"); + assert_eq!(v["summary"]["skipped"], 0, "first apply; envelope={v}"); + assert_eq!( + dupvuln_events(&v), + vec![("applied".to_string(), String::new())], + "first apply; envelope={v}" + ); + + let (code, v) = run_apply(root); + assert_eq!(code, 0, "second apply; envelope={v}"); + assert_eq!(v["summary"]["applied"], 0, "second apply; envelope={v}"); + assert_eq!(v["summary"]["skipped"], 1, "second apply; envelope={v}"); + assert_eq!( + dupvuln_events(&v), + vec![("skipped".to_string(), "already_patched".to_string())], + "second apply; envelope={v}" + ); + + // `--preserve-state` keeps the manifest entry for the scoped run below. + let (code, v) = run_rollback_with(root, &["--preserve-state".as_ref()]); + assert_eq!(code, 0, "rollback; envelope={v}"); + assert_eq!(std::fs::read(&store_copy).unwrap(), original); + assert_eq!(v["rolledBack"], 1, "rollback; envelope={v}"); + assert_eq!(v["alreadyOriginal"], 0, "rollback; envelope={v}"); + + // A path target still selects the copy through the member's link. + let (code, v) = run_apply(root); + assert_eq!(code, 0, "re-apply; envelope={v}"); + assert_eq!(std::fs::read(&store_copy).unwrap(), patched); + let (code, v) = run_rollback_with(root, &["packages/a".as_ref()]); + assert_eq!(code, 0, "scoped rollback; envelope={v}"); + assert_eq!(std::fs::read(&store_copy).unwrap(), original); + assert_eq!(v["rolledBack"], 1, "scoped rollback; envelope={v}"); + assert_eq!(v["alreadyOriginal"], 0, "scoped rollback; envelope={v}"); +} + +/// One pnpm 11+ isolated global install, `//node_modules`, with +/// its own `.pnpm` holding a real `dupvuln@1.0.0`, linked at the top level +/// (`direct`) or reached only through `wrapper` (transitive-only). +/// Returns the store copy's `index.js`. +#[cfg(unix)] +fn write_pnpm_global_install(v11: &Path, hash: &str, direct: bool, original: &[u8]) -> PathBuf { + let nm = v11.join(hash).join("node_modules"); + std::fs::create_dir_all(&nm).unwrap(); + std::fs::write(v11.join(hash).join("package.json"), "{}").unwrap(); + std::fs::write(nm.join(".modules.yaml"), "layoutVersion: 5\n").unwrap(); + let store = nm.join(".pnpm"); + let pkg = store.join("dupvuln@1.0.0/node_modules/dupvuln"); + let index = write_copy(&pkg, "dupvuln", "1.0.0", original); + if direct { + std::os::unix::fs::symlink(&pkg, nm.join("dupvuln")).unwrap(); + } else { + let wrapper = store.join("wrapper@1.0.0/node_modules/wrapper"); + write_copy(&wrapper, "wrapper", "1.0.0", b"require('dupvuln');\n"); + std::os::unix::fs::symlink(&pkg, store.join("wrapper@1.0.0/node_modules/dupvuln")).unwrap(); + std::os::unix::fs::symlink(&wrapper, nm.join("wrapper")).unwrap(); + } + index +} + +/// #435: pnpm 11+ gives every `pnpm add -g` its own install dir +/// (`$PNPM_HOME/global/v11//node_modules`, each with its own +/// `.pnpm`), and `pnpm root -g` prints their parent. With one install +/// linking the package directly and another holding it only +/// transitively, apply patched one copy and reported success while the +/// other stayed vulnerable. Both orientations are built so the guard +/// fails whatever the directory listing order. +#[cfg(unix)] +#[test] +fn apply_global_prefix_patches_every_pnpm_isolated_global_install() { + let original = b"module.exports = function(){ return 'VULNERABLE'; };\n"; + let mut patched = original.to_vec(); + patched.extend_from_slice(b"// SOCKET-PATCHED-MULTICOPY\n"); + for direct_first in [true, false] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("proj"); + std::fs::create_dir_all(&root).unwrap(); + stage_manifest_and_blob( + &root, + "pkg:npm/dupvuln@1.0.0", + &git_sha256(original), + &git_sha256(&patched), + &patched, + ); + let v11 = tmp.path().join("pnpm-home/global/v11"); + let a = write_pnpm_global_install(&v11, "aaa", direct_first, original); + let b = write_pnpm_global_install(&v11, "bbb", !direct_first, original); + + let (code, v) = run_apply_with(&root, &["--global-prefix".as_ref(), v11.as_os_str()]); + assert_eq!(code, 0, "direct_first={direct_first}; envelope={v}"); + for index in [&a, &b] { + assert_eq!( + std::fs::read(index).unwrap(), + patched, + "{index:?} left unpatched; direct_first={direct_first}; envelope={v}" + ); + } + assert_eq!( + dupvuln_events(&v), + vec![ + ("applied".to_string(), String::new()), + ("applied".to_string(), String::new()) + ], + "direct_first={direct_first}; envelope={v}" + ); + } +} + +/// Splitting `global/v11` into one root per install must not turn one +/// physical copy that two installs both link (pnpm 11's global virtual +/// store is the real-world case, refused there as a shared store) into +/// two events. +#[cfg(unix)] +#[test] +fn apply_global_prefix_visits_a_copy_shared_by_two_pnpm_global_installs_once() { + let original = b"module.exports = function(){ return 'VULNERABLE'; };\n"; + let mut patched = original.to_vec(); + patched.extend_from_slice(b"// SOCKET-PATCHED-MULTICOPY\n"); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("proj"); + std::fs::create_dir_all(&root).unwrap(); + stage_manifest_and_blob( + &root, + "pkg:npm/dupvuln@1.0.0", + &git_sha256(original), + &git_sha256(&patched), + &patched, + ); + // An installed copy (under a `node_modules`), not linked first-party + // source, which apply refuses. + let shared = tmp.path().join("shared/node_modules/dupvuln"); + let index = write_copy(&shared, "dupvuln", "1.0.0", original); + let v11 = tmp.path().join("pnpm-home/global/v11"); + for hash in ["aaa", "bbb"] { + let nm = v11.join(hash).join("node_modules"); + std::fs::create_dir_all(nm.join(".pnpm")).unwrap(); + std::fs::write(v11.join(hash).join("package.json"), "{}").unwrap(); + std::fs::write(nm.join(".modules.yaml"), "layoutVersion: 5\n").unwrap(); + std::os::unix::fs::symlink(&shared, nm.join("dupvuln")).unwrap(); + } + + let (code, v) = run_apply_with(&root, &["--global-prefix".as_ref(), v11.as_os_str()]); + assert_eq!(code, 0, "envelope={v}"); + assert_eq!(std::fs::read(&index).unwrap(), patched, "envelope={v}"); + assert_eq!( + dupvuln_events(&v), + vec![("applied".to_string(), String::new())], + "envelope={v}" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs index 393ef32b7..14d960245 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs @@ -77,8 +77,8 @@ mod hermetic; #[path = "vex_e2e_common/mod.rs"] mod vex_e2e_common; use vex_e2e_common::{ - assert_absent, assert_attested, assert_not_attested, patch_view, run_vex, strip_ledgers, - strip_manifest, Marker, PatchApi, VexRun, VexVia, + assert_absent, assert_attested, assert_not_attested, installed_pnpm_pre_10_5, patch_view, + run_vex, strip_ledgers, strip_manifest, Marker, PatchApi, VexRun, VexVia, }; const ORG: &str = "test-org"; @@ -642,7 +642,11 @@ async fn redirect_scanned_pnpm_project( // neither the lockfile policy nor the setting, so legacy runs rewrite // ONLY the lock and keep the manual flag guidance. let v9_lock = lock_before.starts_with("lockfileVersion: '9.0'"); - let auto_trust = v9_lock && !no_trust_config; + // pnpm 9.0–10.4 (as the install recorded it) get no pnpm-workspace.yaml: + // a root-only workspace breaks `pnpm add` there and they never read + // `trustLockfile` (#734). + let pre_10_5 = installed_pnpm_pre_10_5(&proj); + let auto_trust = v9_lock && !no_trust_config && !pre_10_5; let expected_rewrites = if auto_trust { serde_json::json!(["pnpm-lock.yaml", "pnpm-workspace.yaml"]) } else { @@ -671,6 +675,12 @@ async fn redirect_scanned_pnpm_project( trust_detail.contains("trustLockfile: true"), "the v9 warning must name the auto-configured trustLockfile key; got: {trust_detail}" ); + } else if v9_lock && !no_trust_config { + assert!( + trust_detail.contains("ERR_PNPM_ADDING_TO_ROOT"), + "the pnpm 9.0–10.4 warning must say why no workspace file was created; \ + got: {trust_detail}" + ); } else if v9_lock { // Opted out on a v9 lock: the manual two-recovery guidance stands. assert!( @@ -709,8 +719,8 @@ async fn redirect_scanned_pnpm_project( assert_eq!( ws_path.exists(), ws_existed_before, - "a legacy-lock or --no-trust-lockfile-config run must not create \ - pnpm-workspace.yaml ({tag})" + "a legacy-lock, pnpm 9.0–10.4 or --no-trust-lockfile-config run must not \ + create pnpm-workspace.yaml ({tag})" ); None }; @@ -1141,6 +1151,26 @@ async fn pnpm9_redirect_fresh_checkout_frozen_install_lands_patched_bytes() { let (fresh, ci) = fresh_checkout_install(&fx, PNPM_SECONDARY, "pnpm9", &[], false); assert_marker_landed(&fresh, &fx.patched, &ci, "pnpm9"); assert_fixture_manifestless_vex(&fx, &fresh, "pnpm9"); + // #734: the scan left the project a plain single package, so pnpm 9's + // everyday `pnpm add` still works (a root-only workspace fails it with + // ERR_PNPM_ADDING_TO_ROOT). + assert!(!fx.proj.join("pnpm-workspace.yaml").exists()); + let store = fx.tmp.path().join("pnpm-store"); + let add = corepack( + &fx.proj, + PNPM_SECONDARY, + &[ + "add", + "is-number@7.0.0", + &format!("--store-dir={}", store.display()), + ], + ); + let stderr = String::from_utf8_lossy(&add.stderr); + let stdout = String::from_utf8_lossy(&add.stdout); + assert!( + !stdout.contains("ADDING_TO_ROOT") && !stderr.contains("ADDING_TO_ROOT"), + "pnpm 9 `pnpm add` must not hit ERR_PNPM_ADDING_TO_ROOT:\n{stdout}\n{stderr}" + ); } /// pnpm@11 ZERO-TOUCH leg: pnpm 11's lockfile supply-chain policy verifies diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs index c1ddf64a5..a0c2de7c1 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -79,8 +79,8 @@ mod hermetic; #[path = "vex_e2e_common/mod.rs"] mod vex_e2e_common; use vex_e2e_common::{ - assert_absent, assert_attested, assert_not_attested, patch_view, run_vex, strip_ledgers, - strip_manifest, Marker, PatchApi, VexRun, VexVia, + assert_absent, assert_attested, assert_not_attested, installed_pnpm_pre_10_5, patch_view, + run_vex, strip_ledgers, strip_manifest, Marker, PatchApi, VexRun, VexVia, }; const ORG: &str = "test-org"; @@ -660,17 +660,28 @@ async fn run_pnpm_capstone(pm: &str, driver: VendorDriver) { // refuses a workspace file whose `packages` field is missing/empty, and // `.` cannot glob a stray subtree into the workspace the way `packages/*` // could. That makes the committable set install on pnpm 9/10/11 alike. + // pnpm 9.0–10.4 (as the install recorded it) are the exception: they + // read package.json, and a root-only workspace breaks their `pnpm add`, + // so no file is created (#734). let ws_path = proj.join("pnpm-workspace.yaml"); - let ws_after = - std::fs::read_to_string(&ws_path).expect("vendoring must create pnpm-workspace.yaml"); - assert!( - ws_after.contains(&format!("{DEP}@{DEP_VERSION}: file:{tgz_rel}")), - "pnpm-workspace.yaml `overrides:` must point at the vendored tarball; got:\n{ws_after}" - ); - assert!( - ws_after.contains("packages:") && ws_after.contains("- '.'"), - "created pnpm-workspace.yaml must carry a root-only packages list; got:\n{ws_after}" - ); + let ws_created = !installed_pnpm_pre_10_5(&proj); + if ws_created { + let ws_after = + std::fs::read_to_string(&ws_path).expect("vendoring must create pnpm-workspace.yaml"); + assert!( + ws_after.contains(&format!("{DEP}@{DEP_VERSION}: file:{tgz_rel}")), + "pnpm-workspace.yaml `overrides:` must point at the vendored tarball; got:\n{ws_after}" + ); + assert!( + ws_after.contains("packages:") && ws_after.contains("- '.'"), + "created pnpm-workspace.yaml must carry a root-only packages list; got:\n{ws_after}" + ); + } else { + assert!( + !ws_path.exists(), + "{pm}: no root-only pnpm-workspace.yaml on pnpm 9.0–10.4" + ); + } eprintln!("VENDOR OK ({pm}, {driver:?})"); // 4. FRESH-CHECKOUT PROOF: committable files only, EMPTY store, @@ -679,7 +690,9 @@ async fn run_pnpm_capstone(pm: &str, driver: VendorDriver) { std::fs::create_dir_all(&fresh).unwrap(); std::fs::copy(&pkg_path, fresh.join("package.json")).unwrap(); std::fs::copy(&lock_path, fresh.join("pnpm-lock.yaml")).unwrap(); - std::fs::copy(&ws_path, fresh.join("pnpm-workspace.yaml")).unwrap(); + if ws_created { + std::fs::copy(&ws_path, fresh.join("pnpm-workspace.yaml")).unwrap(); + } copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); let fresh_store = tmp.path().join("fresh-pnpm-store"); @@ -738,7 +751,8 @@ async fn run_pnpm_capstone(pm: &str, driver: VendorDriver) { // Manifest-less VEX over the real fresh install (kept ledger, deleted // ledgers, offline, embedded vendor/apply --vex, reverted lock). - // The fixture had no pnpm-workspace.yaml: vendoring created it. + // The fixture had no pnpm-workspace.yaml: vendoring created it (or, on + // pnpm 9.0–10.4, did not). assert_manifestless_vendored_vex( &fresh, pm, @@ -762,7 +776,7 @@ async fn run_pnpm_capstone(pm: &str, driver: VendorDriver) { // 5. Idempotency: a re-run exits 0 and leaves ALL THREE files byte-stable. let lock_wired = std::fs::read(&lock_path).unwrap(); let pkg_wired = std::fs::read(&pkg_path).unwrap(); - let ws_wired = std::fs::read(&ws_path).unwrap(); + let ws_wired = std::fs::read(&ws_path).ok(); let (code, stdout, stderr) = run_socket( &proj, &[ @@ -790,7 +804,7 @@ async fn run_pnpm_capstone(pm: &str, driver: VendorDriver) { "re-vendor must leave package.json byte-identical" ); assert_eq!( - std::fs::read(&ws_path).unwrap(), + std::fs::read(&ws_path).ok(), ws_wired, "re-vendor must leave pnpm-workspace.yaml byte-identical" ); @@ -1622,6 +1636,183 @@ fn run_legacy_hermetic(lock_text: &str, after_template: &str, version: &str) { ); } +// ── gated real-pnpm two-document lock legs (#466) ───────────────────── + +/// pnpm 12 with `packageManager` set: `pnpm-lock.yaml` is two documents +/// (pnpm's `packageManagerDependencies` env document, then the project +/// lock). Vendoring used to refuse `vendor_lock_entry_not_found` here. +#[test] +fn pnpm12_package_manager_two_document_lock_vendors_and_reverts() { + run_two_document_capstone("pnpm@12.8.1", DEP, DEP_VERSION, true, false); +} + +/// pnpm 11 config dependency sharing the vendored package's key: the env +/// document carries its own `is-number@7.0.0`. Vendoring used to report +/// success after rewriting the env document, and a fresh frozen install +/// then failed. +#[test] +fn pnpm11_config_dependency_two_document_lock_vendors_the_project_copy() { + run_two_document_capstone("pnpm@11.27.0", "is-number", "7.0.0", false, true); +} + +/// pnpm 12 with both: `packageManager` set and a config dependency sharing +/// the vendored package's key. +#[test] +fn pnpm12_config_dependency_and_package_manager_two_document_lock() { + run_two_document_capstone("pnpm@12.8.1", "is-number", "7.0.0", true, true); +} + +/// Vendor `dep` into a real two-document lock, prove a fresh checkout's +/// frozen install lands the patched bytes in the project copy (the env +/// document byte-untouched; a config dependency's own copy stays the +/// registry's, with a `vendor_config_dependency_unpatched` warning), and +/// revert byte-for-byte. +fn run_two_document_capstone( + pm: &str, + dep: &str, + version: &str, + package_manager: bool, + config_dep: bool, +) { + if !has_corepack_pm(pm) { + println!("SKIP: `corepack {pm}` unavailable"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + let mut pkg_doc = serde_json::json!({ + "name": "pnpm-two-doc", + "version": "0.0.0", + "private": true, + "dependencies": { dep: version }, + }); + if package_manager { + pkg_doc["packageManager"] = serde_json::json!(pm); + } + std::fs::write( + proj.join("package.json"), + format!("{}\n", serde_json::to_string_pretty(&pkg_doc).unwrap()), + ) + .unwrap(); + let store = tmp.path().join("pnpm-store"); + let store = store.to_str().unwrap(); + let config = format!("{dep}@{version}"); + let mut steps: Vec> = Vec::new(); + if config_dep { + steps.push(vec!["add", "--config", &config, "--store-dir", store]); + } + steps.push(vec!["install", "--store-dir", store]); + for args in steps { + let out = corepack(&proj, pm, &args); + if !out.status.success() { + assert!(!pnpm_required(), "required {pm} fixture failed: {out:?}"); + println!("SKIP ({pm}): fixture `pnpm {args:?}` failed: {out:?}"); + return; + } + } + + let lock_path = proj.join("pnpm-lock.yaml"); + let ws_path = proj.join("pnpm-workspace.yaml"); + let pkg_path = proj.join("package.json"); + let lock_before = std::fs::read_to_string(&lock_path).unwrap(); + let ws_before = std::fs::read(&ws_path).ok(); + let pkg_before = std::fs::read(&pkg_path).unwrap(); + let env_doc = |lock: &str| { + let docs: Vec<&str> = lock.split("\n---\n").collect(); + assert!( + lock.starts_with("---\n") && docs.len() == 2, + "{pm} must write a two-document lock:\n{lock}" + ); + (docs[0].to_string(), docs[1].to_string()) + }; + let (env_before, _) = env_doc(&lock_before); + + let installed_index = proj.join("node_modules").join(dep).join("index.js"); + let orig = std::fs::read(&installed_index).expect("installed index.js"); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let purl = format!("pkg:npm/{dep}@{version}"); + stage_patch(&proj, &purl, "package/index.js", &orig, &patched); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "vendor failed ({pm}):\n{stdout}\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "{env}"); + assert_eq!(env["summary"]["applied"], 1, "{env}"); + assert_eq!( + stdout.contains("vendor_config_dependency_unpatched"), + config_dep, + "{env}" + ); + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + let (env_after, project_after) = env_doc(&lock_after); + assert_eq!(env_after, env_before, "the env document is never edited"); + assert!( + project_after.contains(&format!("{dep}@file:.socket/vendor/npm/{UUID}/")), + "the project document is wired:\n{lock_after}" + ); + + // Fresh checkout: committable files only, empty store, frozen. + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + for file in ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"] { + std::fs::copy(proj.join(file), fresh.join(file)).unwrap(); + } + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + let fresh_store = tmp.path().join("fresh-store"); + let ci = corepack( + &fresh, + pm, + &[ + "install", + "--frozen-lockfile", + "--store-dir", + fresh_store.to_str().unwrap(), + ], + ); + assert!(ci.status.success(), "fresh frozen install ({pm}): {ci:?}"); + let landed = std::fs::read(fresh.join("node_modules").join(dep).join("index.js")).unwrap(); + assert!( + landed.starts_with(MARKER.as_bytes()), + "the project copy installs the vendored bytes ({pm})" + ); + if config_dep { + let config_copy = fresh + .join("node_modules/.pnpm-config") + .join(dep) + .join("index.js"); + let config_bytes = std::fs::read(&config_copy).expect("config dependency installed"); + assert!( + !config_bytes.starts_with(MARKER.as_bytes()), + "the config dependency's copy is the registry's, as the warning says" + ); + } + + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--revert", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "revert failed ({pm}):\n{stdout}\n{stderr}"); + assert_eq!(std::fs::read_to_string(&lock_path).unwrap(), lock_before); + assert_eq!(std::fs::read(&pkg_path).unwrap(), pkg_before); + assert_eq!(std::fs::read(&ws_path).ok(), ws_before); + assert!(!proj.join(".socket/vendor").exists()); +} + // ── gated real-pnpm legacy lifecycle legs ───────────────────────────── #[test] diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index b9246fcb3..ee3687b1c 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -1335,3 +1335,199 @@ async fn yarn_berry_pin_takes_bin_from_the_served_tarball() { } } } + +/// #734 in memory: with no node_modules in the view, package.json's +/// `packageManager` is the only pin. pnpm 9.15.9 gets its lock pinned and +/// no root-only pnpm-workspace.yaml; pnpm 11 still gets the trust scaffold. +#[tokio::test] +async fn pnpm_9_pin_gets_no_root_only_workspace_in_memory() { + let dir = fixtures_root().join("redirect/npm/pnpm/basic"); + let server = MockServer::start().await; + mount_api( + &server, + &patches_from_overrides(&dir.join("overrides.json"), None), + ) + .await; + for (pin, scaffold) in [("9.15.9", false), ("11.0.0", true)] { + let mut files = fixture_files(&dir.join("input")); + files.insert( + "package.json".into(), + format!(r#"{{"name":"app","packageManager":"pnpm@{pin}"}}"#).into_bytes(), + ); + let output = run_engine(&server, build_input(&files, &[], options(false))).await; + let project = &output.projects[0]; + assert!(project.error.is_none(), "{:?}", project.error); + let paths: Vec<&str> = output + .changed_files + .iter() + .map(|f| f.path.as_str()) + .collect(); + assert!(paths.contains(&"pnpm-lock.yaml"), "{paths:?}"); + assert_eq!( + paths.contains(&"pnpm-workspace.yaml"), + scaffold, + "{pin}: {paths:?}" + ); + let warnings = project.redirect["warnings"].to_string(); + assert_eq!( + warnings.contains("ERR_PNPM_ADDING_TO_ROOT"), + !scaffold, + "{warnings}" + ); + } +} + +/// #492 in memory: the lock of a `sharedWorkspaceLockfile: false` pnpm +/// workspace member is demoted into the workspace root, which pins it and +/// trusts it in its own pnpm-workspace.yaml (the only one pnpm reads), +/// never in a nested scaffold. A project the root file does not list +/// stays a standalone root. A member named alone as a project root is not +/// pinned on its own; a warning names the workspace root to scan. +#[tokio::test] +async fn a_pnpm_workspace_member_lock_is_demoted_into_the_workspace_root_in_memory() { + let dir = fixtures_root().join("redirect/npm/pnpm/basic"); + let server = MockServer::start().await; + mount_api( + &server, + &patches_from_overrides(&dir.join("overrides.json"), None), + ) + .await; + let mut member = prefixed("packages/a", &fixture_files(&dir.join("input"))); + member.insert("packages/a/package.json".into(), b"{}".to_vec()); + for (globs, listed) in [("packages/*", true), ("tools/*", false)] { + let mut files = member.clone(); + files.insert( + "pnpm-workspace.yaml".into(), + format!("packages:\n - {globs}\nsharedWorkspaceLockfile: false\n").into_bytes(), + ); + files.insert( + "pnpm-lock.yaml".into(), + b"lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n".to_vec(), + ); + let output = run_engine(&server, build_input(&files, &[], options(false))).await; + let roots: Vec<&str> = output.projects.iter().map(|p| p.root.as_str()).collect(); + let paths: Vec<&str> = output + .changed_files + .iter() + .map(|f| f.path.as_str()) + .collect(); + assert!( + output.projects.iter().all(|p| p.error.is_none()), + "{:?}", + output.projects + ); + assert!(paths.contains(&"packages/a/pnpm-lock.yaml"), "{paths:?}"); + if listed { + assert_eq!(roots, [""]); + assert!(paths.contains(&"pnpm-workspace.yaml"), "{paths:?}"); + assert!( + !paths.contains(&"packages/a/pnpm-workspace.yaml"), + "{paths:?}" + ); + } else { + assert_eq!(roots, ["", "packages/a"]); + assert!( + paths.contains(&"packages/a/pnpm-workspace.yaml"), + "a standalone project gets its own file: {paths:?}" + ); + } + // The trust auto-config off pins the lock without any scaffold. + let mut opts = options(false); + opts.trust_lockfile_config = Some(false); + let output = run_engine(&server, build_input(&files, &[], opts)).await; + let paths: Vec<&str> = output + .changed_files + .iter() + .map(|f| f.path.as_str()) + .collect(); + assert_eq!(paths, ["packages/a/pnpm-lock.yaml"]); + + // The member named alone: its lock is the workspace root's. + let mut opts = options(false); + opts.project_roots = Some(vec!["packages/a".into()]); + let output = run_engine(&server, build_input(&files, &[], opts)).await; + let ignored = output + .warnings + .iter() + .any(|w| w.code == "pnpm_member_lock_ignored"); + assert_eq!(ignored, listed, "{:?}", output.warnings); + assert_eq!(output.changed_files.is_empty(), listed); + } +} + +/// Path selection fetches the nearest ancestor pnpm-workspace.yaml of a +/// pnpm root that has none of its own, so the session can tell a member +/// from a standalone project. +#[test] +fn selection_fetches_the_governing_pnpm_workspace_file() { + let blob = |path: &str| TreeEntryInput { + path: path.into(), + mode: "100644".into(), + kind: "blob".into(), + size: Some(10), + }; + let entries = vec![ + blob("pnpm-workspace.yaml"), + blob("packages/pnpm-workspace.yaml"), + blob("packages/a/pnpm-lock.yaml"), + blob("packages/a/package.json"), + blob("tools/b/pnpm-lock.yaml"), + ]; + let s = select_paths(&entries, &SelectOptions::default()); + assert!( + s.fetch_text + .iter() + .any(|p| p == "packages/pnpm-workspace.yaml"), + "{:?}", + s.fetch_text + ); + assert!( + s.fetch_text.iter().any(|p| p == "pnpm-workspace.yaml"), + "{:?}", + s.fetch_text + ); +} + +/// #734 re-scan: a project pinned to pnpm 9.0–10.4 that still carries the +/// root-only scaffold an earlier run created is told to delete it; a +/// workspace file the user wrote (or one in a pnpm 11 project) is not. +#[tokio::test] +async fn a_leftover_root_only_scaffold_on_pnpm_9_is_flagged() { + let dir = fixtures_root().join("redirect/npm/pnpm/basic"); + let server = MockServer::start().await; + mount_api( + &server, + &patches_from_overrides(&dir.join("overrides.json"), None), + ) + .await; + let scaffold = "packages:\n - '.'\ntrustLockfile: true\n"; + let user = "packages:\n - '.'\ncatalog: {}\ntrustLockfile: true\n"; + for (pin, workspace, flagged) in [ + ("9.15.9", scaffold, true), + ("9.15.9", user, false), + ("11.0.0", scaffold, false), + ] { + let mut files = fixture_files(&dir.join("input")); + files.insert( + "package.json".into(), + format!(r#"{{"name":"app","packageManager":"pnpm@{pin}"}}"#).into_bytes(), + ); + files.insert("pnpm-workspace.yaml".into(), workspace.as_bytes().to_vec()); + let output = run_engine(&server, build_input(&files, &[], options(false))).await; + let project = &output.projects[0]; + assert!(project.error.is_none(), "{:?}", project.error); + let warnings = project.redirect["warnings"].to_string(); + assert_eq!( + warnings.contains("ERR_PNPM_ADDING_TO_ROOT"), + flagged, + "{pin} {workspace:?}: {warnings}" + ); + assert!( + !output + .changed_files + .iter() + .any(|f| f.path == "pnpm-workspace.yaml"), + "the file is never rewritten or removed" + ); + } +} diff --git a/crates/socket-patch-cli/tests/hosted_memory_parity.rs b/crates/socket-patch-cli/tests/hosted_memory_parity.rs index 0af392eb4..e7f80a637 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -35,7 +35,8 @@ fn case(fixture: &'static str) -> Case { } } -async fn assert_parity(case: Case) { +/// Returns the (shared) `redirect` block so a case can assert on it too. +async fn assert_parity(case: Case) -> Value { let dir = fixtures_root().join("redirect").join(case.fixture); let server = MockServer::start().await; let patches = patches_from_overrides(&dir.join("overrides.json"), Some(&server.uri())); @@ -112,6 +113,7 @@ async fn assert_parity(case: Case) { "{}", case.fixture ); + disk_redirect } #[tokio::test] @@ -169,6 +171,39 @@ async fn parity_rush() { .await; } +/// Rush subspaces keep a repo-state.json (the pnpmShrinkwrapHash carrier) +/// next to each subspace lock, and there is no common one (#714). Both the +/// disk run and the in-memory selector must see it, so both emit the +/// stale-hash warning; parity alone would pass with neither emitting it. +#[tokio::test] +async fn parity_rush_subspace() { + let lock = std::fs::read( + fixtures_root() + .join("redirect/npm/pnpm/nested-rush-lock/input/common/config/rush/pnpm-lock.yaml"), + ) + .unwrap(); + let redirect = assert_parity(Case { + extra: vec![ + ("rush.json", b"{}\n".to_vec()), + ("common/config/subspaces/x/pnpm-lock.yaml", lock), + ( + "common/config/subspaces/x/repo-state.json", + b"{}\n".to_vec(), + ), + ], + ..case("npm/pnpm/nested-rush-lock") + }) + .await; + let codes: Vec<&str> = redirect["warnings"] + .as_array() + .map(|arr| arr.iter().filter_map(|w| w["code"].as_str()).collect()) + .unwrap_or_default(); + assert!( + codes.contains(&"redirect_rush_repo_state_stale"), + "a subspace repo-state.json must trigger the stale-hash warning; got {codes:?}" + ); +} + #[tokio::test] async fn parity_uv() { assert_parity(Case { @@ -1226,3 +1261,466 @@ async fn memory_negation_reincludes_a_default_ignored_root() { describe(&memory_changed) ); } + +/// A pnpm workspace (#492): the root `pnpm-workspace.yaml` (`ws`), a root +/// `package.json`, the root lock when given, `extra` files, and each +/// `members` directory with a manifest and the golden pnpm fixture's lock +/// (left-pad as a direct dependency). +fn pnpm_workspace( + ws: &str, + root_lock: Option<&str>, + members: &[&str], + extra: &[(&str, &str)], +) -> BTreeMap> { + let lock = read_fixture("redirect/npm/pnpm/basic/input/pnpm-lock.yaml"); + let mut files = BTreeMap::from([ + ("pnpm-workspace.yaml".to_string(), ws.as_bytes().to_vec()), + ( + "package.json".to_string(), + br#"{ "name": "root", "private": true }"#.to_vec(), + ), + ]); + if let Some(text) = root_lock { + files.insert("pnpm-lock.yaml".into(), text.as_bytes().to_vec()); + } + for member in members { + files.insert( + format!("{member}/package.json"), + format!(r#"{{ "name": "{member}", "dependencies": {{ "left-pad": "1.3.0" }} }}"#) + .into_bytes(), + ); + files.insert(format!("{member}/pnpm-lock.yaml"), lock.clone()); + } + for (rel, text) in extra { + files.insert(rel.to_string(), text.as_bytes().to_vec()); + } + files +} + +/// The root lock of a workspace whose members have no lock of their own. +const PNPM_ROOT_ONLY_LOCK: &str = "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n"; + +/// The in-memory run over `files` (straight, and through path selection) +/// against the disk run from the repo root: both memory runs have the +/// project `roots` (a member keeps a root of its own only with another +/// lock), none fails, the root project's `redirect` block and every changed +/// byte match the disk run, and no other project redirects anything. +/// Returns the disk run's `redirect` block and changed files. +async fn assert_pnpm_workspace_parity( + files: &BTreeMap>, + trust_lockfile_config: bool, +) -> (Value, BTreeMap>) { + assert_pnpm_workspace_parity_with_roots(files, trust_lockfile_config, &[""]).await +} + +async fn assert_pnpm_workspace_parity_with_roots( + files: &BTreeMap>, + trust_lockfile_config: bool, + roots: &[&str], +) -> (Value, BTreeMap>) { + let dir = fixtures_root().join("redirect/npm/pnpm/basic"); + let server = MockServer::start().await; + let patches = patches_from_overrides(&dir.join("overrides.json"), Some(&server.uri())); + mount_api(&server, &patches).await; + let extra: &[&str] = if trust_lockfile_config { + &[] + } else { + &["--no-trust-lockfile-config"] + }; + let disk = run_disk_with(&server, files, false, extra); + let mut opts = options(false); + opts.trust_lockfile_config = Some(trust_lockfile_config); + let straight = run_engine(&server, build_input(files, &[], opts)).await; + let mut selected = selected_input(files); + selected.options.trust_lockfile_config = Some(trust_lockfile_config); + let through_selection = run_engine(&server, selected).await; + for (how, memory) in [("straight", &straight), ("selection", &through_selection)] { + let found: Vec<&str> = memory.projects.iter().map(|p| p.root.as_str()).collect(); + assert_eq!(found, roots, "{how}: member locks belong to the workspace root"); + for project in &memory.projects { + assert!(project.error.is_none(), "{how}: {}: {:?}", project.root, project.error); + } + let project = memory + .projects + .iter() + .find(|p| p.root.is_empty()) + .unwrap_or_else(|| panic!("{how}: no workspace root project")); + assert!(project.error.is_none(), "{how}: {:?}", project.error); + assert_eq!( + project.redirect, disk.envelope["redirect"], + "{how}: redirect block differs\nstderr: {}", + disk.stderr + ); + for other in memory.projects.iter().filter(|p| !p.root.is_empty()) { + assert!(other.redirected.is_empty(), "{how}: {}", other.root); + } + let changed = engine_changed(memory); + assert_eq!( + changed, + disk.changed, + "{how}\nmemory:\n{}\ndisk:\n{}", + describe(&changed), + describe(&disk.changed) + ); + } + (disk.envelope["redirect"].clone(), disk.changed) +} + +/// #492: under `sharedWorkspaceLockfile: false` (or pnpm 7's `.npmrc` +/// spelling with no root lock at all) both engines pin every member's own +/// lock from the workspace root and trust them in the root file. +#[tokio::test] +async fn parity_pnpm_member_locks_are_pinned_from_the_workspace_root() { + let members = ["packages/a", "packages/b"]; + for files in [ + pnpm_workspace( + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n", + Some(PNPM_ROOT_ONLY_LOCK), + &members, + &[], + ), + pnpm_workspace( + "packages:\n - 'packages/*'\n", + None, + &members, + &[(".npmrc", "shared-workspace-lockfile=false\n")], + ), + ] { + let (redirect, changed) = assert_pnpm_workspace_parity(&files, true).await; + assert_eq!(redirect["redirected"], 1, "{redirect:#}"); + let paths: Vec<&str> = changed.keys().map(String::as_str).collect(); + assert_eq!( + paths, + [ + "packages/a/pnpm-lock.yaml", + "packages/b/pnpm-lock.yaml", + "pnpm-workspace.yaml" + ] + ); + assert!(String::from_utf8_lossy(&changed["pnpm-workspace.yaml"]) + .ends_with("trustLockfile: true\n")); + } +} + +/// #492: the trust auto-config over member locks is the same in both +/// engines: an explicit root-file value is respected, an existing `true` +/// is kept, and with the auto-config off nothing but the locks is written. +#[tokio::test] +async fn parity_pnpm_member_lock_trust_config() { + let ws = "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n"; + for (trust_line, auto_config) in [ + ("trustLockfile: false\n", true), + ("trustLockfile: true\n", true), + ("", false), + ] { + let files = pnpm_workspace( + &format!("{ws}{trust_line}"), + Some(PNPM_ROOT_ONLY_LOCK), + &["packages/a"], + &[], + ); + let (redirect, changed) = assert_pnpm_workspace_parity(&files, auto_config).await; + let paths: Vec<&str> = changed.keys().map(String::as_str).collect(); + assert_eq!(paths, ["packages/a/pnpm-lock.yaml"], "{trust_line:?}"); + let codes = redirect["warnings"].to_string(); + assert!(codes.contains("redirect_pnpm_trust_lockfile"), "{codes}"); + } +} + +/// #492 + #556: a member's branch lock under `gitBranchLockfile` refuses +/// every pnpm pin in both engines, nothing written. +#[tokio::test] +async fn parity_pnpm_member_branch_lock_refusal() { + let files = pnpm_workspace( + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\ngitBranchLockfile: true\n", + Some(PNPM_ROOT_ONLY_LOCK), + &["packages/a"], + &[( + "packages/a/pnpm-lock.feature.yaml", + "lockfileVersion: '9.0'\n", + )], + ); + let (redirect, changed) = assert_pnpm_workspace_parity(&files, true).await; + assert!(changed.is_empty(), "{}", describe(&changed)); + assert!( + redirect["warnings"] + .to_string() + .contains("redirect_pnpm_git_branch_lockfile"), + "{redirect:#}" + ); +} + +/// #492: a member lock beside a shared root lock (one listing the member's +/// importer) is a stale leftover pnpm never reads: both engines pin the +/// root lock alone. +#[tokio::test] +async fn parity_pnpm_stale_member_lock_under_a_shared_lock() { + let lock = String::from_utf8(read_fixture("redirect/npm/pnpm/basic/input/pnpm-lock.yaml")) + .unwrap(); + let shared = lock.replacen(" .:\n", " .: {}\n packages/a:\n", 1); + assert_ne!(shared, lock); + let files = pnpm_workspace( + "packages:\n - 'packages/*'\n", + Some(&shared), + &["packages/a"], + &[], + ); + let (redirect, changed) = assert_pnpm_workspace_parity(&files, true).await; + assert_eq!(redirect["redirected"], 1, "{redirect:#}"); + let paths: Vec<&str> = changed.keys().map(String::as_str).collect(); + assert_eq!(paths, ["pnpm-lock.yaml", "pnpm-workspace.yaml"]); +} + +/// The two in-memory runs of `files`: straight, and through path selection +/// (its `roots` passed back as `projectRoots`), both with `opts`. +async fn pnpm_memory_runs( + server: &MockServer, + files: &BTreeMap>, + opts: &socket_patch_cli::hosted_memory::HostedScanOptions, +) -> [(&'static str, socket_patch_cli::hosted_memory::HostedScanOutput); 2] { + let straight = run_engine(server, build_input(files, &[], opts.clone())).await; + let mut selected = selected_input(files); + let roots = selected.options.project_roots.take(); + selected.options = socket_patch_cli::hosted_memory::HostedScanOptions { + project_roots: roots, + ..opts.clone() + }; + let through_selection = run_engine(server, selected).await; + [("straight", straight), ("selection", through_selection)] +} + +fn project_roots(output: &socket_patch_cli::hosted_memory::HostedScanOutput) -> Vec<&str> { + output.projects.iter().map(|p| p.root.as_str()).collect() +} + +fn changed_paths(output: &socket_patch_cli::hosted_memory::HostedScanOutput) -> Vec<&str> { + output.changed_files.iter().map(|f| f.path.as_str()).collect() +} + +async fn pnpm_server() -> MockServer { + let dir = fixtures_root().join("redirect/npm/pnpm/basic"); + let server = MockServer::start().await; + mount_api(&server, &patches_from_overrides(&dir.join("overrides.json"), Some(&server.uri()))).await; + server +} + +/// #492: a member holding another lock (npm, yarn, Cargo) stays a root of +/// its own for that lock, while its pnpm lock is pinned from the workspace +/// root, as on disk; only the workspace root's project redirects. +#[tokio::test] +async fn parity_pnpm_member_with_another_lock_keeps_its_root() { + let files = pnpm_workspace( + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n", + Some(PNPM_ROOT_ONLY_LOCK), + &["packages/a", "packages/b"], + &[( + "packages/a/package-lock.json", + r#"{ "name": "a", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "a" } } }"#, + )], + ); + let (_, changed) = assert_pnpm_workspace_parity_with_roots(&files, true, &["", "packages/a"]).await; + let paths: Vec<&str> = changed.keys().map(String::as_str).collect(); + assert_eq!( + paths, + ["packages/a/pnpm-lock.yaml", "packages/b/pnpm-lock.yaml", "pnpm-workspace.yaml"] + ); +} + +/// #492: a member lock whose workspace root cannot be confirmed to read it +/// (an unreadable root file, a glob the matcher does not model, no package +/// manifest beside the lock, or a shared workspace with no root lock to +/// install from) is never silently dropped: it stays a root of its own, +/// pinned with the trust auto-config off and refused with it on, as before. +#[tokio::test] +async fn an_unconfirmed_pnpm_member_keeps_its_lock() { + let server = pnpm_server().await; + let per_member = "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n"; + let unmodeled = pnpm_workspace( + "packages:\n - 'packages/{a,b}'\nsharedWorkspaceLockfile: false\n", + Some(PNPM_ROOT_ONLY_LOCK), + &["packages/a"], + &[], + ); + let mut no_manifest = pnpm_workspace(per_member, Some(PNPM_ROOT_ONLY_LOCK), &["packages/a"], &[]); + no_manifest.remove("packages/a/package.json"); + let shared_no_root_lock = pnpm_workspace("packages:\n - 'packages/*'\n", None, &["packages/a"], &[]); + let readable = pnpm_workspace(per_member, Some(PNPM_ROOT_ONLY_LOCK), &["packages/a"], &[]); + // (name, files, paths passed presence-only, expected roots) + type Case<'a> = (&'a str, &'a BTreeMap>, &'a [&'a str], &'a [&'a str]); + let cases: [Case; 4] = [ + ("unmodeled glob", &unmodeled, &[], &["", "packages/a"]), + ("no manifest", &no_manifest, &[], &["", "packages/a"]), + ("shared, no root lock", &shared_no_root_lock, &[], &["packages/a"]), + ("unreadable root file", &readable, &["pnpm-workspace.yaml"], &["", "packages/a"]), + ]; + for (name, files, present, roots) in cases { + let mut files = files.clone(); + for path in present { + files.remove(*path); + } + for trust in [true, false] { + let mut opts = options(false); + opts.trust_lockfile_config = Some(trust); + let output = run_engine(&server, build_input(&files, present, opts)).await; + assert_eq!(project_roots(&output), roots, "{name}"); + let member = output + .projects + .iter() + .find(|p| p.root == "packages/a") + .unwrap(); + let paths = changed_paths(&output); + if trust { + let error = member.error.as_ref().unwrap_or_else(|| panic!("{name}: not refused")); + assert_eq!(error.code, "redirect_pnpm_settings_elsewhere", "{name}: {error:?}"); + assert!(!paths.iter().any(|p| p.starts_with("packages/a/")), "{name}: {paths:?}"); + } else { + assert!(member.error.is_none(), "{name}: {:?}", member.error); + assert!(paths.contains(&"packages/a/pnpm-lock.yaml"), "{name}: {paths:?}"); + } + assert!( + !output.warnings.iter().any(|w| w.code == "pnpm_member_lock_ignored"), + "{name}: {:?}", + output.warnings + ); + } + } +} + +/// #492 with `--ecosystems`: a workspace root is added only for npm. A +/// member detected for its Cargo.lock under a cargo-only filter keeps its +/// root and no lockless workspace root joins it, straight or through +/// selection. +#[tokio::test] +async fn pnpm_workspace_roots_follow_the_ecosystem_filter() { + use socket_patch_cli::hosted_memory::{select_paths, SelectOptions, TreeEntryInput}; + let server = pnpm_server().await; + let files = pnpm_workspace( + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n", + None, + &["packages/a"], + &[("packages/a/Cargo.lock", "version = 3\n")], + ); + let entries: Vec = files + .keys() + .map(|p| TreeEntryInput { + path: p.clone(), + mode: "100644".into(), + kind: "blob".into(), + size: None, + }) + .collect(); + let cargo = Some(vec!["cargo".to_string()]); + let selection = select_paths( + &entries, + &SelectOptions { + ecosystems: cargo.clone(), + ..SelectOptions::default() + }, + ); + assert_eq!(selection.roots, ["packages/a"]); + let mut opts = options(false); + opts.ecosystems = cargo; + let output = run_engine(&server, build_input(&files, &[], opts)).await; + assert_eq!(project_roots(&output), ["packages/a"]); +} + +/// #492 with socket.yml: a member lock is demoted only into a workspace +/// root the path policy admits. With `includePaths` leaving the root out, +/// each member stays a root of its own and is pinned, through the host's +/// two-phase flow and straight alike. +#[tokio::test] +async fn a_pnpm_member_stays_a_root_when_socket_yml_leaves_the_workspace_root_out() { + let server = pnpm_server().await; + let mut files = pnpm_workspace( + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n", + Some(PNPM_ROOT_ONLY_LOCK), + &["packages/a", "packages/b"], + &[], + ); + files.insert( + "socket.yml".into(), + b"version: 2\npatches:\n includePaths: [\"packages/**\"]\n".to_vec(), + ); + let mut opts = options(false); + opts.trust_lockfile_config = Some(false); + let (_, two_phase_input) = two_phase(&files, opts.clone()); + let mut direct_opts = policy_options(&files); + direct_opts.trust_lockfile_config = Some(false); + for (how, input) in [ + ("two-phase", two_phase_input), + ("direct", build_input(&files, &[], direct_opts)), + ] { + let output = run_engine(&server, input).await; + assert_eq!(project_roots(&output), ["packages/a", "packages/b"], "{how}"); + assert_eq!( + changed_paths(&output), + ["packages/a/pnpm-lock.yaml", "packages/b/pnpm-lock.yaml"], + "{how}" + ); + } +} + +/// #492 with nested workspaces: a member belongs to the nearest +/// `pnpm-workspace.yaml` (pnpm looks no further), so its lock is demoted +/// into the inner workspace root, which the outer one lists as a member +/// with its own file and so stays a root. Straight and through selection +/// give the same roots and writes, with no conflicting write. +#[tokio::test] +async fn nested_pnpm_workspaces_demote_into_the_nearest_root() { + let server = pnpm_server().await; + let per_member = |globs: &str| format!("packages:\n - '{globs}'\nsharedWorkspaceLockfile: false\n"); + let lock = String::from_utf8(read_fixture("redirect/npm/pnpm/basic/input/pnpm-lock.yaml")).unwrap(); + let inner_ws = per_member("m/*"); + let files = pnpm_workspace( + &per_member("packages/*"), + Some(PNPM_ROOT_ONLY_LOCK), + &["packages/x/m/a"], + &[ + ("packages/x/pnpm-workspace.yaml", inner_ws.as_str()), + ("packages/x/package.json", r#"{ "name": "x" }"#), + ("packages/x/pnpm-lock.yaml", lock.as_str()), + ], + ); + let runs = pnpm_memory_runs(&server, &files, &options(false)).await; + for (how, output) in &runs { + assert_eq!(project_roots(output), ["", "packages/x"], "{how}"); + for project in &output.projects { + assert!(project.error.is_none(), "{how}: {}: {:?}", project.root, project.error); + } + let paths = changed_paths(output); + for path in ["packages/x/m/a/pnpm-lock.yaml", "packages/x/pnpm-lock.yaml"] { + assert!(paths.contains(&path), "{how}: {paths:?}"); + } + assert!( + !output.warnings.iter().any(|w| w.code == "conflicting_write"), + "{how}: {:?}", + output.warnings + ); + } + assert_eq!(engine_changed(&runs[0].1), engine_changed(&runs[1].1)); +} + +/// #492 through selection: a lockless directory selection adds only +/// because a `pnpm-workspace.yaml` sits above a pnpm root, which that file +/// does not list, is no project; the root keeps its lock. +#[tokio::test] +async fn a_pnpm_workspace_file_listing_nothing_adds_no_root() { + let server = pnpm_server().await; + let lock = String::from_utf8(read_fixture("redirect/npm/pnpm/basic/input/pnpm-lock.yaml")).unwrap(); + let files: BTreeMap> = [ + ("examples/pnpm-workspace.yaml", "packages: []\n"), + ("examples/demo/package.json", r#"{ "name": "demo" }"#), + ("examples/demo/pnpm-lock.yaml", lock.as_str()), + ] + .into_iter() + .map(|(p, t)| (p.to_string(), t.as_bytes().to_vec())) + .collect(); + for (how, output) in pnpm_memory_runs(&server, &files, &options(false)).await { + assert_eq!(project_roots(&output), ["examples/demo"], "{how}"); + assert!( + changed_paths(&output).contains(&"examples/demo/pnpm-lock.yaml"), + "{how}: {:?}", + output.changed_files + ); + } +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 431e1cf8a..d1f3a9ed4 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1684,10 +1684,22 @@ fn rollback_json_with_origin( cwd: &Path, registry: &MockServer, origin: &str, +) -> (Option, serde_json::Value) { + hosted_unwind_json(cwd, registry, origin, &["rollback"]) +} + +/// ` --json --yes` as a subprocess against the hosted pins of +/// `origin`, the upstream restore reading `registry` — the shared runner +/// behind [`rollback_json`] and [`remove_json`]. +fn hosted_unwind_json( + cwd: &Path, + registry: &MockServer, + origin: &str, + command: &[&str], ) -> (Option, serde_json::Value) { let out = scrubbed_cli() + .args(command) .args([ - "rollback", "--json", "--yes", "--patch-server-url", @@ -1700,10 +1712,11 @@ fn rollback_json_with_origin( format!("{}/npm-registry", registry.uri()), ) .output() - .expect("run socket-patch rollback"); + .unwrap_or_else(|e| panic!("run socket-patch {}: {e}", command[0])); let env_json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { panic!( - "rollback --json stdout must be JSON: {e}\nstdout:\n{}\nstderr:\n{}", + "{} --json stdout must be JSON: {e}\nstdout:\n{}\nstderr:\n{}", + command[0], String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ) @@ -2770,6 +2783,211 @@ async fn rush_repo_state_stale_warning_is_gated_on_repo_state_presence() { ); } +/// With Rush subspaces enabled, the pnpmShrinkwrapHash carrier lives next to +/// each subspace lock, at common/config/subspaces//repo-state.json, and +/// there is no common/config/rush/repo-state.json (#714). A rewrite that +/// landed in a subspace lock must still warn, keyed on that sibling file, and +/// must leave it byte-identical. +#[tokio::test] +#[serial] +async fn rush_subspace_repo_state_stale_warning_fires_for_subspace_repo_state() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("rush.json"), + r#"{ "rushVersion": "5.100.0" }"#, + ) + .unwrap(); + for name in ["default", "tools"] { + let dir = tmp.path().join("common/config/subspaces").join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("pnpm-lock.yaml"), rush_pnpm_lock(NAME)).unwrap(); + } + let state_rel = "common/config/subspaces/default/repo-state.json"; + let state = "{\n \"pnpmShrinkwrapHash\": \"deadbeef\"\n}\n"; + std::fs::write(tmp.path().join(state_rel), state).unwrap(); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["status"], "success", "envelope: {env}"); + for name in ["default", "tools"] { + let lock = std::fs::read_to_string( + tmp.path() + .join(format!("common/config/subspaces/{name}/pnpm-lock.yaml")), + ) + .unwrap(); + assert!( + lock.contains(HOSTED_URL), + "the {name} subspace lock must be redirected; got:\n{lock}" + ); + } + assert!( + warning_codes(&env).contains(&"redirect_rush_repo_state_stale".to_string()), + "a subspace repo-state.json beside a rewritten subspace lock must trigger \ + the stale-hash warning; got warnings {:?}", + warning_codes(&env) + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(state_rel)).unwrap(), + state, + "the redirect must not rewrite the subspace repo-state.json" + ); +} + +/// The stale-hash warning pairs each rewritten lock with the repo-state.json +/// in its own directory: a subspace repo-state.json says nothing about a +/// rewrite that landed only in the common lock (that subspace's lock, and so +/// its hash, is untouched). +#[tokio::test] +#[serial] +async fn rush_subspace_repo_state_does_not_flag_a_common_only_rewrite() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("rush.json"), + r#"{ "rushVersion": "5.100.0" }"#, + ) + .unwrap(); + let common = tmp.path().join("common/config/rush"); + std::fs::create_dir_all(&common).unwrap(); + std::fs::write(common.join("pnpm-lock.yaml"), rush_pnpm_lock(NAME)).unwrap(); + let subspace = tmp.path().join("common/config/subspaces/tools"); + std::fs::create_dir_all(&subspace).unwrap(); + let sub_lock = rush_pnpm_lock("unrelated-pkg"); + std::fs::write(subspace.join("pnpm-lock.yaml"), &sub_lock).unwrap(); + std::fs::write(subspace.join("repo-state.json"), "{}\n").unwrap(); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["status"], "success", "envelope: {env}"); + let lock = std::fs::read_to_string(common.join("pnpm-lock.yaml")).unwrap(); + assert!( + lock.contains(HOSTED_URL), + "common lock must be redirected; got:\n{lock}" + ); + assert_eq!( + std::fs::read_to_string(subspace.join("pnpm-lock.yaml")).unwrap(), + sub_lock, + "the subspace lock resolves nothing patched and must be untouched" + ); + assert!( + !warning_codes(&env).contains(&"redirect_rush_repo_state_stale".to_string()), + "only the common lock changed and it has no repo-state.json beside it; got \ + warnings {:?}", + warning_codes(&env) + ); +} + +/// pnpm >=11 rejects (or, under Rush's `--no-prefer-frozen-lockfile` +/// install, silently re-resolves) a hosted-redirected lock unless the +/// lockfile is trusted — but the generic `redirect_pnpm_trust_lockfile` +/// remedies (`pnpm install --trust-lockfile`, a repo-root +/// pnpm-workspace.yaml `trustLockfile` key, a `--store-dir` reinstall) do +/// nothing in a Rush repo: rush runs pnpm in common/temp with a workspace +/// file it generates itself (#713). A run that spliced only Rush locks must +/// carry the Rush remedy instead: the `pnpm_config_trust_lockfile=true rush +/// install` env var, the pnpm 11 `usePnpmFrozenLockfileForRushInstall` +/// experiment, and a `rush purge` clean reinstall. +#[tokio::test] +#[serial] +async fn rush_pnpm_trust_warning_gives_rush_remedy() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_rush_project(tmp.path(), false); + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["status"], "success", "envelope: {env}"); + let detail = env["redirect"]["warnings"] + .as_array() + .and_then(|arr| { + arr.iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| panic!("redirect_pnpm_trust_lockfile must fire; envelope: {env}")); + for needle in [ + "pnpm_config_trust_lockfile=true rush install", + "usePnpmFrozenLockfileForRushInstall", + "common/config/rush/experiments.json", + "rush purge", + "socket-patch vex", + ] { + assert!( + detail.contains(needle), + "the Rush trust detail must name `{needle}`; got:\n{detail}" + ); + } + for needle in ["pnpm install --trust-lockfile", "--store-dir", "pnpm clean --lockfile"] { + assert!( + !detail.contains(needle), + "the Rush trust detail must not offer the pnpm-only `{needle}`; got:\n{detail}" + ); + } + assert!( + !tmp.path().join("pnpm-workspace.yaml").exists(), + "rush nested-lock redirects must not create a root pnpm-workspace.yaml" + ); +} + +/// HEAL-ON-RERUN for #713: a Rush repo whose locks an earlier run (any +/// release) already redirected splices nothing on a re-scan, yet its `rush +/// install` still needs the Rush trust remedy — so the re-run re-issues it +/// instead of going silent, and still writes no root pnpm-workspace.yaml. +#[tokio::test] +#[serial] +async fn rush_rerun_on_redirected_locks_reissues_the_rush_remedy() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_rush_project(tmp.path(), false); + run_redirect_subprocess(tmp.path(), &server.uri()); + let common_rel = "common/config/rush/pnpm-lock.yaml"; + let redirected = std::fs::read_to_string(tmp.path().join(common_rel)).unwrap(); + assert!( + redirected.contains(HOSTED_URL), + "first run must redirect:\n{redirected}" + ); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join(common_rel)).unwrap(), + redirected, + "the re-run must leave the redirected Rush lock byte-identical" + ); + let detail = env["redirect"]["warnings"] + .as_array() + .and_then(|arr| { + arr.iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| panic!("the re-run must re-issue the Rush remedy; envelope: {env}")); + assert!( + detail.contains("pnpm_config_trust_lockfile=true rush install"), + "{detail}" + ); + assert!(!detail.contains("pnpm install --trust-lockfile"), "{detail}"); + assert!( + !tmp.path().join("pnpm-workspace.yaml").exists(), + "a Rush re-run must not create a root pnpm-workspace.yaml" + ); +} + /// The `redirect_rush_repo_state_stale` warning claims a Rush lock "was edited /// outside `rush update`" — so it must fire only when the rewrite actually /// landed in a Rush lock. A Rush repo whose locks resolve only an UNRELATED @@ -4891,6 +5109,12 @@ fn write_pnpm_tarball_project(root: &Path, tarball: &str) -> String { /// Scan hosted, then roll back, and return the restored pnpm-lock.yaml. fn pnpm_pin_and_rollback(root: &Path, server: &MockServer) -> String { + pnpm_pin_and_rollback_env(root, server).0 +} + +/// [`pnpm_pin_and_rollback`], also returning the rollback's `--json` +/// envelope. +fn pnpm_pin_and_rollback_env(root: &Path, server: &MockServer) -> (String, serde_json::Value) { let env = run_redirect_subprocess(root, &server.uri()); assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); let pinned = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); @@ -4902,7 +5126,10 @@ fn pnpm_pin_and_rollback(root: &Path, server: &MockServer) -> String { serde_json::json!([PURL]), "{env:#}" ); - std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap() + ( + std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(), + env, + ) } /// #557: under `.npmrc` `lockfile-include-tarball-url=true` (pnpm 9/10), @@ -4974,6 +5201,601 @@ async fn pnpm_rollback_keeps_an_unconventional_registry_tarball() { assert_eq!(pnpm_pin_and_rollback(tmp.path(), &server), pristine); } +/// Mount the version document of the project's `.npmrc` mirror at +/// `/mirror`, advertising `tarball` as `dist.tarball`, and return +/// the mirror's base URL. +async fn mock_pnpm_mirror(server: &MockServer, tarball: &str) -> String { + Mock::given(method("GET")) + .and(path(format!("/mirror/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": NAME, + "version": VERSION, + "dist": { "tarball": tarball, "integrity": "sha512-UPSTREAMupstream==" }, + }))) + .mount(server) + .await; + format!("{}/mirror/", server.uri()) +} + +/// #919: pnpm's restore reads `dist.tarball` from the registry the +/// project's `.npmrc` names, not from the default registry. A CDN-style +/// mirror tarball pnpm recorded as `tarball:` stays, even though the +/// default registry (`SOCKET_NPM_REGISTRY` here, npmjs normally) serves a +/// conventional URL pnpm would derive — and the mirror would 404 on. +#[tokio::test] +#[serial] +async fn pnpm_rollback_reads_the_npmrc_mirror_document_for_an_offpath_tarball() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await; + let advertised = format!("{}/cdn/files/{NAME}-{VERSION}.tgz", server.uri()); + let mirror = mock_pnpm_mirror(&server, &advertised).await; + + let tmp = tempfile::tempdir().unwrap(); + let pristine = write_pnpm_tarball_project(tmp.path(), &advertised); + std::fs::write(tmp.path().join(".npmrc"), format!("registry={mirror}\n")).unwrap(); + assert_eq!(pnpm_pin_and_rollback(tmp.path(), &server), pristine); +} + +/// #919, under `lockfile-include-tarball-url=true`: the restored `tarball:` +/// is the mirror's URL pnpm wrote, not the default registry's. +#[tokio::test] +#[serial] +async fn pnpm_rollback_keeps_the_mirror_tarball_under_include_tarball_url() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await; + let advertised = format!("{}/mirror/{NAME}/-/{NAME}-{VERSION}.tgz", server.uri()); + let mirror = mock_pnpm_mirror(&server, &advertised).await; + + let tmp = tempfile::tempdir().unwrap(); + let pristine = write_pnpm_tarball_project(tmp.path(), &advertised); + std::fs::write( + tmp.path().join(".npmrc"), + format!("registry={mirror}\nlockfile-include-tarball-url=true\n"), + ) + .unwrap(); + assert_eq!(pnpm_pin_and_rollback(tmp.path(), &server), pristine); +} + +/// #919 on pnpm 11+: the mirror is named by pnpm-workspace.yaml's +/// `registry:` (which pnpm 11 reads ahead of `.npmrc`), not `.npmrc`. +#[tokio::test] +#[serial] +async fn pnpm_rollback_reads_the_workspace_mirror_on_pnpm_11() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await; + let advertised = format!("{}/cdn/files/{NAME}-{VERSION}.tgz", server.uri()); + let mirror = mock_pnpm_mirror(&server, &advertised).await; + + let tmp = tempfile::tempdir().unwrap(); + let pristine = write_pnpm_tarball_project(tmp.path(), &advertised); + write_pnpm_modules_json(tmp.path(), "11.27.0"); + std::fs::write( + tmp.path().join("pnpm-workspace.yaml"), + format!("packages:\n - '.'\nregistry: {mirror}\n"), + ) + .unwrap(); + assert_eq!(pnpm_pin_and_rollback(tmp.path(), &server), pristine); +} + +/// The scoped package and hosted URL of the #919 scope-registry case. +const SCOPED_NAME: &str = "@socktest/scoped-pkg"; +const SCOPED_HOSTED_URL: &str = "http://patch.test/patch/npm/%40socktest/scoped-pkg/1.0.0/22222222-2222-4222-8222-222222222222/11111111-1111-4111-8111-111111111111/scoped-pkg-1.0.0.tgz"; + +/// A v9 pnpm lock resolving `SCOPED_NAME@VERSION` with `resolution`. +fn scoped_pnpm_lock(resolution: &str) -> String { + format!( + "lockfileVersion: '9.0'\n\nimporters:\n .:\n dependencies:\n \ + '{SCOPED_NAME}':\n specifier: {VERSION}\n version: {VERSION}\n\n\ + packages:\n\n '{SCOPED_NAME}@{VERSION}':\n resolution: {resolution}\n\n\ + snapshots:\n\n '{SCOPED_NAME}@{VERSION}': {{}}\n" + ) +} + +/// #919, scoped: a scoped name resolves against `.npmrc`'s +/// `@scope:registry`, not `registry`. Its version document is read from +/// there (an off-path CDN tarball stays recorded), and a URL conventional +/// under it stays derived (the bare `{integrity}` stays bare). `registry` +/// names a mirror that 404s and the default registry advertises the other +/// shape each time, so reading either one changes the restored lock. +#[tokio::test] +#[serial] +async fn pnpm_rollback_reads_the_scope_registry_for_a_scoped_name() { + let server = MockServer::start().await; + let scope_registry = format!("{}/scoped/", server.uri()); + let cdn = format!("{}/cdn/scoped-pkg-{VERSION}.tgz", server.uri()); + let conventional = format!("{scope_registry}{SCOPED_NAME}/-/scoped-pkg-{VERSION}.tgz"); + for (advertised, default_advertises, recorded) in [ + (&cdn, &conventional, Some(&cdn)), + (&conventional, &cdn, None), + ] { + server.reset().await; + for (prefix, tarball) in [("scoped", advertised), ("npm-registry", default_advertises)] { + Mock::given(method("GET")) + .and(path_regex(format!( + "^/{prefix}/@socktest%2[fF]scoped-pkg/{VERSION}$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": SCOPED_NAME, + "version": VERSION, + "dist": { "tarball": tarball, "integrity": "sha512-UPSTREAMupstream==" }, + }))) + .mount(&server) + .await; + } + + let tmp = tempfile::tempdir().unwrap(); + let pristine = scoped_pnpm_lock(&match recorded { + Some(tarball) => { + format!("{{integrity: sha512-UPSTREAMupstream==, tarball: {tarball}}}") + } + None => "{integrity: sha512-UPSTREAMupstream==}".to_string(), + }); + let pinned = scoped_pnpm_lock(&format!( + "{{integrity: {PATCHED_SHA512}, tarball: {SCOPED_HOSTED_URL}}}" + )); + std::fs::write(tmp.path().join("pnpm-lock.yaml"), &pinned).unwrap(); + std::fs::write( + tmp.path().join(".npmrc"), + format!( + "registry={}/unscoped/\n@socktest:registry={scope_registry}\n", + server.uri() + ), + ) + .unwrap(); + + let (code, env) = rollback_json(tmp.path(), &server); + assert_eq!(code, Some(0), "rollback: {env:#}"); + assert_eq!( + env["hosted"]["reverted"], + serde_json::json!(["pkg:npm/@socktest/scoped-pkg@1.0.0"]), + "{env:#}" + ); + assert!( + !env["warnings"] + .to_string() + .contains("upstream_registry_fallback"), + "{env:#}" + ); + let restored = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); + assert_eq!(restored, pristine, "advertised {advertised}"); + } +} + +/// #919: when the `.npmrc` mirror cannot be read (here it answers 401), +/// pnpm's restore falls back to the default registry's document, exits 0, +/// and says so with `upstream_registry_fallback`. +#[tokio::test] +#[serial] +async fn pnpm_rollback_falls_back_from_an_unreadable_mirror_and_warns() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await; + Mock::given(method("GET")) + .and(path_regex("^/private/")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let pristine = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); + std::fs::write( + tmp.path().join(".npmrc"), + format!("registry={}/private/\n", server.uri()), + ) + .unwrap(); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let (code, env) = rollback_json(tmp.path(), &server); + assert_eq!(code, Some(0), "rollback: {env:#}"); + assert_eq!( + env["hosted"]["reverted"], + serde_json::json!([PURL]), + "{env:#}" + ); + let codes: Vec<_> = env["warnings"] + .as_array() + .unwrap() + .iter() + .map(|w| w["code"].as_str().unwrap()) + .collect(); + assert!(codes.contains(&"upstream_registry_fallback"), "{env:#}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(), + pristine + ); +} + +/// `remove ` as a subprocess, like [`rollback_json`]. +fn remove_json(cwd: &Path, registry: &MockServer) -> (Option, serde_json::Value) { + hosted_unwind_json(cwd, registry, "http://patch.test", &["remove", PURL]) +} + +/// The `node_modules/.modules.yaml` install record pnpm 10+ writes (JSON), +/// naming `pnpm@{version}` as the pnpm that installed the project. +fn write_pnpm_modules_json(root: &Path, version: &str) { + std::fs::write( + root.join("node_modules/.modules.yaml"), + format!( + "{{\n \"layoutVersion\": 5,\n \"nodeLinker\": \"isolated\",\n \ + \"packageManager\": \"pnpm@{version}\",\n \"pendingBuilds\": []\n}}\n" + ), + ) + .unwrap(); +} + +/// #902: pnpm 11/12 ignore `lockfile-include-tarball-url` in `.npmrc`, so +/// the lock they wrote has no `tarball:` and `rollback` must keep it that +/// way, byte-exact. +#[tokio::test] +#[serial] +async fn pnpm_rollback_stays_bare_when_pnpm11_ignores_npmrc_include_tarball_url() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + write_pnpm_modules_json(tmp.path(), "11.28.3"); + std::fs::write( + tmp.path().join(".npmrc"), + "lockfile-include-tarball-url=true\n", + ) + .unwrap(); + let pristine = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); + assert_eq!(pnpm_pin_and_rollback(tmp.path(), &server), pristine); +} + +/// #902, `remove` shares the restore: same project as above. +#[tokio::test] +#[serial] +async fn pnpm_remove_stays_bare_when_pnpm12_ignores_npmrc_include_tarball_url() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + write_pnpm_modules_json(tmp.path(), "12.8.1"); + std::fs::write( + tmp.path().join(".npmrc"), + "lockfile-include-tarball-url=true\n", + ) + .unwrap(); + let lock_path = tmp.path().join("pnpm-lock.yaml"); + let pristine = std::fs::read_to_string(&lock_path).unwrap(); + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert!(std::fs::read_to_string(&lock_path) + .unwrap() + .contains("patch.test")); + let (code, env) = remove_json(tmp.path(), &server); + assert_eq!(code, Some(0), "remove: {env:#}"); + assert_eq!(std::fs::read_to_string(&lock_path).unwrap(), pristine); +} + +/// #902: pnpm <= 9 ignores pnpm-workspace.yaml settings, so a +/// `lockfileIncludeTarballUrl: true` there left the lock bare. The pnpm 9 +/// install record is YAML. +#[tokio::test] +#[serial] +async fn pnpm_rollback_stays_bare_when_pnpm9_ignores_workspace_include_tarball_url() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + std::fs::write( + tmp.path().join("node_modules/.modules.yaml"), + "hoistPattern:\n - '*'\nlayoutVersion: 5\nnodeLinker: isolated\n\ + packageManager: pnpm@9.15.9\npendingBuilds: []\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("pnpm-workspace.yaml"), + "packages:\n - '.'\nlockfileIncludeTarballUrl: true\n", + ) + .unwrap(); + let pristine = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); + assert_eq!(pnpm_pin_and_rollback(tmp.path(), &server), pristine); +} + +/// #902: with no install record, package.json's corepack `packageManager` +/// pin names the pnpm major. +#[tokio::test] +#[serial] +async fn pnpm_rollback_reads_the_pnpm_major_from_package_json_package_manager() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + std::fs::write( + tmp.path().join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "packageManager": "pnpm@9.15.9+sha512.abc", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + std::fs::write( + tmp.path().join("pnpm-workspace.yaml"), + "packages:\n - '.'\nlockfileIncludeTarballUrl: true\n", + ) + .unwrap(); + let pristine = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); + assert_eq!(pnpm_pin_and_rollback(tmp.path(), &server), pristine); +} + +/// #902: the lock itself is the best witness. An unpinned registry entry +/// pnpm wrote without `tarball:` proves the setting was not in effect, +/// whatever the settings files say. +#[tokio::test] +#[serial] +async fn pnpm_rollback_follows_lock_evidence_over_an_ignored_setting() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let lock = rush_pnpm_lock(NAME).replace( + "\nsnapshots:\n", + " other-dep@2.0.0:\n resolution: {integrity: sha512-OTHERother==}\n\n\ + snapshots:\n", + ) + " other-dep@2.0.0: {}\n"; + std::fs::write(tmp.path().join("pnpm-lock.yaml"), &lock).unwrap(); + std::fs::write( + tmp.path().join(".npmrc"), + "lockfile-include-tarball-url=true\n", + ) + .unwrap(); + assert_eq!(pnpm_pin_and_rollback(tmp.path(), &server), lock); +} + +/// Whether a rollback / remove `--json` envelope carries the run-level +/// warning `code`. +fn has_unwind_warning(env: &serde_json::Value, code: &str) -> bool { + env["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w["code"] == code) +} + +/// #902: with no evidence of the pnpm version (no unpinned registry entry +/// that shows the setting, no install record, no `packageManager` pin), +/// `rollback` follows pnpm 10's reading of the settings and warns +/// `upstream_pnpm_tarball_setting_guessed` when pnpm 9 (`.npmrc` only) or +/// pnpm >= 11 (pnpm-workspace.yaml only) would read them the other way. +/// Every evidenced reading, settings every pnpm reads alike, and a tarball +/// pnpm records anyway restore without the warning. +#[tokio::test] +#[serial] +async fn pnpm_rollback_warns_when_it_guesses_the_npmrc_include_tarball_url() { + const CODE: &str = "upstream_pnpm_tarball_setting_guessed"; + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await; + let tarball = format!( + "{}/npm-registry/{NAME}/-/{NAME}-{VERSION}.tgz", + server.uri() + ); + const RC_ON: &str = "lockfile-include-tarball-url=true\n"; + + // The guess: the tarball comes back, and the warning says why. + let tmp = tempfile::tempdir().unwrap(); + let pristine = write_pnpm_tarball_project(tmp.path(), &tarball); + std::fs::write(tmp.path().join(".npmrc"), RC_ON).unwrap(); + let (restored, env) = pnpm_pin_and_rollback_env(tmp.path(), &server); + assert_eq!(restored, pristine); + let warning = env["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == CODE) + .unwrap_or_else(|| panic!("{CODE} expected: {env:#}")); + let detail = warning["detail"].as_str().unwrap(); + let entry = format!("{NAME}@{VERSION}"); + for needle in [ + "pnpm-lock.yaml: nothing shows which pnpm wrote this lock", + "from `lockfile-include-tarball-url=true` in .npmrc", + entry.as_str(), + "pnpm >= 11 reads only pnpm-workspace.yaml", + "`packageManager`", + "the same value", + ] { + assert!(detail.contains(needle), "{needle}: {detail}"); + } + + // pnpm 9 would ignore a workspace-only setting: the same guess. + let tmp = tempfile::tempdir().unwrap(); + let pristine = write_pnpm_tarball_project(tmp.path(), &tarball); + std::fs::write( + tmp.path().join("pnpm-workspace.yaml"), + "packages:\n - '.'\nlockfileIncludeTarballUrl: true\n", + ) + .unwrap(); + let (restored, env) = pnpm_pin_and_rollback_env(tmp.path(), &server); + assert_eq!(restored, pristine); + let detail = env["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == CODE) + .and_then(|w| w["detail"].as_str()) + .unwrap_or_else(|| panic!("{CODE} expected: {env:#}")); + assert!(detail.contains("pnpm 9 reads only .npmrc"), "{detail}"); + + // Evidence of a pnpm that reads `.npmrc` (a pnpm 10 install record or + // corepack pin): the same restore, no guess. + let tmp = tempfile::tempdir().unwrap(); + let pristine = write_pnpm_tarball_project(tmp.path(), &tarball); + std::fs::write(tmp.path().join(".npmrc"), RC_ON).unwrap(); + write_pnpm_modules_json(tmp.path(), "10.12.1"); + let (restored, env) = pnpm_pin_and_rollback_env(tmp.path(), &server); + assert_eq!(restored, pristine); + assert!(!has_unwind_warning(&env, CODE), "{env:#}"); + + let tmp = tempfile::tempdir().unwrap(); + let pristine = write_pnpm_tarball_project(tmp.path(), &tarball); + std::fs::write(tmp.path().join(".npmrc"), RC_ON).unwrap(); + std::fs::write( + tmp.path().join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "packageManager": "pnpm@9.15.9", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let (restored, env) = pnpm_pin_and_rollback_env(tmp.path(), &server); + assert_eq!(restored, pristine); + assert!(!has_unwind_warning(&env, CODE), "{env:#}"); + + // pnpm 11 installed it: `.npmrc` is ignored, the lock stays bare. + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + write_pnpm_modules_json(tmp.path(), "11.28.3"); + std::fs::write(tmp.path().join(".npmrc"), RC_ON).unwrap(); + let pristine = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); + let (restored, env) = pnpm_pin_and_rollback_env(tmp.path(), &server); + assert_eq!(restored, pristine); + assert!(!has_unwind_warning(&env, CODE), "{env:#}"); + + // The lock's own bare registry entry proves the setting off. + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let lock = rush_pnpm_lock(NAME).replace( + "\nsnapshots:\n", + " other-dep@2.0.0:\n resolution: {integrity: sha512-OTHERother==}\n\n\ + snapshots:\n", + ) + " other-dep@2.0.0: {}\n"; + std::fs::write(tmp.path().join("pnpm-lock.yaml"), &lock).unwrap(); + std::fs::write(tmp.path().join(".npmrc"), RC_ON).unwrap(); + let (restored, env) = pnpm_pin_and_rollback_env(tmp.path(), &server); + assert_eq!(restored, lock); + assert!(!has_unwind_warning(&env, CODE), "{env:#}"); + + // Both files set it, so every pnpm reads it on: no guess. + let tmp = tempfile::tempdir().unwrap(); + let pristine = write_pnpm_tarball_project(tmp.path(), &tarball); + std::fs::write( + tmp.path().join("pnpm-workspace.yaml"), + "packages:\n - '.'\nlockfileIncludeTarballUrl: true\n", + ) + .unwrap(); + std::fs::write(tmp.path().join(".npmrc"), RC_ON).unwrap(); + let (restored, env) = pnpm_pin_and_rollback_env(tmp.path(), &server); + assert_eq!(restored, pristine); + assert!(!has_unwind_warning(&env, CODE), "{env:#}"); + + // A tarball pnpm cannot derive from the registry is recorded whatever + // the setting: restored with it, and no guess to warn about. + let cdn = MockServer::start().await; + mock_discovery(&cdn).await; + mock_reference(&cdn).await; + mock_view(&cdn).await; + let cdn_tarball = format!("{}/cdn/{NAME}.tgz", cdn.uri()); + mock_npm_registry_advertising(&cdn, "sha512-UPSTREAMupstream==", &cdn_tarball).await; + let tmp = tempfile::tempdir().unwrap(); + let pristine = write_pnpm_tarball_project(tmp.path(), &cdn_tarball); + std::fs::write(tmp.path().join(".npmrc"), RC_ON).unwrap(); + let (restored, env) = pnpm_pin_and_rollback_env(tmp.path(), &cdn); + assert_eq!(restored, pristine); + assert!(!has_unwind_warning(&env, CODE), "{env:#}"); +} + +/// #902 on a Rush lock: Rush installs with rush.json's `pnpmVersion` in +/// common/temp, so no install record or package.json sits beside +/// common/config/rush/pnpm-lock.yaml. The `pnpmVersion` decides how the +/// sibling `.npmrc` reads (pnpm 9: `tarball:` restored, no guess); without +/// one the guess warns with the rush.json remedy, never a package.json pin. +#[tokio::test] +#[serial] +async fn pnpm_rollback_reads_rush_pnpm_version_for_the_tarball_setting() { + const CODE: &str = "upstream_pnpm_tarball_setting_guessed"; + const COMMON: &str = "common/config/rush/pnpm-lock.yaml"; + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await; + let tarball = format!( + "{}/npm-registry/{NAME}/-/{NAME}-{VERSION}.tgz", + server.uri() + ); + for (rush_json, warns) in [ + ( + r#"{ "rushVersion": "5.100.0", "pnpmVersion": "9.15.9" }"#, + false, + ), + (r#"{ "rushVersion": "5.100.0" }"#, true), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_rush_project(tmp.path(), false); + std::fs::write(tmp.path().join("rush.json"), rush_json).unwrap(); + let common = tmp.path().join(COMMON); + let pristine = std::fs::read_to_string(&common).unwrap().replace( + "resolution: {integrity: sha512-UPSTREAMupstream==}", + &format!("resolution: {{integrity: sha512-UPSTREAMupstream==, tarball: {tarball}}}"), + ); + std::fs::write(&common, &pristine).unwrap(); + std::fs::write( + tmp.path().join("common/config/rush/.npmrc"), + "lockfile-include-tarball-url=true\n", + ) + .unwrap(); + let code = run(redirect_args(tmp.path(), server.uri())).await; + assert_eq!(code, 0, "{rush_json}"); + assert!(std::fs::read_to_string(&common) + .unwrap() + .contains(HOSTED_URL)); + let (code, env) = rollback_json(tmp.path(), &server); + assert_eq!(code, Some(0), "rollback: {env:#}"); + assert_eq!( + std::fs::read_to_string(&common).unwrap(), + pristine, + "{rush_json}" + ); + let detail = env["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == CODE) + .and_then(|w| w["detail"].as_str()); + assert_eq!(detail.is_some(), warns, "{rush_json}: {env:#}"); + if let Some(detail) = detail { + assert!(detail.starts_with(COMMON), "{detail}"); + assert!(detail.contains("set rush.json `pnpmVersion`"), "{detail}"); + assert!(!detail.contains("packageManager"), "{detail}"); + } + } +} + /// #417: hosted `scan` from a cargo workspace MEMBER treated it as a /// lockless project, wrote `registry = …` into the member's Cargo.toml and /// a `[registries]` block into the member's `.cargo/config.toml`, left the diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 865ae4969..cbfb09831 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -1505,6 +1505,43 @@ async fn hosted_scan_from_pnpm_member_with_own_lock_never_nests_trust_config() { ); } +/// #1006: a project below a `pnpm-workspace.yaml` whose `packages:` globs +/// do not list it (an `examples/` app) is no workspace member. pnpm +/// 11.28+/12 install it standalone, with its own lock, and read +/// `trustLockfile` only from its own settings file, so hosted mode pins the +/// lock and creates that file as for any single project; the unrelated +/// root file is left byte-identical. +#[tokio::test] +#[serial] +async fn hosted_scan_from_pnpm_project_outside_workspace_globs_pins_and_nests_trust() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = member_root(&tmp); + std::fs::write( + root.join("package.json"), + r#"{ "name": "root", "private": true }"#, + ) + .unwrap(); + let root_ws = root.join("pnpm-workspace.yaml"); + let ws_before = "packages:\n - 'packages/*'\n"; + std::fs::write(&root_ws, ws_before).unwrap(); + let demo = root.join("examples/demo"); + std::fs::create_dir_all(&demo).unwrap(); + write_pnpm_project(&demo); + + let (code, doc) = run_hosted_json(&demo, &server.uri()); + assert_eq!(code, Some(0), "{doc}"); + assert_eq!(doc["redirect"]["redirected"], 1, "{doc}"); + let lock = std::fs::read_to_string(demo.join("pnpm-lock.yaml")).unwrap(); + assert!(lock.contains(HOSTED_URL), "{lock}"); + let nested = std::fs::read_to_string(demo.join("pnpm-workspace.yaml")).unwrap(); + assert!(nested.contains("trustLockfile: true"), "{nested}"); + assert_eq!(std::fs::read_to_string(&root_ws).unwrap(), ws_before); +} + /// #880: an explicit `trustLockfile: false` in the root file is the user's /// call, respected as in a single project: the member pins, nothing is /// nested, and the warning names the root file. @@ -1782,6 +1819,501 @@ fn assert_refused_workspace_lock_elsewhere( ); } +/// Pin the project's pnpm: package.json `packageManager` and/or the +/// installed `node_modules/.modules.yaml` record (pnpm 9's YAML spelling). +fn pin_pnpm(root: &Path, package_json: Option<&str>, modules_yaml: Option<&str>) { + if let Some(version) = package_json { + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "packageManager": "pnpm@{version}", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + } + if let Some(version) = modules_yaml { + std::fs::write( + root.join("node_modules/.modules.yaml"), + format!("hoistPattern:\n - '*'\nlayoutVersion: 5\npackageManager: pnpm@{version}\n"), + ) + .unwrap(); + } +} + +/// #734: on pnpm 9.0–10.4 a created `packages: ['.']` file makes a +/// single-package project a root-only workspace where `pnpm add ` +/// fails with ERR_PNPM_ADDING_TO_ROOT, and those releases never read +/// `trustLockfile`. A project pinned there (installed record or +/// package.json) gets its lock pinned and no pnpm-workspace.yaml; the +/// warning says why and how to add trust on pnpm >= 11. After the upgrade a +/// re-run creates the file, and rollback removes it with the pin. +#[tokio::test] +#[serial] +async fn hosted_pnpm_9_through_10_4_project_gets_no_root_only_workspace() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + for (package_json, modules_yaml, named) in [ + ( + None, + Some("9.15.9"), + "pnpm@9.15.9 (node_modules/.modules.yaml)", + ), + ( + Some("10.4.1"), + None, + "pnpm@10.4.1 (package.json packageManager)", + ), + ( + Some("9.15.9"), + Some("9.15.9"), + "pnpm@9.15.9 (package.json packageManager)", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + pin_pnpm(tmp.path(), package_json, modules_yaml); + + let (code, doc) = run_hosted_json(tmp.path(), &server.uri()); + assert_eq!(code, Some(0), "{doc}"); + let lock = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); + assert!(lock.contains(HOSTED_URL), "{lock}"); + assert!( + !tmp.path().join("pnpm-workspace.yaml").exists(), + "no root-only workspace for {package_json:?} / {modules_yaml:?}" + ); + let warnings = warning_texts(&doc); + assert!( + warnings.contains("ERR_PNPM_ADDING_TO_ROOT") + && warnings.contains(named) + && warnings.contains("re-run `socket-patch scan --mode hosted`"), + "{warnings}" + ); + } + + // The upgrade: package.json now pins pnpm 11, which needs the setting; + // the heal-on-rerun path creates the scaffold. + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + pin_pnpm(tmp.path(), Some("9.15.9"), None); + let (code, doc) = run_hosted_json(tmp.path(), &server.uri()); + assert_eq!(code, Some(0), "{doc}"); + let ws_path = tmp.path().join("pnpm-workspace.yaml"); + assert!(!ws_path.exists()); + pin_pnpm(tmp.path(), Some("11.0.0"), None); + let (code, doc) = run_hosted_json(tmp.path(), &server.uri()); + assert_eq!(code, Some(0), "{doc}"); + assert_eq!( + std::fs::read_to_string(&ws_path).unwrap(), + "packages:\n - '.'\ntrustLockfile: true\n" + ); + let code = rollback_hosted(tmp.path(), &server).await; + assert_eq!(code, 0, "rollback must restore the pnpm pin"); + assert!(!ws_path.exists(), "rollback removes the created scaffold"); +} + +/// The #734 gate keeps the scaffold whenever the project's pnpm may read +/// it: no pin, pnpm >= 10.5, or pins that disagree (a stale install record +/// beside a pnpm 11 pin). The detail names the `-w` caveat. +#[tokio::test] +#[serial] +async fn hosted_unknown_or_later_pnpm_still_gets_the_trust_scaffold() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + for (package_json, modules_yaml) in [ + (None, None), + (None, Some("10.5.0")), + (Some("11.0.0"), None), + (Some("11.1.0"), Some("9.15.9")), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + pin_pnpm(tmp.path(), package_json, modules_yaml); + + let (code, doc) = run_hosted_json(tmp.path(), &server.uri()); + assert_eq!(code, Some(0), "{doc}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap(), + "packages:\n - '.'\ntrustLockfile: true\n", + "{package_json:?} / {modules_yaml:?}" + ); + let warnings = warning_texts(&doc); + assert!(warnings.contains("`pnpm add -w `"), "{warnings}"); + // ... and the pin that avoids the file on pnpm 9.0–10.4. + assert!( + warnings.contains("pin that pnpm in package.json `packageManager`"), + "{warnings}" + ); + } +} + + +/// A pnpm workspace that keeps one lock per member +/// (`sharedWorkspaceLockfile: false`): `packages/a` depends on the patched +/// package directly, `packages/b` through `dep-b`, each through its own +/// lock and installed tree. `lock` renders a member's lock from its +/// importer block and the extra packages entries. +fn write_member_lock_workspace( + root: &Path, + workspace: &str, + npmrc: &str, + root_lock: Option<&str>, + lock: impl Fn(&str, &str) -> String, +) { + std::fs::write( + root.join("package.json"), + r#"{ "name": "root", "private": true }"#, + ) + .unwrap(); + std::fs::write(root.join("pnpm-workspace.yaml"), workspace).unwrap(); + std::fs::write(root.join(".npmrc"), npmrc).unwrap(); + if let Some(text) = root_lock { + std::fs::write(root.join("pnpm-lock.yaml"), text).unwrap(); + } + for (member, dep) in [("a", NAME), ("b", "dep-b")] { + let dir = root.join("packages").join(member); + let pkg = dir.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + dir.join("package.json"), + format!( + r#"{{ "name": "{member}", "version": "1.0.0", "dependencies": {{ "{dep}": "1.0.0" }} }}"# + ), + ) + .unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + let version = if dep == NAME { VERSION } else { "1.0.0" }; + std::fs::write(dir.join("pnpm-lock.yaml"), lock(dep, version)).unwrap(); + } +} + +/// A member's lockfileVersion 9.0 lock: `dep@version` is its importer's +/// direct dependency; the patched package is always resolved. +fn v9_member_lock(dep: &str, version: &str) -> String { + let extra = if dep == NAME { + String::new() + } else { + format!(" {dep}@{version}:\n resolution: {{integrity: sha512-DEPb==}}\n\n") + }; + format!( + "lockfileVersion: '9.0' + +importers: + + .: + dependencies: + {dep}: + specifier: {version} + version: {version} + +packages: + +{extra} {NAME}@{VERSION}: + resolution: {{integrity: {UPSTREAM_SHA512}}} + +snapshots: + + {NAME}@{VERSION}: {{}} +" + ) +} + +/// #492: a hosted scan from the root of a `sharedWorkspaceLockfile: false` +/// workspace read only the root lock (`importers: .: {}`), redirected +/// nothing and reported success. Every member lock is now pinned, the trust +/// key goes to the root workspace file (the one pnpm reads), discovery sees +/// the member pins, and `rollback` restores every member lock byte for byte. +#[tokio::test] +#[serial] +async fn hosted_scan_pins_every_member_lock_with_shared_workspace_lockfile_false() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let ws = "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n"; + write_member_lock_workspace( + root, + ws, + "shared-workspace-lockfile=false\n", + Some("lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n"), + v9_member_lock, + ); + let locks = [ + root.join("packages/a/pnpm-lock.yaml"), + root.join("packages/b/pnpm-lock.yaml"), + ]; + let pristine: Vec = locks + .iter() + .map(|l| std::fs::read_to_string(l).unwrap()) + .collect(); + let root_lock_before = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + + let (code, doc) = run_hosted_json(root, &server.uri()); + assert_eq!(code, Some(0), "{doc}"); + assert_eq!(doc["status"], "success", "{doc}"); + assert_eq!(doc["redirect"]["redirected"], 1, "{doc}"); + for lock in &locks { + let text = std::fs::read_to_string(lock).unwrap(); + assert!( + text.contains(&format!("tarball: {HOSTED_URL}")) && text.contains(PATCHED_SHA512), + "{} must be pinned:\n{text}", + lock.display() + ); + } + assert_eq!( + std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(), + root_lock_before, + "the root lock holds no instance and is left alone" + ); + let warnings = warning_texts(&doc); + assert!( + !warnings.contains("no resolution for"), + "no entry_not_found: {warnings}" + ); + assert_eq!( + std::fs::read_to_string(root.join("pnpm-workspace.yaml")).unwrap(), + format!("{ws}trustLockfile: true\n"), + "the trust key goes to the root file" + ); + for member in ["a", "b"] { + assert!(!root + .join(format!("packages/{member}/pnpm-workspace.yaml")) + .exists()); + } + assert_no_ledger(root); + + // Discovery reads the member pins: rollback restores both locks. + let code = rollback_hosted(root, &server).await; + assert_eq!(code, 0, "rollback must restore the member pins"); + for (lock, before) in locks.iter().zip(&pristine) { + assert_eq!( + &std::fs::read_to_string(lock).unwrap(), + before, + "rollback restores {} byte for byte", + lock.display() + ); + } +} + +/// Bugbot on #1007: pnpm reads `lockfileIncludeTarballUrl` (and the +/// registry) for a member lock (`sharedWorkspaceLockfile: false`) from the +/// workspace root's pnpm-workspace.yaml and `.npmrc`, never from the +/// member's directory. Rollback used to look beside the member lock, found +/// no settings there, and restored the entry without the `tarball:` pnpm +/// wrote. +#[tokio::test] +#[serial] +async fn rollback_reads_member_lock_settings_from_the_workspace_root() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let registry = format!("{}/npm-registry", server.uri()); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_member_lock_workspace( + root, + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n\ + lockfileIncludeTarballUrl: true\n", + "shared-workspace-lockfile=false\nlockfile-include-tarball-url=true\n", + Some("lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n"), + |dep, version| { + v9_member_lock(dep, version) + .replace( + &format!("integrity: {UPSTREAM_SHA512}}}"), + &format!( + "integrity: {UPSTREAM_SHA512}, \ + tarball: {registry}/{NAME}/-/{NAME}-{VERSION}.tgz}}" + ), + ) + .replace( + "integrity: sha512-DEPb==}", + &format!( + "integrity: sha512-DEPb==, tarball: {registry}/dep-b/-/dep-b-1.0.0.tgz}}" + ), + ) + }, + ); + let lock = root.join("packages/a/pnpm-lock.yaml"); + let pristine = std::fs::read_to_string(&lock).unwrap(); + assert!(pristine.contains("tarball: "), "{pristine}"); + + let (code, doc) = run_hosted_json(root, &server.uri()); + assert_eq!(code, Some(0), "{doc}"); + assert!( + std::fs::read_to_string(&lock) + .unwrap() + .contains(&format!("tarball: {HOSTED_URL}")), + "{doc}" + ); + let code = rollback_hosted(root, &server).await; + assert_eq!(code, 0, "rollback must restore the member pins"); + assert_eq!(std::fs::read_to_string(&lock).unwrap(), pristine); +} + +/// #492 on pnpm 7: `shared-workspace-lockfile=false` in `.npmrc` writes no +/// root lock at all, only lockfile 5.4 member locks. The run used to warn +/// `redirect_pnpm_no_lockfile` ("run `pnpm install`", which never writes a +/// root lock here) and succeed with nothing pinned. +#[tokio::test] +#[serial] +async fn hosted_scan_pins_pnpm7_member_locks_without_a_root_lock() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let v5 = |dep: &str, version: &str| { + let extra = if dep == NAME { + String::new() + } else { + format!(" /{dep}/{version}:\n resolution: {{integrity: sha512-DEPb==}}\n dev: false\n\n") + }; + format!( + "lockfileVersion: 5.4\n\nspecifiers:\n {dep}: {version}\n\ndependencies:\n \ + {dep}: {version}\n\npackages:\n\n{extra} /{NAME}/{VERSION}:\n \ + resolution: {{integrity: {UPSTREAM_SHA512}}}\n dev: false\n" + ) + }; + write_member_lock_workspace( + root, + "packages:\n - packages/*\n", + "shared-workspace-lockfile=false\n", + None, + v5, + ); + let (code, doc) = run_hosted_json(root, &server.uri()); + assert_eq!(code, Some(0), "{doc}"); + assert_eq!(doc["redirect"]["redirected"], 1, "{doc}"); + for member in ["a", "b"] { + let text = std::fs::read_to_string(root.join(format!("packages/{member}/pnpm-lock.yaml"))) + .unwrap(); + assert!(text.contains(HOSTED_URL), "{member}:\n{text}"); + } + let codes: Vec<&str> = doc["redirect"]["warnings"] + .as_array() + .into_iter() + .flatten() + .filter_map(|w| w["code"].as_str()) + .collect(); + assert!( + !codes.contains(&"redirect_pnpm_no_lockfile") + && !codes.contains(&"redirect_npm_no_lockfile") + && !codes.contains(&"redirect_pnpm_entry_not_found"), + "{codes:?}" + ); + assert!(!root.join("pnpm-lock.yaml").exists()); +} + +/// #556: with `gitBranchLockfile` on, pnpm installs a branch from its own +/// `pnpm-lock..yaml` whenever one exists. Hosted mode pinned the +/// stale `pnpm-lock.yaml` beside it and reported success while every fresh +/// install on the branch stayed upstream; with only the branch lock it said +/// "run `pnpm install`", which just rewrites the branch lock. Both now +/// refuse with `redirect_pnpm_git_branch_lockfile`, confirm nothing and +/// write nothing (a rewriter-level refusal: the run itself still exits 0, +/// like `redirect_pnpm_member_locks_unresolved`). +#[tokio::test] +#[serial] +async fn hosted_scan_refuses_a_git_branch_lockfile_project() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + for (case, root_lock, workspace, npmrc) in [ + ( + "both locks", + true, + "packages:\n - '.'\ngitBranchLockfile: true\n", + None, + ), + ( + "branch lock only", + false, + "packages:\n - '.'\ngitBranchLockfile: true\n", + None, + ), + ( + "pnpm 10 .npmrc", + true, + "packages:\n - '.'\n", + Some("git-branch-lockfile=true\n"), + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_pnpm_project(root); + let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + std::fs::write(root.join("pnpm-lock.feature.yaml"), &lock).unwrap(); + if !root_lock { + std::fs::remove_file(root.join("pnpm-lock.yaml")).unwrap(); + } + std::fs::write(root.join("pnpm-workspace.yaml"), workspace).unwrap(); + if let Some(npmrc) = npmrc { + std::fs::write(root.join(".npmrc"), npmrc).unwrap(); + } + let pkg = std::fs::read_to_string(root.join("package.json")).unwrap(); + + let (code, doc) = run_hosted_json(root, &server.uri()); + assert_eq!(code, Some(0), "{case}: {doc}"); + assert_eq!(doc["redirect"]["redirected"], 0, "{case}: {doc}"); + assert_eq!( + doc["redirect"]["rewrittenFiles"], + serde_json::json!([]), + "{case}" + ); + let codes: Vec<&str> = doc["redirect"]["warnings"] + .as_array() + .into_iter() + .flatten() + .filter_map(|w| w["code"].as_str()) + .collect(); + assert!( + codes.contains(&"redirect_pnpm_git_branch_lockfile") + && !codes.contains(&"redirect_pnpm_no_lockfile"), + "{case}: {codes:?}" + ); + assert!( + warning_texts(&doc).contains("pnpm-lock.feature.yaml"), + "{case}: {doc}" + ); + if root_lock { + assert_eq!( + std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(), + lock, + "{case}: the stale root lock is left alone" + ); + } + assert_eq!( + std::fs::read_to_string(root.join("pnpm-lock.feature.yaml")).unwrap(), + lock, + "{case}" + ); + assert_eq!( + std::fs::read_to_string(root.join("pnpm-workspace.yaml")).unwrap(), + workspace, + "{case}: no trust key" + ); + assert_eq!( + std::fs::read_to_string(root.join("package.json")).unwrap(), + pkg, + "{case}" + ); + assert_no_ledger(root); + } +} + /// #1094: an npm workspace member holding a stray `package-lock.json` / /// `npm-shrinkwrap.json` of its own (one npm never reads; members install /// from the root lock) used to skip the #884 refusal. `scan` and `get` diff --git a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_parent_child.rs b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_parent_child.rs new file mode 100644 index 000000000..5b1f342c8 --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_parent_child.rs @@ -0,0 +1,457 @@ +//! A vendored pnpm package whose own dependency is vendored too (#830): +//! `debug@4.3.4 → ms@2.1.2`, both patched. Vendoring `ms` after `debug` +//! rewrites the `ms` ref INSIDE debug's rekeyed snapshot, so unwinding +//! `debug` alone used to splice its pre-vendor block back over that ref: +//! +//! - `remove pkg:npm/debug@4.3.4` exited 0 with a lock pnpm rejects +//! (`ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY`: debug → `ms@2.1.2`, which no +//! longer exists while `ms` stays vendored); +//! - `rollback` exited 1 forever (ms's ref record, keyed by debug's gone +//! `file:` key, never matched again) with the artifact and ledger entry +//! stranded; +//! - the vendored → hosted takeover skipped `ms` as +//! `vendored_revert_failed` after its wiring was already gone, so the +//! run succeeded with `ms` unpatched. +//! +//! The API is wiremock; no pnpm binary is needed. Every child process gets +//! the ambient `SOCKET_*` vars scrubbed; each test runs in its own tempdir. + +#[path = "common/hermetic.rs"] +mod hermetic; +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::collections::HashSet; +use std::path::Path; + +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const DEBUG: &str = "pkg:npm/debug@4.3.4"; +const MS: &str = "pkg:npm/ms@2.1.2"; +const DEBUG_UUID: &str = "55555555-5555-4555-8555-555555555555"; +const MS_UUID: &str = "66666666-6666-4666-8666-666666666666"; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + +const PKG: &str = r#"{ + "name": "c", + "version": "1.0.0", + "private": true, + "dependencies": { + "debug": "4.3.4" + } +} +"#; + +const LOCK: &str = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + debug: + specifier: 4.3.4 + version: 4.3.4 + +packages: + + debug@4.3.4: + resolution: {integrity: sha512-3NN8vD3qzN8YtsF8Mxz4wHinpTcRP71BdOdGhzQk7dVDwXhUjS7O9BXaHGhn7m7Y1hB+L4szF+XwoAhBc2upBw==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + +snapshots: + + debug@4.3.4: + dependencies: + ms: 2.1.2 + + ms@2.1.2: {} +"; + +fn parts(purl: &str) -> (&'static str, &'static str, &'static str) { + match purl { + DEBUG => ("debug", "4.3.4", DEBUG_UUID), + MS => ("ms", "2.1.2", MS_UUID), + other => panic!("unknown purl {other}"), + } +} + +fn hosted_url(purl: &str) -> String { + let (name, version, uuid) = parts(purl); + format!( + "https://patch.socket.dev/patch/npm/{name}/{version}/44444444-4444-4444-8444-444444444444/{uuid}/{name}-{version}.tgz" + ) +} + +fn patch_record(purl: &str) -> Value { + json!({ + "uuid": parts(purl).2, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(ORIG_INDEX), + "afterHash": compute_git_sha256_from_bytes(PATCHED_INDEX), + } + }, + "vulnerabilities": {}, + "description": "pnpm parent/child fixture", + "license": "MIT", + "tier": "free" + }) +} + +/// The project (installed copies, lock, manifest + blob) with nothing +/// vendored yet. +fn write_project(root: &Path) { + std::fs::write(root.join("package.json"), PKG).unwrap(); + std::fs::write(root.join("pnpm-lock.yaml"), LOCK).unwrap(); + for purl in [DEBUG, MS] { + let (name, version, _) = parts(purl); + let dir = root.join("node_modules").join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("package.json"), + format!(r#"{{"name":"{name}","version":"{version}"}}"#), + ) + .unwrap(); + std::fs::write(dir.join("index.js"), ORIG_INDEX).unwrap(); + } + let manifest = json!({ "patches": { DEBUG: patch_record(DEBUG), MS: patch_record(MS) } }); + std::fs::create_dir_all(root.join(".socket/blobs")).unwrap(); + std::fs::write( + root.join(".socket/manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write( + root.join(".socket/blobs") + .join(compute_git_sha256_from_bytes(PATCHED_INDEX)), + PATCHED_INDEX, + ) + .unwrap(); +} + +/// Run the built binary hermetically. Returns `(exit_code, envelope)`. +fn run_json(cwd: &Path, args: &[&str]) -> (i32, Value) { + let mut cmd = hermetic::binary_command(); + cmd.current_dir(cwd).env("SOCKET_TELEMETRY_DISABLED", "1"); + let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); + let out = cmd.output().expect("spawn socket-patch binary"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let env: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{args:?} must emit a JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (out.status.code().unwrap_or(-1), env) +} + +/// A vendored project: `vendor` processes the purl-sorted manifest, so the +/// parent (`debug`) is vendored before its dependency (`ms`). +fn vendored_project(root: &Path) { + write_project(root); + let (code, env) = run_json(root, &["vendor", "--json", "--cwd", root.to_str().unwrap()]); + assert_eq!(code, 0, "vendor: {env:#}"); + let lock = read_lock(root); + for purl in [DEBUG, MS] { + let (name, version, uuid) = parts(purl); + assert!( + lock.contains(&format!( + " {name}@file:.socket/vendor/npm/{uuid}/{name}-{version}.tgz:" + )), + "{purl} vendored:\n{lock}" + ); + } +} + +fn read_lock(root: &Path) -> String { + std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap() +} + +fn vendored_uuids(root: &Path) -> Vec { + std::fs::read_dir(root.join(".socket/vendor/npm")) + .map(|dir| { + let mut uuids: Vec = dir + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + uuids.sort(); + uuids + }) + .unwrap_or_default() +} + +/// Every snapshot dependency ref names an existing snapshots key: what a +/// frozen pnpm install checks (`ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY`). +fn assert_snapshot_refs_resolve(lock: &str) { + let snapshots = lock + .split("\nsnapshots:\n") + .nth(1) + .expect("snapshots section"); + let mut keys = HashSet::new(); + let mut refs = Vec::new(); + for line in snapshots.lines() { + if let Some(key) = line.strip_prefix(" ").filter(|l| !l.starts_with(' ')) { + let key = key.strip_suffix(": {}").or_else(|| key.strip_suffix(':')); + keys.insert(key.unwrap().to_string()); + } else if let Some(dep) = line.strip_prefix(" ") { + let (name, value) = dep.split_once(": ").unwrap(); + refs.push(format!("{name}@{value}")); + } + } + for r in refs { + assert!(keys.contains(&r), "dangling snapshot ref `{r}`:\n{lock}"); + } +} + +/// No recorded lock entry was reported missing or drifted, and no artifact +/// kept: every record found its live fragment. +fn assert_clean_unwind(env: &Value) { + let text = env.to_string(); + for code in [ + "vendor_lock_entry_removed", + "vendor_lock_entry_drifted", + "vendor_artifact_kept", + ] { + assert!(!text.contains(code), "{code}: {env:#}"); + } +} + +/// #830: `remove ` keeps the vendored child wired and the lock +/// resolvable; `remove ` afterwards lands on the pre-vendor lock. +#[test] +fn remove_parent_keeps_the_vendored_child_wired() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendored_project(root); + let cwd = root.to_str().unwrap(); + + let (code, env) = run_json( + root, + &[ + "remove", + DEBUG, + "--json", + "--offline", + "--yes", + "--cwd", + cwd, + ], + ); + assert_eq!(code, 0, "remove debug: {env:#}"); + assert_clean_unwind(&env); + let lock = read_lock(root); + assert_snapshot_refs_resolve(&lock); + let ms_spec = format!("file:.socket/vendor/npm/{MS_UUID}/ms-2.1.2.tgz"); + assert!( + lock.contains(&format!( + " debug@4.3.4:\n dependencies:\n ms: {ms_spec}\n" + )), + "debug's restored snapshot keeps the vendored ms ref:\n{lock}" + ); + assert_eq!(vendored_uuids(root), [MS_UUID]); + + let (code, env) = run_json( + root, + &["remove", MS, "--json", "--offline", "--yes", "--cwd", cwd], + ); + assert_eq!(code, 0, "remove ms: {env:#}"); + assert_eq!(read_lock(root), LOCK, "lock byte-restored"); + assert!(vendored_uuids(root).is_empty()); +} + +/// #830: `rollback` unwinds both in one run, exits 0 and strands nothing; +/// a second run is a clean no-op. +#[test] +fn rollback_unwinds_parent_and_child_in_one_run() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendored_project(root); + let cwd = root.to_str().unwrap(); + for run in 0..2 { + let (code, env) = run_json( + root, + &["rollback", "--json", "--yes", "--offline", "--cwd", cwd], + ); + assert_eq!(code, 0, "rollback #{run}: {env:#}"); + assert_clean_unwind(&env); + assert_eq!(read_lock(root), LOCK, "rollback #{run}: lock byte-restored"); + assert_eq!( + std::fs::read_to_string(root.join("package.json")).unwrap(), + PKG + ); + assert!(vendored_uuids(root).is_empty(), "rollback #{run}"); + let state = + std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap_or_default(); + assert!(!state.contains("pkg:npm/"), "no ledger entry left: {state}"); + } +} + +/// #830: `vendor --revert` likewise leaves no residue. +#[test] +fn vendor_revert_unwinds_parent_and_child() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendored_project(root); + let (code, env) = run_json( + root, + &[ + "vendor", + "--revert", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "{env:#}"); + assert_clean_unwind(&env); + assert_eq!(read_lock(root), LOCK, "lock byte-restored"); + assert!(vendored_uuids(root).is_empty()); +} + +/// The hosted API for both patches. +async fn mock_api(server: &MockServer) { + let offer = |purl: &str| { + json!({ + "uuid": parts(purl).2, "purl": purl, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", "title": "fixture" + }) + }; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [ + { "purl": DEBUG, "patches": [offer(DEBUG)] }, + { "purl": MS, "patches": [offer(MS)] }, + ], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + for purl in [DEBUG, MS] { + let (name, _, uuid) = parts(purl); + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.*{name}.*$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": uuid, "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + let mut view = patch_record(purl); + view["purl"] = json!(purl); + view["publishedAt"] = json!("2024-01-01T00:00:00Z"); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view)) + .mount(server) + .await; + } + let grant = |purl: &str| { + json!({ + "status": "granted", + "url": hosted_url(purl), + "purl": purl, + "artifacts": [{ + "kind": "tarball", + "url": hosted_url(purl), + "integrity": { "sha512": "sha512-PATCHEDpatchedPATCHEDpatched0123456789==" } + }], + "registryOverride": null + }) + }; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { DEBUG_UUID: grant(DEBUG), MS_UUID: grant(MS) } + }))) + .mount(server) + .await; +} + +/// #830: the vendored → hosted takeover reverts and pins BOTH packages; it +/// never skips the child as `vendored_revert_failed` after its wiring is +/// gone, and the `--dry-run` preview predicts no refusal either. (A guard: +/// since #689 a missing ref record is no longer drift, so this variant +/// already converged; the lock-entry warnings it still raised are what the +/// core fix removes.) +#[tokio::test(flavor = "multi_thread")] +async fn hosted_takeover_migrates_parent_and_child() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendored_project(root); + let scan = |extra: &[&str]| { + let mut args = vec![ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--api-url", + &server.uri(), + "--api-token", + "fake", + "--org", + ORG, + "--cwd", + root.to_str().unwrap(), + ] + .into_iter() + .map(str::to_string) + .collect::>(); + args.extend(extra.iter().map(|s| s.to_string())); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + run_json(root, &args) + }; + + let vendored_lock = read_lock(root); + let (code, env) = scan(&["--dry-run"]); + assert_eq!(code, 0, "{env:#}"); + assert!( + !env.to_string().contains("vendored_revert_failed"), + "{env:#}" + ); + assert_eq!(read_lock(root), vendored_lock, "the dry run writes nothing"); + + let (code, env) = scan(&[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + let text = env.to_string(); + for code in ["vendored_revert_failed", "redirect_takeover_unpatched"] { + assert!(!text.contains(code), "{code}: {env:#}"); + } + assert_clean_unwind(&env); + assert_eq!(env["redirect"]["redirected"], 2, "{env:#}"); + let lock = read_lock(root); + assert!( + !lock.contains(".socket/vendor/"), + "nothing stays vendored:\n{lock}" + ); + for purl in [DEBUG, MS] { + assert!(lock.contains(&hosted_url(purl)), "{purl} pinned:\n{lock}"); + } + assert!(vendored_uuids(root).is_empty()); +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs index 1c009aed6..3b656f4ae 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs @@ -2,15 +2,19 @@ //! pnpm projects whose shape the pnpm vendored backend refuses although //! hosted mode accepts it (#853): a `catalog:` dependency //! (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` -//! (`vendor_lockfile_crlf_unsupported`) and a user exact-pin override in -//! `pnpm-workspace.yaml` (`vendor_override_conflict`). +//! (`vendor_lockfile_crlf_unsupported`) and a conflicting user override +//! (a range) in `pnpm-workspace.yaml` (`vendor_override_conflict`). //! //! `scan`/`get --mode vendored` over such a hosted pin used to commit the //! upstream restore FIRST and only then reach the backend's refusal, so //! the run failed with the hosted pin already gone and the project went //! back to installing the unpatched registry release. A refused takeover //! must leave the hosted wiring byte-for-byte in place; a plain dependency -//! still takes over. +//! and a workspace exact pin equal to the vendored version (#854) still +//! take over. The `--dry-run` preview of the same runs lists a refused +//! pin `would_refuse` with the wet run's code, never `would_vendor`. +//! pnpm 12's two-document lock (#466) takes over both ways, editing the +//! project document only. //! //! The API and the npm registry are wiremock; no pnpm binary is needed. //! Every child process gets the ambient `SOCKET_*` vars scrubbed and @@ -50,12 +54,24 @@ enum Shape { Catalog, /// A plain dependency whose lock is converted to CRLF after hosting. Crlf, - /// A plain dependency plus a user `overrides: { left-pad: 1.3.0 }`. + /// A plain dependency plus a conflicting user + /// `overrides: { left-pad: ^1.3.0 }` in pnpm-workspace.yaml. Override, + /// A plain dependency plus a user exact pin + /// `overrides: { left-pad: 1.3.0 }` in pnpm-workspace.yaml, which + /// vendoring takes over (#854). + ExactPin, /// A plain dependency: the control both modes accept. Plain, + /// A plain dependency in pnpm 12's two-document lock (`packageManager` + /// set): pnpm's env document ahead of the project lock (#466). + TwoDocument, } +/// The env document pnpm 12.8.1 writes ahead of the project lock when +/// `packageManager` is set (the `@pnpm/exe.*` platform entries trimmed). +const ENV_DOC: &str = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n configDependencies: {}\n packageManagerDependencies:\n pnpm:\n specifier: 12.8.1\n version: 12.8.1\n\npackages:\n\n pnpm@12.8.1:\n resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==}\n engines: {node: '>=18.*'}\n hasBin: true\n\nsnapshots:\n\n pnpm@12.8.1: {}\n"; + /// package.json, pnpm-workspace.yaml, the installed (unpatched) copy and /// the pristine lockfileVersion 9.0 lock pnpm writes for `shape`. fn write_pnpm_project(root: &Path, shape: Shape) { @@ -72,8 +88,9 @@ fn write_pnpm_project(root: &Path, shape: Shape) { .unwrap(); let workspace = match shape { Shape::Catalog => format!("packages:\n - .\ncatalog:\n {NAME}: {VERSION}\n"), - Shape::Override => format!("overrides:\n {NAME}: {VERSION}\n"), - Shape::Crlf | Shape::Plain => String::new(), + Shape::Override => format!("overrides:\n {NAME}: ^{VERSION}\n"), + Shape::ExactPin => format!("overrides:\n {NAME}: {VERSION}\n"), + Shape::Crlf | Shape::Plain | Shape::TwoDocument => String::new(), }; if !workspace.is_empty() { std::fs::write(root.join("pnpm-workspace.yaml"), workspace).unwrap(); @@ -87,15 +104,20 @@ fn write_pnpm_project(root: &Path, shape: Shape) { .unwrap(); std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); - let mut lock = String::from( + let mut lock = match shape { + Shape::TwoDocument => format!("---\n{ENV_DOC}\n---\n"), + _ => String::new(), + }; + lock.push_str( "lockfileVersion: '9.0'\n\nsettings:\n autoInstallPeers: true\n excludeLinksFromLockfile: false\n\n", ); match shape { Shape::Catalog => lock.push_str(&format!( "catalogs:\n default:\n {NAME}:\n specifier: {VERSION}\n version: {VERSION}\n\n" )), - Shape::Override => lock.push_str(&format!("overrides:\n {NAME}: {VERSION}\n\n")), - Shape::Crlf | Shape::Plain => {} + Shape::Override => lock.push_str(&format!("overrides:\n {NAME}: ^{VERSION}\n\n")), + Shape::ExactPin => lock.push_str(&format!("overrides:\n {NAME}: {VERSION}\n\n")), + Shape::Crlf | Shape::Plain | Shape::TwoDocument => {} } let specifier = match shape { Shape::Catalog => "'catalog:'".to_string(), @@ -335,6 +357,15 @@ fn assert_refused(env: &Value, exit: i32, code: &str) { ); } +/// The `scan` / `get --mode vendored --dry-run` preview row for `PURL`. +fn preview_row(envelope: &Value) -> Value { + envelope["vendor"]["patches"] + .as_array() + .and_then(|rows| rows.iter().find(|r| r["purl"] == PURL)) + .cloned() + .unwrap_or_else(|| panic!("the dry run must preview {PURL}: {envelope:#}")) +} + async fn refused_takeover_keeps_hosted_pin(shape: Shape, command: &str, code: &str) { let server = MockServer::start().await; mock_api(&server).await; @@ -342,9 +373,21 @@ async fn refused_takeover_keeps_hosted_pin(shape: Shape, command: &str, code: &s let root = tmp.path(); let hosted = host_project(root, &server.uri(), shape); - // The dry run writes nothing. - let (_, env) = run_mode(root, &server.uri(), command, "vendored", &["--dry-run"]); + // The dry run writes nothing, and previews the wet run's refusal + // (status and exit code unchanged) instead of promising the takeover. + let (exit, env) = run_mode(root, &server.uri(), command, "vendored", &["--dry-run"]); assert_still_hosted(root, &hosted, &env); + assert_eq!( + exit, 0, + "a would_refuse preview does not fail the run: {env:#}" + ); + let row = preview_row(&env); + assert_eq!(row["action"], "would_refuse", "{env:#}"); + assert_eq!(row["errorCode"], code, "{env:#}"); + assert!( + row["error"].as_str().is_some_and(|e| !e.is_empty()), + "the preview carries the backend's detail: {env:#}" + ); let (exit, env) = run_mode(root, &server.uri(), command, "vendored", &[]); assert_refused(&env, exit, code); @@ -373,12 +416,89 @@ async fn scan_vendored_over_hosted_pnpm_crlf_lock_keeps_the_hosted_pin() { .await; } -/// #853: a user exact-pin override in `pnpm-workspace.yaml`. +/// #853: `get --mode vendored` over a CRLF lock (a project-level refusal). +#[tokio::test(flavor = "multi_thread")] +async fn get_vendored_over_hosted_pnpm_crlf_lock_keeps_the_hosted_pin() { + refused_takeover_keeps_hosted_pin(Shape::Crlf, "get", "vendor_lockfile_crlf_unsupported").await; +} + +/// #853: a conflicting user override (a range) in `pnpm-workspace.yaml`. #[tokio::test(flavor = "multi_thread")] async fn scan_vendored_over_hosted_pnpm_workspace_override_keeps_the_hosted_pin() { refused_takeover_keeps_hosted_pin(Shape::Override, "scan", "vendor_override_conflict").await; } +/// #556: a hosted pin in `pnpm-lock.yaml` after the project turned +/// `gitBranchLockfile` on and pnpm wrote a branch lock. The vendored +/// backend refuses that project (it wires `pnpm-lock.yaml` only), so the +/// takeover must refuse before the restore rather than strip the pin. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_pnpm_git_branch_lockfile_keeps_the_hosted_pin() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + host_project(root, &server.uri(), Shape::Plain); + let ws = root.join("pnpm-workspace.yaml"); + let mut workspace = std::fs::read_to_string(&ws).unwrap_or_default(); + workspace.push_str("gitBranchLockfile: true\n"); + std::fs::write(&ws, workspace).unwrap(); + let lock = std::fs::read(root.join("pnpm-lock.yaml")).unwrap(); + std::fs::write(root.join("pnpm-lock.feature.yaml"), lock).unwrap(); + let hosted = snapshot(root); + + let (_, env) = run_mode(root, &server.uri(), "scan", "vendored", &["--dry-run"]); + assert_still_hosted(root, &hosted, &env); + let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]); + assert_refused(&env, exit, "vendor_pnpm_git_branch_lockfile"); + assert_still_hosted(root, &hosted, &env); +} + +/// #556, the other direction: `scan --mode hosted` over a vendored pnpm +/// entry once `gitBranchLockfile` is on with a branch lock. Hosted mode +/// pins no pnpm lock there, so reverting the vendored wiring first would +/// leave the package in neither mode: the takeover is refused and the +/// vendored wiring and ledger stay byte-for-byte. +#[tokio::test(flavor = "multi_thread")] +async fn scan_hosted_over_vendored_pnpm_git_branch_lockfile_keeps_the_vendored_wiring() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_pnpm_project(root, Shape::Plain); + let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]); + assert_eq!(exit, 0, "the plain project vendors: {env:#}"); + let ws = root.join("pnpm-workspace.yaml"); + let mut workspace = std::fs::read_to_string(&ws).unwrap_or_default(); + workspace.push_str("gitBranchLockfile: true\n"); + std::fs::write(&ws, workspace).unwrap(); + let lock = std::fs::read(root.join("pnpm-lock.yaml")).unwrap(); + std::fs::write(root.join("pnpm-lock.feature.yaml"), lock).unwrap(); + let vendored = snapshot(root); + let state = std::fs::read(root.join(".socket/vendor/state.json")).unwrap(); + + for extra in [&["--dry-run"][..], &[]] { + let (exit, env) = run_mode(root, &server.uri(), "scan", "hosted", extra); + assert_eq!(exit, 0, "{env:#}"); + assert!( + has_event_code(&env, "redirect_pnpm_git_branch_lockfile"), + "{env:#}" + ); + assert!( + !has_event_code(&env, "redirect_takeover_unpatched"), + "{env:#}" + ); + for ((file, before), (_, now)) in vendored.iter().zip(snapshot(root)) { + assert_eq!(before, &now, "{file} keeps the vendored wiring: {env:#}"); + } + assert_eq!( + std::fs::read(root.join(".socket/vendor/state.json")).unwrap(), + state, + "{env:#}" + ); + } +} + /// Control: a plain dependency is supported by both modes, so the takeover /// still restores the registry entry and vendors it. #[tokio::test(flavor = "multi_thread")] @@ -389,8 +509,11 @@ async fn scan_vendored_over_hosted_pnpm_plain_dep_still_takes_over() { let root = tmp.path(); host_project(root, &server.uri(), Shape::Plain); + // The hosted project (pin + hosted-created workspace scaffold) is not + // over-refused by the preview's takeover gates. let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &["--dry-run"]); assert_eq!(exit, 0, "{env:#}"); + assert_eq!(preview_row(&env)["action"], "would_vendor", "{env:#}"); let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]); assert_eq!(exit, 0, "the plain takeover must succeed: {env:#}"); @@ -406,6 +529,95 @@ async fn scan_vendored_over_hosted_pnpm_plain_dep_still_takes_over() { ); } +/// #854: a user exact pin equal to the vendored version in +/// `pnpm-workspace.yaml` (the pnpm 10.5+/11/12 override map) is taken over +/// like the same pin in package.json: the workspace value becomes the +/// vendored `file:` spec under the user's own key, and package.json is +/// left alone. It used to be refused as `vendor_override_conflict`. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_pnpm_workspace_exact_pin_takes_over() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let hosted = host_project(root, &server.uri(), Shape::ExactPin); + + let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &["--dry-run"]); + assert_eq!(exit, 0, "{env:#}"); + assert_still_hosted(root, &hosted, &env); + assert_eq!(preview_row(&env)["action"], "would_vendor", "{env:#}"); + + let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]); + assert_eq!(exit, 0, "the exact-pin takeover must succeed: {env:#}"); + let spec = format!("file:.socket/vendor/npm/{UUID}/{NAME}-{VERSION}.tgz"); + let ws = std::fs::read_to_string(root.join("pnpm-workspace.yaml")).unwrap(); + // Hosted mode's `trustLockfile: true` line may follow; the override + // entry itself is rewritten in place under the user's key. + assert!( + ws.starts_with(&format!("overrides:\n {NAME}: {spec}\n")), + "{ws}\n{env:#}" + ); + let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + assert!(!lock.contains(HOSTED_URL), "{lock}"); + assert!( + lock.contains(&format!("overrides:\n {NAME}: {spec}\n")), + "the lock override map must equal the workspace file's:\n{lock}" + ); + let pkg = std::fs::read_to_string(root.join("package.json")).unwrap(); + assert!(!pkg.contains("overrides"), "{pkg}"); +} + +/// #466: pnpm 12's two-document lock (`packageManager` set). Hosted → +/// vendored takes over in the project document, and the default hosted +/// `scan` takes the vendored project back: the vendored revert used to +/// read pnpm's env document, find every lock record "drifted", and unwind +/// the override surfaces around a lock left wired to the artifact — so the +/// takeover reported `redirected: 0`, dropped the vendor ledger, and left a +/// lock frozen installs reject. The env document is never edited. +#[tokio::test(flavor = "multi_thread")] +async fn two_document_lock_takes_over_both_ways() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + host_project(root, &server.uri(), Shape::TwoDocument); + let lock_path = root.join("pnpm-lock.yaml"); + let env_doc = |lock: &str| -> String { + let (env, _) = lock + .strip_prefix("---\n") + .and_then(|rest| rest.split_once("\n---\n")) + .unwrap_or_else(|| panic!("two documents expected:\n{lock}")); + env.to_string() + }; + let read_lock = || std::fs::read_to_string(&lock_path).unwrap(); + assert_eq!(env_doc(&read_lock()), ENV_DOC); + + let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]); + assert_eq!(exit, 0, "the two-document takeover must succeed: {env:#}"); + let lock = read_lock(); + assert_eq!(env_doc(&lock), ENV_DOC, "{lock}"); + assert!(!lock.contains(HOSTED_URL), "{lock}"); + assert!( + lock.contains(&format!("{NAME}@file:.socket/vendor/npm/{UUID}/")), + "the project document is wired:\n{lock}\n{env:#}" + ); + + let (exit, env) = run_mode(root, &server.uri(), "scan", "hosted", &[]); + assert_eq!(exit, 0, "{env:#}"); + assert!( + has_event_code(&env, "redirect_takeover_reverted_vendored"), + "{env:#}" + ); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let lock = read_lock(); + assert_eq!(env_doc(&lock), ENV_DOC, "{lock}"); + assert!(lock.contains(HOSTED_URL), "{lock}"); + assert!(!lock.contains(".socket/vendor"), "{lock}"); + let pkg = std::fs::read_to_string(root.join("package.json")).unwrap(); + assert!(!pkg.contains(".socket/vendor"), "{pkg}"); + assert!(!root.join(".socket/vendor/npm").exists(), "{env:#}"); +} + /// `vendor --dry-run` over the hosted pin previews the backend's refusal of /// the RESTORED project (staged in memory, never written) with the wet /// run's code, instead of promising the takeover; the wet `vendor` then diff --git a/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs index 64ea1197c..5b50a617f 100644 --- a/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs @@ -685,3 +685,140 @@ async fn paths_echo_always_present() { ); assert_eq!(v["paths"], serde_json::json!([])); } + +// --------------------------------------------------------------------------- +// 7. Scope sees EVERY installed copy of a purl, not just the crawler's +// one-per-name@version representative (#778). +// --------------------------------------------------------------------------- + +/// One `scan --mode agent --dry-run ` against a fresh mock: the +/// batch-POST bodies it sent (empty when the scope selected nothing) and +/// its envelope. +async fn scoped_agent_scan(root: &Path, pattern: &str) -> (Vec, serde_json::Value) { + let server = MockServer::start().await; + mock_batch_empty(&server).await; + let (code, stdout, stderr) = run_scan( + root, + &server.uri(), + &[pattern, "--mode", "agent", "--dry-run"], + ); + assert_eq!( + code, 0, + "scoped scan of {pattern} must exit 0; stdout={stdout}; stderr={stderr}" + ); + let reqs = recorded(&server).await; + let bodies = batch_posts(&reqs).into_iter().map(req_body).collect(); + (bodies, parse_envelope(&stdout)) +} + +/// pnpm's isolated linker: each member's `node_modules/` is a symlink +/// into the root `node_modules/.pnpm` store, which the crawl reaches +/// first. Scoping to the member must still select its dependencies, the +/// same copies `rollback packages/a` selects. +#[cfg(unix)] +#[tokio::test] +async fn paths_scope_selects_pnpm_member_linked_copy() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + r#"{ "name": "root", "version": "1.0.0", "private": true }"#, + ) + .unwrap(); + std::fs::write( + root.join("pnpm-workspace.yaml"), + "packages: ['packages/*']\n", + ) + .unwrap(); + for (member, name, version) in [("a", "left-pad", "1.3.0"), ("b", "is-number", "7.0.0")] { + write_npm_package_at( + root, + &format!("node_modules/.pnpm/{name}@{version}"), + name, + version, + ); + let member_dir = root.join("packages").join(member); + std::fs::create_dir_all(member_dir.join("node_modules")).unwrap(); + std::fs::write( + member_dir.join("package.json"), + format!( + r#"{{ "name": "{member}", "version": "1.0.0", "dependencies": {{ "{name}": "{version}" }} }}"# + ), + ) + .unwrap(); + std::os::unix::fs::symlink( + format!("../../../node_modules/.pnpm/{name}@{version}/node_modules/{name}"), + member_dir.join("node_modules").join(name), + ) + .unwrap(); + } + + for pattern in [ + "packages/a", + "packages/a/**", + "packages/a/node_modules/left-pad", + ] { + let (bodies, v) = scoped_agent_scan(root, pattern).await; + assert_eq!( + bodies.len(), + 1, + "scan {pattern} must query the batch API once; envelope {v}" + ); + assert!( + bodies[0].contains("pkg:npm/left-pad@1.3.0"), + "scan {pattern} must select member a's left-pad; body: {}", + bodies[0] + ); + assert!( + !bodies[0].contains("is-number"), + "scan {pattern} must not select member b's is-number; body: {}", + bodies[0] + ); + assert_eq!(v["scannedPackages"], 1, "scan {pattern}: {v}"); + } +} + +/// yarn classic / npm workspaces with a version conflict: two real member +/// copies of left-pad@1.3.0 (no symlinks), the root at 1.2.0. Whichever +/// member copy the walk meets first, BOTH members must scope to it. +#[tokio::test] +async fn paths_scope_selects_every_nested_member_copy() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + r#"{ "name": "root", "version": "1.0.0", "private": true, "workspaces": ["packages/*"], "dependencies": { "left-pad": "1.2.0" } }"#, + ) + .unwrap(); + write_npm_package_at(root, "", "left-pad", "1.2.0"); + for member in ["a", "b"] { + let prefix = format!("packages/{member}"); + std::fs::create_dir_all(root.join(&prefix)).unwrap(); + std::fs::write( + root.join(&prefix).join("package.json"), + format!(r#"{{ "name": "{member}", "version": "1.0.0", "dependencies": {{ "left-pad": "1.3.0" }} }}"#), + ) + .unwrap(); + write_npm_package_at(root, &prefix, "left-pad", "1.3.0"); + } + + for pattern in ["packages/a", "packages/b"] { + let (bodies, v) = scoped_agent_scan(root, pattern).await; + assert_eq!( + bodies.len(), + 1, + "scan {pattern} must query the batch API once; envelope {v}" + ); + assert!( + bodies[0].contains("pkg:npm/left-pad@1.3.0"), + "scan {pattern} must select its member copy of left-pad@1.3.0; body: {}", + bodies[0] + ); + assert!( + !bodies[0].contains("left-pad@1.2.0"), + "scan {pattern} must not select the root left-pad@1.2.0; body: {}", + bodies[0] + ); + assert_eq!(v["scannedPackages"], 1, "scan {pattern}: {v}"); + } +} diff --git a/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs b/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs index 5d0f27e92..bc219bc99 100644 --- a/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs +++ b/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs @@ -81,6 +81,24 @@ pub const DEFAULT_PRODUCT: &str = "pkg:npm/app@1.0.0"; /// Default document path, relative to the project. pub const DEFAULT_OUTPUT: &str = "out.vex.json"; +/// Whether the pnpm that installed `proj` (the `packageManager` its +/// `node_modules/.modules.yaml` records) is 9.0–10.4, the releases that get +/// no root-only pnpm-workspace.yaml (#734). +pub fn installed_pnpm_pre_10_5(proj: &Path) -> bool { + let Ok(text) = std::fs::read_to_string(proj.join("node_modules/.modules.yaml")) else { + return false; + }; + let Some(at) = text.find("pnpm@") else { + return false; + }; + let mut parts = text[at + "pnpm@".len()..].split(|c: char| !c.is_ascii_digit()); + let mut next = || parts.next().and_then(|p| p.parse::().ok()); + match (next(), next()) { + (Some(major), Some(minor)) => (9, 0) <= (major, minor) && (major, minor) < (10, 5), + _ => false, + } +} + /// Absolute path of the `socket-patch` binary under test. pub fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 7c9cffad2..0dce2d001 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -966,6 +966,47 @@ fn list_dir_sync(path: &Path) -> Listing { } } +/// The `node_modules` roots a global prefix stands for. pnpm 11+ gives +/// every `pnpm add -g` its own install, `$PNPM_HOME/global/v11//`, +/// each with its own `node_modules` (and its own `.pnpm` on pnpm 12, or +/// with `enableGlobalVirtualStore: false`), and `pnpm root -g` prints +/// their parent. Walked as one root, the installs share one +/// `resolve_pending_targets` pass, whose store-entry filter drops a +/// target from every later `.pnpm` once any install matched it, so the +/// other installs' copies were silently missed (#435). Each install is +/// therefore its own root. +/// +/// `prefix` splits only when it is a layout-version dir (`v`) that is +/// not itself a `node_modules` (no `.modules.yaml` or `.pnpm`) and at +/// least one child's `node_modules` carries pnpm's `.modules.yaml`. Every +/// child `node_modules` is then a root, marked or not, so an interrupted +/// install is still walked, in sorted order and deduplicated by real +/// path. Anything else (pnpm <= 10's `global/5/node_modules`, an npm or +/// custom prefix) is returned as the single root it always was. +fn pnpm_isolated_global_install_roots(prefix: &Path) -> Vec { + let single = || vec![prefix.to_path_buf()]; + let is_version_dir = prefix + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(crate::patch::shared_store::is_pnpm_store_version_dir); + if !is_version_dir || prefix.join(".modules.yaml").exists() || prefix.join(".pnpm").exists() { + return single(); + } + let mut installs: Vec = list_dir_sync(prefix) + .entries + .into_iter() + .map(|entry| prefix.join(entry.name).join("node_modules")) + .filter(|nm| is_dir_sync(nm)) + .collect(); + if !installs.iter().any(|nm| nm.join(".modules.yaml").is_file()) { + return single(); + } + installs.sort(); + let mut seen = HashSet::new(); + installs.retain(|nm| seen.insert(std::fs::canonicalize(nm).unwrap_or_else(|_| nm.clone()))); + installs +} + /// Whether `dir/node_modules` is a directory, following symlinks — the /// workspace roots walk's `is_dir` probe — skipping the stat when `dir`'s /// own listing (which the walk reads anyway) already proves the answer is @@ -1575,6 +1616,9 @@ impl NpmCrawler { // engine's fan-out job, and re-probing the store for it would // add a readdir storm. A target with no importer-tree copy // (transitive-only) still gets its store entries probed. + // The filter is walk-wide, so it is only sound within ONE + // install: separate pnpm installs must be separate roots + // (see `pnpm_isolated_global_install_roots`, #435). let unmatched_names: HashSet<&str> = pending .iter() .filter(|t| !result.contains_key(&t.purl)) @@ -1957,7 +2001,9 @@ impl NpmCrawler { add(PathBuf::from(npm_path)); } if let Some(pnpm_path) = get_pnpm_global_prefix() { - add(PathBuf::from(pnpm_path)); + for root in pnpm_isolated_global_install_roots(Path::new(&pnpm_path)) { + add(root); + } } if let Some(yarn_path) = get_yarn_global_prefix() { add(PathBuf::from(yarn_path)); @@ -2012,7 +2058,7 @@ impl NpmCrawler { fn node_modules_paths_sync(options: &CrawlerOptions) -> Vec { if options.global || options.global_prefix.is_some() { if let Some(ref custom) = options.global_prefix { - return vec![custom.clone()]; + return pnpm_isolated_global_install_roots(custom); } return NpmCrawler.get_global_node_modules_paths(); } @@ -6326,4 +6372,121 @@ mod tests { .expect("a FIFO .yarnrc must not block root discovery"); assert_eq!(roots, vec![root.join("node_modules")]); } + + /// One pnpm 11+ isolated global install, `//node_modules`, + /// with its `.modules.yaml` marker (when `marked`) and its own + /// `.pnpm` virtual store holding a real `left-pad@1.3.0`. `direct` + /// links `left-pad` at the install's top level; otherwise it is + /// reached only through `lp-wrapper` (a transitive-only copy). + fn write_pnpm_global_install(v11: &Path, hash: &str, direct: bool, marked: bool) -> PathBuf { + let nm = v11.join(hash).join("node_modules"); + let store = nm.join(".pnpm"); + let pad = store.join("left-pad@1.3.0/node_modules/left-pad"); + write_pkg(&pad, "left-pad", "1.3.0"); + std::fs::write(v11.join(hash).join("package.json"), "{}").unwrap(); + if marked { + std::fs::write(nm.join(".modules.yaml"), "layoutVersion: 5\n").unwrap(); + } + if direct { + link_dir(&pad, &nm.join("left-pad")); + } else { + let wrapper = store.join("lp-wrapper@1.0.0/node_modules/lp-wrapper"); + write_pkg(&wrapper, "lp-wrapper", "1.0.0"); + link_dir(&pad, &store.join("lp-wrapper@1.0.0/node_modules/left-pad")); + link_dir(&wrapper, &nm.join("lp-wrapper")); + } + nm + } + + fn global_prefix_options(prefix: &Path) -> CrawlerOptions { + CrawlerOptions { + cwd: prefix.to_path_buf(), + global: true, + global_prefix: Some(prefix.to_path_buf()), + } + } + + /// #435: pnpm 11+ gives every `pnpm add -g` its own install dir under + /// `pnpm root -g` (`$PNPM_HOME/global/v11`). Each install is its own + /// root, so one walk's matches can never hide another install's copy; + /// an unmarked sibling (an interrupted install) is still walked. + #[test] + fn global_prefix_splits_pnpm_isolated_global_installs() { + let tmp = tempfile::tempdir().unwrap(); + let v11 = tmp.path().join("global").join("v11"); + let b = write_pnpm_global_install(&v11, "bbb", true, true); + let a = write_pnpm_global_install(&v11, "aaa", false, true); + let c = write_pnpm_global_install(&v11, "ccc", false, false); + // Neither a stray file nor a dir without `node_modules` is a root. + std::fs::write(v11.join("stray"), "").unwrap(); + std::fs::create_dir_all(v11.join("empty")).unwrap(); + let roots = NpmCrawler::node_modules_paths_sync(&global_prefix_options(&v11)); + assert_eq!(roots, vec![a, b, c]); + } + + /// The split only applies to pnpm's layout-version dir: pnpm <= 10's + /// `global/5/node_modules`, an npm prefix and a `v1` dir with no + /// marked install are each still the single root they always were. + #[test] + fn global_prefix_keeps_non_isolated_roots() { + let tmp = tempfile::tempdir().unwrap(); + let legacy = tmp.path().join("global/5/node_modules"); + write_pkg(&legacy.join("left-pad"), "left-pad", "1.3.0"); + std::fs::write(legacy.join(".modules.yaml"), "layoutVersion: 5\n").unwrap(); + let npm = tmp.path().join("npm/lib/node_modules"); + write_pkg(&npm.join("left-pad"), "left-pad", "1.3.0"); + let v1 = tmp.path().join("other/v1"); + write_pkg(&v1.join("pkg/node_modules/left-pad"), "left-pad", "1.3.0"); + // A `v` dir that IS a pnpm node_modules is never split. + let nm_v2 = tmp.path().join("nm/v2"); + write_pnpm_global_install(&nm_v2, "aaa", true, true); + std::fs::write(nm_v2.join(".modules.yaml"), "layoutVersion: 5\n").unwrap(); + for root in [legacy, npm, v1, nm_v2] { + let roots = NpmCrawler::node_modules_paths_sync(&global_prefix_options(&root)); + assert_eq!(roots, vec![root]); + } + } + + /// #435 end to end through the crawler: one install links `left-pad` + /// directly and the other holds it only transitively in its own + /// `.pnpm`. Walking `global/v11` as one root let whichever install + /// listed first remove `left-pad` from the store filter, so the other + /// install's copy was silently skipped. Both orientations are built + /// so the guard fails whatever the directory listing order. + #[tokio::test] + async fn global_prefix_finds_every_pnpm_isolated_install_copy() { + for direct_first in [true, false] { + let tmp = tempfile::tempdir().unwrap(); + let v11 = tmp.path().join("global").join("v11"); + let a = write_pnpm_global_install(&v11, "aaa", direct_first, true); + let b = write_pnpm_global_install(&v11, "bbb", !direct_first, true); + let crawler = NpmCrawler::new(); + let purl = "pkg:npm/left-pad@1.3.0".to_string(); + let mut real: Vec = Vec::new(); + for root in crawler + .get_node_modules_paths(&global_prefix_options(&v11)) + .await + .unwrap() + { + let found = crawler + .find_by_purls(&root, std::slice::from_ref(&purl)) + .await + .unwrap(); + for pkg in found.get(&purl).into_iter().flatten() { + let canon = std::fs::canonicalize(&pkg.path).unwrap(); + if !real.contains(&canon) { + real.push(canon); + } + } + } + real.sort(); + let want = |nm: &Path| { + std::fs::canonicalize(nm.join(".pnpm/left-pad@1.3.0/node_modules/left-pad")) + .unwrap() + }; + let mut expected = vec![want(&a), want(&b)]; + expected.sort(); + assert_eq!(real, expected, "direct_first={direct_first}"); + } + } } diff --git a/crates/socket-patch-core/src/formats/pnpm/grammar.rs b/crates/socket-patch-core/src/formats/pnpm/grammar.rs index 40fd21fba..6030b57bf 100644 --- a/crates/socket-patch-core/src/formats/pnpm/grammar.rs +++ b/crates/socket-patch-core/src/formats/pnpm/grammar.rs @@ -88,6 +88,26 @@ pub(crate) fn is_pnpm_lock_text(text: &str) -> bool { .any(|line| line.starts_with("lockfileVersion:") || line.starts_with("shrinkwrapVersion:")) } +/// The main document of a pnpm lock: the text after its last column-0 +/// `---` marker, else all of it. pnpm 11+ writes the env lockfile +/// (`configDependencies` / `packageManagerDependencies`, whose resolutions +/// never carry `tarball:`) as a first document ahead of the project lock. +pub(crate) fn main_document(content: &str) -> &str { + let mut start = 0; + let mut offset = 0; + for line in content.split_inclusive('\n') { + offset += line.len(); + let text = line.trim_end_matches(['\r', '\n']); + if text + .strip_prefix("---") + .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t'])) + { + start = offset; + } + } + &content[start..] +} + /// The value of the entry-level (four-space) `field:` line of a packages /// entry body, unquoted; `None` when absent, empty, or given more than once /// (ambiguous — fail closed). Nested (deeper-indented) lines are never diff --git a/crates/socket-patch-core/src/formats/pnpm/lines.rs b/crates/socket-patch-core/src/formats/pnpm/lines.rs index 7bc049379..0badfcb72 100644 --- a/crates/socket-patch-core/src/formats/pnpm/lines.rs +++ b/crates/socket-patch-core/src/formats/pnpm/lines.rs @@ -24,6 +24,79 @@ pub(crate) fn section_bounds(lines: &[String], name: &str) -> Option<(usize, usi Some((start, end)) } +/// The importer dependency fields pnpm writes into the env document of a +/// two-document lock. +const ENV_DOC_IMPORTER_FIELDS: [&str; 2] = ["configDependencies", "packageManagerDependencies"]; + +/// Split a lock into `(prefix, project document)`, `prefix + project == +/// text`. pnpm >= 11 writes `pnpm-lock.yaml` as two YAML documents when the +/// project has config dependencies (pnpm 11 and 12) or pins pnpm through +/// `packageManager` (pnpm 12): `---`, an env document whose `importers:` +/// hold only `configDependencies` / `packageManagerDependencies` (plus its +/// own `packages:` / `snapshots:`), `---`, then the project lock. The line +/// planners address sections by their first column-0 header, so they must +/// see the project document alone; a config dependency may share the +/// vendored package's key, so skipping documents without the key is not +/// enough. +/// +/// A lock without a column-0 `---` line is one document (`("", text)`); a +/// lone leading `---` is kept in the prefix. Anything else — more +/// documents, a first document that is not the env document, a document +/// end marker, a byte-order mark before a separator — is `Err` (the +/// reason): the caller refuses rather than guess which document to edit. +pub(crate) fn split_project_document(text: &str) -> Result<(&str, &str), String> { + // `(start, end)` byte offsets of every separator line, `end` past its `\n`. + let mut separators: Vec<(usize, usize)> = Vec::new(); + let mut offset = 0; + for line in text.split('\n') { + let end = (offset + line.len() + 1).min(text.len()); + let marker = crate::formats::text::strip_bom(line); + if marker.starts_with("---") || marker.starts_with("...") { + if marker.len() != line.len() { + return Err("has a byte-order mark before its `---` separator".to_string()); + } + if line != "---" { + return Err(format!("has a YAML document marker line `{line}`")); + } + separators.push((offset, end)); + } + offset = end; + } + match separators[..] { + [] => Ok(("", text)), + [(start, _), ..] if start != 0 => { + Err("has a `---` document separator after its first document".to_string()) + } + [(_, end)] => Ok(text.split_at(end)), + [(_, env_start), (env_end, end)] => { + check_env_document(&text[env_start..env_end])?; + Ok(text.split_at(end)) + } + _ => Err(format!("has {} YAML documents", separators.len())), + } +} + +/// The first of two documents must be pnpm's env document: an `importers:` +/// section whose importers carry only [`ENV_DOC_IMPORTER_FIELDS`]. +fn check_env_document(doc: &str) -> Result<(), String> { + let lines = split_lines(doc); + let Some((start, end)) = section_bounds(&lines, "importers") else { + return Err("has a first document without `importers:`".to_string()); + }; + for line in &lines[start + 1..end] { + if let Some((field, _, _)) = parse_key_line(line, 4) { + if !ENV_DOC_IMPORTER_FIELDS.contains(&field) { + return Err(format!( + "has a first document whose importers carry `{field}` (pnpm's \ + config-dependency document carries only {})", + ENV_DOC_IMPORTER_FIELDS.join(" / ") + )); + } + } + } + Ok(()) +} + /// One 2-space-keyed block inside a section (`[header, end)`; `end` stops at /// the blank separator / next block header, so the captured fragment is the /// verbatim entry without surrounding blanks). @@ -235,6 +308,48 @@ pub(crate) fn yaml_key_like(key: &str, original_repr: &str) -> String { mod tests { use super::*; + const PROJECT: &str = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n a:\n specifier: 1.0.0\n version: 1.0.0\n"; + const ENV_CONFIG: &str = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n configDependencies:\n a:\n specifier: 1.0.0\n version: 1.0.0\n\npackages:\n\n a@1.0.0:\n resolution: {integrity: sha512-x}\n\nsnapshots:\n\n a@1.0.0: {}\n"; + const ENV_PM: &str = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n configDependencies: {}\n packageManagerDependencies:\n pnpm:\n specifier: 12.8.1\n version: 12.8.1\n"; + + /// #466: one document is the whole lock; pnpm's env document (config + /// dependencies, `packageManager`, or both) splits off at the second + /// separator, and the prefix plus the project document is the text. + #[test] + fn split_project_document_keeps_the_env_document_in_the_prefix() { + assert_eq!(split_project_document(PROJECT), Ok(("", PROJECT))); + let lone = format!("---\n{PROJECT}"); + assert_eq!(split_project_document(&lone), Ok(("---\n", PROJECT))); + let both = ENV_PM.replace( + "configDependencies: {}", + "configDependencies:\n a:\n specifier: 1.0.0\n version: 1.0.0", + ); + for env in [ENV_CONFIG, ENV_PM, both.as_str()] { + let text = format!("---\n{env}\n---\n{PROJECT}"); + let (prefix, project) = split_project_document(&text).unwrap(); + assert_eq!(project, PROJECT); + assert_eq!(prefix, format!("---\n{env}\n---\n")); + } + } + + /// Anything but pnpm's env document followed by the project lock is + /// refused rather than guessed at. + #[test] + fn split_project_document_refuses_unrecognized_documents() { + let not_env = ENV_CONFIG.replace("configDependencies", "dependencies"); + for text in [ + format!("---\n{ENV_CONFIG}\n---\n{ENV_PM}\n---\n{PROJECT}"), + format!("---\n{not_env}\n---\n{PROJECT}"), + format!("---\npackages:\n\n a@1.0.0: {{}}\n---\n{PROJECT}"), + format!("{PROJECT}---\n{ENV_CONFIG}"), + format!("\u{feff}---\n{ENV_CONFIG}\n---\n{PROJECT}"), + format!("---\n{ENV_CONFIG}\n---\n{PROJECT}...\n"), + format!("--- !tag\n{PROJECT}"), + ] { + assert!(split_project_document(&text).is_err(), "{text}"); + } + } + /// Values pnpm's writer leaves plain stay byte-identical — the spellings /// the vendored legacy splice already round-trips (#754's passing cells). #[test] diff --git a/crates/socket-patch-core/src/formats/pnpm/mod.rs b/crates/socket-patch-core/src/formats/pnpm/mod.rs index 03ad7d97d..737367300 100644 --- a/crates/socket-patch-core/src/formats/pnpm/mod.rs +++ b/crates/socket-patch-core/src/formats/pnpm/mod.rs @@ -27,8 +27,11 @@ pub(crate) mod grammar; pub(crate) mod hosted; pub(crate) mod lines; +mod version; pub(crate) mod workspace; +pub use version::root_only_workspace_breaks_add; + pub(crate) use grammar::{ entry_bundled, entry_field, is_pnpm_lock_text, Bundled, Entry, Resolution, }; @@ -296,8 +299,10 @@ fn lock_versions(text: &str) -> impl Iterator, u32)> + '_ { } /// `lockfileVersion` major of a pnpm lock. pnpm 9-12 emit -/// `lockfileVersion: '9.0'` (single doc, first line); pnpm 8 emits `'6.0'`, -/// pnpm 7 an unquoted `5.4`. `None` when no parseable version line exists — +/// `lockfileVersion: '9.0'` (first line; pnpm >= 11 with config +/// dependencies or a `packageManager` pin writes two documents, each with +/// its own version line after a `---`); pnpm 8 emits `'6.0'`, pnpm 7 an +/// unquoted `5.4`. `None` when no parseable version line exists — /// the hosted trust-config gate treats that as "not trust-policy era" and /// stays hands-off (fail closed: never write config for a lock it can't /// read). diff --git a/crates/socket-patch-core/src/formats/pnpm/version.rs b/crates/socket-patch-core/src/formats/pnpm/version.rs new file mode 100644 index 000000000..036b96762 --- /dev/null +++ b/crates/socket-patch-core/src/formats/pnpm/version.rs @@ -0,0 +1,274 @@ +//! Which pnpm a project runs, as far as its files say, and whether that +//! pnpm breaks on a root-only `pnpm-workspace.yaml` (#734). +//! +//! pnpm 9.0.0 through 10.4.x treat any pnpm-workspace.yaml as a workspace +//! whose root is the workspace dir, so after one is created for a +//! single-package project, `pnpm add ` refuses with +//! ERR_PNPM_ADDING_TO_ROOT unless it is given `-w`. 10.5.0 is the first +//! release that adds at the root of a root-only workspace (measured on +//! 9.0.0, 9.7.1, 9.15.9, 10.0.0–10.4.1 failing and 10.5.0+ passing). Those +//! versions also read nothing the modes would write there: `trustLockfile` +//! is a pnpm >= 11 setting, and `overrides:` in pnpm-workspace.yaml is read +//! from 10.5 on (older ones read package.json `pnpm.overrides`). +//! +//! The evidence is read conservatively, because a wrong "skip" loses a +//! setting pnpm >= 11 needs: every present source must pin pnpm inside +//! 9.0–10.4. A source that names a later pnpm, a range reaching past 10.4, +//! one that does not parse, or no source at all keeps the file. The sources +//! are the installed `node_modules/.modules.yaml` `packageManager` (the pnpm +//! that last installed) and package.json `packageManager`, +//! `devEngines.packageManager` and `engines.pnpm`; a pin for another tool +//! is no evidence either way. + +use crate::formats::text::strip_bom; +use crate::utils::package_manager::pinned_version; + +/// `(major, minor)`; patch levels never matter here. +type MajorMinor = (u64, u64); + +/// The pnpm releases that refuse `pnpm add` in a root-only workspace. +fn breaks_root_only_workspace((lo, hi): (MajorMinor, MajorMinor)) -> bool { + lo >= (9, 0) && hi < (10, 5) +} + +/// The inclusive `(major, minor)` span a version spec admits: an exact +/// version (`9.15.9`, `=9.15.9`, `v9.15.9`, a prerelease), `^X.Y.Z`, +/// `~X.Y.Z`, or a wildcard (`9`, `9.x`, `10.4.x`, `9.*`). `None` for +/// anything else (comparators, unions, hyphen ranges, junk). +fn version_span(spec: &str) -> Option<(MajorMinor, MajorMinor)> { + let spec = spec.trim(); + let (op, rest) = match spec.as_bytes().first()? { + b'^' => ('^', &spec[1..]), + b'~' => ('~', &spec[1..]), + b'=' => ('=', &spec[1..]), + _ => ('=', spec), + }; + let rest = rest.strip_prefix('v').unwrap_or(rest); + let core = rest.split(['-', '+']).next()?; + let wild = |part: &str| matches!(part, "x" | "X" | "*"); + let mut parts = core.split('.'); + let major: u64 = parts.next()?.parse().ok()?; + let minor = match parts.next() { + None => None, + Some(part) if wild(part) => None, + Some(part) => Some(part.parse::().ok()?), + }; + if let Some(patch) = parts.next() { + if !(wild(patch) || patch.parse::().is_ok()) { + return None; + } + } + if parts.next().is_some() { + return None; + } + Some(match (op, minor) { + (_, None) => ((major, 0), (major, u64::MAX)), + ('^', Some(minor)) if major > 0 => ((major, minor), (major, u64::MAX)), + (_, Some(minor)) => ((major, minor), (major, minor)), + }) +} + +/// The `packageManager` value of a `.modules.yaml`: JSON on pnpm 10+, +/// YAML before (a top-level scalar, maybe quoted). +fn modules_yaml_package_manager(text: &str) -> Option { + let text = strip_bom(text); + if let Ok(value) = serde_json::from_str::(text) { + return value.get("packageManager")?.as_str().map(str::to_string); + } + text.split('\n').find_map(|line| { + let (key, value) = super::workspace::top_level_key(line)?; + (key == "packageManager").then(|| value.trim_matches(|c| c == '\'' || c == '"').to_string()) + }) +} + +/// Every pnpm version pin the project's files carry, as `(where, spec)`. +fn pnpm_pins( + package_json: Option<&str>, + modules_yaml: Option<&str>, +) -> Vec<(&'static str, String)> { + let mut pins = Vec::new(); + if let Some(spec) = modules_yaml.and_then(modules_yaml_package_manager) { + if let Some(version) = pinned_version(&spec, "pnpm") { + pins.push(("node_modules/.modules.yaml", version.to_string())); + } + } + let Some(pkg) = package_json + .and_then(|text| serde_json::from_str::(strip_bom(text)).ok()) + else { + return pins; + }; + if let Some(version) = pkg + .get("packageManager") + .and_then(|v| v.as_str()) + .and_then(|spec| pinned_version(spec, "pnpm")) + { + pins.push(("package.json packageManager", version.to_string())); + } + let dev_engines = pkg.get("devEngines").and_then(|d| d.get("packageManager")); + let entries: Vec<&serde_json::Value> = match dev_engines { + Some(serde_json::Value::Array(items)) => items.iter().collect(), + Some(entry) => vec![entry], + None => Vec::new(), + }; + for entry in entries { + if entry.get("name").and_then(|n| n.as_str()) == Some("pnpm") { + // No version admits every pnpm: kept as an unparseable pin. + let version = entry.get("version").and_then(|v| v.as_str()).unwrap_or(""); + pins.push(("package.json devEngines", version.to_string())); + } + } + if let Some(range) = pkg.get("engines").and_then(|e| e.get("pnpm")) { + pins.push(( + "package.json engines.pnpm", + range.as_str().unwrap_or("").to_string(), + )); + } + pins +} + +/// When every pnpm pin the project carries (at least one) is a release that +/// refuses `pnpm add` in a root-only workspace, the pins as prose +/// (``pnpm@9.15.9 (node_modules/.modules.yaml)``): creating a +/// pnpm-workspace.yaml would then turn the single-package project into a +/// workspace that pnpm reads nothing from. `None` keeps today's scaffold. +pub fn root_only_workspace_breaks_add( + package_json: Option<&str>, + modules_yaml: Option<&str>, +) -> Option { + let pins = pnpm_pins(package_json, modules_yaml); + let all_break = !pins.is_empty() + && pins + .iter() + .all(|(_, spec)| version_span(spec).is_some_and(breaks_root_only_workspace)); + all_break.then(|| { + pins.iter() + .map(|(source, spec)| format!("pnpm@{spec} ({source})")) + .collect::>() + .join(", ") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_span_reads_exact_caret_tilde_and_wildcards() { + for (spec, span) in [ + ("9.15.9", Some(((9, 15), (9, 15)))), + ("=10.4.1", Some(((10, 4), (10, 4)))), + ("v10.0.0-rc.3", Some(((10, 0), (10, 0)))), + ("^9.15.0", Some(((9, 15), (9, u64::MAX)))), + ("~10.4.1", Some(((10, 4), (10, 4)))), + ("9.x", Some(((9, 0), (9, u64::MAX)))), + ("9", Some(((9, 0), (9, u64::MAX)))), + ("10.4.x", Some(((10, 4), (10, 4)))), + (">=9", None), + ("9 || 10", None), + ("9.0.0 - 10.4.0", None), + ("", None), + ("latest", None), + ] { + assert_eq!(version_span(spec), span, "{spec}"); + } + } + + #[test] + fn breaks_root_only_workspace_on_9_0_through_10_4() { + let at = |major, minor| breaks_root_only_workspace(((major, minor), (major, minor))); + assert!(!at(8, 15)); + assert!(at(9, 0)); + assert!(at(10, 4)); + assert!(!at(10, 5)); + assert!(!at(11, 0)); + // `^10.2.0` reaches 10.5+, `^9.0.0` stays below 10. + assert!(!breaks_root_only_workspace( + version_span("^10.2.0").unwrap() + )); + assert!(breaks_root_only_workspace(version_span("^9.0.0").unwrap())); + } + + #[test] + fn every_source_is_read() { + let breaks = |pkg: Option<&str>, modules: Option<&str>| { + root_only_workspace_breaks_add(pkg, modules).is_some() + }; + // .modules.yaml, YAML (pnpm 9) and JSON (pnpm 10) spellings. + let yaml = "hoistPattern:\n - '*'\nlayoutVersion: 5\npackageManager: pnpm@9.15.9\n"; + assert_eq!( + root_only_workspace_breaks_add(None, Some(yaml)).as_deref(), + Some("pnpm@9.15.9 (node_modules/.modules.yaml)") + ); + assert!(breaks(None, Some("packageManager: 'pnpm@9.0.0'\r\n"))); + assert!(breaks( + None, + Some(r#"{"layoutVersion":5,"packageManager":"pnpm@10.4.1"}"#) + )); + assert!(!breaks(None, Some(r#"{"packageManager":"pnpm@10.5.0"}"#))); + // package.json packageManager, with Corepack's hash suffix and a BOM. + assert!(breaks( + Some("\u{feff}{\"packageManager\":\"pnpm@10.4.1+sha512.abc==\"}"), + None + )); + assert!(!breaks(Some(r#"{"packageManager":"pnpm@11.0.0"}"#), None)); + // devEngines, object and array forms. + assert!(breaks( + Some(r#"{"devEngines":{"packageManager":{"name":"pnpm","version":"^9.15.0"}}}"#), + None + )); + assert!(breaks( + Some( + r#"{"devEngines":{"packageManager":[{"name":"npm","version":"11"},{"name":"pnpm","version":"10.4.x"}]}}"# + ), + None + )); + // `10.x` reaches 10.5+, which reads the file. + assert!(!breaks( + Some(r#"{"devEngines":{"packageManager":{"name":"pnpm","version":"10.x"}}}"#), + None + )); + assert!(!breaks( + Some(r#"{"devEngines":{"packageManager":{"name":"pnpm"}}}"#), + None + )); + // engines.pnpm. + assert!(breaks(Some(r#"{"engines":{"pnpm":"~9.15.0"}}"#), None)); + assert!(!breaks(Some(r#"{"engines":{"pnpm":">=9"}}"#), None)); + } + + #[test] + fn any_disagreeing_unparseable_or_missing_pin_keeps_the_scaffold() { + let modules_9 = Some("packageManager: pnpm@9.15.9\n"); + // A stale install record after an upgrade the pin names. + assert!(root_only_workspace_breaks_add( + Some(r#"{"packageManager":"pnpm@11.1.0"}"#), + modules_9 + ) + .is_none()); + assert!( + root_only_workspace_breaks_add(Some(r#"{"engines":{"pnpm":">=9"}}"#), modules_9) + .is_none() + ); + // Agreeing sources: both named. + assert_eq!( + root_only_workspace_breaks_add(Some(r#"{"packageManager":"pnpm@9.15.9"}"#), modules_9) + .as_deref(), + Some( + "pnpm@9.15.9 (node_modules/.modules.yaml), \ + pnpm@9.15.9 (package.json packageManager)" + ) + ); + // No pnpm pin at all: unknown, so the scaffold stays. + for (pkg, modules) in [ + (None, None), + (Some(r#"{"name":"app"}"#), None), + (Some(r#"{"packageManager":"yarn@1.22.22"}"#), None), + (Some("not json"), Some("layoutVersion: 5\n")), + ] { + assert!( + root_only_workspace_breaks_add(pkg, modules).is_none(), + "{pkg:?}" + ); + } + } +} diff --git a/crates/socket-patch-core/src/formats/pnpm/workspace.rs b/crates/socket-patch-core/src/formats/pnpm/workspace.rs index c93f45295..66fabf6a8 100644 --- a/crates/socket-patch-core/src/formats/pnpm/workspace.rs +++ b/crates/socket-patch-core/src/formats/pnpm/workspace.rs @@ -56,6 +56,17 @@ pub(crate) fn top_level_key(line: &str) -> Option<(String, &str)> { Some((key, strip_comment(rest).trim())) } +/// The value of the last top-level `key` in a YAML settings file +/// (pnpm-workspace.yaml, .yarnrc.yml), quotes removed. +pub(crate) fn yaml_top_level_value(text: &str, key: &str) -> Option { + strip_bom(text) + .lines() + .filter_map(top_level_key) + .rfind(|(k, _)| k == key) + .map(|(_, value)| value.trim_matches(['"', '\'']).to_string()) + .filter(|value| !value.is_empty()) +} + /// The line index a new top-level key is inserted at: after the document's /// last non-blank line, before a `...` end marker. `Err` names why the /// document is not a single block mapping a line append can extend. @@ -131,6 +142,102 @@ pub(crate) fn block_section_bounds(lines: &[String], name: &str) -> Option<(usiz Some((start, end)) } +/// The `packages:` globs of a pnpm-workspace.yaml, quotes removed (a +/// negation keeps its leading `!`), from a block sequence (items indented +/// or at column 0) or a one-line flow sequence. `Ok(None)` when the file +/// has no top-level `packages:` key (pnpm <= 8 then finds projects in +/// every directory) or a null one (`packages:` spelled `~` or `null`, +/// which pnpm reads as an absent key); `Err` names a value this reader +/// cannot follow (a scalar, a multi-line flow, an anchor or alias, a +/// nested node), which a caller must not take for "no members". Callers +/// read through [`read_package_globs`], which falls back to a full YAML +/// parse for those. +pub(crate) fn package_globs(text: &str) -> Result>, String> { + let text = strip_bom(text); + let lines: Vec<&str> = text + .lines() + .map(|l| l.strip_suffix('\r').unwrap_or(l)) + .collect(); + let Some(start) = lines + .iter() + .rposition(|l| top_level_key(l).is_some_and(|(key, _)| key == "packages")) + else { + return Ok(None); + }; + let inline = top_level_key(lines[start]).map_or("", |(_, value)| value); + if matches!(inline, "~" | "null" | "Null" | "NULL") { + return Ok(None); + } + if !inline.is_empty() { + let inner = inline + .strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) + .ok_or_else(|| format!("`packages: {inline}` is not a one-line list"))?; + return inner + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(glob_scalar) + .collect::>() + .map(Some); + } + let mut out = Vec::new(); + for line in &lines[start + 1..] { + let trimmed = line.trim_start_matches([' ', '\t']); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let item = trimmed + .strip_prefix('-') + .filter(|rest| rest.is_empty() || rest.starts_with([' ', '\t'])); + match item { + Some(item) => out.push(glob_scalar(strip_comment(item).trim())?), + None if line.starts_with([' ', '\t']) => { + return Err(format!("`packages:` holds a non-list line {trimmed:?}")); + } + // The next top-level key (or a document marker) ends the list. + None => break, + } + } + Ok(Some(out)) +} + +/// The `packages:` globs as [`package_globs`] reads them, falling back to a +/// full YAML parse of the file when the line reader cannot follow the +/// value (a flow list spread over several lines, an anchor and its alias). +/// `Err` (the line reader's reason) only when neither reader can: a value +/// that is not a list of strings, or a file that does not parse. +pub(crate) fn read_package_globs(text: &str) -> Result>, String> { + #[derive(serde::Deserialize)] + struct Workspace { + packages: Option>, + } + package_globs(text).or_else(|why| { + serde_saphyr::from_str::>(strip_bom(text)) + .map(|workspace| workspace.and_then(|w| w.packages)) + .map_err(|_| why) + }) +} + +/// One `packages:` item: a plain or quoted scalar, quotes removed. +fn glob_scalar(raw: &str) -> Result { + let quoted = match raw.as_bytes().first() { + Some(b'"') => double_quoted(raw), + Some(b'\'') => single_quoted(raw), + // An anchor, alias, block scalar, nested node, or tag (YAML reads an + // unquoted `!x` as a tag, so a negation must be quoted). + Some(b'&' | b'*' | b'|' | b'>' | b'{' | b'[' | b'?' | b'!' | b'%' | b'@' | b'`') => { + return Err(format!("`packages:` item {raw:?} is not a plain glob")); + } + Some(_) => return Ok(raw.to_string()), + None => return Err("`packages:` holds an empty item".to_string()), + }; + match quoted { + Some((value, len)) if len == raw.len() && !value.is_empty() => Ok(value), + _ => Err(format!("`packages:` item {raw:?} is not a plain glob")), + } +} + /// Whether `line` is the document marker `marker` (`---` / `...`), alone or /// followed by a blank. fn is_marker(line: &str, marker: &str) -> bool { @@ -139,7 +246,7 @@ fn is_marker(line: &str, marker: &str) -> bool { } /// `text` up to a ` #` comment that sits outside quotes. -fn strip_comment(text: &str) -> &str { +pub(crate) fn strip_comment(text: &str) -> &str { let bytes = text.as_bytes(); let mut quote = None; for (i, &b) in bytes.iter().enumerate() { @@ -253,6 +360,67 @@ mod tests { } } + #[test] + fn package_globs_read_block_flow_quoted_and_negated_items() { + let globs = |text: &str| package_globs(text).map(Option::unwrap_or_default); + assert_eq!( + globs("packages:\n - packages/*\n - 'apps/**' # web\n - \"!**/fixtures/**\"\nsharedWorkspaceLockfile: false\n"), + Ok(vec![ + "packages/*".to_string(), + "apps/**".to_string(), + "!**/fixtures/**".to_string() + ]) + ); + // Items at column 0, CRLF, a BOM, comments between items. + assert_eq!( + globs("\u{feff}packages:\r\n# members\r\n- a\r\n\r\n- b\r\ncatalog: {}\r\n"), + Ok(vec!["a".to_string(), "b".to_string()]) + ); + assert_eq!( + globs("packages: ['packages/*', \"!packages/x\"]\n"), + Ok(vec!["packages/*".to_string(), "!packages/x".to_string()]) + ); + assert_eq!(globs("packages: []\n"), Ok(Vec::new())); + // An absent key is not an empty list. + assert_eq!(package_globs("packages: []\n"), Ok(Some(Vec::new()))); + assert_eq!(package_globs("trustLockfile: true\n"), Ok(None)); + // A null value is an absent key, in every spelling (#1006). + for null in ["~", "null", "Null", "NULL", "~ # root only"] { + let text = format!("packages: {null}\ntrustLockfile: true\n"); + assert_eq!(package_globs(&text), Ok(None), "{text:?}"); + assert_eq!(read_package_globs(&text), Ok(None), "{text:?}"); + } + // Shapes this reader cannot follow are errors, never "no members". + for text in [ + "packages: *members\n", + "packages: packages/*\n", + "packages: [a,\n b]\n", + "packages:\n - !packages/x\n", + "packages:\n - *alias\n", + "packages:\n key: value\n", + "packages:\n - 'unterminated\n", + ] { + assert!(globs(text).is_err(), "{text:?}"); + } + // The full parse reads what the line reader cannot follow... + assert_eq!( + read_package_globs("packages: [\n 'a',\n \"!b\"\n]\nx: 1\n"), + Ok(Some(vec!["a".to_string(), "!b".to_string()])) + ); + assert_eq!( + read_package_globs("packages: [a,\n b]\n"), + Ok(Some(vec!["a".to_string(), "b".to_string()])) + ); + // ...and the line reader's reason stands when it cannot either. + for text in [ + "packages: packages/*\n", + "packages:\n key: value\n", + "packages:\n - 'unterminated\n", + ] { + assert!(read_package_globs(text).is_err(), "{text:?}"); + } + } + #[test] fn block_insert_point_follows_the_document() { assert_eq!(block_insert_point(&lines("a: 1\nb:\n - c\n")), Ok(3)); diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 9bbfae941..cbf901cb2 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -47,11 +47,12 @@ use super::guidance::{ npm_allow_remote_outer_set_detail, npm_allow_remote_unreadable_detail, npm_allow_remote_user_set_detail, npm_lock_url_needles, plan_workspace_trust, pnpm_heal_root, pnpm_is_shrinkwrap_lock, pnpm_lock_may_need_store_flag, pnpm_lock_version_major, - pnpm_trust_configured_detail, pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, - pnpm_trust_policy_preamble, pnpm_trust_workspace_unreadable_detail, + pnpm_root_only_workspace_breaks_add, pnpm_trust_configured_detail, pnpm_trust_legacy_detail, + pnpm_trust_manual_guidance, pnpm_trust_not_needed_detail, pnpm_trust_policy_preamble, + pnpm_trust_rush_detail, pnpm_trust_workspace_unreadable_detail, pnpm_trust_workspace_unsupported_detail, read_npmrc_for_allow_remote, read_workspace_for_trust, - url_host, TrustPlan, NPM_LOCKS, PNPM_TRUST_TRADEOFF_AND_CAUTION, PNPM_WORKSPACE_REL, - REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND, + url_host, TrustPlan, NPM_LOCKS, PNPM_TRUST_RUSH_MIXED_NOTE, PNPM_TRUST_TRADEOFF_AND_CAUTION, + PNPM_WORKSPACE_REL, REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND, }; use super::vlt::bun_lockb_present; @@ -348,6 +349,9 @@ pub struct CandidateFiles { pub files: BTreeMap, /// The Rush locks among `files` (the repo-state warning keys on them). pub rush_lock_keys: Vec, + /// The workspace members' own locks among `files`, read beside the + /// root's when the workspace turned the shared lock off (#492). + pub pnpm_member_lock_keys: Vec, /// In memory only: candidate files the disk flow reads through a /// symbolic link. Their bytes are unknown here, so a project whose /// candidates could rewrite one is refused like the disk symlink guard @@ -373,6 +377,13 @@ pub struct CandidateFiles { /// lock was left out of `files`, and the rewrite reports this instead /// of a redirect. pub gem_refusal: Option, + /// Set when the root pnpm lock pins nothing pnpm installs: the + /// workspace keeps one lock per member but the members cannot be + /// listed (#492), or `gitBranchLockfile` installs the branch from a + /// `pnpm-lock..yaml` (#556). The root lock was left out of + /// `files`, and the rewrite reports this instead of a redirect or a + /// "no lockfile" hint. + pub pnpm_refusal: Option, } impl CandidateFiles { @@ -500,7 +511,8 @@ async fn read_advisory( /// Read the project's candidate files: [`REDIRECT_CANDIDATE_FILES`], the /// Cargo workspace members (when a cargo candidate meets a root -/// `Cargo.toml`), the Python locks and their scripts, and the Rush locks. +/// `Cargo.toml`), the Python locks and their scripts, the Rush locks, and +/// the pnpm workspace members' own locks. /// `unreadable` are the in-memory paths that exist without content. pub async fn read_candidate_files( view: &ProjectView<'_>, @@ -647,6 +659,12 @@ pub async fn read_candidate_files( } } } + } else if candidates.iter().any(|c| c.dep.ecosystem == "npm") { + if let Some(branch) = crate::utils::pnpm_workspace::git_branch_locks(view).await { + refuse_git_branch_locks(&mut out, &branch); + } else { + read_pnpm_member_locks(view, unreadable, &mut out).await; + } } if candidates.iter().any(|c| c.dep.ecosystem == "gem") { keep_bundler_loaded_gem_files(view, candidates, &mut out).await; @@ -695,6 +713,110 @@ pub async fn read_candidate_files( out } +/// A pnpm workspace with `sharedWorkspaceLockfile: false` installs each +/// member from the member's own `pnpm-lock.yaml`; the root lock covers the +/// root project alone (pnpm 7 writes none at all). Every member lock is a +/// rewrite target, read strictly under its root-relative key: the pnpm +/// rewriter is basename-generalized and the write-back path-generic, like +/// the Rush locks above. Members whose list cannot be read leave the root +/// lock out too and refuse (see [`CandidateFiles::pnpm_refusal`]). +/// In memory too: the in-memory engine demotes each member lock into its +/// workspace root once that root's files confirm it reads them +/// (`confirm_pnpm_members` in [`super::memory`]), +/// so the root reads them here as a disk run from it does; a member lock +/// whose content was not provided refuses the project like an unreadable +/// root lock. +async fn read_pnpm_member_locks( + view: &ProjectView<'_>, + unreadable: &BTreeSet, + out: &mut CandidateFiles, +) { + use crate::utils::pnpm_workspace::{member_locks, MemberLocks}; + match member_locks(view).await { + MemberLocks::Shared => {} + MemberLocks::PerMember(keys) => { + for key in keys { + if out.read(view, unreadable, &key).await { + out.pnpm_member_lock_keys.push(key); + continue; + } + // A member lock that exists but cannot be read (a FIFO, not + // UTF-8) still drives that member's install: pinning the + // others would confirm the dep while it stays upstream there. + for read in std::mem::take(&mut out.pnpm_member_lock_keys) { + out.files.remove(&read); + } + refuse_pnpm_members( + out, + format!( + "{key} (a workspace member's own lock under \ + sharedWorkspaceLockfile: false) cannot be read as text" + ), + ); + return; + } + } + MemberLocks::Unresolved(why) => refuse_pnpm_members(out, why), + } +} + +/// Leave the root pnpm lock out and record why (see +/// [`CandidateFiles::pnpm_refusal`]). +fn refuse_pnpm_members(out: &mut CandidateFiles, why: String) { + out.files.remove("pnpm-lock.yaml"); + out.pnpm_refusal = Some(RewriteWarning { + code: PNPM_MEMBER_LOCKS_UNRESOLVED.into(), + detail: format!( + "{why}; no pnpm lock was rewritten — make every member lock a readable \ + file listed by plain `packages:` globs (or install the members) and re-run" + ), + }); +} + +/// `gitBranchLockfile` with a branch lock present (#556): pnpm installs +/// the branch from that lock, which hosted mode cannot pin, so the root +/// lock (stale on the branch) is left out and the run refuses every pnpm +/// pin (see [`CandidateFiles::pnpm_refusal`]). In memory the root lock is +/// dropped from the link and unreadable lists too: it is not written. +fn refuse_git_branch_locks( + out: &mut CandidateFiles, + branch: &crate::utils::pnpm_workspace::GitBranchLocks, +) { + const ROOT_LOCKS: [&str; 2] = ["pnpm-lock.yaml", "shrinkwrap.yaml"]; + for lock in ROOT_LOCKS { + out.files.remove(lock); + } + out.symlinked_reads + .retain(|r| !ROOT_LOCKS.contains(&r.as_str())); + out.unreadable_reads + .retain(|r| !ROOT_LOCKS.contains(&r.as_str())); + out.pnpm_refusal = Some(git_branch_lock_refusal(branch)); +} + +/// The [`PNPM_GIT_BRANCH_LOCKFILE`] warning for `branch`: the hosted +/// rewrite's, and the vendored→hosted takeover's (which keeps the vendored +/// wiring rather than revert it into a lock pnpm does not install from). +pub fn git_branch_lock_refusal( + branch: &crate::utils::pnpm_workspace::GitBranchLocks, +) -> RewriteWarning { + RewriteWarning { + code: PNPM_GIT_BRANCH_LOCKFILE.into(), + detail: format!( + "{}; no pnpm lock was rewritten — {}", + branch.describe(), + crate::utils::pnpm_workspace::GitBranchLocks::REMEDY + ), + } +} + +/// Refusal code for a `sharedWorkspaceLockfile: false` workspace whose +/// member locks cannot be listed. +pub const PNPM_MEMBER_LOCKS_UNRESOLVED: &str = "redirect_pnpm_member_locks_unresolved"; + +/// Refusal code for a project whose `gitBranchLockfile` setting makes pnpm +/// install from a `pnpm-lock..yaml` (#556). +pub const PNPM_GIT_BRANCH_LOCKFILE: &str = "redirect_pnpm_git_branch_lockfile"; + /// Read what the hosted Gradle planner needs into `out` (see /// [`crate::patch::redirect::gradle::GradleFiles`]): the script graph is /// re-walked over what was read so far until it asks for nothing new @@ -1129,14 +1251,25 @@ fn read_npmrc(view: &ProjectView<'_>) -> Result, String> { } } -/// Whether Rush's repo-state file is present (disk: a regular file). -fn rush_repo_state_present(view: &ProjectView<'_>) -> bool { +/// The repo-state.json that carries the pnpmShrinkwrapHash of the Rush lock +/// at `lock_key`: the file beside it. The common lock maps to +/// [`RUSH_REPO_STATE_REL`]; with subspaces enabled each subspace keeps its +/// own, at `common/config/subspaces//repo-state.json`. +fn rush_repo_state_for_lock(lock_key: &str) -> String { + match lock_key.rsplit_once('/') { + Some((dir, _)) => format!("{dir}/repo-state.json"), + None => "repo-state.json".to_string(), + } +} + +/// Whether the Rush repo-state file `rel` is present (disk: a regular file). +fn rush_repo_state_present(view: &ProjectView<'_>, rel: &str) -> bool { match view { ProjectView::Disk(cwd) | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { root: cwd, .. - }) => cwd.join(RUSH_REPO_STATE_REL).is_file(), - ProjectView::Memory(project) => project.contains(RUSH_REPO_STATE_REL), + }) => cwd.join(rel).is_file(), + ProjectView::Memory(project) => project.contains(rel), } } @@ -1195,11 +1328,13 @@ pub async fn rewrite( let CandidateFiles { files, rush_lock_keys, + pnpm_member_lock_keys, symlinked_reads, unreadable_reads, undecodable_reads, gradle_unreadable, gem_refusal, + pnpm_refusal, } = read; // The rewriters' override slice — materialized ONCE, after the last // candidate filter, so it can never disagree with `candidates`. @@ -1283,6 +1418,14 @@ pub async fn rewrite( .retain(|w| w.code != "redirect_gem_no_gemfile"); rewrite.warnings.push(warning); } + // The pnpm locks were withheld on purpose: say why, not "run `pnpm + // install`" (which never writes a root lock in either layout). + if let Some(warning) = pnpm_refusal { + rewrite.warnings.retain(|w| { + w.code != "redirect_pnpm_no_lockfile" && w.code != "redirect_npm_no_lockfile" + }); + rewrite.warnings.push(warning); + } if let Some(content) = binary_content { rewrite .warnings @@ -1332,12 +1475,14 @@ pub async fn rewrite( // refuses until `rush update` refreshes that hash — but the redirect // survives `rush update` (pnpm preserves locked resolutions for // unchanged specifiers). Warn only when the rewrite actually landed in - // a Rush lock and the repo-state file that carries the hash is present. + // a Rush lock and the repo-state file that carries THAT lock's hash is + // present: it sits beside the lock, so with subspaces enabled each + // subspace lock pairs with its own subspace's repo-state.json (#714). let mut rush_warnings: Vec = Vec::new(); if rush_lock_keys .iter() - .any(|key| rewrite.files.contains_key(key)) - && rush_repo_state_present(view) + .filter(|key| rewrite.files.contains_key(*key)) + .any(|key| rush_repo_state_present(view, &rush_repo_state_for_lock(key))) { rush_warnings.push(warning( "redirect_rush_repo_state_stale", @@ -1348,8 +1493,15 @@ pub async fn rewrite( )); } - let (pnpm_warnings, trust_config_write, pnpm_rerun_only, workspace_symlinked) = - pnpm_trust(view, &files, &rewrite, &overrides, &options); + let (pnpm_warnings, trust_config_write, pnpm_rerun_only, workspace_symlinked) = pnpm_trust( + view, + &files, + &pnpm_member_lock_keys, + &rewrite, + &rush_lock_keys, + &overrides, + &options, + ); let (npm_warnings, npmrc_config_write) = npm_allow_remote(view, &files, &rewrite, &overrides, &options); if let Some((text, edit)) = trust_config_write { @@ -1403,22 +1555,33 @@ type ConfigWrite = Option<(String, FileEdit)>; /// `trustLockfile: true` key; the `.npmrc` `trust-lockfile=true` spelling /// is IGNORED by pnpm and must never be recommended. /// -/// ZERO-TOUCH DEFAULT: when this run rewrote the ROOT pnpm-lock.yaml and -/// its lockfileVersion is >= 9 (5.x/6.0 locks mean pnpm 7/8, which have -/// neither the policy nor the flag and get their own guidance), the run +/// ZERO-TOUCH DEFAULT: when this run rewrote a GOVERNING pnpm lock (the +/// root pnpm-lock.yaml, or a workspace member's own lock under +/// `sharedWorkspaceLockfile: false`, #492) and its lockfileVersion is >= 9 +/// (5.x/6.0 locks mean pnpm 7/8, which have neither the policy nor the +/// flag and get their own guidance), the run /// auto-ensures `trustLockfile: true` in pnpm-workspace.yaml. The same -/// auto-config re-engages on a run that spliced NOTHING when the root v9 -/// lock already carries a granted hosted artifact URL (HEAL-ON-RERUN). +/// auto-config re-engages on a run that spliced NOTHING when a governing v9 +/// lock already carries a granted hosted artifact URL (HEAL-ON-RERUN). The +/// key always goes to the root pnpm-workspace.yaml, the only one pnpm reads +/// for every member. /// pnpm <=10 ignores the key; the per-entry sha512 pin still fails closed /// on tampered bytes. An explicit user `trustLockfile: ` is /// RESPECTED (never flipped), and `--no-trust-lockfile-config` opts out. -/// Rush nested/subspace locks are excluded: rush runs pnpm in common/temp, -/// which never reads the repo-root pnpm-workspace.yaml. The warning names -/// the host(s) the lock now points at (they follow --api-url). +/// Rush common/subspace locks (`rush_lock_keys`) are excluded from the +/// write: rush runs pnpm in common/temp with a pnpm-workspace.yaml it +/// generates, which never reads the repo-root one. When every spliced lock +/// is a Rush lock the warning carries the Rush remedy instead of the +/// pnpm-only one (#713); a run that also spliced a non-Rush pnpm lock keeps +/// the generic text plus a Rush note. The warning names the host(s) the +/// lock now points at (they follow --api-url). +#[allow(clippy::too_many_arguments)] fn pnpm_trust( view: &ProjectView<'_>, files: &BTreeMap, + member_lock_keys: &[String], rewrite: &RewriteResult, + rush_lock_keys: &[String], overrides: &[DepOverride], options: &RewriteOptions<'_>, ) -> (Vec, ConfigWrite, bool, bool) { @@ -1428,7 +1591,7 @@ fn pnpm_trust( let mut workspace_symlinked = false; // pnpm locks spliced THIS run (any depth — the rewriter is // basename-generalized). - let mut pnpm_lock_texts: Vec<&String> = rewrite + let (spliced_keys, mut pnpm_lock_texts): (Vec<&String>, Vec<&String>) = rewrite .files .iter() .filter(|(key, _)| { @@ -1437,22 +1600,47 @@ fn pnpm_trust( .and_then(|n| n.to_str()) .is_some_and(|name| matches!(name, "pnpm-lock.yaml" | "shrinkwrap.yaml")) }) - .map(|(_, content)| content) + .unzip(); + // Rush locks spliced this run. The heal and takeover roots below are + // only ever governing locks, never a Rush one. + let mut spliced_rush = spliced_keys + .iter() + .filter(|key| rush_lock_keys.contains(key)) + .count(); + // The locks pnpm installs the project from: the root lock, plus each + // member's own under `sharedWorkspaceLockfile: false` (#492). A Rush + // lock is never one: rush installs it from common/temp. + let governing: Vec<&str> = std::iter::once("pnpm-lock.yaml") + .chain(member_lock_keys.iter().map(String::as_str)) + .filter(|key| !rush_lock_keys.iter().any(|rush| rush == key)) + .collect(); + // HEAL-ON-RERUN: a governing v9 lock that ALREADY carries a granted + // hosted artifact URL (spliced by an earlier run) still plans the trust + // config even though this run spliced nothing — so a project that + // missed the config once (opted-out first run, or a crash between the + // lock write and the workspace write) is healed by simply re-running + // the scan. An AlreadyTrue workspace keeps the re-run a byte-stable + // no-op. + let heal_locks: Vec<&String> = governing + .iter() + .filter_map(|key| { + pnpm_heal_root(rewrite.files.contains_key(*key), files.get(*key), overrides) + }) .collect(); - // HEAL-ON-RERUN: a root v9 lock that ALREADY carries a granted hosted - // artifact URL (spliced by an earlier run) still plans the trust config - // even though this run spliced nothing — so a project that missed the - // config once (opted-out first run, or a crash between the lock write - // and the workspace write) is healed by simply re-running the scan. An - // AlreadyTrue workspace keeps the re-run a byte-stable no-op. - let heal_root: Option<&String> = pnpm_heal_root( - rewrite.files.contains_key("pnpm-lock.yaml"), - files.get("pnpm-lock.yaml"), - overrides, - ); let spliced_pnpm_locks = pnpm_lock_texts.len(); - if let Some(text) = heal_root { - pnpm_lock_texts.push(text); + pnpm_lock_texts.extend(heal_locks.iter().copied()); + // HEAL-ON-RERUN for Rush: a common/subspace v9 lock an earlier run + // already redirected splices nothing now, but its `rush install` still + // needs the Rush trust remedy (#713) — nothing persists it, so it is + // re-issued on every run. Same gate as the governing heal; never + // configured. + for key in rush_lock_keys { + if let Some(text) = + pnpm_heal_root(rewrite.files.contains_key(key), files.get(key), overrides) + { + pnpm_lock_texts.push(text); + spliced_rush += 1; + } } if pnpm_lock_texts.is_empty() { return ( @@ -1487,17 +1675,15 @@ fn pnpm_trust( } else { format!("the hosted patch server ({})", hosts.join(", ")) }; - // Root-lock gate: only the plain project lock at lockfileVersion >= 9 - // gets the auto-config — spliced this run, or detected - // already-redirected (heal path). - let root_lock_v9 = heal_root - .and_then(|text| pnpm_lock_version_major(text)) - .is_some_and(|major| major >= 9) - || rewrite - .files - .get("pnpm-lock.yaml") - .and_then(|text| pnpm_lock_version_major(text)) - .is_some_and(|major| major >= 9); + // Governing-lock gate: only a governing lock (never a Rush one) at + // lockfileVersion >= 9 gets the auto-config — spliced this run, or + // detected already-redirected (heal path). + let is_v9 = |text: &String| pnpm_lock_version_major(text).is_some_and(|major| major >= 9); + let governing_lock_v9 = heal_locks.iter().copied().any(is_v9) + || governing + .iter() + .filter_map(|key| rewrite.files.get(*key)) + .any(is_v9); // Every touched pnpm lock is a KNOWN legacy (5.x/6.0) format, where // `--trust-lockfile` is rejected as an unknown option. An unparseable // version stays on the manual guidance: never claim "no trust step @@ -1506,9 +1692,12 @@ fn pnpm_trust( pnpm_lock_version_major(text).is_some_and(|major| major < 9) || pnpm_is_shrinkwrap_lock(text) }); + let rush_only = spliced_rush > 0 && spliced_rush == pnpm_lock_texts.len(); let detail = if all_locks_legacy { pnpm_trust_legacy_detail(&server) - } else if !root_lock_v9 || !options.trust_lockfile_config { + } else if rush_only { + pnpm_trust_rush_detail(&server) + } else if !governing_lock_v9 || !options.trust_lockfile_config { pnpm_trust_manual_guidance(&server) } else if let Some(root_file) = governing_workspace(view) { // A workspace member with its own lock: pnpm reads `trustLockfile` @@ -1541,7 +1730,19 @@ fn pnpm_trust( original: None, new: Some(serde_json::json!("true")), }; + // No workspace file and a project pinned to pnpm 9.0–10.4: creating + // one would make it a root-only workspace those releases refuse + // `pnpm add` in, for a key they never read (#734). + let pinned_pre_10_5 = match &workspace { + Ok(None) if !symlinked => root_only_workspace_breaks_add(view), + _ => None, + }; match workspace { + Ok(None) if pinned_pre_10_5.is_some() => pnpm_trust_not_needed_detail( + &server, + pinned_pre_10_5.as_deref().unwrap_or_default(), + options.dry_run, + ), // Present but UNREADABLE: never plan a Create (it would // overwrite the user's workspace file) — fall back to // warning-only guidance naming the file and the error. @@ -1557,7 +1758,18 @@ fn pnpm_trust( } TrustPlan::AlreadyTrue => { pnpm_rerun_only = spliced_pnpm_locks == 0; - pnpm_trust_already_true_detail(&server, PNPM_WORKSPACE_REL) + let detail = pnpm_trust_already_true_detail(&server, PNPM_WORKSPACE_REL); + // The root-only scaffold an earlier run created (4.x and + // early v5 did so whatever the pnpm), in a project pinned + // to a pnpm that refuses `pnpm add` there (#734). + match ws_existing + .as_deref() + .filter(|text| is_trust_scaffold(text)) + .and_then(|_| root_only_workspace_breaks_add(view)) + { + Some(pins) => format!("{detail} {}", pnpm_scaffold_breaks_add_note(&pins)), + None => detail, + } } TrustPlan::UserSet(value) => { pnpm_trust_user_set_detail(&server, PNPM_WORKSPACE_REL, &value) @@ -1578,8 +1790,17 @@ fn pnpm_trust( } else { "" }; - pnpm_warnings.push(warning( - "redirect_pnpm_trust_lockfile", + // Rush keeps its own store under common/temp and the Rush detail + // carries its own reinstall advice; the legacy text is right for Rush on + // pnpm 7/8 as well, so it keeps the generic tail. + let message = if rush_only && !all_locks_legacy { + format!("{detail}.") + } else { + let rush_note = if spliced_rush > 0 && !all_locks_legacy { + format!(" {PNPM_TRUST_RUSH_MIXED_NOTE}.") + } else { + String::new() + }; format!( "{}. After a lock-only change, existing node_modules or a warm pnpm store \ can still contain upstream files. For a reliable reinstall, use a clean \ @@ -1587,10 +1808,11 @@ fn pnpm_trust( `pnpm install --frozen-lockfile --store-dir `\ {store_note}. Do not rely on `--force`: some versions re-resolve the \ upstream artifact. Run `socket-patch vex` after installation to verify \ - the patched files.", + the patched files.{rush_note}", detail.trim_end_matches('.') - ), - )); + ) + }; + pnpm_warnings.push(warning("redirect_pnpm_trust_lockfile", message)); ( pnpm_warnings, trust_config_write, @@ -1610,6 +1832,29 @@ fn pnpm_trust_already_true_detail(server: &str, file: &str) -> String { ) } +/// Whether a pnpm-workspace.yaml is exactly the root-only scaffold the +/// trust auto-config creates ([`plan_workspace_trust`] with no file), in +/// either line ending. +fn is_trust_scaffold(text: &str) -> bool { + let TrustPlan::Create(scaffold) = plan_workspace_trust(None) else { + return false; + }; + text == scaffold || text == scaffold.replace('\n', "\r\n") +} + +/// The note added when a project pinned to pnpm 9.0–10.4 (`pins`, as +/// prose) still carries the root-only scaffold (#734). +fn pnpm_scaffold_breaks_add_note(pins: &str) -> String { + format!( + "Note: {PNPM_WORKSPACE_REL} is the root-only file an earlier socket-patch run \ + created, but the project's pnpm ({pins}) does not read `trustLockfile`, and \ + in a root-only workspace pnpm 9.0–10.4 refuse `pnpm add ` \ + (ERR_PNPM_ADDING_TO_ROOT). Delete {PNPM_WORKSPACE_REL} (re-run after \ + upgrading to pnpm >= 11 to recreate it), or add dependencies with \ + `pnpm add -w `." + ) +} + /// The trust detail for a settings file (`file`) whose explicit /// `trustLockfile: ` was respected. fn pnpm_trust_user_set_detail(server: &str, file: &str, value: &str) -> String { @@ -1622,6 +1867,29 @@ fn pnpm_trust_user_set_detail(server: &str, file: &str, value: &str) -> String { ) } +/// The pnpm pins of a project with no pnpm-workspace.yaml, when every one +/// is a release that refuses `pnpm add` in a root-only workspace (see +/// [`pnpm_root_only_workspace_breaks_add`]). Reads the root package.json +/// and the installed `node_modules/.modules.yaml`, both advisory: FIFO-safe +/// on disk, and an in-memory entry that is not text counts as absent. +fn root_only_workspace_breaks_add(view: &ProjectView<'_>) -> Option { + let read = |rel: &str| match view { + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => crate::utils::fs::read_regular_to_string_sync(&cwd.join(rel)).ok(), + ProjectView::Memory(project) if !project.is_symlink(rel) => match project.get(rel) { + Some(MemoryEntry::Text(text)) => Some(text.to_string()), + _ => None, + }, + ProjectView::Memory(_) => None, + }; + pnpm_root_only_workspace_breaks_add( + read(crate::hosted::memory::select::NPM_MANIFEST_REL).as_deref(), + read("node_modules/.modules.yaml").as_deref(), + ) +} + /// The ancestor `pnpm-workspace.yaml` governing a disk project's pnpm /// settings (see [`governing_workspace_file`]); an in-memory project has /// no ancestors. @@ -2618,6 +2886,116 @@ mod tests { assert!(redirected(&done), "{:?}", done.rewrite.warnings); } + const LEFT_PAD_V9_LOCK: &str = "lockfileVersion: '9.0' + +importers: + .: + dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + left-pad@1.3.0: + resolution: {integrity: sha512-UPSTREAM==} + +snapshots: + left-pad@1.3.0: {} +"; + + #[test] + fn rush_repo_state_pairs_with_the_lock_beside_it() { + assert_eq!( + rush_repo_state_for_lock(RUSH_COMMON_LOCK_REL), + RUSH_REPO_STATE_REL + ); + assert_eq!( + rush_repo_state_for_lock("common/config/subspaces/tools/pnpm-lock.yaml"), + "common/config/subspaces/tools/repo-state.json" + ); + } + + fn pnpm_trust_detail(done: &Rewritten) -> &str { + done.pnpm_warnings + .iter() + .find(|w| w.code == "redirect_pnpm_trust_lockfile") + .map(|w| w.detail.as_str()) + .expect("redirect_pnpm_trust_lockfile") + } + + /// #713: a run that spliced only Rush locks gets the Rush remedy (the + /// env var rush forwards, the pnpm 11 experiment, `rush purge`), never + /// the pnpm-only one, and writes no workspace file. The same lock at the + /// repo root still plans the trustLockfile write, and a run that spliced + /// both keeps the generic text plus the Rush note. + #[tokio::test] + async fn issue_713_rush_locks_get_the_rush_trust_remedy() { + let mut rush = MemoryProject::new(); + rush.insert_text("rush.json", r#"{ "rushVersion": "5.100.0" }"#); + rush.insert_text(RUSH_COMMON_LOCK_REL, LEFT_PAD_V9_LOCK); + let (read, done) = npm_rewrite(&ProjectView::Memory(&rush), &BTreeSet::new()).await; + assert_eq!(read.rush_lock_keys, [RUSH_COMMON_LOCK_REL]); + assert!(done.rewrite.files.contains_key(RUSH_COMMON_LOCK_REL)); + assert!(!done.rewrite.files.contains_key(PNPM_WORKSPACE_REL)); + let detail = pnpm_trust_detail(&done); + assert!( + detail.contains("pnpm_config_trust_lockfile=true rush install"), + "{detail}" + ); + assert!( + detail.contains("usePnpmFrozenLockfileForRushInstall"), + "{detail}" + ); + assert!(detail.contains("rush purge"), "{detail}"); + assert!( + !detail.contains("pnpm install --trust-lockfile"), + "{detail}" + ); + assert!(!detail.contains("--store-dir"), "{detail}"); + + let mut root = MemoryProject::new(); + root.insert_text("pnpm-lock.yaml", LEFT_PAD_V9_LOCK); + let (_, done) = npm_rewrite(&ProjectView::Memory(&root), &BTreeSet::new()).await; + assert!(done.rewrite.files.contains_key(PNPM_WORKSPACE_REL)); + assert!(!pnpm_trust_detail(&done).contains("rush")); + + let mut mixed = rush.clone(); + mixed.insert_text("pnpm-lock.yaml", LEFT_PAD_V9_LOCK); + let (_, done) = npm_rewrite(&ProjectView::Memory(&mixed), &BTreeSet::new()).await; + assert!(done.rewrite.files.contains_key(RUSH_COMMON_LOCK_REL)); + let detail = pnpm_trust_detail(&done); + assert!(detail.contains("--store-dir"), "{detail}"); + assert!(detail.contains(PNPM_TRUST_RUSH_MIXED_NOTE), "{detail}"); + } + + /// #713 HEAL-ON-RERUN: a Rush lock an earlier run already redirected + /// splices nothing on a re-run, yet its `rush install` still needs the + /// Rush remedy — the re-run re-issues it (and still writes nothing). + #[tokio::test] + async fn issue_713_rerun_on_a_redirected_rush_lock_keeps_the_rush_remedy() { + let mut first = MemoryProject::new(); + first.insert_text("rush.json", r#"{ "rushVersion": "5.100.0" }"#); + first.insert_text(RUSH_COMMON_LOCK_REL, LEFT_PAD_V9_LOCK); + let (_, done) = npm_rewrite(&ProjectView::Memory(&first), &BTreeSet::new()).await; + let redirected = done.rewrite.files[RUSH_COMMON_LOCK_REL].clone(); + + let mut rerun = MemoryProject::new(); + rerun.insert_text("rush.json", r#"{ "rushVersion": "5.100.0" }"#); + rerun.insert_text(RUSH_COMMON_LOCK_REL, redirected.as_str()); + let (_, done) = npm_rewrite(&ProjectView::Memory(&rerun), &BTreeSet::new()).await; + assert!(done.rewrite.files.is_empty(), "{:?}", done.rewrite.files); + assert!(!done.pnpm_rerun_only); + let detail = pnpm_trust_detail(&done); + assert!( + detail.contains("pnpm_config_trust_lockfile=true rush install"), + "{detail}" + ); + assert!( + !detail.contains("pnpm install --trust-lockfile"), + "{detail}" + ); + } + /// REGRESSION (#367), binary lock: a `bun.lockb`-only project's root /// manifest is read for its `patchedDependencies`, and a package the /// project patches itself with `bun patch` keeps its registry record, @@ -3564,6 +3942,460 @@ mod tests { assert_eq!(file_ecosystem("build.gradle"), None); assert_eq!(file_ecosystem("Pipfile"), None); } + + // ── #492: `sharedWorkspaceLockfile: false` member locks ── + + const MEMBER_UUID: &str = "u-member"; + const MEMBER_URL: &str = + "https://patch.example/patch/npm/is-number/7.0.0/tok/u-member/is-number-7.0.0.tgz"; + + fn is_number_candidates() -> Vec { + let mut refs: HashMap = HashMap::new(); + refs.insert( + MEMBER_UUID.into(), + reference(serde_json::json!({ + "status": "granted", + "url": MEMBER_URL, + "purl": "pkg:npm/is-number@7.0.0", + "artifacts": [{"kind": "tarball", "url": MEMBER_URL, + "integrity": {"sha512": "sha512-PATCHED=="}}], + "registryOverride": null + })), + ); + let selected = vec![( + "pkg:npm/is-number@7.0.0".to_string(), + MEMBER_UUID.to_string(), + )]; + build_candidates(&selected, &refs, &mut Vec::new()) + } + + fn v9_member_lock(direct: bool) -> String { + let importer = if direct { + " is-number:\n specifier: 7.0.0\n version: 7.0.0\n" + } else { + " to-regex-range:\n specifier: 5.0.1\n version: 5.0.1\n" + }; + format!( + "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n{importer}\n\ + packages:\n\n is-number@7.0.0:\n resolution: {{integrity: sha512-UPSTREAM==}}\n\n\ + snapshots:\n\n is-number@7.0.0: {{}}\n" + ) + } + + const V5_MEMBER_LOCK: &str = "lockfileVersion: 5.4\n\nspecifiers:\n is-number: 7.0.0\n\n\ + dependencies:\n is-number: 7.0.0\n\npackages:\n\n /is-number/7.0.0:\n \ + resolution: {integrity: sha512-UPSTREAM==}\n dev: false\n"; + + const ROOT_ONLY_LOCK: &str = "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n"; + + fn write_rel(root: &std::path::Path, rel: &str, text: &str) { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + /// The issue's layout: a root lock covering only `.` and one lock per + /// member (`a` direct, `b` transitive). + fn write_member_workspace(root: &std::path::Path, workspace: &str) { + write_rel(root, "package.json", r#"{"name":"root","private":true}"#); + write_rel(root, "pnpm-workspace.yaml", workspace); + write_rel(root, "pnpm-lock.yaml", ROOT_ONLY_LOCK); + write_rel(root, "packages/a/package.json", r#"{"name":"a"}"#); + write_rel(root, "packages/a/pnpm-lock.yaml", &v9_member_lock(true)); + write_rel(root, "packages/b/package.json", r#"{"name":"b"}"#); + write_rel(root, "packages/b/pnpm-lock.yaml", &v9_member_lock(false)); + // An installed copy's own lock is never a member's. + write_rel( + root, + "packages/a/node_modules/x/pnpm-lock.yaml", + &v9_member_lock(true), + ); + } + + async fn member_rewrite(root: &std::path::Path) -> (CandidateFiles, Rewritten) { + view_rewrite(&ProjectView::Disk(root)).await + } + + async fn view_rewrite(view: &ProjectView<'_>) -> (CandidateFiles, Rewritten) { + let outer = OuterAllowRemote::default; + let options = RewriteOptions { + dry_run: false, + targets_pipenv_lock: false, + pipenv_major: None, + pipenv_unknown_detail: String::new(), + trust_lockfile_config: true, + npm_allow_remote_config: true, + npm_outer: &outer, + yarn_classic_outer: &OuterYarnMirror::default, + blocking: false, + }; + let candidates = is_number_candidates(); + let read = read_candidate_files(view, &BTreeSet::new(), &candidates).await; + let done = rewrite( + view, + read.clone(), + &candidates, + BTreeMap::new(), + &BTreeSet::new(), + options, + ) + .await; + (read, done) + } + + const MEMBER_KEYS: [&str; 2] = ["packages/a/pnpm-lock.yaml", "packages/b/pnpm-lock.yaml"]; + + #[tokio::test] + async fn member_locks_are_pinned_when_the_shared_lock_is_off() { + let tmp = tempfile::tempdir().unwrap(); + let ws = "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n"; + write_member_workspace(tmp.path(), ws); + let (read, done) = member_rewrite(tmp.path()).await; + assert_eq!(read.pnpm_member_lock_keys, MEMBER_KEYS); + for key in MEMBER_KEYS { + let text = done + .rewrite + .files + .get(key) + .unwrap_or_else(|| panic!("{key} must be pinned: {:?}", done.rewrite.warnings)); + assert!(text.contains(&format!("tarball: {MEMBER_URL}")), "{text}"); + } + assert!(!done.rewrite.files.contains_key("pnpm-lock.yaml")); + assert_eq!(done.confirmed.len(), 1, "{:?}", done.confirmed); + assert!( + !warning_codes(&done).contains(&"redirect_pnpm_entry_not_found"), + "{:?}", + warning_codes(&done) + ); + // pnpm reads `trustLockfile` from the root file for every member. + assert_eq!( + done.rewrite + .files + .get(PNPM_WORKSPACE_REL) + .map(String::as_str), + Some(format!("{ws}trustLockfile: true\n").as_str()) + ); + assert_eq!( + done.rewrite.edits.last().map(|e| e.kind.as_str()), + Some(REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND) + ); + + // HEAL-ON-RERUN: the member locks already pinned, the trust key + // missing (an opted-out first run): a re-run plans it again. + for key in MEMBER_KEYS { + write_rel(tmp.path(), key, &done.rewrite.files[key]); + } + let (_, again) = member_rewrite(tmp.path()).await; + assert!( + MEMBER_KEYS + .iter() + .all(|k| !again.rewrite.files.contains_key(*k)), + "{:?}", + again.rewrite.files.keys() + ); + assert_eq!(again.confirmed.len(), 1); + assert!(again.rewrite.files.contains_key(PNPM_WORKSPACE_REL)); + } + + #[tokio::test] + async fn member_locks_are_ignored_while_the_root_lock_is_shared() { + // The default shared lock: member locks are stale leftovers. + let tmp = tempfile::tempdir().unwrap(); + write_member_workspace(tmp.path(), "packages:\n - 'packages/*'\n"); + let (read, done) = member_rewrite(tmp.path()).await; + assert!(read.pnpm_member_lock_keys.is_empty()); + assert!(MEMBER_KEYS.iter().all(|k| !read.files.contains_key(*k))); + assert!(done.confirmed.is_empty()); + + // The setting says off, but the root lock lists member importers: + // pnpm installs from it, so the member locks are still stale. + let tmp = tempfile::tempdir().unwrap(); + write_member_workspace( + tmp.path(), + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n", + ); + write_rel( + tmp.path(), + "pnpm-lock.yaml", + "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n\n packages/a: {}\n", + ); + let (read, _) = member_rewrite(tmp.path()).await; + assert!(read.pnpm_member_lock_keys.is_empty()); + } + + /// pnpm 7 with `shared-workspace-lockfile=false` in `.npmrc` writes no + /// root lock at all, only the members' (lockfile 5.4). + #[tokio::test] + async fn npmrc_member_locks_without_a_root_lock_are_pinned() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_rel(root, "package.json", r#"{"name":"root","private":true}"#); + write_rel(root, "pnpm-workspace.yaml", "packages:\n - packages/*\n"); + write_rel(root, ".npmrc", "shared-workspace-lockfile=false\n"); + write_rel(root, "node_modules/.modules.yaml", "layoutVersion: 5\n"); + write_rel(root, "packages/a/package.json", r#"{"name":"a"}"#); + write_rel(root, "packages/a/pnpm-lock.yaml", V5_MEMBER_LOCK); + let (read, done) = member_rewrite(root).await; + assert_eq!( + read.pnpm_member_lock_keys, + vec!["packages/a/pnpm-lock.yaml"] + ); + let text = &done.rewrite.files["packages/a/pnpm-lock.yaml"]; + assert!(text.contains(MEMBER_URL), "{text}"); + assert_eq!(done.confirmed.len(), 1); + let codes = warning_codes(&done); + assert!( + !codes.contains(&"redirect_pnpm_no_lockfile") + && !codes.contains(&"redirect_npm_no_lockfile"), + "{codes:?}" + ); + // A legacy lock gets no trust config. + assert!(!done.rewrite.files.contains_key(PNPM_WORKSPACE_REL)); + } + + /// Members that cannot be listed refuse instead of pinning the root + /// lock alone (which no member installs from) and reporting success. + #[tokio::test] + async fn unlisted_member_locks_refuse_every_pnpm_pin() { + let tmp = tempfile::tempdir().unwrap(); + write_member_workspace( + tmp.path(), + "packages: *members\nsharedWorkspaceLockfile: false\n", + ); + let (read, done) = member_rewrite(tmp.path()).await; + assert!(!read.files.contains_key("pnpm-lock.yaml")); + assert!(done.rewrite.files.is_empty(), "{:?}", done.rewrite.files); + assert!(done.confirmed.is_empty()); + let codes = warning_codes(&done); + assert!(codes.contains(&PNPM_MEMBER_LOCKS_UNRESOLVED), "{codes:?}"); + assert!(!codes.contains(&"redirect_npm_no_lockfile"), "{codes:?}"); + + // A member lock that cannot be read refuses the whole set. + let tmp = tempfile::tempdir().unwrap(); + write_member_workspace( + tmp.path(), + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n", + ); + std::fs::write(tmp.path().join("packages/b/pnpm-lock.yaml"), [0xff, 0xfe]).unwrap(); + let (read, done) = member_rewrite(tmp.path()).await; + assert!(read.pnpm_member_lock_keys.is_empty()); + assert!(MEMBER_KEYS.iter().all(|k| !read.files.contains_key(*k))); + assert!(done.rewrite.files.is_empty(), "{:?}", done.rewrite.files); + assert!(done.confirmed.is_empty()); + assert!(warning_codes(&done).contains(&PNPM_MEMBER_LOCKS_UNRESOLVED)); + + // No member lock and no root lock: not "run `pnpm install`". + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_rel(root, "pnpm-workspace.yaml", "packages:\n - packages/*\n"); + write_rel(root, ".npmrc", "shared-workspace-lockfile=false\n"); + write_rel(root, "node_modules/.modules.yaml", "layoutVersion: 5\n"); + write_rel(root, "packages/a/package.json", r#"{"name":"a"}"#); + let (_, done) = member_rewrite(root).await; + let codes = warning_codes(&done); + assert!(codes.contains(&PNPM_MEMBER_LOCKS_UNRESOLVED), "{codes:?}"); + assert!(!codes.contains(&"redirect_pnpm_no_lockfile"), "{codes:?}"); + } + + const BRANCH_LOCK: &str = "pnpm-lock.feature.yaml"; + const BRANCH_WS: &str = "packages:\n - '.'\ngitBranchLockfile: true\n"; + + /// The issue's layout: `pnpm-lock.yaml` as committed on main, and the + /// lock pnpm writes for the `feature` branch under `gitBranchLockfile`. + fn write_branch_lock_project(root: &std::path::Path, workspace: &str, root_lock: bool) { + write_rel( + root, + "package.json", + r#"{"name":"app","dependencies":{"is-number":"7.0.0"}}"#, + ); + write_rel(root, "pnpm-workspace.yaml", workspace); + write_rel(root, "node_modules/.modules.yaml", "layoutVersion: 5\n"); + if root_lock { + write_rel(root, "pnpm-lock.yaml", &v9_member_lock(true)); + } + write_rel(root, BRANCH_LOCK, &v9_member_lock(true)); + } + + fn assert_branch_lock_refused(read: &CandidateFiles, done: &Rewritten) { + assert!(!read.files.contains_key("pnpm-lock.yaml")); + assert!(done.rewrite.files.is_empty(), "{:?}", done.rewrite.files); + assert!(done.confirmed.is_empty(), "{:?}", done.confirmed); + let codes = warning_codes(done); + assert!(codes.contains(&PNPM_GIT_BRANCH_LOCKFILE), "{codes:?}"); + assert!( + !codes.contains(&"redirect_pnpm_no_lockfile") + && !codes.contains(&"redirect_npm_no_lockfile"), + "{codes:?}" + ); + let detail = &done + .rewrite + .warnings + .iter() + .find(|w| w.code == PNPM_GIT_BRANCH_LOCKFILE) + .unwrap() + .detail; + assert!( + detail.contains(BRANCH_LOCK) && detail.contains("--merge-git-branch-lockfiles"), + "{detail}" + ); + } + + /// `gitBranchLockfile` with a branch lock beside `pnpm-lock.yaml`: pnpm + /// installs the branch from the branch lock, so pinning the stale + /// `pnpm-lock.yaml` would confirm a pin nothing installs (#556). + #[tokio::test] + async fn a_git_branch_lock_refuses_the_stale_root_lock() { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), BRANCH_WS, true); + let (read, done) = member_rewrite(tmp.path()).await; + assert_branch_lock_refused(&read, &done); + + // pnpm 10 and older read the setting from `.npmrc`. + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), "packages:\n - '.'\n", true); + write_rel(tmp.path(), ".npmrc", "git-branch-lockfile=true\n"); + let (read, done) = member_rewrite(tmp.path()).await; + assert_branch_lock_refused(&read, &done); + + // The in-memory engine refuses the same project. + let mut project = MemoryProject::new(); + project.insert_text("package.json", r#"{"name":"app"}"#); + project.insert_text("pnpm-workspace.yaml", BRANCH_WS); + project.insert_text("pnpm-lock.yaml", v9_member_lock(true)); + project.insert_text(BRANCH_LOCK, v9_member_lock(true)); + let (read, done) = view_rewrite(&ProjectView::Memory(&project)).await; + assert_branch_lock_refused(&read, &done); + } + + /// Only the branch lock: not "run `pnpm install`", which just rewrites + /// the branch lock again (#556). + #[tokio::test] + async fn a_lone_git_branch_lock_is_not_a_missing_lock() { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), BRANCH_WS, false); + let (read, done) = member_rewrite(tmp.path()).await; + assert_branch_lock_refused(&read, &done); + } + + /// Under `sharedWorkspaceLockfile: false` pnpm looks for the branch + /// lock in every member's directory too: a member whose deps changed on + /// the branch installs from `/pnpm-lock..yaml`, so its + /// `pnpm-lock.yaml` is stale and no member lock is pinned (#492 x #556). + #[tokio::test] + async fn a_member_git_branch_lock_refuses_the_stale_member_locks() { + // pnpm 8+: the settings in the YAML, a root lock covering `.` only, + // and no branch lock at the root. + let tmp = tempfile::tempdir().unwrap(); + let ws = "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n\ + gitBranchLockfile: true\n"; + write_member_workspace(tmp.path(), ws); + write_rel( + tmp.path(), + &format!("packages/a/{BRANCH_LOCK}"), + &v9_member_lock(true), + ); + let (read, done) = member_rewrite(tmp.path()).await; + assert_branch_lock_refused(&read, &done); + assert!(read.pnpm_member_lock_keys.is_empty()); + for key in MEMBER_KEYS { + assert!(!read.files.contains_key(key), "{key}"); + } + + // pnpm 7: both settings in `.npmrc` and no root lock at all. + let tmp = tempfile::tempdir().unwrap(); + write_member_workspace(tmp.path(), "packages:\n - 'packages/*'\n"); + std::fs::remove_file(tmp.path().join("pnpm-lock.yaml")).unwrap(); + write_rel( + tmp.path(), + ".npmrc", + "shared-workspace-lockfile=false\ngit-branch-lockfile=true\n", + ); + write_rel( + tmp.path(), + &format!("packages/a/{BRANCH_LOCK}"), + &v9_member_lock(true), + ); + let (read, done) = member_rewrite(tmp.path()).await; + assert_branch_lock_refused(&read, &done); + assert!(read.pnpm_member_lock_keys.is_empty()); + + // The setting off: the member branch lock is a stray file. + let tmp = tempfile::tempdir().unwrap(); + write_member_workspace( + tmp.path(), + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n", + ); + write_rel( + tmp.path(), + &format!("packages/a/{BRANCH_LOCK}"), + &v9_member_lock(true), + ); + let (read, done) = member_rewrite(tmp.path()).await; + assert_eq!(read.pnpm_member_lock_keys, MEMBER_KEYS); + assert!(!warning_codes(&done).contains(&PNPM_GIT_BRANCH_LOCKFILE)); + } + + /// Run from a member, the setting lives in the ancestor + /// `pnpm-workspace.yaml` pnpm reads it from, not in the member. + #[tokio::test] + async fn a_member_run_reads_the_setting_from_the_governing_workspace() { + let tmp = tempfile::tempdir().unwrap(); + let ws = "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n\ + gitBranchLockfile: true\n"; + write_member_workspace(tmp.path(), ws); + let member = tmp.path().join("packages/a"); + write_rel(&member, BRANCH_LOCK, &v9_member_lock(true)); + let (read, done) = member_rewrite(&member).await; + assert_branch_lock_refused(&read, &done); + let detail = &done + .rewrite + .warnings + .iter() + .find(|w| w.code == PNPM_GIT_BRANCH_LOCKFILE) + .unwrap() + .detail; + assert!(detail.contains("gitBranchLockfile: true"), "{detail}"); + + // pnpm 10 and older: the `.npmrc` beside the ancestor file. + let tmp = tempfile::tempdir().unwrap(); + write_member_workspace( + tmp.path(), + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n", + ); + write_rel(tmp.path(), ".npmrc", "git-branch-lockfile=true\n"); + let member = tmp.path().join("packages/a"); + write_rel(&member, BRANCH_LOCK, &v9_member_lock(true)); + let (read, done) = member_rewrite(&member).await; + assert_branch_lock_refused(&read, &done); + } + + /// The setting alone (no branch lock: main, or a branch whose deps never + /// changed) installs from `pnpm-lock.yaml`, so it is pinned as usual; so + /// is a stray branch lock while the setting is off. + #[tokio::test] + async fn the_root_lock_is_pinned_without_a_live_git_branch_lock() { + for (workspace, branch_lock) in [ + (BRANCH_WS, false), + ("packages:\n - '.'\ngitBranchLockfile: false\n", true), + ("packages:\n - '.'\n", true), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), workspace, true); + if !branch_lock { + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + } + let (_, done) = member_rewrite(tmp.path()).await; + let text = done + .rewrite + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("{workspace}: {:?}", done.rewrite.warnings)); + assert!(text.contains(MEMBER_URL), "{text}"); + assert_eq!(done.confirmed.len(), 1, "{workspace}"); + assert!(!warning_codes(&done).contains(&PNPM_GIT_BRANCH_LOCKFILE)); + assert!(!done.rewrite.files.contains_key(BRANCH_LOCK)); + } + } } /// The one-pass multi-needle confirmation probe answers exactly what the diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 2700665ad..b4562b04f 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -26,6 +26,7 @@ use crate::formats::governing_locks::{npm_lock_files, NpmLockFamily}; use crate::patch::redirect::npmrc::npmrc_top_level_value; use crate::utils::fs::{read_regular_to_string, read_regular_to_string_sync}; use crate::utils::pnpm_workspace::governing_workspace_file; +use crate::utils::workspace_globs::workspaces_include; use crate::vendor::cargo::NOT_WORKSPACE_ROOT; use crate::vendor::cargo_manifest; use crate::vendor::lock_inventory::ProjectView; @@ -131,9 +132,10 @@ pub async fn refusal( } /// A pnpm v9 project lock (the one the trust auto-config serves) in a -/// workspace member whose settings come from an ancestor -/// `pnpm-workspace.yaml` that neither trusts the lock nor explicitly opts -/// out. The auto-config used to create a nested file pnpm ignores (#880); +/// workspace member (listed by the `packages:` globs of the nearest +/// ancestor `pnpm-workspace.yaml`, #1006) whose settings come from that +/// file, which neither trusts the lock nor explicitly opts out. The +/// auto-config used to create a nested file pnpm ignores (#880); /// socket-patch writes only inside the project, so the user adds the key /// to the root file. An explicit `trustLockfile: ` is respected, /// as in a single project. @@ -533,238 +535,6 @@ fn workspace_patterns(package_json: &str) -> Option> { ) } -/// Whether the member path (`rel`, relative to the workspace root, one -/// entry per component) matches a `workspaces` pattern and no later -/// `!`-negated one. A pattern is a `/`-separated glob in the grammar npm -/// (minimatch), yarn and Bun share: brace sets (`{a,b}`, nested, and -/// `{1..3}` / `{a..c}` sequences) expand first, then `*`, `?` and -/// character classes (`[abc]`, `[a-c]`, `[!a]`, `[^a]`) match within one -/// component and `**` matches any number of components (#1071). -fn workspaces_include(patterns: &[String], rel: &[String]) -> bool { - if rel.is_empty() { - return false; - } - let rel: Vec> = rel.iter().map(|c| c.chars().collect()).collect(); - let mut included = false; - for pattern in patterns { - let (negated, pattern) = match pattern.strip_prefix('!') { - Some(rest) => (true, rest), - None => (false, pattern.as_str()), - }; - let matched = expand_braces(pattern.trim()).iter().any(|alternative| { - let segments: Vec> = alternative - .split(['/', '\\']) - .filter(|s| !s.is_empty() && *s != ".") - .map(|s| s.chars().collect()) - .collect(); - !segments.is_empty() && path_glob_matches(&segments, &rel) - }); - if matched { - included = !negated; - } - } - included -} - -/// Cap on the alternatives one pattern expands to, so a pathological -/// sequence (`{1..1000000}`) cannot stall the run. -const MAX_BRACE_EXPANSIONS: usize = 4096; - -/// The brace expansion of a glob, as minimatch's `brace-expansion` does it: -/// the first `{...}` group holding a top-level `,` or a `x..y[..step]` -/// sequence is replaced by each alternative, recursively. A group with -/// neither, and an unbalanced `{`, stay literal. -fn expand_braces(pattern: &str) -> Vec { - let mut out = Vec::new(); - expand_braces_into(pattern, &mut out); - out -} - -fn expand_braces_into(pattern: &str, out: &mut Vec) { - if out.len() >= MAX_BRACE_EXPANSIONS { - return; - } - for (open, _) in pattern.match_indices('{') { - let Some(close) = matching_brace(pattern, open) else { - continue; - }; - let body = &pattern[open + 1..close]; - let alternatives = split_top_level_commas(body); - let alternatives = if alternatives.len() > 1 { - alternatives - } else if let Some(sequence) = brace_sequence(body) { - sequence - } else { - continue; - }; - let (prefix, suffix) = (&pattern[..open], &pattern[close + 1..]); - for alternative in alternatives { - expand_braces_into(&format!("{prefix}{alternative}{suffix}"), out); - if out.len() >= MAX_BRACE_EXPANSIONS { - return; - } - } - return; - } - out.push(pattern.to_string()); -} - -/// The byte index of the `}` closing the `{` at `open`. -fn matching_brace(pattern: &str, open: usize) -> Option { - let mut depth = 0usize; - for (i, c) in pattern[open..].char_indices() { - match c { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - return Some(open + i); - } - } - _ => {} - } - } - None -} - -fn split_top_level_commas(body: &str) -> Vec { - let mut parts = Vec::new(); - let (mut depth, mut start) = (0usize, 0usize); - for (i, c) in body.char_indices() { - match c { - '{' => depth += 1, - '}' => depth = depth.saturating_sub(1), - ',' if depth == 0 => { - parts.push(body[start..i].to_string()); - start = i + 1; - } - _ => {} - } - } - parts.push(body[start..].to_string()); - parts -} - -/// A `{x..y}` or `{x..y..step}` sequence body: integers (zero-padded when -/// either end is) or single characters. -fn brace_sequence(body: &str) -> Option> { - let parts: Vec<&str> = body.split("..").collect(); - let (from, to, step) = match parts.as_slice() { - [from, to] => (*from, *to, None), - [from, to, step] => (*from, *to, Some(*step)), - _ => return None, - }; - let step = match step { - Some(step) => step.parse::().ok()?.unsigned_abs().max(1), - None => 1, - }; - let (start, end, width, as_char) = - if let (Ok(a), Ok(b)) = (from.parse::(), to.parse::()) { - let padded = |s: &str| { - s.trim_start_matches('-').len() > 1 && s.trim_start_matches('-').starts_with('0') - }; - let width = if padded(from) || padded(to) { - from.len().max(to.len()) - } else { - 0 - }; - (a, b, width, false) - } else { - let (mut a, mut b) = (from.chars(), to.chars()); - let (Some(a), None, Some(b), None) = (a.next(), a.next(), b.next(), b.next()) else { - return None; - }; - (a as i64, b as i64, 0, true) - }; - let mut out = Vec::new(); - let mut n = start; - loop { - out.push(if as_char { - char::from_u32(u32::try_from(n).ok()?)?.to_string() - } else { - format!("{n:0width$}") - }); - if n == end || out.len() >= MAX_BRACE_EXPANSIONS { - break; - } - let next = if start <= end { - n.checked_add(step as i64)? - } else { - n.checked_sub(step as i64)? - }; - if (start <= end && next > end) || (start > end && next < end) { - break; - } - n = next; - } - Some(out) -} - -fn path_glob_matches(pattern: &[Vec], path: &[Vec]) -> bool { - match pattern.split_first() { - None => path.is_empty(), - Some((first, rest)) if first.as_slice() == ['*', '*'] => { - (0..=path.len()).any(|skip| path_glob_matches(rest, &path[skip..])) - } - Some((first, rest)) => path.split_first().is_some_and(|(head, tail)| { - segment_glob_matches(first, head) && path_glob_matches(rest, tail) - }), - } -} - -fn segment_glob_matches(pattern: &[char], name: &[char]) -> bool { - match pattern.split_first() { - None => name.is_empty(), - Some(('*', rest)) => (0..=name.len()).any(|skip| segment_glob_matches(rest, &name[skip..])), - Some(('?', rest)) => !name.is_empty() && segment_glob_matches(rest, &name[1..]), - Some(('[', rest)) => match char_class(rest) { - Some((class, after)) => name - .split_first() - .is_some_and(|(c, tail)| class.matches(*c) && segment_glob_matches(after, tail)), - None => name.first() == Some(&'[') && segment_glob_matches(rest, &name[1..]), - }, - Some((c, rest)) => name.first() == Some(c) && segment_glob_matches(rest, &name[1..]), - } -} - -/// A parsed `[...]` character class. -struct CharClass { - negated: bool, - ranges: Vec<(char, char)>, -} - -impl CharClass { - fn matches(&self, c: char) -> bool { - self.ranges.iter().any(|&(lo, hi)| lo <= c && c <= hi) != self.negated - } -} - -/// The class after a `[`, and the pattern after its closing `]`. A `!` or -/// `^` first negates it; a `]` right after that is a member. `None` when -/// the class never closes (the `[` is then literal). -fn char_class(pattern: &[char]) -> Option<(CharClass, &[char])> { - let (negated, mut i) = match pattern.first() { - Some('!' | '^') => (true, 1), - _ => (false, 0), - }; - let mut ranges = Vec::new(); - let first = i; - while i < pattern.len() { - let c = pattern[i]; - if c == ']' && i > first { - return Some((CharClass { negated, ranges }, &pattern[i + 1..])); - } - if pattern.get(i + 1) == Some(&'-') && pattern.get(i + 2).is_some_and(|&h| h != ']') { - ranges.push((c, pattern[i + 2])); - i += 3; - } else { - ranges.push((c, c)); - i += 1; - } - } - None -} - async fn npmrc_lockfile_dir(root: &Path) -> Option { let npmrc = read_regular_to_string(&root.join(".npmrc")).await.ok()?; npmrc_top_level_value(&npmrc, "lockfile-dir") @@ -881,6 +651,55 @@ mod tests { assert_eq!(code(&member, "npm").await, None); } + /// #1006: a project the nearest `pnpm-workspace.yaml` does not list + /// under `packages:` is standalone on pnpm 11.28+/12 and reads only its + /// own settings file, so the trust auto-config's nested file is the + /// one pnpm reads: nothing to refuse. + #[tokio::test] + async fn pnpm_project_outside_the_workspace_globs_is_not_refused() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "pnpm-workspace.yaml", + "packages:\n - packages/*\n", + ); + write(tmp.path(), "examples/demo/package.json", "{}"); + write( + tmp.path(), + "examples/demo/pnpm-lock.yaml", + "lockfileVersion: '9.0'\n", + ); + let demo = tmp.path().join("examples/demo"); + let refused = refusal(&ProjectView::Disk(&demo), &[candidate("npm")], true).await; + assert!(refused.is_none(), "{refused:?}"); + // A settings-only root file lists no project but the root. No trust + // key, so only the membership rule keeps it from refusing. + write( + tmp.path(), + "pnpm-workspace.yaml", + "sharedWorkspaceLockfile: false\n", + ); + let refused = refusal(&ProjectView::Disk(&demo), &[candidate("npm")], true).await; + assert!(refused.is_none(), "{refused:?}"); + // `**` does not list a dot directory (pnpm 12 installs it with its + // own lock), so a GitHub action project is not refused either. + write(tmp.path(), "pnpm-workspace.yaml", "packages:\n - '**'\n"); + write(tmp.path(), ".github/actions/demo/package.json", "{}"); + write( + tmp.path(), + ".github/actions/demo/pnpm-lock.yaml", + "lockfileVersion: '9.0'\n", + ); + let action = tmp.path().join(".github/actions/demo"); + let refused = refusal(&ProjectView::Disk(&action), &[candidate("npm")], true).await; + assert!(refused.is_none(), "{refused:?}"); + // ...while `examples/demo`, which `**` lists, is refused. + assert_eq!( + code(&demo, "npm").await.as_deref(), + Some(PNPM_SETTINGS_ELSEWHERE) + ); + } + /// #880: a member with its own v9 lock is pinned through that lock, but /// pnpm reads `trustLockfile` only from the root `pnpm-workspace.yaml`. /// Until that file trusts the lock (or opts out), the hosted run refuses @@ -1421,88 +1240,6 @@ mod tests { ); } - #[test] - fn workspaces_patterns_match_like_npm_and_yarn() { - let rel = |p: &str| p.split('/').map(str::to_string).collect::>(); - let pats = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); - assert!(workspaces_include( - &pats(&["packages/*"]), - &rel("packages/a") - )); - assert!(!workspaces_include( - &pats(&["packages/*"]), - &rel("packages/a/b") - )); - assert!(workspaces_include( - &pats(&["./packages/*/"]), - &rel("packages/a") - )); - assert!(workspaces_include( - &pats(&["packages/**"]), - &rel("packages/a/b") - )); - assert!(workspaces_include( - &pats(&["**/pkg-*"]), - &rel("x/y/pkg-one") - )); - assert!(workspaces_include(&pats(&["app"]), &rel("app"))); - assert!(!workspaces_include(&pats(&["app"]), &rel("apps"))); - assert!(workspaces_include(&pats(&["app?"]), &rel("apps"))); - assert!(!workspaces_include( - &pats(&["packages/*", "!packages/b"]), - &rel("packages/b") - )); - assert!(!workspaces_include(&pats(&["*"]), &[])); - } - - /// #1071: npm (minimatch), yarn and Bun expand brace sets and match - /// character classes in `workspaces`. - #[test] - fn workspaces_patterns_expand_braces_and_match_classes() { - let rel = |p: &str| p.split('/').map(str::to_string).collect::>(); - let pats = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); - let yes = |p: &str, r: &str| assert!(workspaces_include(&pats(&[p]), &rel(r)), "{p} ~ {r}"); - let no = - |p: &str, r: &str| assert!(!workspaces_include(&pats(&[p]), &rel(r)), "{p} !~ {r}"); - // Brace sets, nested, spanning separators, and sequences. - yes("packages/{a,b}", "packages/a"); - yes("packages/{a,b}", "packages/b"); - no("packages/{a,b}", "packages/c"); - yes("{apps,packages}/*", "apps/web"); - yes("packages/{a,{b,c}x}", "packages/cx"); - no("packages/{a,{b,c}x}", "packages/c"); - yes("{packages/a,tools/*}", "tools/t"); - yes("packages/pkg-{1..3}", "packages/pkg-2"); - no("packages/pkg-{1..3}", "packages/pkg-4"); - yes("packages/{a..c}", "packages/b"); - yes("packages/v{01..10}", "packages/v07"); - yes("packages/{,x}a", "packages/a"); - // A brace group with no comma or range is literal, as in minimatch. - yes("packages/{a}", "packages/{a}"); - no("packages/{a}", "packages/a"); - yes("packages/{a,b", "packages/{a,b"); - // Character classes: sets, ranges, negation, a literal `]` first. - yes("packages/[a-c]", "packages/b"); - no("packages/[a-c]", "packages/d"); - yes("packages/[ab]x", "packages/bx"); - yes("packages/[!b]", "packages/a"); - no("packages/[!b]", "packages/b"); - yes("packages/[^b]", "packages/c"); - yes("packages/[]a]", "packages/]"); - yes("packages/[a-c]*", "packages/core"); - // An unclosed class is a literal `[`. - yes("packages/[a", "packages/[a"); - no("packages/[a", "packages/a"); - // Non-ASCII names match one character per `?` and class. - yes("packages/?", "packages/é"); - yes("packages/[é]", "packages/é"); - // Negation applies to the expanded alternatives too. - assert!(!workspaces_include( - &pats(&["packages/*", "!packages/{b,c}"]), - &rel("packages/b") - )); - } - /// #942: vlt's `workspaces` in `vlt.json` is a string, an array or an /// object of named groups, each a string or an array. #[test] diff --git a/crates/socket-patch-core/src/hosted/guidance.rs b/crates/socket-patch-core/src/hosted/guidance.rs index 4d430290c..d45fe8aa6 100644 --- a/crates/socket-patch-core/src/hosted/guidance.rs +++ b/crates/socket-patch-core/src/hosted/guidance.rs @@ -72,6 +72,56 @@ pub fn pnpm_trust_manual_guidance(server: &str) -> String { ) } +/// The Rush variant (#713): every touched pnpm lock is a Rush common or +/// subspace lock. rush runs pnpm in common/temp with a pnpm-workspace.yaml +/// it generates itself, and pnpm-config.json has no `trustLockfile` key, so +/// neither the repo-root workspace key nor `pnpm install --trust-lockfile` +/// reaches the install — the only knob rush forwards is pnpm's +/// `pnpm_config_trust_lockfile` env var. pnpm 11 additionally re-resolves a +/// trusted hosted entry back to the registry under the +/// `--no-prefer-frozen-lockfile` flag `rush install` passes by default +/// (exit 0, upstream bytes), which the `usePnpmFrozenLockfileForRushInstall` +/// experiment turns off. Verified with rush 5.180.0 on pnpm 11.0.0, 11.28.3 +/// and 12.8.1. The tail replaces the generic `--store-dir` reinstall: rush +/// keeps its own store and node_modules under common/temp. +pub fn pnpm_trust_rush_detail(server: &str) -> String { + format!( + "The Rush pnpm lockfile was repointed at {server}. pnpm >=11 does not \ + install it under rush's default flow: it either fails \ + (ERR_PNPM_TARBALL_URL_MISMATCH / \ + ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION) or — pnpm 11 — SILENTLY \ + re-resolves the patched entries back to the vulnerable upstream \ + artifact while still exiting 0. rush runs pnpm in common/temp with a \ + pnpm-workspace.yaml it generates itself, so a repo-root \ + `trustLockfile` setting or `--trust-lockfile` flag never reaches it; \ + nothing was written. On pnpm 12, install with \ + `pnpm_config_trust_lockfile=true rush install` (set the variable in \ + CI too). On pnpm 11, also set \ + `\"usePnpmFrozenLockfileForRushInstall\": true` in \ + common/config/rush/experiments.json so `rush install` stops passing \ + `--no-prefer-frozen-lockfile`. pnpm <=10 installs work unchanged. \ + Note: trusting the lockfile makes pnpm skip its lockfile \ + re-verification (minimumReleaseAge / trustPolicy re-checks) for ALL \ + lockfile entries, not just the patched ones — the per-entry sha512 \ + integrity pins are still enforced. Do NOT rebuild the lockfile \ + (`rush update --full`, or deleting it): that silently discards the \ + hosted patches. After a lock-only change, rush's store and \ + node_modules under common/temp can still hold upstream files; run \ + `rush purge` before `rush install` for a reliable reinstall, then \ + run `socket-patch vex` to verify the patched files" + ) +} + +/// Appended to the generic trust warning when a run spliced a Rush lock +/// ALONGSIDE a non-Rush pnpm lock, so the Rush half is not left with +/// remedies that never reach rush's install. +pub const PNPM_TRUST_RUSH_MIXED_NOTE: &str = + "For the Rush lockfiles this run also repointed, none of the above \ + reaches rush's install: use `pnpm_config_trust_lockfile=true rush \ + install` (pnpm 11 also needs `\"usePnpmFrozenLockfileForRushInstall\": \ + true` in common/config/rush/experiments.json) and run `rush purge` \ + first for a clean reinstall"; + /// The LEGACY-lock variant (lockfileVersion 5.x/6.0 — pnpm 7/8): those /// majors have neither the pnpm >=11 lockfile trust policy nor any trust /// flag or setting, so installs consume the redirected lock unchanged and @@ -177,10 +227,11 @@ pub fn npm_lock_url_needles(artifact_url: &str) -> Vec { needles } -/// The HEAL-ON-RERUN gate: when this run spliced no root pnpm-lock.yaml -/// (`root_spliced` false) but the on-disk root lock is v9 and already -/// carries a granted hosted artifact URL, return its text so the trust -/// block engages anyway. Legacy (<9) and unparseable-version locks stay +/// The HEAL-ON-RERUN gate: when this run spliced no governing pnpm lock +/// (`root_spliced` false; the root pnpm-lock.yaml, or a member's own lock +/// under `sharedWorkspaceLockfile: false`) but the on-disk lock is v9 and +/// already carries a granted hosted artifact URL, return its text so the +/// trust block engages anyway. Legacy (<9) and unparseable-version locks stay /// `None` (fail closed: never write config for a lock era we can't read), /// as does a root lock this run DID splice (the splice path covers it). pub fn pnpm_heal_root<'a>( @@ -206,9 +257,47 @@ pub fn pnpm_trust_configured_detail(server: &str, created: bool, dry_run: bool) (true, true) => "`trustLockfile: true` would be written to a new", (false, true) => "`trustLockfile: true` would be merged into the existing", }; + // A created file makes the project a root-only workspace, where pnpm + // 9.0–10.4 refuse `pnpm add` without `-w` (#734); a project pinned to + // those releases never gets one, so the note names the pin as the way + // out for an unpinned project with no install record. + let root_only = if created { + let then = if dry_run { + "before the real run".to_string() + } else { + format!("then delete {PNPM_WORKSPACE_REL} and re-run") + }; + format!( + " On pnpm 9.0–10.4 a root-only workspace needs `pnpm add -w ` \ + to add dependencies; to avoid the file there, pin that pnpm in \ + package.json `packageManager` (any other pnpm pin — `engines.pnpm`, \ + `devEngines.packageManager`, node_modules/.modules.yaml — must name \ + 9.0–10.4 too), {then}." + ) + } else { + String::new() + }; format!( "{}, so {how} {PNPM_WORKSPACE_REL} — commit it alongside the lock; \ - installs need no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + installs need no extra flags.{root_only} {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(server), + ) +} + +/// The single-package variant on pnpm 9.0–10.4 (#734): the project has no +/// pnpm-workspace.yaml and every pin it carries (`pins`, as prose) is a +/// pnpm that never reads `trustLockfile` but would treat a created file as +/// a root-only workspace and refuse `pnpm add` (ERR_PNPM_ADDING_TO_ROOT), +/// so nothing was written. Names the pnpm >= 11 recovery. +pub fn pnpm_trust_not_needed_detail(server: &str, pins: &str, dry_run: bool) -> String { + let was = if dry_run { "would be" } else { "was" }; + format!( + "{}. The project's pnpm ({pins}) does not read `trustLockfile`, so no \ + {PNPM_WORKSPACE_REL} {was} created: on pnpm 9.0–10.4 one would make the \ + project a root-only workspace where `pnpm add ` fails with \ + ERR_PNPM_ADDING_TO_ROOT. After upgrading to pnpm >=11, re-run \ + `socket-patch scan --mode hosted` to create it, or install with \ + `pnpm install --trust-lockfile`. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", pnpm_trust_policy_preamble(server), ) } @@ -217,6 +306,7 @@ pub fn pnpm_trust_configured_detail(server: &str, created: bool, dry_run: bool) pub use crate::formats::pnpm::{ is_shrinkwrap_lock as pnpm_is_shrinkwrap_lock, lock_version_major as pnpm_lock_version_major, may_need_store_flag as pnpm_lock_may_need_store_flag, + root_only_workspace_breaks_add as pnpm_root_only_workspace_breaks_add, }; /// The planned pnpm-workspace.yaml `trustLockfile: true` edit. @@ -430,6 +520,38 @@ pub fn read_npmrc_for_allow_remote(path: &std::path::Path) -> Result`"), "{detail}"); + assert!( + detail.contains( + "to avoid the file there, pin that pnpm in package.json `packageManager`" + ), + "{detail}" + ); + assert!( + detail.contains("any other pnpm pin — `engines.pnpm`"), + "{detail}" + ); + assert_eq!( + detail.contains(&format!("delete {PNPM_WORKSPACE_REL} and re-run")), + !dry_run, + "{detail}" + ); + // No `@` (no `pnpm@x.y.z` example): the trust warning's + // no-userinfo-leak check rejects any. + assert!(!detail.contains('@'), "{detail}"); + } + let merged = pnpm_trust_configured_detail("patch.test", false, false); + assert!(!merged.contains("pnpm add -w"), "{merged}"); + assert!(!merged.contains("packageManager"), "{merged}"); + } + /// #904 (hosted): a BOM-prefixed `trustLockfile:` first line is the /// user's explicit setting — kept, never shadowed by an appended /// duplicate key — and a BOM file that needs the key keeps its BOM. diff --git a/crates/socket-patch-core/src/hosted/memory/mod.rs b/crates/socket-patch-core/src/hosted/memory/mod.rs index 1751c5478..4c38b1c00 100644 --- a/crates/socket-patch-core/src/hosted/memory/mod.rs +++ b/crates/socket-patch-core/src/hosted/memory/mod.rs @@ -33,6 +33,18 @@ //! `maxTotalBytes`, so such a repo can fail with a `limit` error where the //! disk run would succeed. Fetching fewer would instead silently drop //! manifests the disk run pins. +//! +//! A pnpm workspace member's own `pnpm-lock.yaml` (a root the nearest +//! ancestor `pnpm-workspace.yaml` lists) is demoted into that workspace +//! root, which reads it as a disk run from the root does: pinned beside the +//! root lock under `sharedWorkspaceLockfile: false`, ignored beside a +//! shared root lock (#492). Only once the root's files confirm it (the +//! globs read and list the member, which has a package manifest, and the +//! root pins or knowingly ignores its lock) and socket.yml admits the root: +//! otherwise the member keeps its lock as a root of its own. Path selection +//! cannot read the globs, so under a workspace root it fetches every +//! `pnpm-lock.yaml` beside a package manifest outside pnpm's skipped trees, +//! the same over-fetch tradeoff as Cargo member manifests above. use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; @@ -301,6 +313,157 @@ fn demote_cargo_members(states: &mut [RootState], warnings: &mut Vec, + candidates: Vec<(String, String)>, + admitted: impl Fn(&str) -> bool, +) -> Vec<(String, String)> { + let mut views: BTreeMap = BTreeMap::new(); + let mut out = Vec::new(); + for (member, workspace) in candidates { + if !admitted(&workspace) { + continue; + } + let Some(rel) = roots::strip_root(&workspace, &member).map(str::to_string) else { + continue; + }; + let project = views + .entry(workspace.clone()) + .or_insert_with(|| project_for(&workspace, files).0); + let view = ProjectView::Memory(project); + if crate::utils::pnpm_workspace::root_accounts_for_member_lock(&view, &rel).await { + out.push((member, workspace)); + } + } + out +} + +/// A pnpm root with a v9 lock but no `pnpm-workspace.yaml` of its own that +/// the nearest ancestor `pnpm-workspace.yaml` may list as a workspace +/// project, and whose lock was not demoted into that workspace root +/// ([`confirm_pnpm_members`]): pnpm reads `trustLockfile` only from that +/// ancestor file, so the trust auto-config's `pnpm-workspace.yaml` in the +/// member would be ignored and pnpm >= 11 would reject the hosted pins. +/// The disk run refuses a member run the same way +/// (`redirect_pnpm_settings_elsewhere`), so the member is refused rather +/// than given a file pnpm never reads. A governing file the engine cannot +/// read (a symlink, oversize, content not provided) or whose globs it +/// cannot model counts as listing the member, the refusing side. +fn refuse_governed_pnpm_members(files: &BTreeMap, states: &mut [RootState]) { + use crate::hosted::governing_root::PNPM_SETTINGS_ELSEWHERE; + use crate::hosted::guidance::PNPM_WORKSPACE_REL; + for state in states.iter_mut() { + let Some(project) = state.project.as_ref() else { + continue; + }; + if state.root.is_empty() || project.contains(PNPM_WORKSPACE_REL) { + continue; + } + let v9 = matches!( + project.get("pnpm-lock.yaml"), + Some(MemoryEntry::Text(lock)) + if crate::formats::pnpm::lock_version_major(lock).is_some_and(|major| major >= 9) + ); + if !v9 { + continue; + } + let mut dir = state.root.as_str(); + let governing = loop { + dir = roots::split_path(dir).0; + let path = roots::join_root(dir, PNPM_WORKSPACE_REL); + if let Some(file) = files.get(&path) { + break Some((path, file)); + } + if dir.is_empty() { + break None; + } + }; + let Some((path, file)) = governing else { + continue; + }; + let rel: Vec = roots::strip_root(dir, &state.root) + .unwrap_or(&state.root) + .split('/') + .map(str::to_string) + .collect(); + let member = match &file.entry { + MemoryEntry::Text(text) if !file.unreadable => { + crate::utils::pnpm_workspace::lists_as_member(text, &rel) + } + _ => true, + }; + if member { + let workspace = if dir.is_empty() { "." } else { dir }; + state.fail( + PNPM_SETTINGS_ELSEWHERE, + format!( + "{} may be a project of the pnpm workspace whose settings live in {path}: \ + pnpm reads `trustLockfile` only from that file, so a \ + pnpm-workspace.yaml created in {} would be ignored and pnpm >= 11 \ + would reject the hosted pins (ERR_PNPM_TARBALL_URL_MISMATCH); the \ + in-memory engine pins a member's lock from the workspace root `{workspace}` \ + only when that root's pnpm-workspace.yaml, lock and the member's \ + package.json say the root installs or ignores it — scan `{workspace}` \ + with those files, run hosted mode from the workspace root on a \ + checkout, or turn the trust auto-config off to pin without it; \ + nothing was written", + state.root, state.root + ), + ); + } + } +} + +/// A pnpm workspace member's own `pnpm-lock.yaml` is the workspace root's +/// to read (#492, [`confirm_pnpm_members`]): pinned beside the +/// root's under `sharedWorkspaceLockfile: false`, a stale leftover beside a +/// shared root lock. A member that stays a root (it holds another lock, or +/// the caller named it) drops that lock, as a Cargo member drops its +/// Cargo.lock ([`demote_cargo_members`]); when the workspace root is not +/// among the roots, the lock is not scanned at all and a warning says so. +fn demote_pnpm_members( + states: &mut [RootState], + members: &[(String, String)], + warnings: &mut Vec, +) { + let roots: BTreeSet = states.iter().map(|s| s.root.clone()).collect(); + for (member, workspace) in members { + let Some(state) = states.iter_mut().find(|s| &s.root == member) else { + continue; + }; + let Some(project) = state.project.as_mut() else { + continue; + }; + if project.remove("pnpm-lock.yaml").is_none() { + continue; + } + state.unreadable.remove("pnpm-lock.yaml"); + if roots.contains(workspace) { + continue; + } + let workspace = if workspace.is_empty() { "." } else { workspace }; + warnings.push(EngineWarning::new( + "pnpm_member_lock_ignored", + format!( + "{member} is a project of the pnpm workspace at `{workspace}`, whose root \ + decides which lock pnpm installs it from; its pnpm-lock.yaml was not \ + scanned — scan `{workspace}` as a project root to pin it" + ), + Some(member), + )); + } +} + fn ecosystem_allowed(ecosystems: Option<&[String]>, purl: &str) -> bool { match ecosystems { None => true, @@ -515,13 +678,64 @@ async fn engine( } let mut policy_filtered: Vec = Vec::new(); - let root_list: Vec = match &options.project_roots { + let mut root_list: Vec = match &options.project_roots { Some(roots) => roots.clone(), None => roots::detect_roots(files.keys().map(String::as_str), ecosystems).0, }; // The full policy (paths from the file too) judges every root before // the project limit; roots named in `projectRoots` are explicit. let explicit_roots = options.project_roots.is_some(); + // pnpm workspace members' locks belong to the workspace root (#492): + // a confirmed member with no other lock is no root of its own, and the + // workspace root is one even with no lock there (pnpm 7 writes none). + let admitted = |root: &str| { + let markers = roots::root_markers(root, files.keys().map(String::as_str)); + policy + .admits_root(&Root { + rel_dir: root, + markers: &markers, + explicit: explicit_roots, + }) + .is_ok() + }; + let candidates = + roots::pnpm_member_candidates(&root_list, |path| files.contains_key(path), ecosystems); + let pnpm_members = confirm_pnpm_members(&files, candidates.clone(), admitted).await; + let other_marker = + |root: &str| roots::has_other_root_marker(root, files.keys().map(String::as_str), ecosystems); + if !explicit_roots { + root_list.retain(|root| { + !pnpm_members.iter().any(|(member, _)| member == root) || other_marker(root) + }); + root_list.extend(pnpm_members.iter().map(|(_, workspace)| workspace.clone())); + root_list.sort(); + root_list.dedup(); + } else { + // Named roots are kept, with two exceptions that make a run over + // path selection's `roots` match the detected one: a confirmed + // member named beside its workspace root (and holding no other + // lock), and a lockless directory selection named only because a + // pnpm-workspace.yaml sits above candidate members, none of which + // it turned out to pin. + let named: BTreeSet = root_list.iter().cloned().collect(); + let speculative: BTreeSet<&str> = candidates + .iter() + .map(|(_, workspace)| workspace.as_str()) + .filter(|workspace| { + !pnpm_members.iter().any(|(_, w)| w == workspace) + && roots::root_markers(workspace, files.keys().map(String::as_str)).is_empty() + }) + .collect(); + root_list.retain(|root| { + if speculative.contains(root.as_str()) { + return false; + } + !pnpm_members + .iter() + .any(|(member, workspace)| member == root && named.contains(workspace)) + || other_marker(root) + }); + } let detected_roots = root_list.clone(); let root_list: Vec = root_list .into_iter() @@ -586,6 +800,10 @@ async fn engine( error: None, }) .collect(); + demote_pnpm_members(&mut states, &pnpm_members, &mut warnings); + if options.trust_lockfile_config { + refuse_governed_pnpm_members(&files, &mut states); + } drop(files); demote_cargo_members(&mut states, &mut warnings); phases.mark("roots"); diff --git a/crates/socket-patch-core/src/hosted/memory/roots.rs b/crates/socket-patch-core/src/hosted/memory/roots.rs index 405e5c5a5..7b15a0a3e 100644 --- a/crates/socket-patch-core/src/hosted/memory/roots.rs +++ b/crates/socket-patch-core/src/hosted/memory/roots.rs @@ -3,7 +3,9 @@ //! outside vendored / test-fixture trees, and not an internal directory of //! an enclosing Rush monorepo. A nested Cargo.lock stays a root here: only //! the enclosing workspace's `members`/`exclude` can say whether it is a -//! member, so the engine demotes members once manifests are readable. +//! member, so the engine demotes members once manifests are readable. A +//! pnpm workspace member's lock is demoted into the workspace root the +//! same way, by the `packages:` globs ([`pnpm_member_candidates`]). use std::collections::{BTreeMap, BTreeSet}; @@ -181,10 +183,125 @@ pub(crate) fn detect_roots<'a>( (roots, ignored) } +/// The candidate workspace members among the pnpm `roots` (#492): a root +/// holding a `pnpm-lock.yaml` and no `pnpm-workspace.yaml` of its own, with +/// a `pnpm-workspace.yaml` in a strict ancestor, paired with the nearest +/// such file's directory (pnpm never looks past it). Empty when the +/// `ecosystems` filter leaves npm out. +/// +/// Decided from paths alone, so a candidate is only that: the engine reads +/// the workspace root's files to confirm that root pins (or knowingly +/// ignores) the member's lock +/// ([`root_accounts_for_member_lock`](crate::utils::pnpm_workspace::root_accounts_for_member_lock)) +/// before it demotes the lock into it, the way a Cargo member's lock is. +/// Path selection, which has no content yet, fetches the workspace root of +/// every candidate. +pub(crate) fn pnpm_member_candidates( + roots: &[String], + has: impl Fn(&str) -> bool, + ecosystems: Option<&[String]>, +) -> Vec<(String, String)> { + use crate::utils::pnpm_workspace::PNPM_WORKSPACE; + let mut out = Vec::new(); + if !allowed(ecosystems, "npm") { + return out; + } + for root in roots { + if root.is_empty() + || !has(&join_root(root, "pnpm-lock.yaml")) + || has(&join_root(root, PNPM_WORKSPACE)) + { + continue; + } + let mut dir = root.as_str(); + while !dir.is_empty() { + dir = split_path(dir).0; + if has(&join_root(dir, PNPM_WORKSPACE)) { + out.push((root.clone(), dir.to_string())); + break; + } + } + } + out +} + +/// Whether `root` holds a root marker of an `ecosystems` ecosystem besides +/// its `pnpm-lock.yaml`: a pnpm member demoted into its workspace root +/// ([`pnpm_member_candidates`]) stays a root of its own only then. +pub(crate) fn has_other_root_marker<'a>( + root: &str, + paths: impl IntoIterator, + ecosystems: Option<&[String]>, +) -> bool { + paths.into_iter().any(|path| { + let (dir, base) = split_path(path); + dir == root + && base != "pnpm-lock.yaml" + && marker_ecosystem(base).is_some_and(|eco| allowed(ecosystems, eco)) + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn pnpm_member_candidates_sit_below_the_nearest_workspace_file() { + let paths: BTreeSet<&str> = [ + "pnpm-workspace.yaml", + "pnpm-lock.yaml", + "packages/a/pnpm-lock.yaml", + "packages/b/pnpm-lock.yaml", + "packages/b/pnpm-workspace.yaml", + "tools/c/pnpm-lock.yaml", + "nested/pnpm-workspace.yaml", + "nested/x/pnpm-lock.yaml", + ] + .into_iter() + .collect(); + let roots: Vec = ["", "nested/x", "packages/a", "packages/b", "tools/c"] + .iter() + .map(|r| r.to_string()) + .collect(); + let has = |p: &str| paths.contains(p); + assert_eq!( + pnpm_member_candidates(&roots, has, None), + [ + ("nested/x".to_string(), "nested".to_string()), + ("packages/a".to_string(), String::new()), + ("tools/c".to_string(), String::new()), + ], + "b has its own file and the repo root is no member; x's nearest file is nested's" + ); + let cargo_only = ["cargo".to_string()]; + assert!(pnpm_member_candidates(&roots, has, Some(&cargo_only)).is_empty()); + let npm = ["npm".to_string()]; + assert_eq!(pnpm_member_candidates(&roots, has, Some(&npm)).len(), 3); + } + + #[test] + fn other_root_markers_are_scoped_to_the_member_directory() { + let paths = [ + "packages/a/pnpm-lock.yaml", + "packages/a/package.json", + "packages/a/sub/package-lock.json", + "packages/ab/Cargo.lock", + "packages/package-lock.json", + "Cargo.lock", + ]; + assert!( + !has_other_root_marker("packages/a", paths, None), + "markers in a parent, a sibling or a subdirectory are not the member's" + ); + let mut own = paths.to_vec(); + own.push("packages/a/yarn.lock"); + assert!(has_other_root_marker("packages/a", own.iter().copied(), None)); + let cargo = ["cargo".to_string()]; + assert!(!has_other_root_marker("packages/a", own.iter().copied(), Some(&cargo))); + own.push("packages/a/Cargo.lock"); + assert!(has_other_root_marker("packages/a", own.iter().copied(), Some(&cargo))); + } + fn roots(paths: &[&str]) -> Vec { detect_roots(paths.iter().copied(), None).0 } diff --git a/crates/socket-patch-core/src/hosted/memory/select.rs b/crates/socket-patch-core/src/hosted/memory/select.rs index 449732997..83a53d0ae 100644 --- a/crates/socket-patch-core/src/hosted/memory/select.rs +++ b/crates/socket-patch-core/src/hosted/memory/select.rs @@ -1,7 +1,8 @@ //! Which repository files the in-memory engine needs: root detection plus, //! per root, the same root-relative candidate set the disk hosted flow //! reads (`REDIRECT_CANDIDATE_FILES`, Python lock / script pairs, Cargo -//! member manifests, Rush locks, the install-policy configs, the +//! member manifests, Rush locks, pnpm workspace members' own locks, the +//! install-policy configs, the //! Plug'n'Play markers and the vendored ledger), plus one //! presence-only Maven / NuGet marker per ecosystem so a repo holding only //! those still gets its `ecosystem_unsupported_in_memory` warning. @@ -20,8 +21,8 @@ use crate::policy::{ }; use super::roots::{ - detect_roots, join_root, root_markers, split_path, strict_ancestors, strip_root, - EXCLUDED_ROOT_SEGMENTS, UNSUPPORTED_MARKERS, YARNRC_NAME, + detect_roots, join_root, pnpm_member_candidates, root_markers, split_path, strict_ancestors, + strip_root, EXCLUDED_ROOT_SEGMENTS, UNSUPPORTED_MARKERS, YARNRC_NAME, }; use super::types::{ IgnoredPath, PathSelection, PolicyErrorInfo, PolicyFileInput, SelectOptions, TreeEntryInput, @@ -134,14 +135,15 @@ enum Need { Present, } -fn is_rush_subspace_lock(rel: &str) -> bool { +/// Whether `rel` is `common/config/subspaces//`. +fn is_rush_subspace_file(rel: &str, basename: &str) -> bool { let Some(rest) = rel .strip_prefix(RUSH_SUBSPACES_DIR) .and_then(|r| r.strip_prefix('/')) else { return false; }; - matches!(rest.split_once('/'), Some((name, "pnpm-lock.yaml")) if !name.is_empty()) + matches!(rest.split_once('/'), Some((name, file)) if !name.is_empty() && file == basename) } /// What `rel` (relative to a root whose files are `root_files`) is needed @@ -163,15 +165,27 @@ fn classify(rel: &str, root_files: &BTreeSet<&str>) -> Option { if rel.ends_with(".py") && root_files.contains(format!("{rel}.lock").as_str()) { return Some(Need::Text); } + // A pnpm branch lock refuses the pnpm pins under + // `gitBranchLockfile` (#556); only its presence matters. + if crate::utils::pnpm_workspace::is_git_branch_lock_name(rel) { + return Some(Need::Present); + } return None; } let rush = root_files.contains("rush.json"); - if rush && (rel == RUSH_COMMON_LOCK_REL || is_rush_subspace_lock(rel)) { + if rush && (rel == RUSH_COMMON_LOCK_REL || is_rush_subspace_file(rel, "pnpm-lock.yaml")) { return Some(Need::Text); } - if rush && rel == RUSH_REPO_STATE_REL { + // Presence-only: the stale-hash warning keys on the repo-state.json + // beside each rewritten lock (per subspace when subspaces are enabled). + if rush && (rel == RUSH_REPO_STATE_REL || is_rush_subspace_file(rel, "repo-state.json")) { return Some(Need::Present); } + if root_files.contains(PNPM_WORKSPACE_REL) { + if let Some(need) = pnpm_member_need(rel, root_files) { + return Some(need); + } + } if let Some(manifest_dir) = rel.strip_suffix("/Cargo.toml") { if root_files.contains("Cargo.toml") && !manifest_dir.split('/').any(|seg| seg == CARGO_SKIP_SEGMENT) @@ -183,6 +197,48 @@ fn classify(rel: &str, root_files: &BTreeSet<&str>) -> Option { None } +/// What a pnpm workspace root (one with a `pnpm-workspace.yaml`) needs of +/// its nested file `rel` to read its members' own locks (#492, see +/// `utils::pnpm_workspace::member_locks`): in a directory holding a package +/// manifest (pnpm's test for a project; a lock in any other directory is +/// one pnpm never installs from), the `pnpm-lock.yaml`, the branch locks +/// (#556) and the manifest itself, outside the trees pnpm's project finder +/// skips. The `packages:` globs decide the members once the text is in; +/// like Cargo member manifests, a lock the globs do not list is fetched and +/// ignored (see the module docs of [`super`]). +fn pnpm_member_need(rel: &str, root_files: &BTreeSet<&str>) -> Option { + use crate::utils::pnpm_workspace::{is_git_branch_lock_name, MEMBER_SKIP, PROJECT_MANIFESTS}; + let (dir, base) = split_path(rel); + if dir.split('/').any(|seg| MEMBER_SKIP.contains(&seg)) { + return None; + } + let lock = base == "pnpm-lock.yaml"; + let branch = is_git_branch_lock_name(base); + let manifest = PROJECT_MANIFESTS.contains(&base); + if !(lock || branch || manifest) { + return None; + } + let beside = |name: &str| root_files.contains(format!("{dir}/{name}").as_str()); + if !PROJECT_MANIFESTS.iter().any(|m| beside(m)) { + return None; + } + if lock { + return Some(Need::Text); + } + if branch { + return Some(Need::Present); + } + let prefix = format!("{dir}/"); + root_files + .range::<&str, _>(prefix.as_str()..) + .take_while(|path| path.starts_with(prefix.as_str())) + .filter_map(|path| path.strip_prefix(prefix.as_str())) + .any(|name| { + !name.contains('/') && (name == "pnpm-lock.yaml" || is_git_branch_lock_name(name)) + }) + .then_some(Need::Present) +} + /// The listed root policy files with the text the caller fetched first. A /// listed file with no text (not passed, `missing`, or a symlink) is present /// without content, so loading it fails closed. @@ -303,6 +359,24 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path options.ecosystems.as_deref(), ); ignored.extend(skipped); + // A pnpm workspace member's lock is read from its workspace + // root (#492), which becomes a root of its own even with no + // lock there (pnpm 7 writes none). Without content every root + // a workspace file sits above is a candidate member: its + // workspace root is added (and fetched) and the candidate kept. + // The session confirms members from the files, and given these + // roots as `projectRoots` it drops a confirmed member beside its + // workspace root and a lockless workspace root that pins none, + // so it detects the same roots either way. + let mut found = found; + let candidates = pnpm_member_candidates( + &found, + |p| blobs.contains_key(p), + options.ecosystems.as_deref(), + ); + found.extend(candidates.into_iter().map(|(_, workspace)| workspace)); + found.sort(); + found.dedup(); found } }; @@ -379,6 +453,28 @@ pub fn select_paths(entries: &[TreeEntryInput], options: &SelectOptions) -> Path } } + // A pnpm root with no pnpm-workspace.yaml of its own reads its settings + // from the nearest ancestor one when that file lists it (#492): the + // session needs that file to tell a workspace member, whose lock it + // demotes into the workspace root, from a standalone project (see + // `roots::pnpm_member_candidates`), also when the caller named the + // roots. + for (root, files) in &per_root { + if !files.contains("pnpm-lock.yaml") || files.contains(PNPM_WORKSPACE_REL) { + continue; + } + let mut dir: &str = root; + while !dir.is_empty() { + dir = split_path(dir).0; + let path = join_root(dir, PNPM_WORKSPACE_REL); + if blobs.contains_key(&path) { + let slot = needs.entry(path).or_insert(Need::Text); + *slot = (*slot).min(Need::Text); + break; + } + } + } + for (eco, markers) in UNSUPPORTED_MARKERS { if !options .ecosystems @@ -449,10 +545,12 @@ pub fn candidate_files() -> Vec { RUSH_COMMON_LOCK_REL, RUSH_REPO_STATE_REL, "common/config/subspaces/*/pnpm-lock.yaml", + "common/config/subspaces/*/repo-state.json", "*.py.lock", "*.py (beside *.py.lock)", "pylock.toml", "pylock.*.toml", + "pnpm-lock.*.yaml (presence only)", "**/Cargo.toml (Cargo workspaces)", ] { out.insert(pattern.to_string()); @@ -489,6 +587,7 @@ mod tests { blob("web/yarn.lock"), blob("web/.yarnrc.yml"), blob("web/node_modules/x/package-lock.json"), + blob("pnpm-lock.feature.yaml"), ]; entries.push(TreeEntryInput { path: "pnpm-workspace.yaml".into(), @@ -518,7 +617,7 @@ mod tests { ] ); assert_eq!(s.fetch_binary, vec!["bun.lockb"]); - assert_eq!(s.present_only, vec![".pnp.cjs"]); + assert_eq!(s.present_only, vec![".pnp.cjs", "pnpm-lock.feature.yaml"]); assert_eq!(s.symlinks, vec!["pnpm-workspace.yaml"]); assert_eq!(s.ignored_count, 2); } @@ -557,6 +656,8 @@ mod tests { blob("common/config/rush/pnpm-lock.yaml"), blob("common/config/rush/repo-state.json"), blob("common/config/subspaces/a/pnpm-lock.yaml"), + blob("common/config/subspaces/a/repo-state.json"), + blob("common/config/subspaces/a/b/repo-state.json"), blob("rs/Cargo.toml"), blob("rs/Cargo.lock"), blob("rs/crates/x/Cargo.toml"), @@ -583,7 +684,11 @@ mod tests { ); assert_eq!( s.present_only, - vec!["common/config/rush/repo-state.json", "rush.json"] + vec![ + "common/config/rush/repo-state.json", + "common/config/subspaces/a/repo-state.json", + "rush.json" + ] ); } 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 4b95c7c07..5f38d1324 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -113,6 +113,11 @@ pub(super) async fn fetch_dists_on( match found { Ok(found) => { if let Some((base, why)) = fell_back { + // The detail is printed and persisted in `--json` + // output (CI logs): never the URL's credentials. + let clean = crate::vex::product::strip_url_userinfo(&base); + let why = why.replace(base.trim_end_matches('/'), &clean); + let base = clean; result.warnings.push(( "upstream_registry_fallback", format!( @@ -527,18 +532,7 @@ fn berry_registry_locator( } } -/// The value of the last top-level `key` in a YAML settings file -/// (pnpm-workspace.yaml, .yarnrc.yml), quotes removed. -fn yaml_top_level_value(text: &str, key: &str) -> Option { - use crate::formats::pnpm::workspace::top_level_key; - text.strip_prefix('\u{feff}') - .unwrap_or(text) - .lines() - .filter_map(top_level_key) - .rfind(|(k, _)| k == key) - .map(|(_, value)| value.trim_matches(['"', '\'']).to_string()) - .filter(|value| !value.is_empty()) -} +use crate::formats::pnpm::workspace::yaml_top_level_value; /// The registry a berry restore reads `name`'s version document from: /// `.yarnrc.yml`'s `npmRegistryServer`. A scoped package may resolve @@ -823,45 +817,564 @@ async fn restore_berry( /// What decides whether pnpm records a resolution's `tarball:` in the lock /// at `rel`, read from its sibling settings files. struct PnpmTarballPolicy { - /// `lockfileIncludeTarballUrl` in pnpm-workspace.yaml (pnpm 10+, which - /// wins over `.npmrc`), else `lockfile-include-tarball-url` in `.npmrc`: - /// every resolution carries its tarball. + /// pnpm's `lockfileIncludeTarballUrl` was in effect when it wrote the + /// lock, so every resolution carries its tarball + /// (see [`pnpm_include_tarball`]). always: bool, - /// The `.npmrc` `registry`, which pnpm derives tarball URLs from. - registry: Option, + /// `always` rests on the tier-3 guess that another pnpm major would + /// read the other way (see [`PnpmIncludeTarball::guess`]). + guess: Option, + /// The lock is a Rush lock (`rush.json` at the Rush root): its pnpm is + /// rush.json's `pnpmVersion`, not a sibling package.json pin. + rush: bool, + /// The sibling pnpm-workspace.yaml and `.npmrc`, and the pnpm major + /// that reads them ([`pnpm_settings_major`]), which name the registry + /// pnpm resolves a package against (see [`pnpm_lookup_registry`]). + workspace: Option, + npmrc: Option, + major: Option, + /// The `dir/` prefix those settings were read from + /// ([`pnpm_settings_prefix`]), for the guess warning. + settings_prefix: String, +} + +impl PnpmTarballPolicy { + fn registry(&self, name: &str) -> Option { + pnpm_lookup_registry( + self.workspace.as_deref(), + self.npmrc.as_deref(), + self.major, + name, + ) + } +} + +/// The pnpm major whose settings reading applies to the lock `text`: the +/// installed / pinned `pm_major`, else 8 for a pre-9 lock or a shrinkwrap +/// (only pnpm <= 8 writes those), else 11 for a lock carrying pnpm 11+'s +/// env lockfile document ahead of the project lock (only pnpm >= 11 +/// writes one, so its `.npmrc` pnpm settings are ignored). +fn pnpm_settings_major(text: &str, pm_major: Option) -> Option { + use crate::formats::pnpm::grammar::{is_pnpm_lock_text, main_document}; + use crate::formats::pnpm::lock_version_major; + + let legacy_lock = lock_version_major(text).is_some_and(|major| major < 9) + || text + .lines() + .any(|line| line.starts_with("shrinkwrapVersion:")); + let main = main_document(text); + let env_document = is_pnpm_lock_text(&text[..text.len() - main.len()]); + pm_major + .or(legacy_lock.then_some(8)) + .or(env_document.then_some(11)) +} + +/// The value of `key` in the top-level block mapping `section` of the YAML +/// `text` (its direct children only, the last one winning, quotes +/// removed); `None` when absent, empty or flow-styled. +fn yaml_block_value(text: &str, section: &str, key: &str) -> Option { + use crate::formats::pnpm::workspace::{block_section_bounds, top_level_key}; + + let lines: Vec = crate::formats::text::strip_bom(text) + .lines() + .map(str::to_string) + .collect(); + let (start, end) = block_section_bounds(&lines, section)?; + let children = &lines[start + 1..end]; + let indent = children.iter().find_map(|line| { + let rest = line.trim_start_matches(' '); + (!rest.trim().is_empty() && !rest.starts_with('#')).then(|| line.len() - rest.len()) + })?; + children + .iter() + .filter_map(|line| { + line.get(indent..) + .filter(|_| line[..indent].trim().is_empty()) + }) + .filter_map(top_level_key) + .rfind(|(k, _)| k == key) + .map(|(_, value)| value.trim_matches(['"', '\'']).to_string()) + .filter(|value| !value.is_empty()) } -async fn pnpm_tarball_policy(view: &mut View<'_>, rel: &str) -> PnpmTarballPolicy { +/// The registry pnpm resolves `name` against (`None`: the default +/// registry). pnpm both reads the version document from it and derives +/// conventional tarball URLs under it (#919). Which settings file names it +/// follows the pnpm `major` ([`pnpm_settings_major`]): +/// +/// - pnpm <= 9: the lock's sibling `.npmrc`, `@scope:registry` for a +/// scoped name when set, else `registry`; +/// - pnpm 10: pnpm-workspace.yaml's `registries` map when present (it +/// replaces `.npmrc`'s registries wholesale: the scope's key, else +/// `default`), else `.npmrc`; a workspace `registry:` is ignored; +/// - pnpm 11+ (and an unknown major, since a workspace `registry:` only +/// takes effect there): the two merged, the workspace file winning per +/// key: `registries."@scope"`, `.npmrc` `@scope:registry`, then +/// `registry:`, `registries.default`, `.npmrc` `registry`. +/// +/// (Measured with pnpm 9.15, 10.34 and 11.27.) A value still holding a +/// `${VAR}` reference is read as unset: the restore does not expand the +/// user's environment, and must not fetch it as a URL. +fn pnpm_lookup_registry( + workspace: Option<&str>, + npmrc: Option<&str>, + major: Option, + name: &str, +) -> Option { use super::super::npmrc::npmrc_top_level_value; + use crate::formats::pnpm::workspace::top_level_key; - let dir_prefix = match rel.rsplit_once('/') { - Some((dir, _)) => format!("{dir}/"), - None => String::new(), - }; - let workspace = view - .read(&format!("{dir_prefix}pnpm-workspace.yaml")) - .await - .ok() - .flatten(); - let npmrc = view - .read(&format!("{dir_prefix}.npmrc")) - .await - .ok() - .flatten(); - let npmrc_value = |key: &str| { + let workspace = workspace.filter(|_| major.is_none_or(|major| major > 9)); + let scope = name + .strip_prefix('@') + .and_then(|rest| rest.split_once('/')) + .map(|(scope, _)| format!("@{scope}")); + let rc = |key: &str| { npmrc - .as_deref() .and_then(|text| npmrc_top_level_value(text, key)) .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }; + let registries = + |key: &str| workspace.and_then(|text| yaml_block_value(text, "registries", key)); + let from_npmrc = || { + scope + .as_ref() + .and_then(|scope| rc(&format!("{scope}:registry"))) + .or_else(|| rc("registry")) + }; + let value = if major == Some(10) { + let has_registries = workspace.is_some_and(|text| { + crate::formats::text::strip_bom(text) + .lines() + .filter_map(top_level_key) + .any(|(key, _)| key == "registries") + }); + if has_registries { + scope + .as_deref() + .and_then(registries) + .or_else(|| registries("default")) + } else { + from_npmrc() + } + } else { + scope + .as_ref() + .and_then(|scope| registries(scope).or_else(|| rc(&format!("{scope}:registry")))) + .or_else(|| workspace.and_then(|text| yaml_top_level_value(text, "registry"))) + .or_else(|| registries("default")) + .or_else(|| rc("registry")) + }; + value.filter(|value| !value.contains("${")) +} + +/// What [`pnpm_include_tarball`] concluded about the lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PnpmIncludeTarball { + /// pnpm wrote the lock under `lockfileIncludeTarballUrl`. + on: bool, + /// `on` came from the tier-3 fallback (no lock evidence, no known pnpm + /// major) and pnpm 9 or pnpm >= 11, which also write a 9.0 lock, would + /// read the two settings files the other way. + guess: Option, +} + +/// The settings a tier-3 guess read (`None`: unset; anything but `true` +/// reads as off): pnpm 10 follows the workspace file, else `.npmrc`; pnpm 9 +/// reads only `.npmrc`, pnpm >= 11 only the workspace file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PnpmTarballGuess { + workspace: Option, + npmrc: Option, +} + +impl PnpmTarballGuess { + /// pnpm 10's reading, which the restore follows. + fn on(self) -> bool { + self.workspace.or(self.npmrc).unwrap_or(false) + } + + /// pnpm 9's reading (`.npmrc` only). + fn pnpm9(self) -> bool { + self.npmrc.unwrap_or(false) + } + + /// pnpm >= 11's reading (pnpm-workspace.yaml only). + fn pnpm11(self) -> bool { + self.workspace.unwrap_or(false) + } +} + +/// Whether pnpm wrote the lock `text` under `lockfileIncludeTarballUrl` +/// (#902). The setting lives in pnpm-workspace.yaml or `.npmrc`, but which +/// of those pnpm reads depends on its major: pnpm <= 9 ignores workspace +/// settings, pnpm 11+ ignores pnpm settings in `.npmrc`, and pnpm 10 reads +/// both (the workspace file wins). Strongest signal first: +/// +/// 1. the lock's own unpinned registry resolutions ([`pnpm_lock_tarball_evidence`]); +/// 2. the settings file the installed pnpm major (`pm_major`, or a pre-9 +/// lock / shrinkwrap, which only pnpm <= 8 writes) reads; +/// 3. with neither, pnpm 10's reading (workspace file, else `.npmrc`). +/// That covers a lock with no sibling install record (a fresh clone, a +/// Rush lock whose node_modules lives in common/temp) and only pinned +/// entries; a global `~/.npmrc` or `npm_config_*` setting is only +/// visible through tier 1. A 9.0 lock may come from pnpm 9, 10 or 11+, +/// so when pnpm 9's reading (`.npmrc` only) or pnpm 11+'s (the +/// workspace file only) differs from pnpm 10's, the result says so +/// ([`PnpmIncludeTarball::guess`]) so the restore can warn. +fn pnpm_include_tarball( + text: &str, + workspace: Option<&str>, + npmrc: Option<&str>, + pm_major: Option, + is_hosted: impl Fn(&str) -> bool, +) -> PnpmIncludeTarball { + use super::super::npmrc::npmrc_top_level_value; + + let major = pnpm_settings_major(text, pm_major); + let registry = |name: &str| pnpm_lookup_registry(workspace, npmrc, major, name); + if let Some(on) = pnpm_lock_tarball_evidence(text, registry, is_hosted) { + return PnpmIncludeTarball { on, guess: None }; + } + // js-yaml reads `True` / `TRUE` as true too; `.npmrc` (ini + nopt) + // only a lowercase `true`. + let from_workspace = || { + workspace + .and_then(|text| yaml_top_level_value(text, "lockfileIncludeTarballUrl")) + .map(|value| value.trim().eq_ignore_ascii_case("true")) }; - let always = workspace + let from_npmrc = || { + npmrc + .and_then(|text| npmrc_top_level_value(text, "lockfile-include-tarball-url")) + .map(|value| value.trim() == "true") + }; + let value = match major { + Some(major) if major <= 9 => from_npmrc(), + Some(major) if major >= 11 => from_workspace(), + Some(_) => from_workspace().or_else(from_npmrc), + // Tier 3: pnpm 10's reading, flagged when another major differs. + None => { + let guess = PnpmTarballGuess { + workspace: from_workspace(), + npmrc: from_npmrc(), + }; + let on = guess.on(); + let differs = guess.pnpm9() != on || guess.pnpm11() != on; + return PnpmIncludeTarball { + on, + guess: differs.then_some(guess), + }; + } + }; + PnpmIncludeTarball { + on: value.unwrap_or(false), + guess: None, + } +} + +/// What the lock's unpinned registry resolutions (integrity, a registry +/// key, no `type` / `directory` / `commit` / `repo`, a non-hosted and +/// non-`file:` tarball if any) say about `lockfileIncludeTarballUrl`. +/// With it on, pnpm records `tarball:` on every one of them, so a single +/// bare one proves it off, whatever else the lock holds (a conventional +/// URL can still appear with it off, under a second registry). With no +/// bare one, a tarball pnpm could have derived proves it on. `None`: no +/// such resolution (an unconventional URL is recorded either way). Only +/// the main document counts: pnpm 11+'s env lockfile document records its +/// resolutions bare whatever the setting. +fn pnpm_lock_tarball_evidence( + text: &str, + registry: impl Fn(&str) -> Option, + is_hosted: impl Fn(&str) -> bool, +) -> Option { + use crate::formats::pnpm::{classify_pnpm_key, grammar::main_document, pnpm_packages, PnpmKey}; + + let mut derived = false; + for package in pnpm_packages(main_document(text)) { + let PnpmKey::Registry { name, version } = classify_pnpm_key(package.key) else { + continue; + }; + let Some(resolution) = package.resolution.as_ref() else { + continue; + }; + if resolution.integrity().is_none() + || resolution + .fields + .iter() + .any(|(k, _)| matches!(*k, "type" | "directory" | "commit" | "repo")) + { + continue; + } + match resolution.tarball() { + None => return Some(false), + Some(tarball) if tarball.starts_with("file:") || is_hosted(tarball) => {} + Some(tarball) => { + derived |= + registry_derives_tarball(registry(name).as_deref(), name, version, tarball); + } + } + } + derived.then_some(true) +} + +/// The major of a `pnpm@` package-manager spec (`.modules.yaml`'s +/// `packageManager`, package.json's corepack pin, `+sha…` suffix allowed). +fn pnpm_spec_major(spec: &str) -> Option { + let version = spec + .trim() + .trim_matches(['"', '\'']) + .strip_prefix("pnpm@")?; + let major = version.split(['.', '+', '-']).next()?; + major.parse().ok() +} + +/// The pnpm major an install record names: `node_modules/.modules.yaml`'s +/// `packageManager` (JSON on pnpm 10+, a top-level YAML scalar before). +fn modules_yaml_pnpm_major(text: &str) -> Option { + let text = crate::formats::text::strip_bom(text); + if let Ok(value) = serde_json::from_str::(text) { + return value + .get("packageManager") + .and_then(|v| v.as_str()) + .and_then(pnpm_spec_major); + } + yaml_top_level_value(text, "packageManager") .as_deref() - .and_then(|text| yaml_top_level_value(text, "lockfileIncludeTarballUrl")) - .or_else(|| npmrc_value("lockfile-include-tarball-url")) - .is_some_and(|value| value == "true"); + .and_then(pnpm_spec_major) +} + +/// The pnpm major package.json's corepack `packageManager` pins. +fn package_json_pnpm_major(text: &str) -> Option { + serde_json::from_str::(crate::formats::text::strip_bom(text)) + .ok()? + .get("packageManager")? + .as_str() + .and_then(pnpm_spec_major) +} + +/// The Rush root (`""` or a `dir/` prefix) of a Rush lock path: the common +/// lock `common/config/rush/pnpm-lock.yaml` or a subspace's +/// `common/config/subspaces//pnpm-lock.yaml`. Whether it is one is +/// settled by a `rush.json` at that root. +fn rush_lock_root(rel: &str) -> Option<&str> { + use crate::constants::npm_family::{RUSH_COMMON_LOCK_REL, RUSH_SUBSPACES_DIR}; + + let root = match rel.strip_suffix(RUSH_COMMON_LOCK_REL) { + Some(root) => root, + None => { + let (dir, name) = rel.strip_suffix("/pnpm-lock.yaml")?.rsplit_once('/')?; + if name.is_empty() { + return None; + } + dir.strip_suffix(RUSH_SUBSPACES_DIR)? + } + }; + (root.is_empty() || root.ends_with('/')).then_some(root) +} + +/// The pnpm major rush.json's `pnpmVersion` names (rush.json is JSONC). +fn rush_json_pnpm_major(text: &str) -> Option { + let text = crate::vendor::bun_lock_text::strip_jsonc(crate::formats::text::strip_bom(text)); + serde_json::from_str::(&text) + .ok()? + .get("pnpmVersion")? + .as_str()? + .trim() + .split('.') + .next()? + .parse() + .ok() +} + +/// The text of `name` next to the lock (`dir_prefix`); `None` when absent +/// or unreadable. +async fn read_sibling(view: &mut View<'_>, dir_prefix: &str, name: &str) -> Option { + view.read(&format!("{dir_prefix}{name}")) + .await + .ok() + .flatten() +} + +/// The directory (as a `dir/` prefix, `""` for the project root) whose +/// pnpm-workspace.yaml, `.npmrc` and `package.json` pnpm reads for the lock +/// `rel`: the lock's own directory, except for a workspace member lock +/// (`sharedWorkspaceLockfile: false`). pnpm reads a member's settings only +/// from its workspace root: the nearest ancestor with a +/// pnpm-workspace.yaml whose `packages:` lists the member's directory +/// ([`lists_as_member`]), when the member has no such file of its own. A +/// Rush lock keeps its own directory (see [`rush_lock_root`]). +/// +/// [`lists_as_member`]: crate::utils::pnpm_workspace::lists_as_member +async fn pnpm_settings_prefix(view: &mut View<'_>, rel: &str) -> String { + let Some((dir, _)) = rel.rsplit_once('/') else { + return String::new(); + }; + let own = format!("{dir}/"); + if rush_lock_root(rel).is_some() + || read_sibling(view, &own, "pnpm-workspace.yaml") + .await + .is_some() + { + return own; + } + let parts: Vec<&str> = dir.split('/').collect(); + for depth in (0..parts.len()).rev() { + let prefix: String = parts[..depth].iter().map(|p| format!("{p}/")).collect(); + let Some(yaml) = read_sibling(view, &prefix, "pnpm-workspace.yaml").await else { + continue; + }; + let member: Vec = parts[depth..].iter().map(|p| p.to_string()).collect(); + // pnpm stops at the nearest workspace file. + return if crate::utils::pnpm_workspace::lists_as_member(&yaml, &member) { + prefix + } else { + own + }; + } + own +} + +/// The `upstream_pnpm_tarball_setting_guessed` detail (#902): nothing +/// showed which pnpm wrote the lock `rel`, so the restore read +/// `lockfileIncludeTarballUrl` as pnpm 10 does (`guess`) for the derivable +/// `entries`, and pnpm 9 or pnpm >= 11 would read it the other way. `rush`: +/// a Rush lock, whose pnpm is rush.json's `pnpmVersion`. Worded for a dry +/// run and every caller (rollback, remove, a vendor takeover or eject). +fn pnpm_tarball_guess_warning( + rel: &str, + dir_prefix: &str, + guess: PnpmTarballGuess, + rush: bool, + entries: &[&str], +) -> String { + let workspace = format!("{dir_prefix}pnpm-workspace.yaml"); + let npmrc = format!("{dir_prefix}.npmrc"); + let ws_value = |value: Option| match value { + Some(value) => format!("`lockfileIncludeTarballUrl: {value}`"), + None => "`lockfileIncludeTarballUrl` unset".to_string(), + }; + let rc_value = |value: Option| match value { + Some(value) => format!("`lockfile-include-tarball-url={value}`"), + None => "`lockfile-include-tarball-url` unset".to_string(), + }; + let on = guess.on(); + let followed = if guess.workspace.is_some() { + format!("{} in {workspace}", ws_value(guess.workspace)) + } else { + format!("{} in {npmrc}", rc_value(guess.npmrc)) + }; + let effect = |on: bool| if on { "keep it" } else { "leave it out" }; + let mut differ = Vec::new(); + if guess.pnpm9() != on { + differ.push(format!( + "pnpm 9 reads only {npmrc} ({}) and would {}", + rc_value(guess.npmrc), + effect(guess.pnpm9()) + )); + } + if guess.pnpm11() != on { + differ.push(format!( + "pnpm >= 11 reads only {workspace} ({}) and would {}", + ws_value(guess.workspace), + effect(guess.pnpm11()) + )); + } + let (unknown, pin) = if rush { + let root = rush_lock_root(rel).unwrap_or_default(); + ( + format!("no {root}rush.json `pnpmVersion`"), + format!( + "set {root}rush.json `pnpmVersion` to the pnpm Rush installs with, so the \ + restore reads the setting as that pnpm does" + ), + ) + } else { + ( + format!( + "no {dir_prefix}node_modules/.modules.yaml install record, no \ + {dir_prefix}package.json `packageManager` pin" + ), + format!( + "pin it in {dir_prefix}package.json `packageManager` (or reinstall so \ + {dir_prefix}node_modules/.modules.yaml records it), or give \ + `lockfile-include-tarball-url` in {npmrc} and `lockfileIncludeTarballUrl` \ + in {workspace} the same value, so every pnpm reads the setting alike" + ), + ) + }; + format!( + "{rel}: nothing shows which pnpm wrote this lock (no unpinned registry entry that \ + shows the setting, {unknown}), so the restore reads `lockfileIncludeTarballUrl` \ + as pnpm 10 does, from {followed}, and restores {} {} `tarball:`; {}. If the \ + project uses that pnpm, those entries' `tarball:` lines differ from what it writes: \ + {pin}. A rollback or remove can then be redone by restoring {rel} from version \ + control and re-running it", + entries.join(", "), + if on { "with" } else { "without" }, + differ.join("; ") + ) +} + +async fn pnpm_tarball_policy( + view: &mut View<'_>, + rel: &str, + text: &str, + ctx: &Ctx<'_>, +) -> PnpmTarballPolicy { + let dir_prefix = pnpm_settings_prefix(view, rel).await; + let lock_prefix = match rel.rsplit_once('/') { + Some((dir, _)) => format!("{dir}/"), + None => String::new(), + }; + let workspace = read_sibling(view, &dir_prefix, "pnpm-workspace.yaml").await; + let npmrc = read_sibling(view, &dir_prefix, ".npmrc").await; + // A Rush lock: Rush installs with rush.json's `pnpmVersion` in + // common/temp, so neither an install record nor a package.json sits + // beside the lock. + let rush_json = match rush_lock_root(rel) { + Some(root) => read_sibling(view, root, "rush.json").await, + None => None, + }; + let rush = rush_json.is_some(); + let pm_major = if let Some(rush_json) = rush_json.as_deref() { + rush_json_pnpm_major(rush_json) + } else { + // What installed the project (the workspace root's install + // record, else the member's own), else what it pins. + let mut installed = None; + for prefix in [&dir_prefix, &lock_prefix] { + installed = read_sibling(view, prefix, "node_modules/.modules.yaml") + .await + .as_deref() + .and_then(modules_yaml_pnpm_major); + if installed.is_some() || dir_prefix == lock_prefix { + break; + } + } + match installed { + Some(major) => Some(major), + None => read_sibling(view, &dir_prefix, "package.json") + .await + .as_deref() + .and_then(package_json_pnpm_major), + } + }; + let include = pnpm_include_tarball( + text, + workspace.as_deref(), + npmrc.as_deref(), + pm_major, + |url| ctx.hosted_uuid(url).is_some(), + ); PnpmTarballPolicy { - always, - registry: npmrc_value("registry").filter(|value| !value.is_empty()), + always: include.on, + guess: include.guess, + rush, + workspace, + npmrc, + major: pnpm_settings_major(text, pm_major), + settings_prefix: dir_prefix, } } @@ -911,10 +1424,11 @@ pub(crate) async fn restore_pnpm_locks( .iter() .map(|(_, u, n, v)| (u.clone(), n.clone(), v.clone())) .collect(); - let dists = fetch_dists(&wanted, ctx, &mut result).await; - let policy = pnpm_tarball_policy(view, rel).await; + let policy = pnpm_tarball_policy(view, rel, &text, ctx).await; + let dists = fetch_dists_on(&wanted, |name| policy.registry(name), ctx, &mut result).await; let mut splices: Vec<(std::ops::Range, String)> = Vec::new(); - let mut handled: Vec = Vec::new(); + // (uuid, name@version whose `tarball:` only the guessed setting decided) + let mut handled: Vec<(String, Option)> = Vec::new(); for entry in pnpm::entries(&text) { let Some(resolution) = pnpm::resolution(&entry) else { continue; @@ -926,7 +1440,7 @@ pub(crate) async fn restore_pnpm_locks( if result.refused.contains_key(uuid) { continue; } - let Some(dist) = dists.get(&(name.clone(), version.clone())) else { + let Some(dist) = dists.get(&(name.clone(), version.clone())).map(|d| &d.dist) else { continue; }; let Some(integrity) = dist.integrity.as_deref() else { @@ -938,31 +1452,48 @@ pub(crate) async fn restore_pnpm_locks( }; // pnpm records `tarball:` under lockfileIncludeTarballUrl and for // a URL it cannot derive from the registry (#557). - let restored = if policy.always - || !registry_derives_tarball( - policy.registry.as_deref(), - name, - version, - &dist.tarball, - ) { + let derived = registry_derives_tarball( + policy.registry(name).as_deref(), + name, + version, + &dist.tarball, + ); + let restored = if policy.always || !derived { resolution.rewrite(integrity, &dist.tarball) } else { resolution.restore(integrity) }; + // A URL pnpm records anyway is no guess. + let guessed = (policy.guess.is_some() && derived).then(|| format!("{name}@{version}")); splices.push((resolution.range.clone(), restored)); - handled.push(uuid.clone()); + handled.push((uuid.clone(), guessed)); } // A refusal recorded after a splice was planned (a second instance // of the same pin) drops that pin's splices too. - let splices: Vec<_> = splices + let kept: Vec<_> = splices .into_iter() .zip(&handled) - .filter(|(_, u)| !result.refused.contains_key(*u)) - .map(|(s, _)| s) + .filter(|(_, (u, _))| !result.refused.contains_key(u)) .collect(); + let mut guessed: Vec<&str> = kept.iter().filter_map(|(_, (_, g))| g.as_deref()).collect(); + guessed.sort_unstable(); + guessed.dedup(); + let splices: Vec<_> = kept.into_iter().map(|(s, _)| s).collect(); if splices.is_empty() { continue; } + if let Some(guess) = policy.guess.filter(|_| !guessed.is_empty()) { + result.warnings.push(( + "upstream_pnpm_tarball_setting_guessed", + pnpm_tarball_guess_warning( + rel, + &policy.settings_prefix, + guess, + policy.rush, + &guessed, + ), + )); + } let mut out = String::with_capacity(text.len()); let mut cursor = 0; let mut sorted = splices; @@ -973,7 +1504,7 @@ pub(crate) async fn restore_pnpm_locks( cursor = range.end; } out.push_str(&text[cursor..]); - for uuid in handled { + for (uuid, _) in handled { if !result.refused.contains_key(&uuid) { result.handled.insert(uuid); } @@ -1163,10 +1694,431 @@ pub(crate) async fn cleanup_side_config( #[cfg(test)] mod tests { use super::{ - berry_lookup_registry, berry_registry_locator, non_default_registry, - registry_derives_tarball, yaml_top_level_value, + berry_lookup_registry, berry_registry_locator, modules_yaml_pnpm_major, + non_default_registry, package_json_pnpm_major, pnpm_include_tarball, pnpm_lookup_registry, + pnpm_tarball_guess_warning, registry_derives_tarball, rush_json_pnpm_major, rush_lock_root, + yaml_top_level_value, PnpmTarballGuess, }; + const HOSTED: &str = "https://patch.test/npm/u/a-1.0.0.tgz"; + const WS_ON: &str = "packages:\n - '.'\nlockfileIncludeTarballUrl: true\n"; + const RC_ON: &str = "lockfile-include-tarball-url=true\n"; + + /// A v9 lock: `a@1.0.0` pinned to the hosted tarball, plus `extra` + /// `packages:` entries. + fn lock(extra: &str) -> String { + format!( + "lockfileVersion: '9.0'\n\npackages:\n a@1.0.0:\n \ + resolution: {{integrity: sha512-A==, tarball: {HOSTED}}}\n{extra}\n\ + snapshots:\n a@1.0.0: {{}}\n" + ) + } + + fn include(text: &str, ws: Option<&str>, rc: Option<&str>, major: Option) -> bool { + pnpm_include_tarball(text, ws, rc, major, |url| url == HOSTED).on + } + + fn guessed(text: &str, ws: Option<&str>, rc: Option<&str>, major: Option) -> bool { + pnpm_include_tarball(text, ws, rc, major, |url| url == HOSTED) + .guess + .is_some() + } + + /// #902 tier 3: with no lock evidence and no pnpm major, the settings + /// are read as pnpm 10 does, and flagged as a guess exactly when pnpm 9 + /// (`.npmrc` only) or pnpm >= 11 (the workspace file only), which also + /// write a 9.0 lock, would read them the other way. + #[test] + fn pnpm_include_tarball_flags_the_guess() { + let text = lock(""); + let rc_off = "lockfile-include-tarball-url=false\n"; + let ws_off = "lockfileIncludeTarballUrl: false\n"; + // (workspace, npmrc, pnpm 10's reading, guessed) + for (ws, rc, on, guess) in [ + // pnpm >= 11 ignores `.npmrc`. + (None, Some(RC_ON), true, true), + // pnpm 9 ignores the workspace file. + (Some(WS_ON), None, true, true), + (Some(WS_ON), Some(rc_off), true, true), + (Some(ws_off), Some(RC_ON), false, true), + // Every major agrees. + (Some(WS_ON), Some(RC_ON), true, false), + (None, Some(rc_off), false, false), + (None, None, false, false), + (Some(ws_off), None, false, false), + (Some(ws_off), Some(rc_off), false, false), + ] { + assert_eq!(include(&text, ws, rc, None), on, "{ws:?} {rc:?}"); + assert_eq!(guessed(&text, ws, rc, None), guess, "{ws:?} {rc:?}"); + } + // Evidence: a known pnpm major, a pre-9 lock, lock entries. + for major in [9, 10, 11] { + assert!(!guessed(&text, None, Some(RC_ON), Some(major))); + assert!(!guessed(&text, Some(WS_ON), None, Some(major))); + } + assert!(!guessed( + &text.replace("'9.0'", "'6.0'"), + None, + Some(RC_ON), + None + )); + let conventional = " c@3.0.0:\n resolution: {integrity: sha512-C==, \ + tarball: https://registry.npmjs.org/c/-/c-3.0.0.tgz}\n"; + assert!(!guessed(&lock(conventional), None, Some(RC_ON), None)); + // An unconventional sibling is no evidence: still a guess. + let cdn = " d@1.0.0:\n resolution: {integrity: sha512-D==, \ + tarball: https://cdn.example/d.tgz}\n"; + assert!(guessed(&lock(cdn), None, Some(RC_ON), None)); + } + + /// The guess warning names what was followed, which pnpm reads it the + /// other way, and remedies that hold on every pnpm: a pin, or the two + /// files agreeing (never moving the setting, which pnpm 9 would lose). + #[test] + fn pnpm_tarball_guess_warning_names_the_disagreeing_pnpm() { + let rel = "apps/web/pnpm-lock.yaml"; + let npmrc_only = PnpmTarballGuess { + workspace: None, + npmrc: Some(true), + }; + let detail = pnpm_tarball_guess_warning(rel, "apps/web/", npmrc_only, false, &["a@1.0.0"]); + for needle in [ + "apps/web/pnpm-lock.yaml: nothing shows which pnpm wrote this lock", + "no unpinned registry entry that shows the setting", + "from `lockfile-include-tarball-url=true` in apps/web/.npmrc", + "restores a@1.0.0 with `tarball:`", + "pnpm >= 11 reads only apps/web/pnpm-workspace.yaml (`lockfileIncludeTarballUrl` \ + unset) and would leave it out", + "apps/web/package.json `packageManager`", + "reinstall", + "the same value", + "restoring apps/web/pnpm-lock.yaml from version control and re-running", + ] { + assert!(detail.contains(needle), "{needle}: {detail}"); + } + assert!(!detail.contains("pnpm 9 reads"), "{detail}"); + assert!(!detail.contains("move the setting"), "{detail}"); + assert!(!detail.contains("wrote `tarball:`"), "{detail}"); + + let workspace_off = PnpmTarballGuess { + workspace: Some(false), + npmrc: Some(true), + }; + let detail = + pnpm_tarball_guess_warning("pnpm-lock.yaml", "", workspace_off, false, &["a@1.0.0"]); + for needle in [ + "from `lockfileIncludeTarballUrl: false` in pnpm-workspace.yaml", + "restores a@1.0.0 without `tarball:`", + "pnpm 9 reads only .npmrc (`lockfile-include-tarball-url=true`) and would keep it", + ] { + assert!(detail.contains(needle), "{needle}: {detail}"); + } + assert!(!detail.contains("pnpm >= 11 reads"), "{detail}"); + + // A Rush lock: the pin is rush.json's, at the Rush root. + let detail = pnpm_tarball_guess_warning( + "repo/common/config/rush/pnpm-lock.yaml", + "repo/common/config/rush/", + npmrc_only, + true, + &["a@1.0.0"], + ); + assert!( + detail.contains("no repo/rush.json `pnpmVersion`"), + "{detail}" + ); + assert!( + detail.contains("set repo/rush.json `pnpmVersion`"), + "{detail}" + ); + assert!(!detail.contains("packageManager"), "{detail}"); + assert!(!detail.contains(".modules.yaml"), "{detail}"); + } + + #[test] + fn rush_lock_roots_and_pnpm_version() { + assert_eq!( + rush_lock_root("common/config/rush/pnpm-lock.yaml"), + Some("") + ); + assert_eq!( + rush_lock_root("repo/common/config/rush/pnpm-lock.yaml"), + Some("repo/") + ); + assert_eq!( + rush_lock_root("common/config/subspaces/web/pnpm-lock.yaml"), + Some("") + ); + assert_eq!(rush_lock_root("xcommon/config/rush/pnpm-lock.yaml"), None); + assert_eq!(rush_lock_root("pnpm-lock.yaml"), None); + assert_eq!( + rush_lock_root("common/config/subspaces/pnpm-lock.yaml"), + None + ); + let rush_json = + "{\n // \"pnpmVersion\": \"8.0.0\",\n /* c */ \"pnpmVersion\": \"9.15.9\",\n \ + \"projects\": [],\n}\n"; + assert_eq!(rush_json_pnpm_major(rush_json), Some(9)); + assert_eq!(rush_json_pnpm_major("{ \"npmVersion\": \"10.0.0\" }"), None); + } + + /// #902 tier 1: the lock's unpinned registry resolutions decide. + #[test] + fn pnpm_include_tarball_follows_lock_evidence() { + let bare = " b@2.0.0:\n resolution: {integrity: sha512-B==}\n"; + let conventional = " c@3.0.0:\n resolution: {integrity: sha512-C==, \ + tarball: https://registry.npmjs.org/c/-/c-3.0.0.tgz}\n"; + // A bare sibling: the setting was off, whatever .npmrc says. + assert!(!include(&lock(bare), None, Some(RC_ON), None)); + assert!(!include(&lock(bare), Some(WS_ON), Some(RC_ON), Some(10))); + // A derivable tarball on the sibling: it was on, with no setting seen. + assert!(include(&lock(conventional), None, None, Some(9))); + // Mixed: bare wins (the conventional one is under a second registry). + let mixed = format!("{bare}{conventional}"); + assert!(!include(&lock(&mixed), None, Some(RC_ON), None)); + // Unconventional, `file:`, git and integrity-less entries are no + // evidence either way: the settings decide. + for silent in [ + " d@1.0.0:\n resolution: {integrity: sha512-D==, tarball: https://cdn.example/d.tgz}\n", + " e@file:.socket/vendor/npm/x/e-1.0.0.tgz:\n resolution: {integrity: sha512-E==, tarball: file:.socket/vendor/npm/x/e-1.0.0.tgz}\n", + " f@1.0.0:\n resolution: {integrity: sha512-F==, tarball: file:x.tgz}\n", + " g@1.0.0:\n resolution: {type: git, repo: https://g.example/g, commit: abc}\n", + " h@1.0.0:\n resolution: {tarball: https://registry.npmjs.org/h/-/h-1.0.0.tgz}\n", + ] { + assert!(include(&lock(silent), None, Some(RC_ON), None), "{silent}"); + assert!(!include(&lock(silent), None, None, None), "{silent}"); + } + } + + /// #902: pnpm 11+'s env lockfile document (as `pnpm add --config` + /// writes it, pnpm 11.27.0) records config dependencies bare even under + /// lockfileIncludeTarballUrl; only the main document is evidence. + #[test] + fn pnpm_include_tarball_ignores_the_env_lockfile_document() { + let env = "---\nlockfileVersion: '9.0'\n\nimporters:\n\n .:\n \ + configDependencies:\n is-number:\n specifier: 7.0.0\n \ + version: 7.0.0\n\npackages:\n\n is-number@7.0.0:\n \ + resolution: {integrity: sha512-N==}\n\nsnapshots:\n\n \ + is-number@7.0.0: {}\n\n---\n"; + let conventional = " c@3.0.0:\n resolution: {integrity: sha512-C==, \ + tarball: https://registry.npmjs.org/c/-/c-3.0.0.tgz}\n"; + let two_docs = format!("{env}{}", lock(conventional)); + assert!(include(&two_docs, None, None, Some(11))); + // No evidence in the main document: the settings decide, not the + // env document's bare entry. + let pinned_only = format!("{env}{}", lock("")); + assert!(include(&pinned_only, Some(WS_ON), None, Some(11))); + assert!(!include(&pinned_only, None, None, Some(11))); + // With no install record or pin, the env document itself proves + // pnpm >= 11, which ignores `.npmrc`'s lockfile-include-tarball-url + // (a fresh clone of the issue's repro). + assert!(!include(&pinned_only, None, Some(RC_ON), None)); + assert!(include(&pinned_only, Some(WS_ON), Some(RC_ON), None)); + // A one-document lock with no pin keeps pnpm 10's reading. + assert!(include(&lock(""), None, Some(RC_ON), None)); + // A lone leading `---` marker is no env document. + let marker_only = format!("---\n{}", lock("")); + assert!(include(&marker_only, None, Some(RC_ON), None)); + // A bare entry in the main document still proves it off. + let bare = " b@2.0.0:\n resolution: {integrity: sha512-B==}\n"; + assert!(!include( + &format!("{env}{}", lock(bare)), + Some(WS_ON), + None, + Some(11) + )); + } + + /// #902 tier 2: the settings file the installed pnpm major reads. + #[test] + fn pnpm_include_tarball_reads_the_settings_file_of_the_pnpm_major() { + let text = lock(""); + // pnpm <= 9 ignores pnpm-workspace.yaml settings. + assert!(!include(&text, Some(WS_ON), None, Some(9))); + assert!(include(&text, None, Some(RC_ON), Some(9))); + // pnpm 10 reads both, the workspace file winning. + assert!(include(&text, Some(WS_ON), None, Some(10))); + assert!(include(&text, None, Some(RC_ON), Some(10))); + let ws_off = "lockfileIncludeTarballUrl: false\n"; + assert!(!include(&text, Some(ws_off), Some(RC_ON), Some(10))); + // pnpm 11+ ignores pnpm settings in .npmrc. + for major in [11, 12] { + assert!(!include(&text, None, Some(RC_ON), Some(major))); + assert!(include(&text, Some(WS_ON), None, Some(major))); + } + // Unknown major: pnpm 10's reading. + assert!(include(&text, Some(WS_ON), None, None)); + assert!(include(&text, None, Some(RC_ON), None)); + // js-yaml reads `True` / `TRUE` as true in the workspace file. + for on in ["True", "TRUE", "'true'"] { + let ws = format!("lockfileIncludeTarballUrl: {on}\n"); + assert!(include(&text, Some(&ws), None, Some(11)), "{on}"); + assert!(include(&text, Some(&ws), None, None), "{on}"); + } + // Only pnpm <= 8 writes a pre-9 lock or a shrinkwrap.yaml. + let v6 = text.replace("'9.0'", "'6.0'"); + assert!(!include(&v6, Some(WS_ON), None, None)); + assert!(include(&v6, None, Some(RC_ON), None)); + let shrinkwrap = format!( + "shrinkwrapVersion: 3\n{}", + text.replace("lockfileVersion: '9.0'\n", "") + ); + assert!(!include(&shrinkwrap, Some(WS_ON), None, None)); + } + + #[test] + fn pnpm_major_from_install_record_and_package_json() { + let json = "{\n \"layoutVersion\": 5,\n \"packageManager\": \"pnpm@11.28.3\"\n}\n"; + assert_eq!(modules_yaml_pnpm_major(json), Some(11)); + assert_eq!( + modules_yaml_pnpm_major("\u{feff}layoutVersion: 5\npackageManager: pnpm@9.15.9\n"), + Some(9) + ); + assert_eq!( + modules_yaml_pnpm_major("packageManager: 'pnpm@8.6.0'\n"), + Some(8) + ); + assert_eq!( + modules_yaml_pnpm_major("packageManager: yarn@4.0.0\n"), + None + ); + assert_eq!(modules_yaml_pnpm_major("{}"), None); + assert_eq!( + package_json_pnpm_major(r#"{"packageManager":"pnpm@10.4.1+sha512.abc"}"#), + Some(10) + ); + assert_eq!( + package_json_pnpm_major(r#"{"packageManager":"npm@10.0.0"}"#), + None + ); + assert_eq!(package_json_pnpm_major("not json"), None); + } + + #[test] + fn pnpm_reads_the_npmrc_registry_and_scope_registries() { + let rc_lookup = |rc, name| pnpm_lookup_registry(None, rc, None, name); + let rc = "registry=https://m.example/npm/\n@s:registry = https://s.example/\n"; + assert_eq!( + rc_lookup(Some(rc), "a").as_deref(), + Some("https://m.example/npm/") + ); + assert_eq!( + rc_lookup(Some(rc), "@s/a").as_deref(), + Some("https://s.example/") + ); + // Another scope, and a name merely starting with the scope's text, + // resolve against `registry`. + assert_eq!( + rc_lookup(Some(rc), "@t/a").as_deref(), + Some("https://m.example/npm/") + ); + assert_eq!( + rc_lookup(Some("@s:registry=https://s.example\n"), "a"), + None + ); + assert_eq!(rc_lookup(None, "@s/a"), None); + // An unexpanded `${VAR}` is never fetched as a URL; a scoped name + // whose scope registry holds one keeps the default, not `registry`. + assert_eq!(rc_lookup(Some("registry=${MIRROR}\n"), "a"), None); + assert_eq!( + rc_lookup( + Some("registry=https://m.example\n@s:registry=${S}/npm\n"), + "@s/a" + ), + None + ); + // pnpm derives a scoped package's tarball under its scope registry, + // so that registry's conventional URL stays out of the lock. + let scope = rc_lookup(Some(rc), "@s/a"); + assert!(registry_derives_tarball( + scope.as_deref(), + "@s/a", + "1.0.0", + "https://s.example/@s/a/-/a-1.0.0.tgz" + )); + } + + /// #919: pnpm 10+ also read registries from pnpm-workspace.yaml, each + /// major its own way (measured with pnpm 9.15, 10.34 and 11.27). + #[test] + fn pnpm_reads_the_workspace_registries_of_the_pnpm_major() { + let rc = "registry=https://rc.example/\n@s:registry=https://rc-s.example/\n"; + let ws_registry = "packages:\n - '.'\nregistry: https://ws.example/\n"; + let ws_map = "registries:\n default: https://ws-default.example/\n \ + '@s': https://ws-s.example/\n # '@t': x\n \ + \"@u\": \"https://ws-u.example/\" # c\n"; + let get = pnpm_lookup_registry; + // pnpm <= 9 reads only .npmrc. + for major in [Some(8), Some(9)] { + assert_eq!(get(Some(ws_registry), None, major, "a"), None); + assert_eq!(get(Some(ws_map), None, major, "@s/a"), None); + assert_eq!( + get(Some(ws_map), Some(rc), major, "@s/a").as_deref(), + Some("https://rc-s.example/") + ); + } + // pnpm 10: a workspace `registries` map replaces .npmrc's wholesale; + // a workspace `registry:` is ignored. + let ten = Some(10); + assert_eq!( + get(Some(ws_registry), Some(rc), ten, "a").as_deref(), + Some("https://rc.example/") + ); + assert_eq!(get(Some(ws_registry), None, ten, "a"), None); + assert_eq!( + get(Some(ws_map), Some(rc), ten, "@s/a").as_deref(), + Some("https://ws-s.example/") + ); + assert_eq!( + get( + Some("registries:\n default: https://d.example/\n"), + Some(rc), + ten, + "@s/a" + ) + .as_deref(), + Some("https://d.example/") + ); + assert_eq!( + get(Some(ws_map), Some(rc), ten, "a").as_deref(), + Some("https://ws-default.example/") + ); + // pnpm 11+ and an unknown major: merged, the workspace winning per key. + for major in [Some(11), Some(12), None] { + assert_eq!( + get(Some(ws_registry), Some(rc), major, "a").as_deref(), + Some("https://ws.example/") + ); + // .npmrc's scope registry beats the workspace's `registry:`. + assert_eq!( + get(Some(ws_registry), Some(rc), major, "@s/a").as_deref(), + Some("https://rc-s.example/") + ); + assert_eq!( + get(Some(ws_map), Some(rc), major, "@s/a").as_deref(), + Some("https://ws-s.example/") + ); + assert_eq!( + get(Some(ws_map), Some(rc), major, "@u/a").as_deref(), + Some("https://ws-u.example/") + ); + // A commented-out scope falls through to `default`. + assert_eq!( + get(Some(ws_map), Some(rc), major, "@t/a").as_deref(), + Some("https://ws-default.example/") + ); + let both = format!("{ws_registry}{ws_map}"); + assert_eq!( + get(Some(&both), Some(rc), major, "a").as_deref(), + Some("https://ws.example/") + ); + assert_eq!( + get(Some("registry: ${MIRROR}\n"), Some(rc), major, "a"), + None + ); + } + } + #[test] fn berry_reads_the_project_registry_except_for_npm_scopes() { let rc = "npmRegistryServer: \"https://m.example/npm/\"\n"; diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs index e7308bfc7..44cc28d24 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs @@ -796,6 +796,41 @@ mod tests { ); } + /// A credentialed registry URL (`https://user:token@host/`) that can't + /// be read never leaks its userinfo into the `upstream_registry_fallback` + /// detail: rollback and remove print it and persist it in `--json` + /// output, which lands in CI logs. + #[tokio::test] + #[serial_test::serial] + async fn registry_fallback_warning_drops_url_credentials() { + let server = registry(&[("left-pad", "1.3.0", Some(LP_UPSTREAM))]).await; + std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); + let host = server.uri().replace("http://", ""); + let mirror = format!("http://ci-user:s3cret-token@{host}/private/"); + let text = format!( + "{{\n \"lockfileVersion\": 1,\n \"options\": {{\"registries\":{{\"npm\":\"{mirror}\"}}}},\n \"nodes\": {{\n \"~npm~left-pad@1.3.0\": [0,\"left-pad\",\"sha512-AA==\",\"{}\"]\n }},\n \"edges\": {{}}\n}}\n", + hosted(LP_UUID, "left-pad-1.3.0.tgz") + ); + let (outcome, _) = run(&text, &[pin("pkg:npm/left-pad@1.3.0", LP_UUID)], false).await; + std::env::remove_var("SOCKET_NPM_REGISTRY"); + assert!(refused(&outcome).is_empty(), "{:?}", refused(&outcome)); + let fallback: Vec<&String> = outcome + .warnings + .iter() + .filter(|(code, _)| *code == "upstream_registry_fallback") + .map(|(_, detail)| detail) + .collect(); + assert_eq!(fallback.len(), 1, "{:?}", outcome.warnings); + assert!( + fallback[0].contains(&format!("http://{host}/private")), + "{}", + fallback[0] + ); + for secret in ["s3cret-token", "ci-user"] { + assert!(!fallback[0].contains(secret), "{}", fallback[0]); + } + } + #[tokio::test] #[serial_test::serial] async fn missing_registry_integrity_refuses() { diff --git a/crates/socket-patch-core/src/patch/shared_store.rs b/crates/socket-patch-core/src/patch/shared_store.rs index c851d7eaf..f17845020 100644 --- a/crates/socket-patch-core/src/patch/shared_store.rs +++ b/crates/socket-patch-core/src/patch/shared_store.rs @@ -403,8 +403,9 @@ pub(crate) fn is_pnpm_global_virtual_store_dir(dir: &Path) -> bool { && parent.is_some_and(|p| p.join("files").is_dir()) } -/// `v3`, `v10`, `v11`, …: the layout-version directory of a pnpm store. -fn is_pnpm_store_version_dir(name: &str) -> bool { +/// `v3`, `v10`, `v11`, …: the layout-version directory of a pnpm store +/// (and of pnpm 11+'s global install dir, `$PNPM_HOME/global/v11`). +pub(crate) fn is_pnpm_store_version_dir(name: &str) -> bool { name.strip_prefix('v') .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) } diff --git a/crates/socket-patch-core/src/utils/cargo_workspace.rs b/crates/socket-patch-core/src/utils/cargo_workspace.rs index c52e6662a..722f5051c 100644 --- a/crates/socket-patch-core/src/utils/cargo_workspace.rs +++ b/crates/socket-patch-core/src/utils/cargo_workspace.rs @@ -22,7 +22,7 @@ use crate::vendor::parse_memo::ParseMemo; /// Upper bound on discovered manifests — a runaway glob (or a hostile tree) /// must not turn one scan into an unbounded walk. -const MAX_MANIFESTS: usize = 4096; +pub(crate) const MAX_MANIFESTS: usize = 4096; /// Repo-relative `/Cargo.toml` keys of the workspace members and /// in-root path dependencies of the project at `root`, sorted. Empty when @@ -45,24 +45,31 @@ pub fn member_manifests_in(view: &ProjectView<'_>) -> Vec { } } -/// The three filesystem questions the member walk asks, keyed by -/// `/`-separated root-relative paths (`""` is the root). -trait Tree { - /// A regular (non-symlink) manifest file's walk facts. - fn read_manifest(&self, rel: &str) -> Option>; +/// The two directory questions a glob walk asks, keyed by `/`-separated +/// root-relative paths (`""` is the root). Shared with the pnpm workspace +/// member walk ([`crate::utils::pnpm_workspace`]). +pub(crate) trait DirTree { /// A real directory (not a symbolic link). fn is_real_dir(&self, rel: &str) -> bool; /// The real sub-directory names of `rel`, or `None` when unreadable. fn child_dirs(&self, rel: &str) -> Option>; } -struct DiskTree<'a>(&'a Path); +/// The member walk's questions: the directory ones plus a manifest read. +trait Tree: DirTree { + /// A regular (non-symlink) manifest file's walk facts. + fn read_manifest(&self, rel: &str) -> Option>; +} + +pub(crate) struct DiskTree<'a>(pub(crate) &'a Path); impl Tree for DiskTree<'_> { fn read_manifest(&self, rel: &str) -> Option> { read_manifest(&self.0.join(rel)) } +} +impl DirTree for DiskTree<'_> { fn is_real_dir(&self, rel: &str) -> bool { is_real_dir(&self.0.join(rel)) } @@ -79,14 +86,16 @@ impl Tree for DiskTree<'_> { } } -struct MemoryTree<'a>(&'a MemoryProject); +pub(crate) struct MemoryTree<'a>(pub(crate) &'a MemoryProject); impl Tree for MemoryTree<'_> { fn read_manifest(&self, rel: &str) -> Option> { let doc: DocumentMut = self.0.text(rel)?.parse().ok()?; Some(Arc::new(ManifestFacts::of(&doc))) } +} +impl DirTree for MemoryTree<'_> { fn is_real_dir(&self, rel: &str) -> bool { self.0.is_dir(rel) } @@ -119,10 +128,12 @@ fn member_manifests_with(tree: &dyn Tree) -> Vec { let mut queue: Vec<(String, Arc)> = Vec::new(); if let Some((members, exclude)) = &facts.workspace { - let excluded: BTreeSet = - exclude.iter().flat_map(|p| expand_glob(tree, p)).collect(); + let excluded: BTreeSet = exclude + .iter() + .flat_map(|p| expand_glob(tree, p, CARGO_SKIP)) + .collect(); for pattern in members { - for dir in expand_glob(tree, pattern) { + for dir in expand_glob(tree, pattern, CARGO_SKIP) { if !excluded.contains(&dir) { enqueue(tree, dir, &mut dirs, &mut queue); } @@ -223,7 +234,9 @@ fn member_manifests_unmemoized(root: &Path) -> Vec { let doc: DocumentMut = std::fs::read_to_string(path).ok()?.parse().ok()?; Some(Arc::new(ManifestFacts::of(&doc))) } + } + impl DirTree for UnmemoizedDiskTree<'_> { fn is_real_dir(&self, rel: &str) -> bool { DiskTree(self.0).is_real_dir(rel) } @@ -243,7 +256,7 @@ fn is_real_dir(path: &Path) -> bool { /// Every component of repo-relative `dir` is a real directory under /// the root — none is a symbolic link (which may lead outside the root). -fn is_real_dir_path(tree: &dyn Tree, dir: &str) -> bool { +pub(crate) fn is_real_dir_path(tree: &dyn DirTree, dir: &str) -> bool { let mut at = String::new(); dir.split('/').all(|seg| { at = join_rel(&at, seg); @@ -332,21 +345,45 @@ pub(crate) fn normalize_rel(base: &str, rel: &str) -> Option { crate::utils::relpath::resolve_rel(base, rel, 0) } +/// The directory names a cargo glob never descends into: its build +/// directory. +const CARGO_SKIP: &[&str] = &["target"]; + /// Expand a cargo `members` / `exclude` glob (`*`, `?`, `**`) to the -/// repo-relative directories it names. -fn expand_glob(tree: &dyn Tree, pattern: &str) -> Vec { +/// repo-relative directories it names. A wildcard segment never matches a +/// dot-directory (unless the segment itself starts with `.`, as `.*` does) +/// or a name in `skip`; a literal segment names any real directory. +pub(crate) fn expand_glob(tree: &dyn DirTree, pattern: &str, skip: &[&str]) -> Vec { + expand_glob_bounded(tree, pattern, skip).0 +} + +/// [`expand_glob`], plus whether the walk stopped at [`MAX_MANIFESTS`] +/// before it was done, so a caller that must list every match can refuse +/// a truncated answer rather than act on part of it. +pub(crate) fn expand_glob_bounded( + tree: &dyn DirTree, + pattern: &str, + skip: &[&str], +) -> (Vec, bool) { let Some(normalized) = normalize_rel("", pattern.trim_end_matches('/')) else { - return Vec::new(); + return (Vec::new(), false); }; let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect(); let mut out = Vec::new(); - expand_from(tree, String::new(), &segments, &mut out); + expand_from(tree, String::new(), &segments, skip, &mut out); + let truncated = out.len() >= MAX_MANIFESTS; out.sort(); out.dedup(); - out + (out, truncated) } -fn expand_from(tree: &dyn Tree, at: String, rest: &[&str], out: &mut Vec) { +fn expand_from( + tree: &dyn DirTree, + at: String, + rest: &[&str], + skip: &[&str], + out: &mut Vec, +) { if out.len() >= MAX_MANIFESTS { return; } @@ -357,7 +394,7 @@ fn expand_from(tree: &dyn Tree, at: String, rest: &[&str], out: &mut Vec if !seg.contains(['*', '?']) { let next = join_rel(&at, seg); if tree.is_real_dir(&next) { - expand_from(tree, next, tail, out); + expand_from(tree, next, tail, skip, out); } return; } @@ -366,19 +403,21 @@ fn expand_from(tree: &dyn Tree, at: String, rest: &[&str], out: &mut Vec }; let mut children: Vec = entries .into_iter() - .filter(|name| !name.starts_with('.') && name != "target") + .filter(|name| { + (!name.starts_with('.') || seg.starts_with('.')) && !skip.contains(&name.as_str()) + }) .collect(); children.sort(); if *seg == "**" { - expand_from(tree, at.clone(), tail, out); + expand_from(tree, at.clone(), tail, skip, out); for child in children { - expand_from(tree, join_rel(&at, &child), rest, out); + expand_from(tree, join_rel(&at, &child), rest, skip, out); } return; } for child in children { if wildcard_match(seg.as_bytes(), child.as_bytes()) { - expand_from(tree, join_rel(&at, &child), tail, out); + expand_from(tree, join_rel(&at, &child), tail, skip, out); } } } diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index cdeba888a..bb86e7418 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod http; pub(crate) mod line_endings; pub mod lock_fragments; pub mod notice; +pub(crate) mod package_manager; pub mod pdm_lock; pub(crate) mod pep440; pub mod pipenv; @@ -30,5 +31,6 @@ pub mod socket_cli_config; pub mod socket_dir; pub(crate) mod toml_edit_ext; pub mod uri; +pub(crate) mod workspace_globs; pub mod hatch; diff --git a/crates/socket-patch-core/src/utils/package_manager.rs b/crates/socket-patch-core/src/utils/package_manager.rs new file mode 100644 index 000000000..b1c6da51a --- /dev/null +++ b/crates/socket-patch-core/src/utils/package_manager.rs @@ -0,0 +1,31 @@ +//! The `packageManager` field of package.json (Corepack's pin) and the same +//! `@[+]` spelling pnpm records in +//! `node_modules/.modules.yaml`. + +/// The version `spec` pins for `tool`: `pnpm@9.15.9+sha512.…` gives +/// `9.15.9` for `pnpm`. `None` when `spec` names another tool. +pub(crate) fn pinned_version<'a>(spec: &'a str, tool: &str) -> Option<&'a str> { + let rest = spec.trim().strip_prefix(tool)?.strip_prefix('@')?; + Some( + rest.split_once('+') + .map_or(rest, |(version, _hash)| version), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pinned_version_strips_the_hash_and_checks_the_tool() { + assert_eq!(pinned_version("pnpm@9.15.9", "pnpm"), Some("9.15.9")); + assert_eq!( + pinned_version(" pnpm@10.4.1+sha512.abc== ", "pnpm"), + Some("10.4.1") + ); + assert_eq!(pinned_version("yarn@1.22.22", "yarn"), Some("1.22.22")); + assert_eq!(pinned_version("yarn@1.22.22", "pnpm"), None); + assert_eq!(pinned_version("pnpmx@9.0.0", "pnpm"), None); + assert_eq!(pinned_version("pnpm", "pnpm"), None); + } +} diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 37b836018..1ed548938 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -8,19 +8,609 @@ //! every install from the root ("The settings in packages/a/ //! pnpm-workspace.yaml do not apply"), so hosted and vendored modes must not //! treat a missing member file as "create one here" (#880, #881). +//! +//! Membership is the file's `packages:` globs, read one way for every +//! question ([`lists_member`]): a directory they do not list (an +//! `examples/` app, a checkout under an unrelated workspace) is no member: +//! pnpm 11.28+ and 12 install it standalone, with its own lock, and read +//! only its own `pnpm-workspace.yaml`, so creating that file is right +//! there (#1006). Older pnpm installs the root workspace from such a +//! directory, leaving it no lock of its own. +//! +//! Run from that workspace root, the members' own locks are the ones pnpm +//! installs from, so hosted mode pins and discovery reads them beside the +//! root's ([`member_locks`], #492). With `gitBranchLockfile` on, a branch +//! installs from its own `pnpm-lock..yaml`, which neither mode can +//! pin, so both refuse ([`git_branch_locks`], #556). use std::path::{Path, PathBuf}; +use crate::formats::pnpm::workspace::read_package_globs; +use crate::utils::cargo_workspace::{ + expand_glob_bounded, DirTree, DiskTree, MemoryTree, MAX_MANIFESTS, +}; +use crate::utils::fs::{read_regular_to_string, read_regular_to_string_sync}; +use crate::utils::workspace_globs::{glob_matches_no_dot, split_negation}; +use crate::vendor::lock_inventory::view::ProjectView; + /// pnpm's workspace and settings file. pub const PNPM_WORKSPACE: &str = "pnpm-workspace.yaml"; +/// pnpm's lock basename. +const PNPM_LOCK: &str = "pnpm-lock.yaml"; + +/// The directories pnpm never finds workspace projects in (the default +/// ignores of its project finder), at any depth. +pub(crate) const MEMBER_SKIP: &[&str] = &["node_modules", "bower_components", "test", "tests"]; + +/// Which lock(s) pnpm installs a workspace from, read at its root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MemberLocks { + /// The single root lock: the default `sharedWorkspaceLockfile: true`, + /// a root lock that lists member importers, or no workspace at all. + Shared, + /// `sharedWorkspaceLockfile: false`: each member installs from its own + /// lock. The root-relative `/pnpm-lock.yaml` keys that exist + /// (sorted; the root's own lock is not among them). + PerMember(Vec), + /// Per-member locks the members cannot be listed for: why. + Unresolved(String), +} + +/// A pnpm setting as one settings source spells it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PnpmSetting { + /// The value, quotes removed. + pub value: String, + /// Where it is set, as a refusal names it (e.g. + /// `` `gitBranchLockfile: true` in pnpm-workspace.yaml ``). + pub source: String, + /// Whether `pnpm-workspace.yaml` (read by js-yaml) sets it, rather + /// than `.npmrc` or the environment (read by ini and nopt). + pub yaml: bool, +} + +impl PnpmSetting { + /// The value as a boolean, as the reader pnpm uses for its source + /// coerces it. js-yaml takes `true` / `false` in any case (`None` for + /// anything else). The npm config reader behind `.npmrc` and the + /// environment maps only lowercase `true` / `false` / `null` / + /// `undefined` and keeps any other string, which pnpm then reads as + /// truthy, so `git-branch-lockfile=False` is on. + pub fn as_bool(&self) -> Option { + let value = self.value.trim(); + if !self.yaml { + return Some(!matches!(value, "" | "false" | "null" | "undefined")); + } + if value.eq_ignore_ascii_case("true") { + Some(true) + } else if value.eq_ignore_ascii_case("false") { + Some(false) + } else { + None + } + } +} + +/// Where a pnpm setting is set: a top-level `yaml_key` in `workspace` +/// (`pnpm-workspace.yaml`) wins; when that file does not set it, +/// `npmrc_key` in `npmrc` (pnpm 10 and older read it there). `None` when +/// neither sets it. +pub fn pnpm_setting( + workspace: Option<&str>, + npmrc: Option<&str>, + yaml_key: &str, + npmrc_key: &str, +) -> Option { + use crate::formats::pnpm::workspace::yaml_top_level_value; + if let Some(value) = workspace.and_then(|text| yaml_top_level_value(text, yaml_key)) { + let source = format!("`{yaml_key}: {value}` in {PNPM_WORKSPACE}"); + return Some(PnpmSetting { + value, + source, + yaml: true, + }); + } + let value = npmrc + .and_then(|text| crate::patch::redirect::npmrc::npmrc_top_level_value(text, npmrc_key))?; + let value = value.trim().to_string(); + let source = format!("`{npmrc_key}={value}` in .npmrc"); + Some(PnpmSetting { + value, + source, + yaml: false, + }) +} + +/// `npmrc_key` from the environment (`pnpm_config_`, then +/// `npm_config_`, dashes as underscores), looked up through `env`: +/// what a CI job sets when neither settings file does. +fn env_setting(npmrc_key: &str, env: impl Fn(&str) -> Option) -> Option { + let key = npmrc_key.replace('-', "_"); + [format!("pnpm_config_{key}"), format!("npm_config_{key}")] + .into_iter() + .find_map(|var| { + let value = env(&var)?.trim().to_string(); + let source = format!("`{var}={value}` in the environment"); + Some(PnpmSetting { + value, + source, + yaml: false, + }) + }) +} + +/// [`pnpm_setting`], falling back to the environment ([`env_setting`]) on +/// disk, where the CLI runs pnpm's environment. +fn view_setting( + view: &ProjectView<'_>, + workspace: Option<&str>, + npmrc: Option<&str>, + yaml_key: &str, + npmrc_key: &str, +) -> Option { + pnpm_setting(workspace, npmrc, yaml_key, npmrc_key).or_else(|| { + if matches!(view, ProjectView::Memory(_)) { + return None; + } + env_setting(npmrc_key, |var| std::env::var(var).ok()) + }) +} + +/// Whether the workspace turned the shared lock off: +/// `sharedWorkspaceLockfile: false` in `workspace` (`pnpm-workspace.yaml`), +/// or, when that file does not set the key, `shared-workspace-lockfile=false` +/// in the root `.npmrc` (see [`pnpm_setting`]). +pub fn shared_lockfile_disabled(workspace: &str, npmrc: Option<&str>) -> bool { + pnpm_setting( + Some(workspace), + npmrc, + "sharedWorkspaceLockfile", + "shared-workspace-lockfile", + ) + .is_some_and(|setting| setting.as_bool() == Some(false)) +} + +/// Whether a root lock is a SHARED workspace lock: its `importers:` names a +/// project other than the root (`.`). Such a lock is what pnpm installs +/// every member from, whatever the settings say now, so member locks +/// beside it are stale leftovers. +pub fn root_lock_lists_members(lock: &str) -> bool { + let mut in_importers = false; + for line in lock.lines() { + let line = line.strip_suffix('\r').unwrap_or(line); + if !line.starts_with([' ', '\t']) && !line.trim().is_empty() { + in_importers = line.trim_end() == "importers:"; + continue; + } + if !in_importers { + continue; + } + // An importer key sits at exactly two spaces. + let Some(key) = line + .strip_prefix(" ") + .filter(|k| !k.starts_with([' ', '#'])) + else { + continue; + }; + let key = key.split(':').next().unwrap_or("").trim(); + let key = key.trim_matches(['\'', '"']); + if !key.is_empty() && key != "." { + return true; + } + } + false +} + +/// Whether pnpm's project finder lists the directory at `rel` (relative +/// to the workspace root, one entry per component) under `globs` +/// (`pnpm-workspace.yaml` `packages:`, as [`read_package_globs`] reads them), as +/// pnpm 11.28+/12 decide it: some pattern matches and no `!` pattern does +/// (pnpm's globber reads every negation as an ignore, wherever it sits), +/// and no component is one of the finder's default ignores +/// ([`MEMBER_SKIP`]). A wildcard never matches a component starting with +/// `.`, so `**` does not list `.github/actions/demo` (see +/// [`glob_matches_no_dot`]). The root itself (`rel` empty) is never one. +/// +/// `Err` when a pattern uses glob syntax the shared matcher does not model +/// (braces, classes, extglobs): the caller must not guess either way. +/// Both membership questions, "is this directory a member" and "which +/// members' locks does pnpm install from", answer through here. +pub(crate) fn lists_member(globs: &[String], rel: &[String]) -> Result { + modeled(globs)?; + if rel.is_empty() || rel.iter().any(|seg| MEMBER_SKIP.contains(&seg.as_str())) { + return Ok(false); + } + let (negated, listed): (Vec<_>, Vec<_>) = globs + .iter() + .map(|g| split_negation(g)) + .partition(|(negated, _)| *negated); + Ok(listed.iter().any(|(_, g)| glob_matches_no_dot(g, rel)) + && !negated.iter().any(|(_, g)| glob_matches_no_dot(g, rel))) +} + +/// `Err` naming the first glob that uses syntax the shared matcher does +/// not model (braces, classes, extglobs). +fn modeled(globs: &[String]) -> Result<(), String> { + match globs + .iter() + .find(|g| g.contains(['{', '}', '[', ']', '(', ')'])) + { + Some(glob) => Err(format!( + "`packages:` glob {glob:?} uses syntax socket-patch does not read" + )), + None => Ok(()), + } +} + +/// The workspace member directories `globs` name (see [`lists_member`]), +/// root-relative and sorted. The positive globs are expanded against the +/// tree for candidates, and [`lists_member`] decides each one, so this +/// lists exactly the existing directories it calls members. A directory +/// reached through a symbolic link is not a member. `Err` when a glob +/// names more than [`MAX_MANIFESTS`] directories: the bounded walk stopped +/// before listing them all, and part of the members is no answer. +pub(crate) fn member_dirs(tree: &dyn DirTree, globs: &[String]) -> Result, String> { + modeled(globs)?; + let mut candidates = std::collections::BTreeSet::new(); + for glob in globs.iter().filter(|g| !g.starts_with('!')) { + let (dirs, truncated) = expand_glob_bounded(tree, glob, MEMBER_SKIP); + if truncated { + return Err(format!( + "`packages:` glob {glob:?} names more than {MAX_MANIFESTS} directories, \ + more than socket-patch walks" + )); + } + candidates.extend(dirs); + } + let mut dirs = Vec::new(); + for dir in candidates { + let rel: Vec = dir + .split('/') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + if lists_member(globs, &rel)? { + dirs.push(dir); + } + } + Ok(dirs) +} + +/// Which lock(s) pnpm installs the workspace rooted at `view` from (see +/// [`MemberLocks`]). Reads the root `pnpm-workspace.yaml`, `.npmrc` and +/// `pnpm-lock.yaml` through the view's FIFO-safe reader; a file that +/// cannot be read counts as absent, which keeps the shared default. +pub async fn member_locks(view: &ProjectView<'_>) -> MemberLocks { + let (dirs, globs, root_lock) = match workspace_members(view).await { + Members::Shared => return MemberLocks::Shared, + Members::Unresolved(why) => return MemberLocks::Unresolved(why), + Members::Dirs { + dirs, + globs, + root_lock, + } => (dirs, globs, root_lock), + }; + let mut keys = Vec::new(); + for dir in dirs { + let key = format!("{dir}/{PNPM_LOCK}"); + if view.exists_no_follow(&key).await { + keys.push(key); + } + } + if keys.is_empty() && !root_lock { + return MemberLocks::Unresolved(format!( + "{PNPM_WORKSPACE} sets sharedWorkspaceLockfile: false, so every workspace \ + member installs from its own {PNPM_LOCK}, but no member lock was found \ + under its `packages:` globs ({})", + globs.join(", ") + )); + } + MemberLocks::PerMember(keys) +} + +/// Whether the workspace rooted at `view` accounts for the +/// `pnpm-lock.yaml` of its directory `dir` (root-relative, never empty) +/// the way a run from that root reads it (#492): pinned as a member lock +/// ([`member_locks`] lists it), or a stale leftover beside a root lock pnpm +/// installs every member from. Only then may the in-memory engine take the +/// lock away from `dir` as a root of its own. +/// +/// `false`, so `dir` keeps its lock, whenever the root's answer is not +/// that: the root `pnpm-workspace.yaml` is unreadable, its `packages:` +/// cannot be read or uses glob syntax the matcher does not model, it does +/// not list `dir`, `dir` has no package manifest (pnpm's test for a +/// project), the member list is unresolved, or the shared default holds +/// with no root lock to install from. +pub(crate) async fn root_accounts_for_member_lock(view: &ProjectView<'_>, dir: &str) -> bool { + let Ok(workspace) = view.read_text(PNPM_WORKSPACE).await else { + return false; + }; + let rel: Vec = dir.split('/').map(str::to_string).collect(); + let listed = matches!( + read_package_globs(&workspace), + Ok(Some(globs)) if matches!(lists_member(&globs, &rel), Ok(true)) + ); + if !listed { + return false; + } + let mut project = false; + for manifest in PROJECT_MANIFESTS { + if view.exists_no_follow(&format!("{dir}/{manifest}")).await { + project = true; + break; + } + } + if !project { + return false; + } + match member_locks(view).await { + MemberLocks::PerMember(keys) => keys.contains(&format!("{dir}/{PNPM_LOCK}")), + MemberLocks::Shared => view.read_text(PNPM_LOCK).await.is_ok(), + MemberLocks::Unresolved(_) => false, + } +} + +/// The workspace members that install from their own lock (see +/// [`member_locks`]). +enum Members { + Shared, + /// The member directories, the globs naming them, and whether the root + /// has a lock of its own. + Dirs { + dirs: Vec, + globs: Vec, + root_lock: bool, + }, + Unresolved(String), +} + +async fn workspace_members(view: &ProjectView<'_>) -> Members { + let Ok(workspace) = view.read_text(PNPM_WORKSPACE).await else { + return Members::Shared; + }; + let npmrc = view.read_text(".npmrc").await.ok(); + let shared = view_setting( + view, + Some(&workspace), + npmrc.as_deref(), + "sharedWorkspaceLockfile", + "shared-workspace-lockfile", + ); + if shared.is_none_or(|setting| setting.as_bool() != Some(false)) { + return Members::Shared; + } + let root_lock = view.read_text(PNPM_LOCK).await.ok(); + if root_lock.as_deref().is_some_and(root_lock_lists_members) { + return Members::Shared; + } + let globs = match read_package_globs(&workspace) { + Ok(Some(globs)) => globs, + // pnpm <= 8 then finds projects in every directory (`**`), more + // than a bounded walk can promise to list. + Ok(None) => { + return Members::Unresolved(format!( + "{PNPM_WORKSPACE} turns the shared lock off, so every workspace member \ + installs from its own {PNPM_LOCK}, but it has no `packages:` list to \ + find the members by" + )) + } + Err(why) => { + return Members::Unresolved(format!( + "{PNPM_WORKSPACE} sets sharedWorkspaceLockfile: false, so every \ + workspace member installs from its own {PNPM_LOCK}, but its \ + member list cannot be read: {why}" + )) + } + }; + let dirs = match view { + ProjectView::Disk(root) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { root, .. }) => { + member_dirs(&DiskTree(root), &globs) + } + ProjectView::Memory(project) => member_dirs(&MemoryTree(project), &globs), + }; + let dirs = match dirs { + Ok(dirs) => dirs, + Err(why) => { + return Members::Unresolved(format!( + "{PNPM_WORKSPACE} sets sharedWorkspaceLockfile: false, so every \ + workspace member installs from its own {PNPM_LOCK}, but its \ + member list cannot be read: {why}" + )) + } + }; + // pnpm's project finder matches `/package.{json,yaml,json5}`: a + // directory without a manifest is no project, and pnpm never installs + // from a lock in it. + let mut projects = Vec::new(); + for dir in dirs { + for manifest in PROJECT_MANIFESTS { + if view.exists_no_follow(&format!("{dir}/{manifest}")).await { + projects.push(dir); + break; + } + } + } + Members::Dirs { + dirs: projects, + globs, + root_lock: root_lock.is_some(), + } +} + +/// The manifest names that make a directory a pnpm project. +pub(crate) const PROJECT_MANIFESTS: [&str; 3] = ["package.json", "package.yaml", "package.json5"]; + +/// pnpm's per-branch locks (`gitBranchLockfile`, #556), found at a project +/// root: the setting is on and at least one `pnpm-lock..yaml` +/// exists. pnpm then installs a branch from its own lock whenever that lock +/// exists (falling back to `pnpm-lock.yaml` only when it does not), so a +/// pin in `pnpm-lock.yaml` is not what the branch installs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitBranchLocks { + /// Where the setting is turned on, as the refusal names it (e.g. + /// `gitBranchLockfile: true` in pnpm-workspace.yaml). + pub setting: String, + /// The branch-lock paths found, root-relative and sorted. + pub locks: Vec, + /// Why the members that install from their own lock cannot be listed, + /// when they cannot: a branch lock in one of them cannot be ruled out. + pub members_unlisted: Option, +} + +impl GitBranchLocks { + /// What pnpm does with the setting, for a refusal's detail. + pub fn describe(&self) -> String { + let found = match (self.locks.is_empty(), &self.members_unlisted) { + (false, None) => self.locks.join(", "), + (false, Some(why)) => format!( + "{}, and the workspace members' own branch locks cannot be listed: {why}", + self.locks.join(", ") + ), + (true, why) => format!( + "any workspace member's own branch lock cannot be listed: {}", + why.as_deref().unwrap_or_default() + ), + }; + format!( + "{} makes pnpm install a git branch from its own lock ({found}) rather than \ + {PNPM_LOCK} whenever that lock exists, and socket-patch cannot tell which \ + branch lock is live or pin one", + self.setting, + ) + } + + /// The remedy every refusal names. + pub const REMEDY: &'static str = "turn gitBranchLockfile off and fold the branch locks \ + into pnpm-lock.yaml with `pnpm install --merge-git-branch-lockfiles`, then re-run"; +} + +/// Where `gitBranchLockfile` is turned on (see [`pnpm_setting`]): its +/// source, `None` when it is off. +pub fn git_branch_lockfile_setting(workspace: Option<&str>, npmrc: Option<&str>) -> Option { + git_branch_on(pnpm_setting( + workspace, + npmrc, + "gitBranchLockfile", + "git-branch-lockfile", + )) +} + +/// The source of a `gitBranchLockfile` setting that turns it on. +fn git_branch_on(setting: Option) -> Option { + setting + .filter(|setting| setting.as_bool() == Some(true)) + .map(|setting| setting.source) +} + +/// Whether `name` is a pnpm branch lock: `pnpm-lock..yaml` (pnpm +/// spells a `/` in the branch name as `!`), never `pnpm-lock.yaml` itself. +pub fn is_git_branch_lock_name(name: &str) -> bool { + name.strip_prefix("pnpm-lock.") + .and_then(|rest| rest.strip_suffix(".yaml")) + .is_some_and(|branch| !branch.is_empty()) +} + +/// The project's per-branch locks (see [`GitBranchLocks`]), `None` unless +/// the setting is on AND a branch lock exists (or the members that install +/// from their own lock cannot be listed, so one cannot be ruled out): with +/// no branch lock pnpm installs from `pnpm-lock.yaml`, which is then pinned +/// as usual. pnpm picks the branch lock's name once and looks for it in +/// every directory it installs a lock from, so the branch locks of the +/// members that install from their own lock ([`member_locks`]) count beside +/// the root's. +/// +/// The setting is read through the view's FIFO-safe reader from the +/// project's own `pnpm-workspace.yaml` / `.npmrc`; on disk, a project with +/// no `pnpm-workspace.yaml` of its own (a workspace member) also reads the +/// governing ancestor's and the `.npmrc` beside it +/// ([`governing_workspace_file`]), and the +/// `npm_config_git_branch_lockfile` / `pnpm_config_git_branch_lockfile` +/// environment spellings count too. +pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option { + let mut locks = branch_lock_names(view, "").await; + let mut members_unlisted = None; + match workspace_members(view).await { + Members::Dirs { dirs, .. } => { + for dir in dirs { + locks.extend(branch_lock_names(view, &dir).await); + } + } + Members::Unresolved(why) => members_unlisted = Some(why), + Members::Shared => {} + } + if locks.is_empty() && members_unlisted.is_none() { + return None; + } + let workspace = view.read_text(PNPM_WORKSPACE).await.ok(); + let npmrc = view.read_text(".npmrc").await.ok(); + const KEYS: (&str, &str) = ("gitBranchLockfile", "git-branch-lockfile"); + let mut setting = pnpm_setting(workspace.as_deref(), npmrc.as_deref(), KEYS.0, KEYS.1); + if setting.is_none() { + if let Some(file) = governing_file(view) { + let workspace = read_regular_to_string(&file).await.ok(); + let npmrc = read_regular_to_string(&file.with_file_name(".npmrc")) + .await + .ok(); + setting = + pnpm_setting(workspace.as_deref(), npmrc.as_deref(), KEYS.0, KEYS.1).map(|found| { + PnpmSetting { + source: format!("{} (at {})", found.source, file.display()), + ..found + } + }); + } + } + let setting = + git_branch_on(setting.or_else(|| view_setting(view, None, None, KEYS.0, KEYS.1)))?; + Some(GitBranchLocks { + setting, + locks, + members_unlisted, + }) +} + +/// The `pnpm-lock..yaml` files in root-relative `dir` (`""` for +/// the root), as root-relative paths, sorted. +async fn branch_lock_names(view: &ProjectView<'_>, dir: &str) -> Vec { + let Ok(entries) = view.list_dir(dir).await else { + return Vec::new(); + }; + let mut names: Vec = entries + .into_iter() + .filter(|entry| !entry.is_dir && is_git_branch_lock_name(&entry.name)) + .map(|entry| match dir { + "" => entry.name, + dir => format!("{dir}/{}", entry.name), + }) + .collect(); + names.sort(); + names +} + +/// The ancestor `pnpm-workspace.yaml` governing a disk view's project +/// ([`governing_workspace_file`]); an in-memory project has none. +fn governing_file(view: &ProjectView<'_>) -> Option { + match view { + ProjectView::Disk(root) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { root, .. }) => { + governing_workspace_file(root) + } + ProjectView::Memory(_) => None, + } +} + /// The `pnpm-workspace.yaml` that governs `project_root`'s pnpm settings /// when it is not the project's own: the nearest regular file of that name -/// in a strict ancestor, for a project directory with none of its own. +/// in a strict ancestor, for a project directory with none of its own that +/// the file's `packages:` globs list (see [`lists_as_member`]). /// /// `None` when the project has its own entry of that name (of any kind: an -/// unreadable file or a link is the caller's to report) or no ancestor -/// has one. Reads only metadata, so a FIFO never blocks it. The path is +/// unreadable file or a link is the caller's to report), no ancestor has +/// one, or the nearest one does not list the project (pnpm does not look +/// further up). Only a regular file is read, through +/// [`read_regular_to_string_sync`], so a FIFO never blocks it. The path is /// canonical, minus Windows' verbatim prefix (see /// [`without_verbatim_prefix`]), because refusals and warnings show it to /// the user. @@ -30,12 +620,38 @@ pub fn governing_workspace_file(project_root: &Path) -> Option { } let canonical = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf()); - canonical + let dir = canonical .ancestors() .skip(1) - .map(|dir| dir.join(PNPM_WORKSPACE)) - .find(|file| file.is_file()) - .map(without_verbatim_prefix) + .find(|dir| dir.join(PNPM_WORKSPACE).is_file())?; + let file = dir.join(PNPM_WORKSPACE); + let rel: Vec = canonical + .strip_prefix(dir) + .ok()? + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + match read_regular_to_string_sync(&file) { + Ok(yaml) if !lists_as_member(&yaml, &rel) => None, + // Unreadable: fail closed, as a member. + _ => Some(without_verbatim_prefix(file)), + } +} + +/// Whether a `pnpm-workspace.yaml` (its text) lists the directory at `rel` +/// (relative to the file's directory, one entry per component, never +/// empty) as a workspace project ([`lists_member`]). No `packages:` key, a +/// null one or an empty list: the workspace is the root alone, so no. +/// +/// Errs toward "member", the refusing side, whenever it cannot decide: a +/// `packages:` value [`read_package_globs`] cannot read, or a pattern using +/// glob syntax the shared matcher does not model. +pub(crate) fn lists_as_member(yaml: &str, rel: &[String]) -> bool { + match read_package_globs(yaml) { + Ok(None) => false, + Ok(Some(globs)) => lists_member(&globs, rel).unwrap_or(true), + Err(_) => true, + } } /// `path` without the verbatim prefix `std::fs::canonicalize` adds on @@ -62,6 +678,7 @@ pub fn without_verbatim_prefix(path: PathBuf) -> PathBuf { #[cfg(test)] mod tests { use super::*; + use crate::formats::pnpm::workspace::package_globs; fn write(root: &Path, rel: &str, text: &str) { let path = root.join(rel); @@ -69,6 +686,408 @@ mod tests { std::fs::write(path, text).unwrap(); } + #[test] + fn the_shared_lock_setting_prefers_the_workspace_file() { + assert!(shared_lockfile_disabled( + "packages: []\nsharedWorkspaceLockfile: false\n", + None + )); + assert!(shared_lockfile_disabled( + "packages: []\n", + Some("shared-workspace-lockfile=false\n") + )); + assert!(!shared_lockfile_disabled("packages: []\n", None)); + assert!(!shared_lockfile_disabled( + "packages: []\n", + Some("shared-workspace-lockfile=true\n") + )); + // pnpm 11+ reads only the YAML: an explicit `true` there wins. + assert!(!shared_lockfile_disabled( + "sharedWorkspaceLockfile: true\n", + Some("shared-workspace-lockfile=false\n") + )); + assert!(shared_lockfile_disabled( + "'sharedWorkspaceLockfile': false # own locks\n", + Some("shared-workspace-lockfile=true\n") + )); + // js-yaml reads every capitalisation of a boolean. + assert!(shared_lockfile_disabled( + "sharedWorkspaceLockfile: False\n", + None + )); + // `.npmrc` maps only a lowercase `false`; pnpm reads any other + // string as on. + assert!(!shared_lockfile_disabled( + "packages: []\n", + Some("shared-workspace-lockfile = FALSE\n") + )); + assert!(shared_lockfile_disabled( + "packages: []\n", + Some("shared-workspace-lockfile = false\n") + )); + assert_eq!( + git_branch_lockfile_setting(None, Some("git-branch-lockfile=False\n")).as_deref(), + Some("`git-branch-lockfile=False` in .npmrc") + ); + assert_eq!( + git_branch_lockfile_setting(None, Some("git-branch-lockfile=false\n")), + None + ); + assert!(!shared_lockfile_disabled( + "sharedWorkspaceLockfile: TRUE\n", + None + )); + assert!(!shared_lockfile_disabled( + "sharedWorkspaceLockfile: no\n", + None + )); + } + + #[test] + fn the_environment_spells_a_setting_with_underscores() { + let env = |vars: &'static [(&'static str, &'static str)]| { + move |var: &str| { + vars.iter() + .find(|(name, _)| *name == var) + .map(|(_, value)| value.to_string()) + } + }; + let found = env_setting( + "shared-workspace-lockfile", + env(&[("npm_config_shared_workspace_lockfile", " false ")]), + ) + .unwrap(); + assert_eq!(found.as_bool(), Some(false)); + assert!(found + .source + .contains("npm_config_shared_workspace_lockfile")); + // The pnpm spelling wins over npm's. + let found = env_setting( + "git-branch-lockfile", + env(&[ + ("npm_config_git_branch_lockfile", "false"), + ("pnpm_config_git_branch_lockfile", "TRUE"), + ]), + ) + .unwrap(); + assert_eq!(found.as_bool(), Some(true)); + assert_eq!(env_setting("git-branch-lockfile", env(&[])), None); + } + + #[test] + fn a_root_lock_listing_member_importers_is_shared() { + let root_only = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n a:\n specifier: 1.0.0\n version: 1.0.0\n\npackages:\n\n a@1.0.0:\n resolution: {integrity: sha512-x}\n"; + assert!(!root_lock_lists_members(root_only)); + assert!(!root_lock_lists_members( + "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n" + )); + assert!(!root_lock_lists_members( + "lockfileVersion: 5.4\n\ndependencies:\n a: 1.0.0\n" + )); + assert!(root_lock_lists_members( + "lockfileVersion: '9.0'\r\n\r\nimporters:\r\n\r\n .: {}\r\n\r\n packages/a:\r\n dependencies: {}\r\n" + )); + assert!(root_lock_lists_members( + "lockfileVersion: '6.0'\nimporters:\n '.': {}\n 'packages/a': {}\n" + )); + } + + #[test] + fn member_dirs_expand_globs_minus_negations_and_ignored_trees() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for dir in [ + "packages/a", + "packages/b", + "packages/x", + "packages/.hidden", + "packages/a/node_modules/dep", + "packages/test", + "apps/web/nested", + "node_modules/c", + ] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + let globs: Vec = ["packages/*", "apps/**", "!packages/x", "."] + .iter() + .map(|g| g.to_string()) + .collect(); + let expected = [ + "apps", + "apps/web", + "apps/web/nested", + "packages/a", + "packages/b", + ]; + assert_eq!( + member_dirs(&DiskTree(root), &globs), + Ok(expected.iter().map(|d| d.to_string()).collect()) + ); + + let mut project = crate::vendor::lock_inventory::MemoryProject::new(); + for dir in [ + "packages/a", + "packages/b", + "packages/x", + "packages/.hidden", + "packages/a/node_modules/dep", + "packages/test", + "apps/web/nested", + "node_modules/c", + ] { + project.insert_text(format!("{dir}/package.json"), "{}"); + } + assert_eq!( + member_dirs(&MemoryTree(&project), &globs), + Ok(expected.iter().map(|d| d.to_string()).collect()) + ); + // An explicit dot segment lists dot directories, as pnpm's globber + // does; glob syntax the matcher does not model is an error, never + // "no members". + let globs = |g: &[&str]| g.iter().map(|g| g.to_string()).collect::>(); + assert_eq!( + member_dirs(&DiskTree(root), &globs(&["packages/.*"])), + Ok(vec!["packages/.hidden".to_string()]) + ); + assert!(member_dirs(&DiskTree(root), &globs(&["packages/{a,b}"])).is_err()); + } + + #[test] + fn member_dirs_refuse_a_glob_the_bounded_walk_cannot_finish() { + // More directories below `packages/` than the walk visits: the + // late members would go unlisted, so the answer is an error, never + // a partial list. + struct Wide(usize); + impl DirTree for Wide { + fn is_real_dir(&self, rel: &str) -> bool { + rel == "packages" || rel.starts_with("packages/") + } + fn child_dirs(&self, rel: &str) -> Option> { + Some(match rel { + "" => vec!["packages".to_string()], + "packages" => (0..self.0).map(|i| format!("p{i:05}")).collect(), + _ => Vec::new(), + }) + } + } + let globs = vec!["packages/**".to_string()]; + let err = member_dirs(&Wide(MAX_MANIFESTS + 1), &globs).unwrap_err(); + assert!(err.contains("packages/**"), "{err}"); + let listed = member_dirs(&Wide(10), &globs).unwrap(); + assert_eq!(listed.len(), 11, "packages itself plus its ten children"); + } + + #[test] + fn member_dirs_and_governing_file_agree_on_membership() { + // One notion of membership: every directory the lock walk lists is + // governed by the root file, and every other one is not. + let tmp = tempfile::tempdir().unwrap(); + let root = without_verbatim_prefix(std::fs::canonicalize(tmp.path()).unwrap()); + let dirs = [ + "packages/a", + "packages/b", + "packages/test", + "packages/.hidden", + "packages/a/node_modules/dep", + "packages/x/y", + "apps/web", + "apps/web/fixtures/f", + ".github/actions/demo", + "examples/demo", + ]; + for dir in dirs { + write(&root, &format!("{dir}/package.json"), "{}"); + } + for ws in [ + "packages:\n - packages/*\n - 'apps/**'\n - '!**/fixtures/**'\n", + "packages:\n - '**'\n - '!packages/b'\n", + "packages:\n - 'packages/.*'\n - '.github/**'\n", + ] { + write(&root, PNPM_WORKSPACE, ws); + let globs = package_globs(ws).unwrap().unwrap(); + let listed = member_dirs(&DiskTree(&root), &globs).unwrap(); + for dir in dirs + .iter() + .copied() + .chain(listed.iter().map(String::as_str)) + { + let governed = governing_workspace_file(&root.join(dir)).is_some(); + assert_eq!(listed.iter().any(|d| d == dir), governed, "{ws} {dir}"); + } + } + // The finder's default ignores hold for the governing check too. + write(&root, PNPM_WORKSPACE, "packages:\n - packages/*\n"); + assert_eq!(governing_workspace_file(&root.join("packages/test")), None); + } + + #[tokio::test] + async fn member_locks_need_the_setting_and_an_unshared_root_lock() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let view = ProjectView::Disk(root); + assert_eq!(member_locks(&view).await, MemberLocks::Shared); + write(root, PNPM_WORKSPACE, "packages:\n - packages/*\n"); + write( + root, + "packages/a/pnpm-lock.yaml", + "lockfileVersion: '9.0'\n", + ); + write(root, "packages/a/package.json", "{}"); + write(root, "packages/b/package.json", "{}"); + // A lock in a directory with no package manifest is no member's: + // pnpm's finder never makes that directory a project. + write( + root, + "packages/scratch/pnpm-lock.yaml", + "lockfileVersion: '9.0'\n", + ); + assert_eq!(member_locks(&view).await, MemberLocks::Shared); + write(root, ".npmrc", "shared-workspace-lockfile=false\n"); + assert_eq!( + member_locks(&view).await, + MemberLocks::PerMember(vec!["packages/a/pnpm-lock.yaml".to_string()]) + ); + // package.yaml / package.json5 manifests count too. + write(root, "packages/scratch/package.yaml", "name: scratch\n"); + assert_eq!( + member_locks(&view).await, + MemberLocks::PerMember(vec![ + "packages/a/pnpm-lock.yaml".to_string(), + "packages/scratch/pnpm-lock.yaml".to_string() + ]) + ); + std::fs::remove_file(root.join("packages/scratch/pnpm-lock.yaml")).unwrap(); + write( + root, + PNPM_LOCK, + "lockfileVersion: '9.0'\nimporters:\n .: {}\n packages/a: {}\n", + ); + assert_eq!(member_locks(&view).await, MemberLocks::Shared); + std::fs::remove_file(root.join(PNPM_LOCK)).unwrap(); + write(root, PNPM_WORKSPACE, "packages: packages/*\n"); + assert!(matches!( + member_locks(&view).await, + MemberLocks::Unresolved(_) + )); + write(root, PNPM_WORKSPACE, "packages:\n - apps/*\n"); + assert!(matches!( + member_locks(&view).await, + MemberLocks::Unresolved(_) + )); + // No `packages:` key (pnpm <= 8 finds projects everywhere): never + // "no members", with or without a root lock. + write(root, PNPM_WORKSPACE, "sharedWorkspaceLockfile: false\n"); + assert!(matches!( + member_locks(&view).await, + MemberLocks::Unresolved(_) + )); + write( + root, + PNPM_LOCK, + "lockfileVersion: '9.0'\nimporters:\n .: {}\n", + ); + assert!(matches!( + member_locks(&view).await, + MemberLocks::Unresolved(_) + )); + } + + #[test] + fn the_git_branch_lock_setting_prefers_the_workspace_file() { + let setting = git_branch_lockfile_setting; + assert!(setting(Some("gitBranchLockfile: true\n"), None).is_some()); + assert!(setting(Some("'gitBranchLockfile': \"True\" # per branch\n"), None).is_some()); + assert!(setting(None, Some("git-branch-lockfile=true\n")).is_some()); + assert!(setting(Some("packages: []\n"), Some("git-branch-lockfile = true\n")).is_some()); + assert_eq!(setting(None, None), None); + assert_eq!(setting(Some("gitBranchLockfile: false\n"), None), None); + assert_eq!(setting(None, Some("; git-branch-lockfile=true\n")), None); + assert_eq!(setting(Some("# gitBranchLockfile: true\n"), None), None); + // pnpm 11+ reads only the YAML: an explicit `false` there wins. + assert_eq!( + setting( + Some("gitBranchLockfile: false\n"), + Some("git-branch-lockfile=true\n") + ), + None + ); + assert!(setting(Some(" gitBranchLockfile: true\n"), None).is_none()); + assert!(setting(Some("gitBranchLockfile: TRUE\n"), None).is_some()); + assert!(setting(None, Some("git-branch-lockfile=True\n")).is_some()); + assert_eq!(setting(Some("gitBranchLockfile: False\n"), None), None); + } + + #[test] + fn branch_lock_names_exclude_the_main_lock() { + for name in [ + "pnpm-lock.feature.yaml", + "pnpm-lock.feat!x.yaml", + "pnpm-lock.v1.2.yaml", + ] { + assert!(is_git_branch_lock_name(name), "{name}"); + } + for name in [ + "pnpm-lock.yaml", + "pnpm-lock..yaml", + "pnpm-lock.feature.yml", + "pnpm-lock.yaml.bak", + "shrinkwrap.yaml", + ] { + assert!(!is_git_branch_lock_name(name), "{name}"); + } + } + + #[tokio::test] + async fn git_branch_locks_need_the_setting_and_a_branch_lock() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let view = ProjectView::Disk(root); + write(root, PNPM_LOCK, "lockfileVersion: '9.0'\n"); + write(root, PNPM_WORKSPACE, "gitBranchLockfile: true\n"); + assert_eq!(git_branch_locks(&view).await, None); + write(root, "pnpm-lock.b.yaml", "lockfileVersion: '9.0'\n"); + write(root, "pnpm-lock.a.yaml", "lockfileVersion: '9.0'\n"); + // A directory of that name is no lock. + std::fs::create_dir(root.join("pnpm-lock.dir.yaml")).unwrap(); + let found = git_branch_locks(&view).await.unwrap(); + assert_eq!(found.locks, ["pnpm-lock.a.yaml", "pnpm-lock.b.yaml"]); + assert!(found.setting.contains(PNPM_WORKSPACE), "{}", found.setting); + write(root, PNPM_WORKSPACE, "packages: []\n"); + assert_eq!(git_branch_locks(&view).await, None); + } + + /// Members that install from their own lock but cannot be listed may + /// hold a `pnpm-lock..yaml` no walk can find, so with the + /// setting on the answer fails closed instead of reading as "no branch + /// lock". + #[tokio::test] + async fn git_branch_locks_fail_closed_on_an_unlisted_member_set() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let view = ProjectView::Disk(root); + write(root, PNPM_LOCK, "lockfileVersion: '9.0'\n"); + // No `packages:` list: the members cannot be found. + write( + root, + PNPM_WORKSPACE, + "sharedWorkspaceLockfile: false\ngitBranchLockfile: true\n", + ); + let found = git_branch_locks(&view).await.unwrap(); + assert!(found.locks.is_empty(), "{:?}", found.locks); + assert!(found.members_unlisted.is_some()); + assert!( + found.describe().contains("cannot be listed"), + "{}", + found.describe() + ); + // The setting off: nothing to refuse. + write(root, PNPM_WORKSPACE, "sharedWorkspaceLockfile: false\n"); + assert_eq!(git_branch_locks(&view).await, None); + // A shared lock: members never install from a lock of their own. + write(root, PNPM_WORKSPACE, "gitBranchLockfile: true\n"); + assert_eq!(git_branch_locks(&view).await, None); + } + #[test] fn the_verbatim_prefix_is_dropped_and_nothing_else_changes() { let strip = |text: &str| without_verbatim_prefix(PathBuf::from(text)); @@ -110,6 +1129,96 @@ mod tests { ); } + #[test] + fn a_project_outside_the_packages_globs_is_not_governed() { + // pnpm 11.28+/12 install a directory the nearest workspace file does + // not list as a standalone project: its own lock, its own settings + // file (#1006). + let tmp = tempfile::tempdir().unwrap(); + let root = without_verbatim_prefix(std::fs::canonicalize(tmp.path()).unwrap()); + let governed = |rel: &str| governing_workspace_file(&root.join(rel)); + let file = Some(root.join(PNPM_WORKSPACE)); + for rel in ["packages/a", "packages/b", "packages/x/y", "examples/demo"] { + write(&root, &format!("{rel}/package.json"), "{}"); + } + write(&root, PNPM_WORKSPACE, "packages:\n - packages/*\n"); + assert_eq!(governed("examples/demo"), None); + assert_eq!(governed("packages/x/y"), None); + assert_eq!(governed("packages/a"), file); + // BOM, quoting and `./` spellings still match. + write( + &root, + PNPM_WORKSPACE, + "\u{feff}packages:\n - './packages/**'\n", + ); + assert_eq!(governed("packages/x/y"), file); + assert_eq!(governed("examples/demo"), None); + // A `!` pattern excludes wherever it sits, as pnpm's globber reads it. + for ws in [ + "packages:\n - packages/*\n - '!packages/b'\n", + "packages:\n - '!packages/b'\n - packages/*\n", + ] { + write(&root, PNPM_WORKSPACE, ws); + assert_eq!(governed("packages/b"), None, "{ws}"); + assert_eq!(governed("packages/a"), file, "{ws}"); + } + // No `packages:` (a settings-only file), a null or an empty list: + // pnpm's workspace is the root alone. + for ws in [ + "trustLockfile: true\n", + "packages:\n", + "packages: []\n", + "", + "packages: ~\ntrustLockfile: true\n", + "packages: null\n", + "packages: NULL # root only\n", + ] { + write(&root, PNPM_WORKSPACE, ws); + assert_eq!(governed("examples/demo"), None, "{ws:?}"); + } + // A flow list spread over several lines is read in full: it lists + // only what it names (#1006). + write( + &root, + PNPM_WORKSPACE, + "packages: [\n 'packages/*',\n 'tools/*'\n]\n", + ); + assert_eq!(governed("examples/demo"), None); + assert_eq!(governed("packages/a"), file); + // `**` lists every directory below the root. + write(&root, PNPM_WORKSPACE, "packages:\n - '**'\n"); + assert_eq!(governed("examples/demo"), file); + // ...except dot directories: pnpm 12 gives each its own lock, as it + // does under `packages/**` and `packages/*` (probed on 12.10.1). An + // explicit dot component lists them. + for rel in [ + ".github/actions/demo", + "packages/.x/demo", + "packages/.hidden", + ] { + write(&root, &format!("{rel}/package.json"), "{}"); + } + assert_eq!(governed(".github/actions/demo"), None); + write(&root, PNPM_WORKSPACE, "packages:\n - 'packages/**'\n"); + assert_eq!(governed("packages/.x/demo"), None); + write(&root, PNPM_WORKSPACE, "packages:\n - packages/*\n"); + assert_eq!(governed("packages/.hidden"), None); + write(&root, PNPM_WORKSPACE, "packages:\n - '.github/**'\n"); + assert_eq!(governed(".github/actions/demo"), file); + // Glob syntax this matcher does not model (braces, classes, + // extglobs) and a file that does not parse fail closed: governed. + for ws in [ + "packages:\n - 'packages/{a,b}'\n", + "packages:\n - 'packages/[ab]'\n", + "packages:\n - '+(examples|packages)/*'\n", + "packages: [unclosed\n", + "packages:\n - 1\n - [nested]\n", + ] { + write(&root, PNPM_WORKSPACE, ws); + assert_eq!(governed("examples/demo"), file, "{ws}"); + } + } + #[test] fn a_project_with_its_own_file_or_no_ancestor_has_none() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/utils/workspace_globs.rs b/crates/socket-patch-core/src/utils/workspace_globs.rs new file mode 100644 index 000000000..04cbf3fe3 --- /dev/null +++ b/crates/socket-patch-core/src/utils/workspace_globs.rs @@ -0,0 +1,385 @@ +//! The workspace glob grammar both workspace-membership checks read: npm, +//! yarn and Bun `package.json` `workspaces` (#884) and pnpm's +//! `pnpm-workspace.yaml` `packages:` (#1006). A pattern is a `/`-separated +//! glob in the grammar npm (minimatch), yarn, Bun and pnpm share: brace +//! sets (`{a,b}`, nested, and `{1..3}` / `{a..c}` sequences) expand first, +//! then `*`, `?` and character classes (`[abc]`, `[a-c]`, `[!a]`, `[^a]`) +//! match within one component and `**` matches any number of components +//! (#1071). A leading `./`, empty components and a trailing `/` are +//! ignored. A leading `!` negates; each caller applies negations the way +//! its package manager does. pnpm's globber also never lets a wildcard +//! match a component that starts with `.` ([`glob_matches_no_dot`]). + +/// Whether the member path (`rel`, relative to the workspace root, one +/// entry per component) matches a `workspaces` pattern and no later +/// `!`-negated one (npm and yarn: the last matching pattern wins). +pub(crate) fn workspaces_include(patterns: &[String], rel: &[String]) -> bool { + if rel.is_empty() { + return false; + } + let mut included = false; + for pattern in patterns { + let (negated, pattern) = split_negation(pattern); + if glob_matches(pattern, rel) { + included = !negated; + } + } + included +} + +/// `(true, rest)` for a `!`-negated pattern, `(false, pattern)` otherwise. +pub(crate) fn split_negation(pattern: &str) -> (bool, &str) { + match pattern.strip_prefix('!') { + Some(rest) => (true, rest), + None => (false, pattern), + } +} + +/// Whether one (un-negated) pattern matches `rel`. A pattern with no +/// components left (`""`, `.`, `./`) matches nothing. +pub(crate) fn glob_matches(pattern: &str, rel: &[String]) -> bool { + pattern_matches(pattern, rel, true) +} + +/// [`glob_matches`] the way pnpm's globber reads `packages:` (`dot: false`, +/// probed on pnpm 12.10.1): `*`, `?` and `**` never match a component that +/// starts with `.`, so `**` does not list `.github/actions/demo` and +/// `packages/*` does not list `packages/.hidden`. A pattern component that +/// itself starts with `.` (`.github/**`) still matches it. +pub(crate) fn glob_matches_no_dot(pattern: &str, rel: &[String]) -> bool { + pattern_matches(pattern, rel, false) +} + +fn pattern_matches(pattern: &str, rel: &[String], dot: bool) -> bool { + let rel: Vec> = rel.iter().map(|c| c.chars().collect()).collect(); + expand_braces(pattern.trim()).iter().any(|alternative| { + let segments: Vec> = alternative + .split(['/', '\\']) + .filter(|s| !s.is_empty() && *s != ".") + .map(|s| s.chars().collect()) + .collect(); + !segments.is_empty() && path_glob_matches(&segments, &rel, dot) + }) +} + +/// Cap on the alternatives one pattern expands to, so a pathological +/// sequence (`{1..1000000}`) cannot stall the run. +const MAX_BRACE_EXPANSIONS: usize = 4096; + +/// The brace expansion of a glob, as minimatch's `brace-expansion` does it: +/// the first `{...}` group holding a top-level `,` or a `x..y[..step]` +/// sequence is replaced by each alternative, recursively. A group with +/// neither, and an unbalanced `{`, stay literal. +fn expand_braces(pattern: &str) -> Vec { + let mut out = Vec::new(); + expand_braces_into(pattern, &mut out); + out +} + +fn expand_braces_into(pattern: &str, out: &mut Vec) { + if out.len() >= MAX_BRACE_EXPANSIONS { + return; + } + for (open, _) in pattern.match_indices('{') { + let Some(close) = matching_brace(pattern, open) else { + continue; + }; + let body = &pattern[open + 1..close]; + let alternatives = split_top_level_commas(body); + let alternatives = if alternatives.len() > 1 { + alternatives + } else if let Some(sequence) = brace_sequence(body) { + sequence + } else { + continue; + }; + let (prefix, suffix) = (&pattern[..open], &pattern[close + 1..]); + for alternative in alternatives { + expand_braces_into(&format!("{prefix}{alternative}{suffix}"), out); + if out.len() >= MAX_BRACE_EXPANSIONS { + return; + } + } + return; + } + out.push(pattern.to_string()); +} + +/// The byte index of the `}` closing the `{` at `open`. +fn matching_brace(pattern: &str, open: usize) -> Option { + let mut depth = 0usize; + for (i, c) in pattern[open..].char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Some(open + i); + } + } + _ => {} + } + } + None +} + +fn split_top_level_commas(body: &str) -> Vec { + let mut parts = Vec::new(); + let (mut depth, mut start) = (0usize, 0usize); + for (i, c) in body.char_indices() { + match c { + '{' => depth += 1, + '}' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(body[start..i].to_string()); + start = i + 1; + } + _ => {} + } + } + parts.push(body[start..].to_string()); + parts +} + +/// A `{x..y}` or `{x..y..step}` sequence body: integers (zero-padded when +/// either end is) or single characters. +fn brace_sequence(body: &str) -> Option> { + let parts: Vec<&str> = body.split("..").collect(); + let (from, to, step) = match parts.as_slice() { + [from, to] => (*from, *to, None), + [from, to, step] => (*from, *to, Some(*step)), + _ => return None, + }; + let step = match step { + Some(step) => step.parse::().ok()?.unsigned_abs().max(1), + None => 1, + }; + let (start, end, width, as_char) = + if let (Ok(a), Ok(b)) = (from.parse::(), to.parse::()) { + let padded = |s: &str| { + s.trim_start_matches('-').len() > 1 && s.trim_start_matches('-').starts_with('0') + }; + let width = if padded(from) || padded(to) { + from.len().max(to.len()) + } else { + 0 + }; + (a, b, width, false) + } else { + let (mut a, mut b) = (from.chars(), to.chars()); + let (Some(a), None, Some(b), None) = (a.next(), a.next(), b.next(), b.next()) else { + return None; + }; + (a as i64, b as i64, 0, true) + }; + let mut out = Vec::new(); + let mut n = start; + loop { + out.push(if as_char { + char::from_u32(u32::try_from(n).ok()?)?.to_string() + } else { + format!("{n:0width$}") + }); + if n == end || out.len() >= MAX_BRACE_EXPANSIONS { + break; + } + let next = if start <= end { + n.checked_add(step as i64)? + } else { + n.checked_sub(step as i64)? + }; + if (start <= end && next > end) || (start > end && next < end) { + break; + } + n = next; + } + Some(out) +} + +/// `dot: false` keeps wildcards off components starting with `.`. +fn path_glob_matches(pattern: &[Vec], path: &[Vec], dot: bool) -> bool { + let hidden = |component: &Vec| !dot && component.first() == Some(&'.'); + match pattern.split_first() { + None => path.is_empty(), + Some((first, rest)) if first.as_slice() == ['*', '*'] => (0..=path.len()) + .take_while(|&skip| skip == 0 || !hidden(&path[skip - 1])) + .any(|skip| path_glob_matches(rest, &path[skip..], dot)), + Some((first, rest)) => path.split_first().is_some_and(|(head, tail)| { + (!hidden(head) || first.first() == Some(&'.')) + && segment_glob_matches(first, head) + && path_glob_matches(rest, tail, dot) + }), + } +} + +fn segment_glob_matches(pattern: &[char], name: &[char]) -> bool { + match pattern.split_first() { + None => name.is_empty(), + Some(('*', rest)) => (0..=name.len()).any(|skip| segment_glob_matches(rest, &name[skip..])), + Some(('?', rest)) => !name.is_empty() && segment_glob_matches(rest, &name[1..]), + Some(('[', rest)) => match char_class(rest) { + Some((class, after)) => name + .split_first() + .is_some_and(|(c, tail)| class.matches(*c) && segment_glob_matches(after, tail)), + None => name.first() == Some(&'[') && segment_glob_matches(rest, &name[1..]), + }, + Some((c, rest)) => name.first() == Some(c) && segment_glob_matches(rest, &name[1..]), + } +} + +/// A parsed `[...]` character class. +struct CharClass { + negated: bool, + ranges: Vec<(char, char)>, +} + +impl CharClass { + fn matches(&self, c: char) -> bool { + self.ranges.iter().any(|&(lo, hi)| lo <= c && c <= hi) != self.negated + } +} + +/// The class after a `[`, and the pattern after its closing `]`. A `!` or +/// `^` first negates it; a `]` right after that is a member. `None` when +/// the class never closes (the `[` is then literal). +fn char_class(pattern: &[char]) -> Option<(CharClass, &[char])> { + let (negated, mut i) = match pattern.first() { + Some('!' | '^') => (true, 1), + _ => (false, 0), + }; + let mut ranges = Vec::new(); + let first = i; + while i < pattern.len() { + let c = pattern[i]; + if c == ']' && i > first { + return Some((CharClass { negated, ranges }, &pattern[i + 1..])); + } + if pattern.get(i + 1) == Some(&'-') && pattern.get(i + 2).is_some_and(|&h| h != ']') { + ranges.push((c, pattern[i + 2])); + i += 3; + } else { + ranges.push((c, c)); + i += 1; + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspaces_patterns_match_like_npm_and_yarn() { + let rel = |p: &str| p.split('/').map(str::to_string).collect::>(); + let pats = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + assert!(workspaces_include( + &pats(&["packages/*"]), + &rel("packages/a") + )); + assert!(!workspaces_include( + &pats(&["packages/*"]), + &rel("packages/a/b") + )); + assert!(workspaces_include( + &pats(&["./packages/*/"]), + &rel("packages/a") + )); + assert!(workspaces_include( + &pats(&["packages/**"]), + &rel("packages/a/b") + )); + assert!(workspaces_include( + &pats(&["**/pkg-*"]), + &rel("x/y/pkg-one") + )); + assert!(workspaces_include(&pats(&["app"]), &rel("app"))); + assert!(!workspaces_include(&pats(&["app"]), &rel("apps"))); + assert!(workspaces_include(&pats(&["app?"]), &rel("apps"))); + assert!(!workspaces_include( + &pats(&["packages/*", "!packages/b"]), + &rel("packages/b") + )); + assert!(!workspaces_include(&pats(&["*"]), &[])); + assert!(!glob_matches("./", &rel("a"))); + // npm and yarn callers keep matching dot components. + assert!(glob_matches("packages/*", &rel("packages/.hidden"))); + } + + /// #1071: npm (minimatch), yarn and Bun expand brace sets and match + /// character classes in `workspaces`. + #[test] + fn workspaces_patterns_expand_braces_and_match_classes() { + let rel = |p: &str| p.split('/').map(str::to_string).collect::>(); + let pats = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + let yes = |p: &str, r: &str| assert!(workspaces_include(&pats(&[p]), &rel(r)), "{p} ~ {r}"); + let no = + |p: &str, r: &str| assert!(!workspaces_include(&pats(&[p]), &rel(r)), "{p} !~ {r}"); + // Brace sets, nested, spanning separators, and sequences. + yes("packages/{a,b}", "packages/a"); + yes("packages/{a,b}", "packages/b"); + no("packages/{a,b}", "packages/c"); + yes("{apps,packages}/*", "apps/web"); + yes("packages/{a,{b,c}x}", "packages/cx"); + no("packages/{a,{b,c}x}", "packages/c"); + yes("{packages/a,tools/*}", "tools/t"); + yes("packages/pkg-{1..3}", "packages/pkg-2"); + no("packages/pkg-{1..3}", "packages/pkg-4"); + yes("packages/{a..c}", "packages/b"); + yes("packages/v{01..10}", "packages/v07"); + yes("packages/{,x}a", "packages/a"); + // A brace group with no comma or range is literal, as in minimatch. + yes("packages/{a}", "packages/{a}"); + no("packages/{a}", "packages/a"); + yes("packages/{a,b", "packages/{a,b"); + // Character classes: sets, ranges, negation, a literal `]` first. + yes("packages/[a-c]", "packages/b"); + no("packages/[a-c]", "packages/d"); + yes("packages/[ab]x", "packages/bx"); + yes("packages/[!b]", "packages/a"); + no("packages/[!b]", "packages/b"); + yes("packages/[^b]", "packages/c"); + yes("packages/[]a]", "packages/]"); + yes("packages/[a-c]*", "packages/core"); + // An unclosed class is a literal `[`. + yes("packages/[a", "packages/[a"); + no("packages/[a", "packages/a"); + // Non-ASCII names match one character per `?` and class. + yes("packages/?", "packages/é"); + yes("packages/[é]", "packages/é"); + // Negation applies to the expanded alternatives too. + assert!(!workspaces_include( + &pats(&["packages/*", "!packages/{b,c}"]), + &rel("packages/b") + )); + } + + #[test] + fn pnpm_wildcards_skip_dot_components() { + let rel = |p: &str| p.split('/').map(str::to_string).collect::>(); + assert!(!glob_matches_no_dot("**", &rel(".github/actions/demo"))); + assert!(!glob_matches_no_dot( + "packages/**", + &rel("packages/.x/demo") + )); + assert!(!glob_matches_no_dot("packages/*", &rel("packages/.hidden"))); + assert!(!glob_matches_no_dot( + "packages/?hidden", + &rel("packages/.hidden") + )); + assert!(!glob_matches_no_dot("**/demo", &rel("a/.b/demo"))); + // An explicit dot component still matches, and below it wildcards + // match ordinary names. + assert!(glob_matches_no_dot( + ".github/**", + &rel(".github/actions/demo") + )); + assert!(glob_matches_no_dot( + "packages/.hidden", + &rel("packages/.hidden") + )); + assert!(glob_matches_no_dot("packages/.*", &rel("packages/.hidden"))); + assert!(glob_matches_no_dot("**", &rel("examples/demo"))); + assert!(glob_matches_no_dot("packages/**", &rel("packages/x/demo"))); + } +} diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index 52b9241b6..5bdf4b97d 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -62,7 +62,7 @@ pub(crate) fn patched_dependency_keys(manifest: Option<&str>, lock: Option<&str> /// `text` with the JSONC Bun accepts in a `package.json` removed: `//` and /// `/* */` comments and a comma before a closing `}` or `]`, all outside /// strings. Everything else, strings included, is kept byte for byte. -fn strip_jsonc(text: &str) -> String { +pub(crate) fn strip_jsonc(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut chars = text.chars().peekable(); let mut in_string = false; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs b/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs index 2269ae52a..52344f094 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs @@ -14,7 +14,8 @@ use crate::vendor::npm_flavor::NpmLockFlavor; use super::bun::{bun_text_lock_present_in, inventory_bun_binary_in, inventory_bun_in}; use super::npm::inventory_package_lock_in; use super::pnpm::{ - inventory_pnpm_lock_in, inventory_pnpm_lock_rel_in, inventory_rush_pnpm_locks_in, + inventory_pnpm_lock_in, inventory_pnpm_lock_rel_in, inventory_pnpm_member_locks_in, + inventory_rush_pnpm_locks_in, }; use super::view::ProjectView; use super::vlt::inventory_vlt_in; @@ -36,7 +37,10 @@ use crate::vendor::npm_flavor::detect_npm_lock_flavor_in; /// otherwise have chosen sits beside it (a pnpm→yarn/npm migration /// leftover), in which case the SIBLING is inventoried instead /// ([`inventory_live_sibling_lock_in`]) — and `vendor_lockfile_missing` reads -/// the pnpm <=2-era `shrinkwrap.yaml` (same v5 grammar, older filename). +/// the pnpm <=2-era `shrinkwrap.yaml` (same v5 grammar, older filename) or, +/// failing that, the workspace members' own locks. A pnpm workspace with +/// `sharedWorkspaceLockfile: false` reads those beside its root lock +/// ([`inventory_pnpm_member_locks_in`], #492). /// Any remaining probe failure falls back to Rush's common lock when /// `rush.json` is present. #[cfg(test)] @@ -133,6 +137,12 @@ pub(super) async fn inventory_npm_lock_raw_in( if !legacy.is_empty() { return Ok(Some((NpmLockFlavor::PnpmLegacy, guard_npm(legacy)))); } + // pnpm 7 under `shared-workspace-lockfile=false` writes no + // root lock at all, only the members' own (#492). + let members = inventory_pnpm_member_locks_in(view).await; + if !members.is_empty() { + return Ok(Some((NpmLockFlavor::Pnpm, guard_npm(members)))); + } } // Rush monorepos have no root package.json/lock pair; their // single pnpm source-of-truth lives under common/config/rush/. @@ -148,7 +158,17 @@ pub(super) async fn inventory_npm_lock_raw_in( // The pnpm reader is grammar-agnostic (it also serves legacy // 5.4/6.0 locks through the version-refusal fallback above), so // both pnpm flavors share it. - NpmLockFlavor::Pnpm | NpmLockFlavor::PnpmLegacy => inventory_pnpm_lock_in(view).await, + // Under `sharedWorkspaceLockfile: false` the members install from + // their own locks, read beside the root's (#492). + NpmLockFlavor::Pnpm | NpmLockFlavor::PnpmLegacy => { + let root = inventory_pnpm_lock_in(view).await; + let members = inventory_pnpm_member_locks_in(view).await; + if members.is_empty() { + root + } else { + Some(root.into_iter().flatten().chain(members).collect()) + } + } NpmLockFlavor::YarnClassic => inventory_yarn_classic_in(view).await, NpmLockFlavor::YarnBerry => inventory_yarn_berry_in(view).await, NpmLockFlavor::Bun => { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs b/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs index f477d9cb6..cf774d40a 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs @@ -73,6 +73,30 @@ pub(super) async fn inventory_pnpm_lock_at(lock_path: &Path) -> Option) -> Vec { + use crate::utils::pnpm_workspace::{member_locks, MemberLocks}; + if view.is_file("rush.json") { + return Vec::new(); + } + let MemberLocks::PerMember(keys) = member_locks(view).await else { + return Vec::new(); + }; + let mut out = Vec::new(); + for key in keys { + out.extend( + inventory_pnpm_lock_rel_in(view, &key) + .await + .unwrap_or_default(), + ); + } + out +} + fn pnpm_lock_text_inventory(text: &str) -> Option> { PnpmLock::parse(text).entries() } diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 0e9477a15..aa8b27a49 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -212,7 +212,7 @@ pub(crate) fn manifest_pins_yarn_classic(manifest: Option<&str>) -> bool { else { return false; }; - let major = pm.trim().strip_prefix("yarn@").map(|rest| { + let major = crate::utils::package_manager::pinned_version(&pm, "yarn").map(|rest| { rest.chars() .take_while(char::is_ascii_digit) .collect::() @@ -861,6 +861,42 @@ pub async fn lock_text_refusals( refusals } +/// [`lock_text_refusals`] for npm `candidates` a HOSTED pin wires, in a +/// pnpm (lockfileVersion 9) project only — the refusals a hosted → vendored +/// takeover of them meets after it restores the registry entry (#853), so +/// a dry-run preview can name them without staging the restore (which +/// needs the registry). Empty for every other flavor. +/// +/// Evaluating the gates on the still-hosted lock is exact for pnpm: the +/// hosted restore only splices each entry's `resolution:` value, which no +/// pnpm gate reads (coordinates, CRLF, catalogs, overrides, entry presence +/// and ref rewritability all key on other text). The one difference is a +/// `pnpm-workspace.yaml` scaffold hosted mode created, which the restore +/// deletes; a gate that depended on it would make this under-predict, and +/// the wet run still refuses. Yarn's restores rewrite key and checksum +/// text, and legacy pnpm 7/8 locks (5.4 / 6.0) are not lock-text gated at +/// all, so neither is predicted here: a CRLF legacy lock, an override +/// conflict or an unsupported legacy entry previews `would_vendor` while +/// the wet takeover refuses it (keeping the hosted pin, #963) — a known +/// preview gap the contract names. +pub async fn pnpm_takeover_lock_text_refusals( + project_root: &Path, + candidates: &[(&str, &str)], +) -> HashMap { + if !matches!( + npm_flavor::detect_npm_lock_flavor(project_root).await, + Ok((npm_flavor::NpmLockFlavor::Pnpm, _)) + ) { + return HashMap::new(); + } + let npm: Vec<(&str, &str)> = candidates + .iter() + .filter(|(purl, _)| ecosystem_dir_for_purl(purl) == Some("npm")) + .copied() + .collect(); + lock_text_refusals(project_root, &npm).await +} + /// [`VendorState::purl_keys`] over the ledger in `project_root`, loaded /// once for callers that match whole purl sets against vendor ownership /// (apply / rollback / scan prune). An unreadable ledger degrades to the @@ -1993,3 +2029,71 @@ mod berry_migration_risk_tests { assert!(yarn_classic_berry_migration_risk(tmp.path()).is_some()); } } + +#[cfg(test)] +mod pnpm_takeover_lock_text_refusal_tests { + use super::pnpm_takeover_lock_text_refusals; + + const PURL: &str = "pkg:npm/left-pad@1.3.0"; + const UUID: &str = "11111111-2222-4333-8444-555555555555"; + /// A hosted pin: the entry's tarball is the patch server's. + const HOSTED_V9: &str = "lockfileVersion: '9.0'\n\n\ + importers:\n\n .:\n dependencies:\n left-pad:\n specifier: 1.3.0\n version: 1.3.0\n\n\ + packages:\n\n left-pad@1.3.0:\n resolution: {integrity: sha512-x==, tarball: https://patch.socket.dev/patch/npm/left-pad/1.3.0/a/11111111-2222-4333-8444-555555555555/left-pad-1.3.0.tgz}\n\n\ + snapshots:\n\n left-pad@1.3.0: {}\n"; + + fn project(lock_name: &str, lock: &str) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("package.json"), + r#"{"name":"c","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}"#, + ) + .unwrap(); + std::fs::write(tmp.path().join(lock_name), lock).unwrap(); + tmp + } + + #[tokio::test] + async fn crlf_hosted_pnpm_lock_is_refused() { + let tmp = project("pnpm-lock.yaml", &HOSTED_V9.replace('\n', "\r\n")); + let refused = pnpm_takeover_lock_text_refusals(tmp.path(), &[(PURL, UUID)]).await; + assert_eq!( + refused.get(PURL).map(|(code, _)| *code), + Some("vendor_lockfile_crlf_unsupported"), + "{refused:?}" + ); + } + + #[tokio::test] + async fn plain_hosted_pnpm_pin_is_not_refused() { + let tmp = project("pnpm-lock.yaml", HOSTED_V9); + let refused = pnpm_takeover_lock_text_refusals(tmp.path(), &[(PURL, UUID)]).await; + assert!(refused.is_empty(), "{refused:?}"); + } + + /// Only pnpm is predicted: a CRLF yarn classic lock over the same purl + /// yields nothing here (its restore rewrites the text the gates read). + #[tokio::test] + async fn yarn_classic_project_is_out_of_scope() { + let lock = "# yarn lockfile v1\r\n\r\n\r\nleft-pad@1.3.0:\r\n version \"1.3.0\"\r\n \ + resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#abc\"\r\n"; + let tmp = project("yarn.lock", lock); + let refused = pnpm_takeover_lock_text_refusals(tmp.path(), &[(PURL, UUID)]).await; + assert!(refused.is_empty(), "{refused:?}"); + } + + /// A known preview gap (CLI_CONTRACT, #853): a legacy pnpm 7/8 lock + /// (5.4 / 6.0) is not lock-text gated, so even a CRLF one the wet + /// takeover refuses (`vendor_lockfile_crlf_unsupported`) previews + /// `would_vendor`. The wet run's refusal keeps the hosted pin (#963). + #[tokio::test] + async fn legacy_pnpm_project_is_out_of_scope() { + let lock = "lockfileVersion: '6.0'\r\n\r\ndependencies:\r\n left-pad:\r\n \ + specifier: 1.3.0\r\n version: 1.3.0\r\n\r\npackages:\r\n\r\n \ + /left-pad@1.3.0:\r\n resolution: {integrity: sha512-x==, tarball: \ + https://patch.socket.dev/patch/npm/left-pad/1.3.0/a/{UUID}/left-pad-1.3.0.tgz}\r\n"; + let tmp = project("pnpm-lock.yaml", &lock.replace("{UUID}", UUID)); + let refused = pnpm_takeover_lock_text_refusals(tmp.path(), &[(PURL, UUID)]).await; + assert!(refused.is_empty(), "{refused:?}"); + } +} diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 6173d0045..083006440 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -242,6 +242,11 @@ pub(crate) async fn detect_npm_lock_flavor_in( ), )); } + // Only a pnpm branch lock (`gitBranchLockfile`, #556): name the + // setting, not a missing lock `pnpm install` would never write. + if let Some(refusal) = pnpm_lock::git_branch_lock_refusal(view).await { + return Err(refusal); + } // 4. Nothing recognizable. let location = match view { ProjectView::Disk(root) @@ -1189,6 +1194,27 @@ mod tests { assert_eq!(code, "vendor_yarn_berry_unsupported"); } + /// Only a `gitBranchLockfile` branch lock: pnpm's lock exists, just + /// not under the name vendoring wires, so the refusal names the + /// setting rather than "no lockfile — run your install first" (#556). + #[tokio::test] + async fn a_lone_git_branch_lock_names_the_setting() { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "package.json", "{}").await; + touch(tmp.path(), "pnpm-lock.feature.yaml", PNPM_9).await; + let (code, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap_err(); + assert_eq!(code, "vendor_lockfile_missing", "setting off: no lock"); + touch( + tmp.path(), + "pnpm-workspace.yaml", + "packages:\n - '.'\ngitBranchLockfile: true\n", + ) + .await; + let (code, detail) = detect_npm_lock_flavor(tmp.path()).await.unwrap_err(); + assert_eq!(code, "vendor_pnpm_git_branch_lockfile"); + assert!(detail.contains("pnpm-lock.feature.yaml"), "{detail}"); + } + #[tokio::test] async fn bun_text_and_binary_locks_route_to_bun() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 706fb239a..2f5458d7e 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -78,8 +78,8 @@ use super::state::{ use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; use crate::constants::npm_family::PNPM_LOCK; use crate::formats::pnpm::lines::{ - indent_of, next_block, parse_key_line, section_bounds, split_lines, unquote_value, yaml_key, - yaml_key_like, YamlBlock, + indent_of, next_block, parse_key_line, section_bounds, split_lines, split_project_document, + unquote_value, yaml_key, yaml_key_like, YamlBlock, }; use crate::formats::pnpm::workspace; use crate::formats::pnpm::{check_v9_lock_version as check_lock_version, vendored_npm_uuids}; @@ -91,11 +91,22 @@ use crate::utils::pnpm_workspace::{governing_workspace_file, PNPM_WORKSPACE}; /// ancestor `pnpm-workspace.yaml` (see [`read_project`]). pub(crate) const VENDOR_PNPM_SETTINGS_ELSEWHERE: &str = "vendor_pnpm_settings_elsewhere"; +/// Refusal code for a multi-document `pnpm-lock.yaml` whose documents are +/// not pnpm's env document followed by the project lock (see +/// [`split_v9_lock`]). +pub(crate) const VENDOR_PNPM_LOCK_MULTI_DOCUMENT: &str = "vendor_pnpm_lock_multi_document"; + +/// Warning code: the vendored package is also a pnpm config dependency, +/// whose own copy (the lock's env document) vendoring leaves unpatched. +pub(crate) const VENDOR_CONFIG_DEPENDENCY_UNPATCHED: &str = "vendor_config_dependency_unpatched"; + /// The root-only workspace member list written into a freshly created /// `pnpm-workspace.yaml`. pnpm 9 refuses a workspace file whose `packages` /// field is missing or empty; `.` (the root, already the sole importer) is a /// no-op that cannot accidentally glob a stray `packages/` subtree into a -/// workspace the way `packages/*` would. +/// workspace the way `packages/*` would. It still makes the project a +/// workspace, so a project pinned to pnpm 9.0–10.4 (which refuse `pnpm add` +/// there) never gets one (#734). const WS_SCAFFOLD_PACKAGES: [&str; 2] = ["packages:", " - '.'"]; /// Wiring kinds (the `WiringRecord.kind` discriminators this backend owns). @@ -207,8 +218,23 @@ pub(super) async fn vendor_pnpm_dialect( pkg_bytes, mut pkg, mut lock, + lock_prefix, ws_text, } = project; + // The env document of a two-document lock is never edited, so a config + // dependency's own copy of the package (installed under + // `node_modules/.pnpm-config`) keeps the registry bytes. + if lock_has_target_package(&split_lines(&lock_prefix), name, version) { + warnings.push(VendorWarning::new( + VENDOR_CONFIG_DEPENDENCY_UNPATCHED, + format!( + "{name}@{version} is also a pnpm config dependency (the first document of \ + {PNPM_LOCK}); vendoring wires the project's copy only, and the copy pnpm \ + installs under node_modules/.pnpm-config stays unpatched — use \ + `--mode hosted` to patch both" + ), + )); + } // ── 4. Stage → patch → pack (shared flavor-agnostic pipeline) ──────── let (staged, result) = match stage_patch_pack( @@ -299,7 +325,13 @@ pub(super) async fn vendor_pnpm_dialect( // Only modern locks mirror overrides into pnpm-workspace.yaml. Legacy // pnpm reads package.json alone; creating a workspace changes its mode. - let ws_edit = if dialect == PnpmDialect::V9 { + // So does a project with no workspace file pinned to pnpm 9.0–10.4: + // those read package.json `pnpm.overrides` (wired above) and refuse + // `pnpm add` in the root-only workspace a created file would make + // (#734). A later run on pnpm >= 10.5 adds the mirror. + let ws_edit = if dialect == PnpmDialect::V9 + && !(ws_text.is_none() && pinned_pre_10_5(project_root, &pkg_bytes).await) + { match apply_workspace_override(ws_text.as_deref(), &effective_key, &spec, &mut wiring) { Ok(edit) => edit, Err(e) => { @@ -352,7 +384,8 @@ pub(super) async fn vendor_pnpm_dialect( .await } }; - let lock_out = lock.lines().join("\n"); + // An env document ahead of the project lock is carried over verbatim. + let lock_out = format!("{lock_prefix}{}", lock.lines().join("\n")); if let Err(e) = commit_surfaces( project_root, pkg_changed.then_some(new_pkg_bytes.as_slice()), @@ -435,7 +468,11 @@ pub(super) async fn vendor_pnpm_dialect( struct PnpmProject { pkg_bytes: Vec, pkg: Value, + /// The project document's lines (the whole lock unless it has an env + /// document, see [`split_v9_lock`]). lock: ProjectLock, + /// Everything ahead of the project document, written back verbatim. + lock_prefix: String, ws_text: Option, } @@ -476,6 +513,27 @@ impl ProjectLock { } } +/// The refusal for a project whose `gitBranchLockfile` setting installs +/// the branch from a `pnpm-lock..yaml` (#556): the wiring edits +/// `pnpm-lock.yaml` only, and pnpm would then fail every +/// `--frozen-lockfile` install on the branch over the overrides the +/// manifest gained. Also raised by the flavor probe when the branch lock +/// is the only one. +pub(super) async fn git_branch_lock_refusal( + view: &crate::vendor::lock_inventory::ProjectView<'_>, +) -> Option<(&'static str, String)> { + use crate::utils::pnpm_workspace::{git_branch_locks, GitBranchLocks}; + let branch = git_branch_locks(view).await?; + Some(( + "vendor_pnpm_git_branch_lockfile", + format!( + "{}; vendoring wires {PNPM_LOCK} only, so nothing was changed — {}", + branch.describe(), + GitBranchLocks::REMEDY + ), + )) +} + /// Read the pair, refusing (before any write) a file that is missing, /// unreadable, not the shape the surgery has fixtures for, or CRLF. async fn read_project( @@ -504,6 +562,13 @@ async fn read_project( ))); } }; + if let Some(refusal) = git_branch_lock_refusal( + &crate::vendor::lock_inventory::ProjectView::Disk(project_root), + ) + .await + { + return Err(Box::new(refused(refusal.0, refusal.1))); + } let lock_text = match read_regular_to_string(&project_root.join(PNPM_LOCK)).await { Ok(text) => text, Err(e) => { @@ -554,14 +619,31 @@ async fn read_project( project_root, grammar, )?), + lock_prefix: String::new(), ws_text: None, }); } + let (lock_prefix, project_text) = match split_v9_lock(&lock_text) { + Ok(split) => split, + Err(why) => { + return Err(Box::new(refused( + VENDOR_PNPM_LOCK_MULTI_DOCUMENT, + format!( + "{why} — vendored mode wires only the project document of a lock in \ + pnpm's two-document layout (its config-dependency / `packageManager` \ + document, then the project lock) and will not guess which document \ + to edit; use `--mode hosted`; nothing was written" + ), + ))); + } + }; // The split (and its section index) is the run's, while the bytes just - // read are the ones it came from; see [`LOCK_MEMO`]. + // read are the ones it came from; see [`LOCK_MEMO`]. The memo maps the + // whole lock's bytes to its project document's lines. let lines = LockLines::Shared(LOCK_MEMO.parse_infallible(lock_text.as_bytes(), || { - LockDoc::new(split_lines(&lock_text)) + LockDoc::new(split_lines(project_text)) })); + let lock_prefix = lock_prefix.to_string(); // `pnpm-workspace.yaml` is optional (single-package projects have none); // its `overrides:` is where pnpm >= 11 reads them. ONLY a missing file // counts as "no file": an existing one we cannot read (non-UTF-8 @@ -589,7 +671,9 @@ async fn read_project( // nest a `packages: ['.']` scaffold pnpm ignores, and every root // install then fails (frozen) or drops the override (#881). The // override cannot go in the root file either: socket-patch writes only - // inside the project. Hosted mode pins such a member. + // inside the project. Hosted mode pins such a member. A directory the + // root's `packages:` globs do not list is standalone and takes the + // create path (#1006). if ws_text.is_none() { if let Some(file) = governing_workspace_file(project_root) { return Err(Box::new(refused( @@ -624,10 +708,25 @@ async fn read_project( pkg_bytes, pkg, lock: ProjectLock::V9(lines), + lock_prefix, ws_text, }) } +/// `(prefix, project document)` of a v9 lock ([`split_project_document`]), +/// the project document's own head version-checked. `Err` names why the +/// lock's documents are not pnpm's layout. +fn split_v9_lock(text: &str) -> Result<(&str, &str), String> { + let (prefix, project) = + split_project_document(text).map_err(|why| format!("{PNPM_LOCK} {why}"))?; + if !prefix.is_empty() { + check_lock_version(project).map_err(|e| { + format!("the project document of {PNPM_LOCK} fails its version check: {e}") + })?; + } + Ok((prefix, project)) +} + /// The per-package pre-flight against an already-read project: override /// conflicts on all three surfaces, the packages entry present, every /// reference to it rewritable. `Ok` is the override key both surfaces @@ -640,18 +739,43 @@ fn preflight_package( override_key: &str, ) -> Result> { // A user-authored exact-version pin equal to `version` is TAKEN OVER - // (the pin's key is rewritten to our spec on both surfaces and the + // (the pin's key is rewritten to our spec on every surface and the // original value recorded for revert); anything else same-name refuses. - let disposition = match classify_pkg_override(&project.pkg, name, version, override_key) { - Ok(d) => d, - Err(detail) => return Err(Box::new(refused("vendor_override_conflict", detail))), + // The pin may live in package.json or, for modern locks, in + // pnpm-workspace.yaml (the map pnpm 10.5+ reads; #854). + let conflict = |detail| Box::new(refused("vendor_override_conflict", detail)); + let pkg_d = + classify_pkg_override(&project.pkg, name, version, override_key).map_err(conflict)?; + let ws_d = match &project.lock { + ProjectLock::V9(_) => { + classify_ws_override(project.ws_text.as_deref(), name, version, override_key) + .map_err(conflict)? + } + ProjectLock::Legacy(_) => OverrideDisposition::Insert, }; - let effective_key = disposition.effective_key(override_key).to_string(); + let (effective_key, source) = match (&pkg_d, &ws_d) { + (OverrideDisposition::Insert, OverrideDisposition::Insert) => (override_key, None), + (OverrideDisposition::Insert, ws) => (ws.effective_key(override_key), Some(PNPM_WORKSPACE)), + (pkg, ws) => { + let pkg_key = pkg.effective_key(override_key); + let ws_key = ws.effective_key(override_key); + if !matches!(ws, OverrideDisposition::Insert) && ws_key != pkg_key { + return Err(conflict(format!( + "{PNPM_WORKSPACE} carries the override key `{ws_key}` for `{name}` while \ + {PACKAGE_JSON} carries `{pkg_key}` — make the two agree before vendoring" + ))); + } + (pkg_key, Some(PACKAGE_JSON)) + } + }; + let effective_key = effective_key.to_string(); if let ProjectLock::V9(lines) = &project.lock { lines.note_probe(); } - if let Err(detail) = check_lock_override(project.lock.lines(), name, version, &effective_key) { - return Err(Box::new(refused("vendor_override_conflict", detail))); + if let Err(detail) = + check_lock_override(project.lock.lines(), name, version, &effective_key, source) + { + return Err(conflict(detail)); } match &project.lock { ProjectLock::V9(lines) => { @@ -842,6 +966,31 @@ pub(super) async fn revert_pnpm_dialect( return blocked; } } + // A v9 lock whose documents are not pnpm's layout cannot be reverted + // without guessing which document holds the wiring: fail before any + // surface is touched (the dry run too, so the preview never advertises + // a revert the wet run refuses) instead of reverting package.json and + // the workspace file around a lock left wired to the artifact. + let mut lock_text: Option> = None; + if dialect == PnpmDialect::V9 + && entry + .wiring + .iter() + .any(|r| r.file != PACKAGE_JSON && dialect.allows_revert_file(&r.file)) + { + let read = read_regular_to_string(&project_root.join(PNPM_LOCK)).await; + if let Ok(text) = &read { + if let Err(why) = split_v9_lock(text) { + return RevertOutcome::failed(format!( + "{why}, so the vendored wiring of {} cannot be located; nothing was \ + reverted — restore the vendored {PNPM_LOCK} (or re-lock it with pnpm) \ + and re-run the revert", + entry.base_purl + )); + } + } + lock_text = Some(read); + } if dry_run { return RevertOutcome::ok(); } @@ -872,9 +1021,26 @@ pub(super) async fn revert_pnpm_dialect( // Load both surfaces up front (fail-closed on unparseable; a missing // file degrades to a warning and the artifact removal still proceeds). let mut lock_lines: Option> = None; + let mut lock_prefix = String::new(); if touches_lock { - match read_regular_to_string(&project_root.join(PNPM_LOCK)).await { - Ok(text) => lock_lines = Some(split_lines(&text)), + let read = match lock_text { + Some(read) => read, + None => read_regular_to_string(&project_root.join(PNPM_LOCK)).await, + }; + match read { + // The wiring lives in the project document; an env document + // ahead of it is written back verbatim. + Ok(text) => match dialect { + PnpmDialect::V9 => { + let (prefix, project) = match split_v9_lock(&text) { + Ok(split) => split, + Err(why) => return RevertOutcome::failed(why), + }; + lock_prefix = prefix.to_string(); + lock_lines = Some(split_lines(project)); + } + PnpmDialect::Legacy => lock_lines = Some(split_lines(&text)), + }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { outcome.warnings.push(VendorWarning::new( "vendor_lockfile_missing", @@ -974,7 +1140,7 @@ pub(super) async fn revert_pnpm_dialect( if let Some(lines) = &lock_lines { if let Err(e) = atomic_write_bytes_preserving_mode( &project_root.join(PNPM_LOCK), - lines.join("\n").as_bytes(), + format!("{lock_prefix}{}", lines.join("\n")).as_bytes(), ) .await { @@ -1311,8 +1477,9 @@ pub(super) fn vendor_value_is_for(value: &str, name: &str, version: &str) -> boo .is_some_and(|p| p.eco == "npm" && p.leaf == tgz_rel_leaf(name, version)) } -/// How the package.json `pnpm.overrides` table relates to the package -/// being vendored. The lock's `overrides:` section must mirror this map +/// How the package.json `pnpm.overrides` table (or the pnpm-workspace.yaml +/// `overrides:` section) relates to the package being vendored. The +/// lock's `overrides:` section must mirror this map /// key-for-key (pnpm hard-checks the two and fails /// `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` on any drift), so whichever key /// this classification yields is the one BOTH surfaces edit. @@ -1362,12 +1529,79 @@ pub(super) fn classify_pkg_override( let Some(map) = overrides.as_object() else { return Err("package.json pnpm.overrides is not an object".to_string()); }; + classify_override_entries( + map.iter().map(|(key, value)| { + ( + key.as_str(), + value.as_str().unwrap_or(""), + value.to_string(), + ) + }), + name, + version, + our_key, + PACKAGE_JSON, + ) +} + +/// Classify the pnpm-workspace.yaml `overrides:` section for `name` with +/// the same rules as [`classify_pkg_override`] — on pnpm 10.5+ (always on +/// 11/12) it is the authoritative override map, so a user exact pin there +/// is taken over just like one in package.json (#854). Values compare as +/// YAML reads them ([`ws_override_value`]): a quoted pin (`'1.3.0'`) or one +/// with a trailing comment is the same pin as the plain `1.3.0`. A missing +/// file, a missing section and an inline/flow section classify as +/// `Insert`; [`check_workspace_override`] refuses the shapes the surgery +/// cannot edit. +fn classify_ws_override( + ws_text: Option<&str>, + name: &str, + version: &str, + our_key: &str, +) -> Result { + let Some(text) = ws_text else { + return Ok(OverrideDisposition::Insert); + }; + let lines = split_lines(text); + let Some((start, end, indent)) = ws_overrides_section(&lines) else { + return Ok(OverrideDisposition::Insert); + }; + classify_override_entries( + lines[start + 1..end] + .iter() + .filter_map(|l| parse_key_line(l, indent)) + .map(|(key, _repr, rest)| (key, ws_override_value(rest), rest.to_string())), + name, + version, + our_key, + PNPM_WORKSPACE, + ) +} + +/// A pnpm-workspace.yaml override's value as YAML reads it: a trailing +/// ` #` comment and one layer of quotes dropped, so a hand-written +/// `left-pad: '1.3.0' # CVE-…` pins the same `1.3.0` its lock mirror +/// records. Comparisons only — the raw text is what a takeover records as +/// the original, so revert restores the line byte-for-byte. +fn ws_override_value(rest: &str) -> &str { + unquote_value(workspace::strip_comment(rest).trim()) +} + +/// The shared per-entry rules of [`classify_pkg_override`] and +/// [`classify_ws_override`] over `(key, value, value as shown)` entries of +/// the override map `source` carries. +fn classify_override_entries<'a>( + entries: impl Iterator, + name: &str, + version: &str, + our_key: &str, + source: &str, +) -> Result { let mut found: Option = None; - for (key, value) in map { + for (key, value_str, shown) in entries { if override_key_name(key) != name { continue; } - let value_str = value.as_str().unwrap_or(""); // A SIBLING version's vendored override coexists — not ours to // touch (and not a conflict): skip it entirely. if is_vendor_value(value_str) && !vendor_value_is_for(value_str, name, version) { @@ -1375,16 +1609,20 @@ pub(super) fn classify_pkg_override( } if found.is_some() { return Err(format!( - "package.json carries more than one pnpm override for `{name}`; vendoring \ + "{source} carries more than one pnpm override for `{name}`; vendoring \ cannot pick one — remove the extras first" )); } let classified = if key.contains('>') { None } else if is_vendor_value(value_str) { - Some(OverrideDisposition::Ours { key: key.clone() }) + Some(OverrideDisposition::Ours { + key: key.to_string(), + }) } else if value_str == version && (key == name || key == our_key) { - Some(OverrideDisposition::Takeover { key: key.clone() }) + Some(OverrideDisposition::Takeover { + key: key.to_string(), + }) } else { None }; @@ -1392,7 +1630,7 @@ pub(super) fn classify_pkg_override( Some(d) => found = Some(d), None => { return Err(format!( - "package.json already carries a pnpm override for `{key}` ({value}); \ + "{source} already carries a pnpm override for `{key}` ({shown}); \ vendoring would fight it — remove the override (or vendor --revert) \ first (an exact-version pin equal to {version} is taken over \ automatically)" @@ -1409,11 +1647,14 @@ pub(super) fn classify_pkg_override( /// drift means the pair is already desynced) with a value the edit can /// own: ours, the exact pinned `version` (takeover), or already our spec. /// A missing section/key is fine — the edit inserts it, restoring parity. +/// `source` names the file `effective_key` came from (`None`: no override +/// for `name` anywhere, so it is the key vendoring would add). pub(super) fn check_lock_override( lines: &[String], name: &str, version: &str, effective_key: &str, + source: Option<&str>, ) -> Result<(), String> { let Some((start, end)) = section_bounds(lines, "overrides") else { return Ok(()); @@ -1428,10 +1669,17 @@ pub(super) fn check_lock_override( continue; } if key != effective_key { + let theirs = match source { + Some(source) => format!("{source}'s `{effective_key}`"), + None => format!( + "the `{effective_key}` vendoring would add (no override for `{name}` \ + exists to take over)" + ), + }; return Err(format!( "{PNPM_LOCK} carries an override key `{key}` for `{name}` that does not \ - match package.json's `{effective_key}` — the two override maps must \ - agree (run `pnpm install` to re-sync them) before vendoring" + match {theirs} — the two override maps must agree (run `pnpm install` \ + to re-sync them) before vendoring" )); } if !(is_vendor_value(rest) || rest == version) { @@ -1755,11 +2003,13 @@ pub(super) fn apply_pkg_override( /// Does the pnpm that wrote this lock read `overrides:` from /// pnpm-workspace.yaml (pnpm 10.5+) rather than package.json? True when /// package.json has no `pnpm.overrides` of its own and the lock's -/// `overrides:` records a user-authored (non-vendored) key of the -/// workspace file's `overrides:` section. pnpm 9 and 10.0–10.4 ignore the -/// workspace block, so their locks never record it; and with no user -/// workspace override both surfaces agree anyway, so the package.json -/// copy stays harmless there. +/// `overrides:` records a key of the workspace file's `overrides:` +/// section. pnpm 9 and 10.0–10.4 ignore the workspace block, so their +/// locks never record it; and with no workspace override both surfaces +/// agree anyway, so the package.json copy stays harmless there. A +/// vendored value counts too: after a workspace-only takeover (#854) or +/// an earlier skipped copy, the lock recording it still shows the +/// workspace file governs, and a copy now would shadow it. fn workspace_overrides_govern(pkg: &Value, ws_text: Option<&str>, lock: &[String]) -> bool { if pkg.get("pnpm").and_then(|p| p.get("overrides")).is_some() { return false; @@ -1781,7 +2031,7 @@ fn workspace_overrides_govern(pkg: &Value, ws_text: Option<&str>, lock: &[String ws_lines[ws_start + 1..ws_end] .iter() .filter_map(|l| parse_key_line(l, indent)) - .any(|(key, _, rest)| !is_vendor_value(rest) && lock_keys.contains(&key)) + .any(|(key, _, _)| lock_keys.contains(&key)) } // ─────────────────────── pnpm-workspace.yaml override ───────────────────── @@ -1790,6 +2040,21 @@ fn workspace_overrides_govern(pkg: &Value, ws_text: Option<&str>, lock: &[String // splices (never a YAML library) so untouched lines stay byte-identical and // revert restores the file byte-for-byte (or deletes a file we created). +/// Whether every pnpm pin of a project (package.json, the installed +/// `node_modules/.modules.yaml`) is a release that refuses `pnpm add` in a +/// root-only workspace, so no pnpm-workspace.yaml is created for it. The +/// install record is advisory: unreadable counts as absent. +async fn pinned_pre_10_5(project_root: &Path, pkg_bytes: &[u8]) -> bool { + let modules = read_regular_to_string(&project_root.join("node_modules/.modules.yaml")) + .await + .ok(); + crate::formats::pnpm::root_only_workspace_breaks_add( + std::str::from_utf8(pkg_bytes).ok(), + modules.as_deref(), + ) + .is_some() +} + /// The bytes of a freshly created pnpm-workspace.yaml: a root-only /// `packages:` list (pnpm 9 refuses a workspace file with no `packages` /// field) plus the single `overrides:` entry. Kept in one place so the @@ -1874,8 +2139,9 @@ fn check_workspace_override( if override_key_name(key) != name { continue; } + let value = ws_override_value(rest); // A sibling version's vendored override coexists — skip it. - if is_vendor_value(rest) && !vendor_value_is_for(rest, name, version) { + if is_vendor_value(value) && !vendor_value_is_for(value, name, version) { continue; } if key != effective_key { @@ -1884,7 +2150,7 @@ fn check_workspace_override( match `{effective_key}` — remove it (or vendor --revert) first" )); } - if !(is_vendor_value(rest) || rest == version) { + if !(is_vendor_value(value) || value == version) { return Err(format!( "{PNPM_WORKSPACE} already carries an override for `{key}` ({rest}); vendoring \ would fight it — remove the override (or vendor --revert) first" @@ -1937,8 +2203,9 @@ fn apply_workspace_override( }); } // Ours (stale uuid, no original) or the user's exact-version pin - // being TAKEN OVER (recorded, live quoting preserved). - let original = (!is_vendor_value(&rest)).then(|| rest.clone()); + // being TAKEN OVER (recorded raw — quotes, comment and all — so + // revert restores the line byte-for-byte). + let original = (!is_vendor_value(ws_override_value(&rest))).then(|| rest.clone()); lines[i] = format!("{pad}{}: {spec}", yaml_key_like(our_key, &repr)); wiring.push(ws_record(our_key, spec, WiringAction::Rewritten, original)); } else { @@ -3204,7 +3471,8 @@ fn revert_importer_dep( /// Restore a rekeyed packages/snapshots block: locate the block by the NEW /// key (from `rec.new`'s first line), verify ownership, splice the original -/// lines back. +/// lines back — merged with any dependency ref ANOTHER entry rewrote inside +/// the block since (#830, [`merge_live_dep_refs`]). fn revert_block( lines: &mut Vec, rec: &WiringRecord, @@ -3258,19 +3526,21 @@ fn revert_block( ))); return; }; - lines.splice(block.header..block.end, original); + let restored = merge_live_dep_refs(&original, &new_lines, &live); + lines.splice(block.header..block.end, restored); *dirty = true; return; } // ALREADY CONVERGED: an earlier partial revert restored this record — // the splice rekeys the block back to its pre-vendor key, so the // recorded `file:` key no longer matches while the original block is - // live verbatim. Not drift: stay silent so the drift-skip keep gate can - // converge instead of keeping the artifacts forever. + // live (up to the dependency refs the merge kept). Not drift: stay + // silent so the drift-skip keep gate can converge instead of keeping + // the artifacts forever. if let Some(orig) = rec.original.as_ref().and_then(value_lines) { let mut j = start + 1; while let Some(block) = next_block(lines, j, end) { - if lines[block.header..block.end] == orig[..] { + if same_modulo_dep_refs(&lines[block.header..block.end], &orig) { return; } j = block.end; @@ -3281,6 +3551,11 @@ fn revert_block( ))); } +/// Restore one dependent snapshot's ref to this entry. The record is keyed +/// by the dependent's snapshot key AT VENDOR TIME, which a later vendor or +/// revert of the dependent itself rekeys (`debug@4.3.4` ⇄ +/// `debug@file:…`): when that key is gone, the ref is found in the block +/// that names the same package under its other key (#830). fn revert_snapshot_ref( lines: &mut [String], rec: &WiringRecord, @@ -3301,48 +3576,214 @@ fn revert_snapshot_ref( )); return; }; + let target = DepRef { + rec, + key, + dep, + entry_uuid, + label: "snapshot ref", + dep_maps_only: false, + }; + for (header, block_end) in ref_record_blocks(lines, start, end, snapshot_key) { + if target.restore_in(lines, header, block_end, dirty, warnings) { + return; + } + } + warnings.push(removed(format!( + "snapshot ref `{key}` no longer exists; nothing to restore" + ))); +} + +/// The `[header, end)` ranges a dependency-ref record keyed by `block_key` +/// may live in: that block itself, else (it was rekeyed since) every block +/// naming the same package ([`key_package_leaves`]). +pub(super) fn ref_record_blocks( + lines: &[String], + start: usize, + end: usize, + block_key: &str, +) -> Vec<(usize, usize)> { let mut i = start + 1; while let Some(block) = next_block(lines, i, end) { - if block.key != snapshot_key { - i = block.end; + if block.key == block_key { + return vec![(block.header, block.end)]; + } + i = block.end; + } + let leaves = key_package_leaves(block_key); + let mut same_package = Vec::new(); + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + if key_package_leaves(&block.key) + .iter() + .any(|l| leaves.contains(l)) + { + same_package.push((block.header, block.end)); + } + i = block.end; + } + same_package +} + +/// The package a packages/snapshots key names, as its vendored tarball +/// leaves ([`tgz_rel_leaf`]): from the embedded vendor path of a `file:` +/// key, else from a registry key in every dialect's spelling (`name@ver`, +/// `/name@ver`, `/name/ver` — the misparses never equal a real leaf). +/// Peer-suffixed keys match nothing, and are never rekeyed anyway. +fn key_package_leaves(key: &str) -> Vec { + let path = key.rsplit_once("@file:").map_or(key, |(_, p)| p); + if let Some(p) = parse_vendor_path(path) { + return if p.eco == "npm" { + vec![p.leaf] + } else { + Vec::new() + }; + } + let bare = key.strip_prefix('/').unwrap_or(key); + [bare.rsplit_once('@'), bare.rsplit_once('/')] + .into_iter() + .flatten() + .filter(|(name, version)| !name.is_empty() && !version.is_empty()) + .map(|(name, version)| tgz_rel_leaf(name, version)) + .collect() +} + +/// One recorded dependency ref to restore (a v9 snapshot ref or a legacy +/// packages dep ref). +pub(super) struct DepRef<'a> { + pub(super) rec: &'a WiringRecord, + pub(super) key: &'a str, + pub(super) dep: &'a str, + pub(super) entry_uuid: &'a str, + /// Warning prefix (`snapshot ref` / `dep ref`). + pub(super) label: &'a str, + /// Only look inside `dependencies:` / `optionalDependencies:` maps (the + /// legacy edit's scope; the v9 edit rewrites any 6-space key). + pub(super) dep_maps_only: bool, +} + +impl DepRef<'_> { + /// Restore the ref inside the block `[header, end)`. `false` when the + /// block has no such ref line (the caller tries the next candidate). + pub(super) fn restore_in( + &self, + lines: &mut [String], + header: usize, + end: usize, + dirty: &mut bool, + warnings: &mut Vec, + ) -> bool { + let at = if self.dep_maps_only { + dep_map_refs(&lines[header..end]) + .into_iter() + .find(|&(_, d, _, _)| d == self.dep) + .map(|(i, ..)| header + i) + } else { + (header + 1..end) + .find(|&k| parse_key_line(&lines[k], 6).is_some_and(|(d, _, _)| d == self.dep)) + }; + let Some(k) = at else { + return false; + }; + let Some((_, _, rest)) = parse_key_line(&lines[k], 6) else { + return false; + }; + let rec = self.rec; + // ALREADY CONVERGED: the live ref already equals the recorded + // pre-vendor original — an earlier partial revert (or the user, by + // hand) already restored it. Not drift. + if rec.original.as_ref().and_then(Value::as_str) == Some(rest) { + return true; + } + let ours = Some(rest) == rec.new.as_ref().and_then(Value::as_str) + || parse_vendor_path(rest).is_some_and(|p| p.eco == "npm" && p.uuid == self.entry_uuid); + if !ours { + warnings.push(drifted(format!( + "{} `{}` was re-resolved since vendoring ({rest}); left alone", + self.label, self.key + ))); + return true; + } + let Some(original) = rec.original.as_ref().and_then(Value::as_str) else { + warnings.push(drifted(format!( + "{} `{}` has no recorded pre-vendor original; left as-is", + self.label, self.key + ))); + return true; + }; + lines[k] = format!(" {}: {original}", yaml_key(self.dep)); + *dirty = true; + true + } +} + +/// `(index, dep, repr, value)` for each ref line of a packages/snapshots +/// `block` (header first) inside a 4-space `dependencies:` / +/// `optionalDependencies:` map — the resolution maps the ref edits rewrite +/// (`peerDependencies` holds ranges, `transitivePeerDependencies` a list). +fn dep_map_refs(block: &[String]) -> Vec<(usize, &str, &str, &str)> { + let mut refs = Vec::new(); + let mut in_dep_map = false; + for (i, line) in block.iter().enumerate().skip(1) { + if let Some((field, _repr, rest)) = parse_key_line(line, 4) { + in_dep_map = + rest.is_empty() && matches!(field, "dependencies" | "optionalDependencies"); continue; } - for line in lines[block.header + 1..block.end].iter_mut() { - let Some((d, _repr, rest)) = parse_key_line(line, 6) else { - continue; - }; - if d != dep { - continue; - } - // ALREADY CONVERGED: the live ref already equals the recorded - // pre-vendor original — an earlier partial revert (or the - // user, by hand) already restored it. Not drift. - if rec.original.as_ref().and_then(Value::as_str) == Some(rest) { - return; - } - let ours = Some(rest) == rec.new.as_ref().and_then(Value::as_str) - || parse_vendor_path(rest).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); - if !ours { - warnings.push(drifted(format!( - "snapshot ref `{key}` was re-resolved since vendoring ({rest}); left alone" - ))); - return; + if in_dep_map { + if let Some((dep, repr, value)) = parse_key_line(line, 6) { + refs.push((i, dep, repr, value)); } - let Some(original) = rec.original.as_ref().and_then(Value::as_str) else { - warnings.push(drifted(format!( - "snapshot ref `{key}` has no recorded pre-vendor original; left as-is" - ))); - return; - }; - *line = format!(" {}: {original}", yaml_key(dep)); - *dirty = true; - return; } - break; } - warnings.push(removed(format!( - "snapshot ref `{key}` no longer exists; nothing to restore" - ))); + refs +} + +/// The block a rekeyed packages/snapshots record restores (#830): its +/// recorded pre-vendor `original`, except a dependency ref whose `live` +/// value moved off what this entry wrote (`new`) — another entry's vendor +/// or revert rewrote it since (a vendored parent's child, vendored or +/// unwound on its own) — keeps that live value, so the lock stays +/// resolvable whichever entry is unwound first. With `live == new` this is +/// `original` verbatim. +pub(super) fn merge_live_dep_refs( + original: &[String], + new: &[String], + live: &[String], +) -> Vec { + let values = |block| -> HashMap<&str, &str> { + dep_map_refs(block) + .into_iter() + .map(|(_, dep, _, value)| (dep, value)) + .collect() + }; + let (new_refs, live_refs) = (values(new), values(live)); + let mut merged = original.to_vec(); + for (i, dep, repr, value) in dep_map_refs(original) { + let Some(&live_value) = live_refs.get(dep) else { + continue; + }; + if new_refs.get(dep) != Some(&live_value) && live_value != value { + merged[i] = format!(" {}: {live_value}", yaml_key_like(dep, repr)); + } + } + merged +} + +/// Are two blocks equal up to their dependency-ref VALUES (what +/// [`merge_live_dep_refs`] may change)? +pub(super) fn same_modulo_dep_refs(a: &[String], b: &[String]) -> bool { + let (refs_a, refs_b) = (dep_map_refs(a), dep_map_refs(b)); + a.len() == b.len() + && refs_a.len() == refs_b.len() + && refs_a + .iter() + .zip(&refs_b) + .all(|(x, y)| x.0 == y.0 && x.1 == y.1) + && a.iter() + .zip(b) + .enumerate() + .all(|(i, (x, y))| x == y || refs_a.iter().any(|r| r.0 == i)) } pub(super) fn drifted(detail: impl Into) -> VendorWarning { @@ -3874,9 +4315,7 @@ snapshots: // As the refusal spells it: canonical (Windows expands 8.3 names), // without the verbatim prefix. let governing = crate::utils::pnpm_workspace::without_verbatim_prefix( - std::fs::canonicalize(&ws_root) - .unwrap() - .join(PNPM_WORKSPACE), + std::fs::canonicalize(ws_root).unwrap().join(PNPM_WORKSPACE), ); for dry_run in [true, false] { let outcome = crate::vendor::test_support::vendor_pnpm( @@ -3930,6 +4369,59 @@ snapshots: assert!(result.success, "{:?}", result.error); } + /// #1006: a project below a `pnpm-workspace.yaml` whose `packages:` + /// globs do not list it is no workspace member. pnpm 11.28+/12 install + /// it standalone (its own lock) and read only its own settings file, so + /// vendoring wires the override into a new nested file, as for any + /// single project, and leaves the unrelated root file alone. + #[tokio::test] + async fn project_outside_the_workspace_globs_vendors_into_its_own_file() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let ws_root = fx.root(); + let project = ws_root.join("examples/demo"); + tokio::fs::create_dir_all(&project).await.unwrap(); + for name in [PACKAGE_JSON, PNPM_LOCK, "node_modules"] { + tokio::fs::rename(ws_root.join(name), project.join(name)) + .await + .unwrap(); + } + let root_ws = "packages:\n - 'packages/*'\n"; + tokio::fs::write(ws_root.join(PNPM_WORKSPACE), root_ws) + .await + .unwrap(); + let blobs = ws_root.join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + let preflight = + preflight_packages(&project, &[("pkg:npm/left-pad@1.3.0", &fx.record)]).await; + assert_eq!(preflight, vec![Ok(())]); + let outcome = crate::vendor::test_support::vendor_pnpm( + "pkg:npm/left-pad@1.3.0", + &project.join("node_modules/left-pad"), + &project, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + let (result, entry, _) = expect_done(outcome); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("success carries a ledger entry"); + assert!(entry.pnpm.as_ref().unwrap().created_workspace_file); + let nested = tokio::fs::read_to_string(project.join(PNPM_WORKSPACE)) + .await + .unwrap(); + assert!(nested.contains("overrides:"), "{nested}"); + assert_eq!( + tokio::fs::read_to_string(ws_root.join(PNPM_WORKSPACE)) + .await + .unwrap(), + root_ws + ); + } + #[tokio::test] async fn p1_fixture_oracle_transform_is_byte_identical_for_both_files() { let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; @@ -5151,6 +5643,48 @@ snapshots: ); } + /// `gitBranchLockfile` with a branch lock: pnpm installs the branch from + /// `pnpm-lock..yaml`, so wiring `pnpm-lock.yaml` (stale there) + /// would break every `--frozen-lockfile` install on an overrides + /// mismatch. Refused before any write, by the pre-flight too (#556). + #[tokio::test] + async fn a_git_branch_lock_refuses_before_any_write() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let workspace = "packages:\n - '.'\ngitBranchLockfile: true\n"; + tokio::fs::write(fx.root().join(PNPM_WORKSPACE), workspace) + .await + .unwrap(); + tokio::fs::write(fx.root().join("pnpm-lock.feature.yaml"), P1_BEFORE_LOCK) + .await + .unwrap(); + for dry_run in [true, false] { + let detail = + expect_refused(fx.vendor(dry_run).await, "vendor_pnpm_git_branch_lockfile"); + assert!( + detail.contains("pnpm-lock.feature.yaml") + && detail.contains("--merge-git-branch-lockfiles"), + "{detail}" + ); + } + let (planned, looped) = preflight_then_vendor(&fx).await; + assert_eq!(planned, Err("vendor_pnpm_git_branch_lockfile")); + assert_eq!(looped, Err("vendor_pnpm_git_branch_lockfile")); + assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK, "lock untouched"); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG, "pkg untouched"); + assert_eq!( + fx.read(PNPM_WORKSPACE).await, + workspace, + "workspace untouched" + ); + assert!(!fx.root().join(".socket/vendor").exists()); + + // The setting with no branch lock installs from pnpm-lock.yaml. + tokio::fs::remove_file(fx.root().join("pnpm-lock.feature.yaml")) + .await + .unwrap(); + assert!(matches!(fx.vendor(false).await, VendorOutcome::Done { .. })); + } + /// A CRLF pnpm-lock.yaml (Windows autocrlf checkout) passes the version /// sniff (`str::lines` strips `\r`) but breaks every structural probe — /// the refusal must name the line endings as the cause, not the @@ -5227,32 +5761,254 @@ snapshots: .exists()); } - /// A CRLF, BOM or BOM+CRLF+tab `package.json` (a Windows / autocrlf - /// checkout) keeps its layout: the vendored file differs from the - /// original only in `pnpm.overrides`, and the revert is byte-exact - /// (#662). - #[tokio::test] - async fn vendor_and_revert_keep_package_json_layout() { - use crate::vendor::test_support::{relayout, JSON_LAYOUTS}; - for (tag, bom, crlf, tab) in JSON_LAYOUTS { - let before = relayout(P1_BEFORE_PKG, bom, crlf, tab); - let fx = fixture_with(&before, P1_BEFORE_LOCK).await; - let (result, entry, _) = expect_done(fx.vendor(false).await); - assert!(result.success, "{tag}: {:?}", result.error); - assert_eq!( - fx.read(PACKAGE_JSON).await, - relayout(P1_AFTER_PKG, bom, crlf, tab), - "{tag}: vendored package.json keeps its layout" - ); - assert_eq!( - fx.read(PNPM_LOCK).await, - P1_AFTER_LOCK.replace(SPIKE_INTEGRITY, &fx.actual_integrity().await), - "{tag}: lock unaffected by the manifest layout" - ); + // ── two-document locks (#466) ────────────────────────────────────────── + // pnpm >= 11 writes `pnpm-lock.yaml` as two YAML documents when the + // project has config dependencies (`pnpm add --config`, pnpm 11 and 12) + // or pins pnpm through `packageManager` (pnpm 12): an env document with + // its own `importers:` / `packages:` / `snapshots:`, then the project + // lock. Provenance: captured from pnpm 12.8.1 (`packageManager: + // "pnpm@12.8.1"`; the 14 `@pnpm/exe.*` platform entries trimmed to one) + // and pnpm 11.27.0 (`pnpm add --config left-pad@1.3.0`). + const ENV_DOC_PACKAGE_MANAGER: &str = "lockfileVersion: '9.0' - let outcome = revert_pnpm(&entry.unwrap(), fx.root(), false).await; - assert!(outcome.success, "{tag}: {:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{tag}: {:?}", outcome.warnings); +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.8.1 + version: 12.8.1 + +packages: + + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} + cpu: [x64] + os: [linux] + libc: [glibc] + + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.linux-x64@12.8.1': + optional: true + + pnpm@12.8.1: + optionalDependencies: + '@pnpm/exe.linux-x64': 12.8.1 +"; + /// The vendored package is ALSO a config dependency: the env document + /// carries its registry key (the config dependency's own copy). + const ENV_DOC_CONFIG_DEP: &str = "lockfileVersion: '9.0' + +importers: + + .: + configDependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + +snapshots: + + left-pad@1.3.0: {} +"; + + /// pnpm's two-document layout: `---`, the env document, `---`, the + /// project lock. + fn two_doc(env: &str, project: &str) -> String { + format!("---\n{env}\n---\n{project}") + } + + /// #466: vendoring a two-document lock edits the PROJECT document only — + /// byte-for-byte the single-document oracle — and leaves the env + /// document untouched, even when a config dependency shares the + /// vendored package's registry key (the edits used to land in the env + /// document, so a frozen install failed). The in-sync re-run is stable + /// and the revert restores the two-document lock byte-for-byte. + #[tokio::test] + async fn two_document_lock_vendors_and_reverts_the_project_document() { + for (env, config_dep) in [(ENV_DOC_PACKAGE_MANAGER, false), (ENV_DOC_CONFIG_DEP, true)] { + let before = two_doc(env, P1_BEFORE_LOCK); + let fx = fixture_with(P1_BEFORE_PKG, &before).await; + let preflight = + preflight_packages(fx.root(), &[("pkg:npm/left-pad@1.3.0", &fx.record)]).await; + assert_eq!(preflight, vec![Ok(())], "config_dep={config_dep}"); + + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("success carries a ledger entry"); + let actual = fx.actual_integrity().await; + let after = two_doc(env, &P1_AFTER_LOCK.replace(SPIKE_INTEGRITY, &actual)); + assert_eq!(fx.read(PNPM_LOCK).await, after, "config_dep={config_dep}"); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_AFTER_PKG); + assert_eq!( + warnings + .iter() + .any(|w| w.code == VENDOR_CONFIG_DEPENDENCY_UNPATCHED), + config_dep, + "{warnings:?}" + ); + + // In sync: nothing rewritten. + let (result, entry2, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(entry2.is_none(), "an in-sync re-run records nothing"); + assert_eq!(fx.read(PNPM_LOCK).await, after); + + for dry_run in [true, false] { + let outcome = revert_pnpm(&entry, fx.root(), dry_run).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + } + assert_eq!(fx.read(PNPM_LOCK).await, before, "config_dep={config_dep}"); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + assert!(!fx.root().join(PNPM_WORKSPACE).exists()); + assert!(!fx.root().join(fx.rel_tgz()).exists()); + } + } + + /// #466: a project vendored on a single-document lock that pnpm later + /// re-wrote as two documents (`packageManager` added on pnpm 12) reverts + /// the project document byte-exactly. Every lock record used to read as + /// drifted, so the revert left the lock wired to the artifact while + /// still removing the package.json / workspace overrides. + #[tokio::test] + async fn revert_after_the_lock_became_two_documents_restores_the_project_document() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let vendored = fx.read(PNPM_LOCK).await; + tokio::fs::write( + fx.root().join(PNPM_LOCK), + two_doc(ENV_DOC_PACKAGE_MANAGER, &vendored), + ) + .await + .unwrap(); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + fx.read(PNPM_LOCK).await, + two_doc(ENV_DOC_PACKAGE_MANAGER, P1_BEFORE_LOCK) + ); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + assert!(!fx.root().join(fx.rel_tgz()).exists()); + } + + /// Multi-document locks the planner cannot classify — three documents, + /// a first document that is not pnpm's env document, a BOM before the + /// separator, an end marker — are refused before any write (dry run and + /// pre-flight included), and a revert over one fails, dry run included, + /// with every surface untouched: a guessed document would either edit + /// the wrong copy or half-revert the project. + #[tokio::test] + async fn unclassifiable_multi_document_lock_is_refused_and_not_half_reverted() { + let not_env = ENV_DOC_PACKAGE_MANAGER.replace("configDependencies: {}", "dependencies: {}"); + let unclassifiable = [ + format!( + "---\n{ENV_DOC_PACKAGE_MANAGER}\n---\n{ENV_DOC_CONFIG_DEP}\n---\n{}", + P1_BEFORE_LOCK + ), + two_doc(¬_env, P1_BEFORE_LOCK), + format!( + "\u{feff}{}", + two_doc(ENV_DOC_PACKAGE_MANAGER, P1_BEFORE_LOCK) + ), + format!("{}...\n", two_doc(ENV_DOC_PACKAGE_MANAGER, P1_BEFORE_LOCK)), + two_doc( + ENV_DOC_PACKAGE_MANAGER, + &P1_BEFORE_LOCK.replace("lockfileVersion: '9.0'", "lockfileVersion: '6.0'"), + ), + ]; + for lock in &unclassifiable { + let fx = fixture_with(P1_BEFORE_PKG, lock).await; + for dry_run in [true, false] { + let blobs = fx.root().join(".socket/blobs"); + let outcome = crate::vendor::test_support::vendor_pnpm( + "pkg:npm/left-pad@1.3.0", + &fx.installed(), + fx.root(), + &fx.record, + &PatchSources::blobs_only(&blobs), + "2026-06-09T00:00:00Z", + dry_run, + false, + None, + ) + .await; + expect_refused(outcome, VENDOR_PNPM_LOCK_MULTI_DOCUMENT); + } + let preflight = + preflight_packages(fx.root(), &[("pkg:npm/left-pad@1.3.0", &fx.record)]).await; + assert_eq!(preflight, vec![Err(VENDOR_PNPM_LOCK_MULTI_DOCUMENT)]); + assert_eq!(&fx.read(PNPM_LOCK).await, lock); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + assert!(!fx.root().join(".socket/vendor").exists()); + assert!(!fx.root().join(PNPM_WORKSPACE).exists()); + } + + // Revert: vendored on a single-document lock, then the lock became + // unclassifiable (three documents). + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let vendored = fx.read(PNPM_LOCK).await; + let lock = + format!("---\n{ENV_DOC_PACKAGE_MANAGER}\n---\n{ENV_DOC_CONFIG_DEP}\n---\n{vendored}"); + tokio::fs::write(fx.root().join(PNPM_LOCK), &lock) + .await + .unwrap(); + let ws = fx.read(PNPM_WORKSPACE).await; + for dry_run in [true, false] { + let outcome = revert_pnpm(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "dry_run={dry_run}: must fail closed"); + let error = outcome.error.unwrap_or_default(); + assert!(error.contains(PNPM_LOCK), "{error}"); + } + assert_eq!(fx.read(PNPM_LOCK).await, lock); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_AFTER_PKG); + assert_eq!(fx.read(PNPM_WORKSPACE).await, ws); + assert!(fx.root().join(fx.rel_tgz()).exists()); + } + + /// A CRLF, BOM or BOM+CRLF+tab `package.json` (a Windows / autocrlf + /// checkout) keeps its layout: the vendored file differs from the + /// original only in `pnpm.overrides`, and the revert is byte-exact + /// (#662). + #[tokio::test] + async fn vendor_and_revert_keep_package_json_layout() { + use crate::vendor::test_support::{relayout, JSON_LAYOUTS}; + for (tag, bom, crlf, tab) in JSON_LAYOUTS { + let before = relayout(P1_BEFORE_PKG, bom, crlf, tab); + let fx = fixture_with(&before, P1_BEFORE_LOCK).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{tag}: {:?}", result.error); + assert_eq!( + fx.read(PACKAGE_JSON).await, + relayout(P1_AFTER_PKG, bom, crlf, tab), + "{tag}: vendored package.json keeps its layout" + ); + assert_eq!( + fx.read(PNPM_LOCK).await, + P1_AFTER_LOCK.replace(SPIKE_INTEGRITY, &fx.actual_integrity().await), + "{tag}: lock unaffected by the manifest layout" + ); + + let outcome = revert_pnpm(&entry.unwrap(), fx.root(), false).await; + assert!(outcome.success, "{tag}: {:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{tag}: {:?}", outcome.warnings); assert_eq!( fx.read(PACKAGE_JSON).await, before, @@ -5691,6 +6447,123 @@ snapshots: ); } + /// #734: a project with no workspace file whose pnpm is 9.0–10.4 (the + /// installed `.modules.yaml`, or package.json's pin) gets none: it would + /// make the project a root-only workspace those releases refuse `pnpm + /// add` in, and they read package.json `pnpm.overrides`, which is wired + /// with the lock. Revert restores both byte-for-byte. A later vendor on + /// pnpm >= 10.5 adds the mirror, and revert then undoes all three. + #[tokio::test] + async fn project_pinned_to_pnpm_9_through_10_4_gets_no_workspace_scaffold() { + let modules = |version: &str| { + format!("hoistPattern:\n - '*'\nlayoutVersion: 5\npackageManager: pnpm@{version}\n") + }; + let pinned_pkg = + P1_BEFORE_PKG.replacen("{\n", "{\n \"packageManager\": \"pnpm@10.4.1\",\n", 1); + assert_ne!(pinned_pkg, P1_BEFORE_PKG); + for (pkg, modules_yaml) in [ + (P1_BEFORE_PKG.to_string(), Some(modules("9.15.9"))), + (pinned_pkg.clone(), None), + ] { + let fx = fixture_with(&pkg, P1_BEFORE_LOCK).await; + if let Some(text) = &modules_yaml { + tokio::fs::write(fx.root().join("node_modules/.modules.yaml"), text) + .await + .unwrap(); + } + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + let spec = format!("file:{}", fx.rel_tgz()); + assert!(!ws_exists(&fx).await, "no root-only workspace for {pkg}"); + assert!(fx + .read(PACKAGE_JSON) + .await + .contains(&format!("\"left-pad@1.3.0\": \"{spec}\""))); + assert!(fx + .read(PNPM_LOCK) + .await + .contains(&format!("overrides:\n left-pad@1.3.0: {spec}"))); + assert!(entry.wiring.iter().all(|r| r.file != PNPM_WORKSPACE)); + assert!(!entry.pnpm.as_ref().unwrap().created_workspace_file); + + // A re-run is in sync: nothing is written, still no workspace. + let (rerun, rerun_entry, _) = expect_done(fx.vendor(false).await); + assert!(rerun.success && rerun_entry.is_none()); + assert!(!ws_exists(&fx).await); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(fx.read(PACKAGE_JSON).await, pkg); + assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK); + } + + // The upgrade: vendored on pnpm 9, then installed with pnpm 11. + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let modules_path = fx.root().join("node_modules/.modules.yaml"); + tokio::fs::write(&modules_path, modules("9.15.9")) + .await + .unwrap(); + let (_, prev, _) = expect_done(fx.vendor(false).await); + let prev = prev.unwrap(); + assert!(!ws_exists(&fx).await); + tokio::fs::write(&modules_path, modules("11.0.0")) + .await + .unwrap(); + let (_, revendored, _) = expect_done(fx.vendor(false).await); + let mut merged = revendored.unwrap(); + let spec = format!("file:{}", fx.rel_tgz()); + assert_eq!( + fx.read(PNPM_WORKSPACE).await, + ws_scaffold_text("left-pad@1.3.0", &spec), + "pnpm 11 reads overrides from the workspace file: the mirror is added" + ); + super::super::state::carry_forward_wiring(&prev, &mut merged); + let outcome = revert_pnpm(&merged, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK); + assert!(!ws_exists(&fx).await); + } + + /// The #734 gate stays closed on pnpm >= 10.5, and on any pin that does + /// not agree: the root-only scaffold is created as before. + #[tokio::test] + async fn pnpm_10_5_or_a_disagreeing_pin_still_gets_the_workspace_scaffold() { + for (pkg_pin, modules_pin) in [ + (None, Some("10.5.0")), + (Some("11.1.0"), Some("9.15.9")), + (Some("9.15.9"), Some("11.0.0")), + ] { + let pkg = match pkg_pin { + Some(v) => P1_BEFORE_PKG.replacen( + "{\n", + &format!("{{\n \"packageManager\": \"pnpm@{v}\",\n"), + 1, + ), + None => P1_BEFORE_PKG.to_string(), + }; + let fx = fixture_with(&pkg, P1_BEFORE_LOCK).await; + if let Some(v) = modules_pin { + tokio::fs::write( + fx.root().join("node_modules/.modules.yaml"), + format!("{{\"packageManager\":\"pnpm@{v}\"}}"), + ) + .await + .unwrap(); + } + let (_, entry, _) = expect_done(fx.vendor(false).await); + let spec = format!("file:{}", fx.rel_tgz()); + assert_eq!( + fx.read(PNPM_WORKSPACE).await, + ws_scaffold_text("left-pad@1.3.0", &spec), + "{pkg_pin:?} / {modules_pin:?}" + ); + assert!(entry.unwrap().pnpm.unwrap().created_workspace_file); + } + } + /// Existing workspace file WITHOUT an `overrides:` section: vendor /// appends one, leaving the `packages:` list untouched; revert restores /// the file byte-for-byte. @@ -6499,6 +7372,291 @@ snapshots: ); } + // ── #854: an exact pin carried ONLY by pnpm-workspace.yaml ──────────── + + /// [`P1_BEFORE_LOCK`] with an `overrides:` section holding `key: value` + /// (what pnpm 10.5+ writes when the override lives in the workspace file). + fn p1_lock_with_override(key: &str, value: &str) -> String { + P1_BEFORE_LOCK.replacen( + "\nimporters:\n", + &format!("\noverrides:\n {key}: {value}\n\nimporters:\n"), + 1, + ) + } + fn ws_with_override(key: &str, value: &str) -> String { + format!("packages:\n - '.'\noverrides:\n {key}: {value}\n") + } + + /// #854: on pnpm 10.5+/11/12 the workspace file is the authoritative + /// override map, so a bare-key exact pin there is taken over exactly + /// like the same pin in package.json: the user's key keeps its + /// spelling in the workspace file and the lock, package.json is left + /// alone (#360), a re-run is in sync, and revert restores every byte. + /// It used to refuse with a detail blaming package.json. + #[tokio::test] + async fn ws_bare_key_exact_pin_is_taken_over_and_revert_restores_it() { + let lock_before = p1_lock_with_override("left-pad", "1.3.0"); + let ws_before = ws_with_override("left-pad", "1.3.0"); + let fx = fixture_with(P1_BEFORE_PKG, &lock_before).await; + write_ws(&fx, &ws_before).await; + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + let spec = format!("file:{}", fx.rel_tgz()); + + let ws_after = fx.read(PNPM_WORKSPACE).await; + assert_eq!(ws_after, ws_with_override("left-pad", &spec)); + let lock_after = fx.read(PNPM_LOCK).await; + assert!( + lock_after.contains(&format!("overrides:\n left-pad: {spec}\n\n")), + "{lock_after}" + ); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + assert!(entry.wiring.iter().all(|r| r.kind != KIND_PKG_OVERRIDE)); + for kind in [KIND_WS_OVERRIDE, KIND_LOCK_OVERRIDES] { + let rec = entry + .wiring + .iter() + .find(|r| r.kind == kind) + .unwrap_or_else(|| panic!("no {kind} record: {:?}", entry.wiring)); + assert_eq!(rec.key.as_deref(), Some("left-pad"), "{kind}"); + assert_eq!(rec.action, WiringAction::Rewritten, "{kind}"); + assert_eq!(rec.original, Some(Value::String("1.3.0".into())), "{kind}"); + } + + // Re-run: in sync, nothing recorded, all three files byte-stable + // (no package.json copy appears once the ws value is ours). + let (result, again, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(again.is_none(), "in-sync re-run records nothing"); + assert!(result + .files_verified + .iter() + .all(|v| v.status == crate::patch::apply::VerifyStatus::AlreadyPatched)); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + assert_eq!(fx.read(PNPM_WORKSPACE).await, ws_after); + assert_eq!(fx.read(PNPM_LOCK).await, lock_after); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + assert_eq!(fx.read(PNPM_WORKSPACE).await, ws_before); + assert_eq!(fx.read(PNPM_LOCK).await, lock_before); + } + + /// #854: a hand-written workspace pin that is quoted or carries a + /// trailing comment is the same exact pin as the plain value its lock + /// mirror records (`left-pad: 1.3.0`), so it is taken over too. The raw + /// text is the recorded original, so revert restores the quotes and the + /// comment byte-for-byte. It used to refuse, with a detail claiming an + /// exact pin equal to 1.3.0 is taken over automatically. + #[tokio::test] + async fn ws_quoted_or_commented_exact_pin_is_taken_over_and_reverted_verbatim() { + let lock_before = p1_lock_with_override("left-pad", "1.3.0"); + for value in [ + "'1.3.0'", + "\"1.3.0\"", + "1.3.0 # CVE-2099-0001", + "'1.3.0' # pinned", + ] { + let ws_before = ws_with_override("left-pad", value); + let fx = fixture_with(P1_BEFORE_PKG, &lock_before).await; + write_ws(&fx, &ws_before).await; + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{value}: {:?}", result.error); + let entry = entry.unwrap(); + let spec = format!("file:{}", fx.rel_tgz()); + assert_eq!( + fx.read(PNPM_WORKSPACE).await, + ws_with_override("left-pad", &spec), + "{value}" + ); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG, "{value}"); + let rec = entry + .wiring + .iter() + .find(|r| r.kind == KIND_WS_OVERRIDE) + .unwrap(); + assert_eq!(rec.original, Some(Value::String(value.into())), "{value}"); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{value}: {:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "{value}: {:?}", + outcome.warnings + ); + assert_eq!(fx.read(PNPM_WORKSPACE).await, ws_before, "{value}"); + assert_eq!(fx.read(PNPM_LOCK).await, lock_before, "{value}"); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG, "{value}"); + } + } + + /// The normalization reads a value, not a version: a quoted or + /// commented range / other version still refuses. + #[tokio::test] + async fn ws_quoted_or_commented_non_matching_pin_still_refuses() { + for value in ["'^1.3.0'", "1.4.0 # 1.3.0", "'1.3.0 # x'"] { + let lock = p1_lock_with_override("left-pad", "1.3.0"); + let ws = ws_with_override("left-pad", value); + let fx = fixture_with(P1_BEFORE_PKG, &lock).await; + write_ws(&fx, &ws).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_override_conflict"); + assert!(detail.contains(PNPM_WORKSPACE), "{value}: {detail}"); + assert_eq!(fx.read(PNPM_WORKSPACE).await, ws, "refusal writes nothing"); + assert_eq!(fx.read(PNPM_LOCK).await, lock); + } + } + + /// #854, pnpm 9 shape: the lock does not record the workspace pin + /// (pnpm 9 reads overrides from package.json only). The pin is still + /// taken over in the workspace file, and the package.json copy plus the + /// lock entry use the same `left-pad` key so all three maps agree. + #[tokio::test] + async fn ws_bare_key_exact_pin_without_lock_mirror_is_taken_over() { + let ws_before = ws_with_override("left-pad", "1.3.0"); + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + write_ws(&fx, &ws_before).await; + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + let spec = format!("file:{}", fx.rel_tgz()); + assert_eq!( + fx.read(PNPM_WORKSPACE).await, + ws_with_override("left-pad", &spec) + ); + let pkg: Value = serde_json::from_str(&fx.read(PACKAGE_JSON).await).unwrap(); + assert_eq!( + pkg["pnpm"]["overrides"]["left-pad"], + Value::String(spec.clone()) + ); + assert!(pkg["pnpm"]["overrides"].get("left-pad@1.3.0").is_none()); + assert!(fx + .read(PNPM_LOCK) + .await + .contains(&format!("overrides:\n left-pad: {spec}\n"))); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + assert_eq!(fx.read(PNPM_WORKSPACE).await, ws_before); + assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK); + } + + /// #854: a genuine workspace-file conflict (a range, another version, a + /// selector chain) still refuses, and the detail names the file that + /// actually carries the override. + #[tokio::test] + async fn ws_conflicting_pins_refuse_naming_the_workspace_file() { + for (key, value) in [ + ("left-pad", "^1.3.0"), + ("left-pad", "1.4.0"), + ("consumer>left-pad", "1.3.0"), + ] { + let lock = p1_lock_with_override(key, value); + let ws = ws_with_override(key, value); + let fx = fixture_with(P1_BEFORE_PKG, &lock).await; + write_ws(&fx, &ws).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_override_conflict"); + assert!(detail.contains(PNPM_WORKSPACE), "{key}: {detail}"); + assert!(!detail.contains("package.json"), "{key}: {detail}"); + assert_eq!(fx.read(PNPM_WORKSPACE).await, ws, "refusal writes nothing"); + assert_eq!(fx.read(PNPM_LOCK).await, lock); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + } + } + + /// #854: ws↔lock key-shape drift refuses (pnpm itself would fail the + /// config check), and the detail compares against the workspace file, + /// not package.json. + #[tokio::test] + async fn ws_and_lock_key_shape_drift_refuses_naming_the_workspace_file() { + let lock = p1_lock_with_override("left-pad@1.3.0", "1.3.0"); + let fx = fixture_with(P1_BEFORE_PKG, &lock).await; + write_ws(&fx, &ws_with_override("left-pad", "1.3.0")).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_override_conflict"); + assert!(detail.contains("does not match"), "{detail}"); + assert!(detail.contains(PNPM_WORKSPACE), "{detail}"); + assert!(!detail.contains("package.json's"), "{detail}"); + assert_eq!(fx.read(PNPM_LOCK).await, lock, "refusal writes nothing"); + } + + /// #854: package.json and the workspace file both pin the version but + /// under different keys; there is no single key all three maps can + /// share, so vendoring refuses naming both files. + #[tokio::test] + async fn pkg_and_ws_pins_with_different_key_shapes_refuse() { + let (pkg, lock) = pin_fixture_inputs("left-pad", "1.3.0"); + let ws = ws_with_override("left-pad@1.3.0", "1.3.0"); + let fx = fixture_with(&pkg, &lock).await; + write_ws(&fx, &ws).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_override_conflict"); + assert!(detail.contains(PNPM_WORKSPACE), "{detail}"); + assert!(detail.contains("package.json"), "{detail}"); + assert_eq!(fx.read(PNPM_WORKSPACE).await, ws, "refusal writes nothing"); + assert_eq!(fx.read(PACKAGE_JSON).await, pkg); + assert_eq!(fx.read(PNPM_LOCK).await, lock); + } + + /// #854: the versioned-key pin on the workspace file and the lock only + /// (package.json has no overrides) was already taken over, but its + /// re-run added a package.json copy once the ws value became ours. + /// The re-run is now in sync and byte-stable. + #[tokio::test] + async fn ws_versioned_key_pin_only_on_ws_and_lock_rerun_is_byte_stable() { + let lock_before = p1_lock_with_override("left-pad@1.3.0", "1.3.0"); + let ws_before = ws_with_override("left-pad@1.3.0", "1.3.0"); + let fx = fixture_with(P1_BEFORE_PKG, &lock_before).await; + write_ws(&fx, &ws_before).await; + + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + let ws_after = fx.read(PNPM_WORKSPACE).await; + let lock_after = fx.read(PNPM_LOCK).await; + + let (result, again, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(again.is_none(), "in-sync re-run records nothing"); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + assert_eq!(fx.read(PNPM_WORKSPACE).await, ws_after); + assert_eq!(fx.read(PNPM_LOCK).await, lock_after); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG); + assert_eq!(fx.read(PNPM_WORKSPACE).await, ws_before); + assert_eq!(fx.read(PNPM_LOCK).await, lock_before); + } + + /// #854 / #360 twin: after a workspace-only takeover every workspace + /// override can be a vendored value. The lock still records them, so + /// the workspace file still governs, and vendoring ANOTHER package + /// must not write a package.json copy that would shadow it on pnpm 10. + #[test] + fn vendored_workspace_overrides_recorded_in_the_lock_still_govern() { + let pkg: Value = serde_json::from_str(P1_BEFORE_PKG).unwrap(); + let spec = + "file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; + let ws = ws_with_override("left-pad", spec); + let lock = p1_lock_with_override("left-pad", spec); + assert!(workspace_overrides_govern( + &pkg, + Some(&ws), + &split_lines(&lock) + )); + // Unrecorded in the lock (pnpm 9): package.json still governs. + assert!(!workspace_overrides_govern( + &pkg, + Some(&ws), + &split_lines(P1_BEFORE_LOCK) + )); + } + /// pnpm-workspace.yaml deleted since vendoring while the record carries a /// takeover original: the pin cannot be given back — warn drifted (the /// keep gate holds), never silently converge. The other surfaces still @@ -9097,6 +10255,7 @@ snapshots: lock: ProjectLock::V9(LockLines::Shared(Arc::new(LockDoc::new(split_lines( P1_BEFORE_LOCK, ))))), + lock_prefix: String::new(), ws_text: None, }; let doc_of = |p: &PnpmProject| match &p.lock { @@ -9527,4 +10686,293 @@ snapshots: revert_in_order(&fx, [IS_NUMBER, LEFT_PAD]).await; assert_eq!(fx.read(PACKAGE_JSON).await, pkg); } + + // ── a vendored parent and its vendored dependency (#830) ───────────── + + const PC_PKG: &str = r#"{ + "name": "fx", + "version": "1.0.0", + "private": true, + "dependencies": { + "debug": "4.3.4" + } +} +"#; + + const PC_LOCK: &str = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + debug: + specifier: 4.3.4 + version: 4.3.4 + +packages: + + debug@4.3.4: + resolution: {integrity: sha512-3NN8vD3qzN8YtsF8Mxz4wHinpTcRP71BdOdGhzQk7dVDwXhUjS7O9BXaHGhn7m7Y1hB+L4szF+XwoAhBc2upBw==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + unrelated@1.0.0: + resolution: {integrity: sha512-unrelated==} + +snapshots: + + debug@4.3.4: + dependencies: + ms: 2.1.2 + + ms@2.1.2: {} + + unrelated@1.0.0: + dependencies: + ms: 2.1.2 +"; + + const PC_DEBUG: &str = "pkg:npm/debug@4.3.4"; + const PC_MS: &str = "pkg:npm/ms@2.1.2"; + const PC_DEBUG_UUID: &str = "55555555-5555-4555-8555-555555555555"; + const PC_MS_UUID: &str = "66666666-6666-4666-8666-666666666666"; + + fn pc_parts(purl: &str) -> (&'static str, &'static str, &'static str) { + match purl { + PC_DEBUG => ("debug", "4.3.4", PC_DEBUG_UUID), + PC_MS => ("ms", "2.1.2", PC_MS_UUID), + other => panic!("unknown purl {other}"), + } + } + + /// Vendor `purl` into `fx` (its own installed dir + patch uuid), + /// persisting the ledger the way the vendor loop does. + async fn pc_vendor(fx: &Fixture, purl: &str) { + let (name, version, uuid) = pc_parts(purl); + let installed = fx.root().join("node_modules").join(name); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write( + installed.join("package.json"), + format!(r#"{{"name":"{name}","version":"{version}"}}"#), + ) + .await + .unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX) + .await + .unwrap(); + let mut record = fx.record.clone(); + record.uuid = uuid.to_string(); + let blobs = fx.root().join(".socket/blobs"); + let outcome = crate::vendor::test_support::vendor_pnpm( + purl, + &installed, + fx.root(), + &record, + &PatchSources::blobs_only(&blobs), + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + let (result, entry, _) = expect_done(outcome); + assert!(result.success, "{purl}: {:?}", result.error); + let mut state = crate::vendor::state::load_state(fx.root()).await.unwrap(); + state.entries.insert(purl.to_string(), entry.unwrap()); + crate::vendor::state::save_state(fx.root(), &state) + .await + .unwrap(); + } + + /// Every snapshot dependency ref names an existing snapshots key — the + /// `ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY` check a frozen install runs. + fn assert_snapshot_refs_resolve(lock: &str) { + let lines = split_lines(lock); + let (start, end) = section_bounds(&lines, "snapshots").unwrap(); + let mut keys = std::collections::HashSet::new(); + let mut refs = Vec::new(); + let mut i = start + 1; + while let Some(block) = next_block(&lines, i, end) { + keys.insert(block.key.clone()); + for line in &lines[block.header + 1..block.end] { + if let Some((dep, _, rest)) = parse_key_line(line, 6) { + refs.push(format!("{dep}@{rest}")); + } + } + i = block.end; + } + for r in refs { + assert!(keys.contains(&r), "dangling snapshot ref `{r}`:\n{lock}"); + } + } + + /// Vendor both packages in `vendor_order`, then unwind them one at a + /// time in `revert_order`: every step must leave a lock pnpm accepts, + /// warn nothing, and keep nothing; a re-run of the first revert is a + /// silent no-op; the end state is byte-identical to the pre-vendor one. + async fn pc_round_trip(vendor_order: [&str; 2], revert_order: [&str; 2]) { + let fx = fixture_with(PC_PKG, PC_LOCK).await; + for purl in vendor_order { + pc_vendor(&fx, purl).await; + } + for (step, purl) in revert_order.into_iter().enumerate() { + let mut state = crate::vendor::state::load_state(fx.root()).await.unwrap(); + let entry = state.entries.get(purl).cloned().unwrap(); + let outcome = revert_pnpm(&entry, fx.root(), false).await; + let label = format!("vendor {vendor_order:?}, revert {purl}"); + assert!(outcome.success, "{label}: {:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "{label}: {:?}", + outcome.warnings + ); + assert!(!outcome.kept_artifact, "{label}: artifact kept"); + state.entries.remove(purl); + crate::vendor::state::save_state(fx.root(), &state) + .await + .unwrap(); + let lock = fx.read(PNPM_LOCK).await; + assert_snapshot_refs_resolve(&lock); + if step == 0 { + // The other package is still vendored, refs included. + let (name, version, uuid) = pc_parts(revert_order[1]); + let spec = format!("file:.socket/vendor/npm/{uuid}/{name}-{version}.tgz"); + assert!( + lock.contains(&format!(" {name}@{spec}:")), + "{label}:\n{lock}" + ); + if name == "ms" { + assert!( + lock.contains(&format!(" ms: {spec}")), + "{label}:\n{lock}" + ); + } + // A re-run of the same revert converges silently. + let again = revert_pnpm(&entry, fx.root(), false).await; + assert!(again.success, "{label} re-run: {:?}", again.error); + assert!( + again.warnings.is_empty(), + "{label} re-run: {:?}", + again.warnings + ); + assert_eq!(fx.read(PNPM_LOCK).await, lock, "{label}: re-run is a no-op"); + } + } + assert_eq!(fx.read(PNPM_LOCK).await, PC_LOCK, "lock byte-restored"); + assert_eq!( + fx.read(PACKAGE_JSON).await, + PC_PKG, + "package.json byte-restored" + ); + for uuid in [PC_DEBUG_UUID, PC_MS_UUID] { + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{uuid}")) + .exists()); + } + } + + /// #830: unwinding the parent alone (`remove debug`) must keep the + /// vendored child's live ref in the parent's restored snapshot; the + /// child's ref record (keyed by the parent's now-gone `file:` key) must + /// still find and restore it afterwards. + #[tokio::test] + async fn parent_first_revert_keeps_the_vendored_child_ref() { + pc_round_trip([PC_DEBUG, PC_MS], [PC_DEBUG, PC_MS]).await; + } + + /// #830, reverse twin: the child vendored first (its ref record keyed by + /// the parent's registry key), then the parent; unwinding the child + /// alone must still restore the ref inside the parent's `file:` block. + #[tokio::test] + async fn child_first_vendored_child_revert_restores_the_ref_in_the_rekeyed_parent() { + pc_round_trip([PC_MS, PC_DEBUG], [PC_MS, PC_DEBUG]).await; + } + + /// Guards: the two orders that already round-tripped stay clean. + #[tokio::test] + async fn parent_child_guard_orders_round_trip() { + pc_round_trip([PC_DEBUG, PC_MS], [PC_MS, PC_DEBUG]).await; + pc_round_trip([PC_MS, PC_DEBUG], [PC_DEBUG, PC_MS]).await; + } + + #[test] + fn merge_live_dep_refs_keeps_only_refs_another_entry_moved() { + let block = |key: &str, ms: &str, peer: &str| -> Vec { + [ + format!(" {key}:"), + " peerDependencies:".to_string(), + format!(" supports-color: '{peer}'"), + " dependencies:".to_string(), + format!(" ms: {ms}"), + " '@s/x': 1.0.0".to_string(), + ] + .to_vec() + }; + let ms = "file:.socket/vendor/npm/66666666-6666-4666-8666-666666666666/ms-2.1.2.tgz"; + let original = block("debug@4.3.4", "2.1.2", "*"); + let new = block("debug@file:x", "2.1.2", "*"); + // live == new: the original verbatim. + assert_eq!(merge_live_dep_refs(&original, &new, &new), original); + // A dep ref another entry moved survives; a peer RANGE edit does + // not (not a resolution ref), nor does the header. + let live = block("debug@file:x", ms, ">=1"); + let merged = merge_live_dep_refs(&original, &new, &live); + assert_eq!(merged, block("debug@4.3.4", ms, "*")); + assert!(same_modulo_dep_refs(&merged, &original)); + assert!(!same_modulo_dep_refs( + &block("debug@4.3.4", ms, ">=1"), + &original + )); + assert!(!same_modulo_dep_refs( + &block("debug@4.3.5", "2.1.2", "*"), + &original + )); + } + + #[test] + fn same_package_key_pairs_a_registry_key_with_its_vendored_rekey() { + let same_package_key = |a: &str, b: &str| { + let leaves = key_package_leaves(a); + key_package_leaves(b).iter().any(|l| leaves.contains(l)) + }; + let d = "file:.socket/vendor/npm/55555555-5555-4555-8555-555555555555/debug-4.3.4.tgz"; + let s = "file:.socket/vendor/npm/55555555-5555-4555-8555-555555555555/@s/n-1.0.0.tgz"; + for (a, b) in [ + ("debug@4.3.4".to_string(), format!("debug@{d}")), + ("/debug/4.3.4".to_string(), d.to_string()), + ("/debug@4.3.4".to_string(), d.to_string()), + ("@s/n@1.0.0".to_string(), format!("@s/n@{s}")), + ("/@s/n/1.0.0".to_string(), s.to_string()), + ("/@s/n@1.0.0".to_string(), s.to_string()), + ] { + assert!(same_package_key(&a, &b), "{a} ~ {b}"); + assert!(same_package_key(&b, &a), "{b} ~ {a}"); + } + for (a, b) in [ + ("debug@4.3.5".to_string(), format!("debug@{d}")), + ("unrelated@1.0.0".to_string(), format!("debug@{d}")), + ( + "debug@4.3.4(supports-color@8.1.1)".to_string(), + format!("debug@{d}"), + ), + ( + "consumer@file:consumer".to_string(), + "debug@4.3.4".to_string(), + ), + ] { + assert!(!same_package_key(&a, &b), "{a} !~ {b}"); + } + } } diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index 660c980d2..c9e00e60a 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -64,8 +64,9 @@ use crate::utils::fs::read_regular_to_string; use super::common::refused; use super::path::parse_vendor_path; use super::pnpm_lock::{ - dep_field_lines, drifted, lines_value, removed, revert_overrides_line, value_lines, - vendor_value_is_for, EditCtx, PnpmDialect, KIND_LOCK_OVERRIDES, KIND_LOCK_PACKAGE, + dep_field_lines, drifted, lines_value, merge_live_dep_refs, ref_record_blocks, removed, + revert_overrides_line, same_modulo_dep_refs, value_lines, vendor_value_is_for, DepRef, EditCtx, + PnpmDialect, KIND_LOCK_OVERRIDES, KIND_LOCK_PACKAGE, }; use super::source::PackageSource; use super::state::{VendorEntry, WiringAction, WiringRecord}; @@ -1098,7 +1099,8 @@ fn revert_root_dep_pair( /// verify ownership, then reinsert the ORIGINAL block at its byte-sorted /// position (the rekey moved it across the `/` vs `file:` sort boundary, so /// an in-place splice would restore it out of order and break byte-identity -/// with the pre-vendor lock). +/// with the pre-vendor lock) — merged with any dependency ref ANOTHER entry +/// rewrote inside the block since (#830, [`merge_live_dep_refs`]). fn revert_package_block( lines: &mut Vec, rec: &WiringRecord, @@ -1153,7 +1155,8 @@ fn revert_package_block( ))); return; }; - if swap_block_sorted(lines, "packages", new_key, orig_key, &original).is_err() { + let restored = merge_live_dep_refs(&original, &new_lines, &live); + if swap_block_sorted(lines, "packages", new_key, orig_key, &restored).is_err() { warnings.push(drifted(format!( "packages entry `{new_key}` vanished mid-restore; left alone" ))); @@ -1165,12 +1168,13 @@ fn revert_package_block( // ALREADY CONVERGED: an earlier partial revert restored this record — // the restore rekeys the block back to its pre-vendor key, so the // recorded `file:` key no longer matches while the original block is - // live verbatim. Not drift: stay silent so the drift-skip keep gate can - // converge instead of keeping the artifacts forever. + // live (up to the dependency refs the merge kept). Not drift: stay + // silent so the drift-skip keep gate can converge instead of keeping + // the artifacts forever. if let Some(orig) = rec.original.as_ref().and_then(value_lines) { let mut j = start + 1; while let Some(block) = next_block(lines, j, end) { - if lines[block.header..block.end] == orig[..] { + if same_modulo_dep_refs(&lines[block.header..block.end], &orig) { return; } j = block.end; @@ -1181,6 +1185,11 @@ fn revert_package_block( ))); } +/// Restore one dependent packages block's dep ref to this entry. Keyed by +/// the dependent's packages key at vendor time, which a later vendor or +/// revert of the dependent itself rekeys (`/debug/4.3.4` ⇄ `file:…`): when +/// that key is gone the ref is found under the same package's other key +/// (#830, [`ref_record_blocks`]). fn revert_pkg_dep_ref( lines: &mut [String], rec: &WiringRecord, @@ -1201,53 +1210,18 @@ fn revert_pkg_dep_ref( )); return; }; - let mut i = start + 1; - while let Some(block) = next_block(lines, i, end) { - if block.key != pkg_key { - i = block.end; - continue; - } - let mut in_dep_map = false; - for line in lines[block.header + 1..block.end].iter_mut() { - if let Some((field, _repr, rest)) = parse_key_line(line, 4) { - in_dep_map = - rest.is_empty() && matches!(field, "dependencies" | "optionalDependencies"); - continue; - } - if !in_dep_map { - continue; - } - let Some((d, _repr, rest)) = parse_key_line(line, 6) else { - continue; - }; - if d != dep { - continue; - } - // ALREADY CONVERGED: the live ref already equals the recorded - // pre-vendor original — an earlier partial revert (or the - // user, by hand) already restored it. Not drift. - if rec.original.as_ref().and_then(Value::as_str) == Some(rest) { - return; - } - let ours = Some(rest) == rec.new.as_ref().and_then(Value::as_str) - || parse_vendor_path(rest).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); - if !ours { - warnings.push(drifted(format!( - "dep ref `{key}` was re-resolved since vendoring ({rest}); left alone" - ))); - return; - } - let Some(original) = rec.original.as_ref().and_then(Value::as_str) else { - warnings.push(drifted(format!( - "dep ref `{key}` has no recorded pre-vendor original; left as-is" - ))); - return; - }; - *line = format!(" {}: {original}", yaml_key(dep)); - *dirty = true; + let target = DepRef { + rec, + key, + dep, + entry_uuid, + label: "dep ref", + dep_maps_only: true, + }; + for (header, block_end) in ref_record_blocks(lines, start, end, pkg_key) { + if target.restore_in(lines, header, block_end, dirty, warnings) { return; } - break; } warnings.push(removed(format!( "dep ref `{key}` no longer exists; nothing to restore" @@ -4915,4 +4889,250 @@ importers: ); } } + + // ── a vendored parent and its vendored dependency (#830) ───────────── + + const PC_PKG: &str = r#"{ + "name": "fx", + "version": "1.0.0", + "private": true, + "dependencies": { + "debug": "4.3.4" + } +} +"#; + + const PC7_LOCK: &str = "lockfileVersion: 5.4 + +specifiers: + debug: 4.3.4 + +dependencies: + debug: 4.3.4 + +packages: + + /debug/4.3.4: + resolution: {integrity: sha512-3NN8vD3qzN8YtsF8Mxz4wHinpTcRP71BdOdGhzQk7dVDwXhUjS7O9BXaHGhn7m7Y1hB+L4szF+XwoAhBc2upBw==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: false + + /ms/2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: false + + /unrelated/1.0.0: + resolution: {integrity: sha512-unrelated==} + dependencies: + ms: 2.1.2 + dev: false +"; + + const PC8_LOCK: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + debug: + specifier: 4.3.4 + version: 4.3.4 + +packages: + + /debug@4.3.4: + resolution: {integrity: sha512-3NN8vD3qzN8YtsF8Mxz4wHinpTcRP71BdOdGhzQk7dVDwXhUjS7O9BXaHGhn7m7Y1hB+L4szF+XwoAhBc2upBw==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: false + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: false + + /unrelated@1.0.0: + resolution: {integrity: sha512-unrelated==} + dependencies: + ms: 2.1.2 + dev: false +"; + + const PC_DEBUG: &str = "pkg:npm/debug@4.3.4"; + const PC_MS: &str = "pkg:npm/ms@2.1.2"; + const PC_DEBUG_UUID: &str = "55555555-5555-4555-8555-555555555555"; + const PC_MS_UUID: &str = "66666666-6666-4666-8666-666666666666"; + + fn pc_parts(purl: &str) -> (&'static str, &'static str, &'static str) { + match purl { + PC_DEBUG => ("debug", "4.3.4", PC_DEBUG_UUID), + PC_MS => ("ms", "2.1.2", PC_MS_UUID), + other => panic!("unknown purl {other}"), + } + } + + async fn pc_vendor(fx: &Fixture, purl: &str) { + let (name, version, uuid) = pc_parts(purl); + let installed = fx.root().join("node_modules").join(name); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write( + installed.join("package.json"), + format!(r#"{{"name":"{name}","version":"{version}"}}"#), + ) + .await + .unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX) + .await + .unwrap(); + let mut record = fx.record.clone(); + record.uuid = uuid.to_string(); + let blobs = fx.root().join(".socket/blobs"); + let outcome = crate::vendor::test_support::vendor_pnpm_legacy( + purl, + &installed, + fx.root(), + &record, + &PatchSources::blobs_only(&blobs), + "2026-08-18T00:00:00Z", + false, + false, + None, + ) + .await; + let (result, entry, _) = expect_done(outcome); + assert!(result.success, "{purl}: {:?}", result.error); + let mut state = crate::vendor::state::load_state(fx.root()).await.unwrap(); + state.entries.insert(purl.to_string(), entry.unwrap()); + crate::vendor::state::save_state(fx.root(), &state) + .await + .unwrap(); + } + + /// Every packages dep ref names an existing packages key (the dep path + /// a frozen install resolves: `/name/ver`, `/name@ver` or the bare + /// `file:` key). + fn assert_pkg_refs_resolve(lock: &str, v54: bool) { + let lines = split_lines(lock); + let (start, end) = section_bounds(&lines, "packages").unwrap(); + let mut keys = std::collections::HashSet::new(); + let mut refs = Vec::new(); + let mut i = start + 1; + while let Some(block) = next_block(&lines, i, end) { + keys.insert(block.key.clone()); + let mut in_dep_map = false; + for line in &lines[block.header + 1..block.end] { + if let Some((field, _, rest)) = parse_key_line(line, 4) { + in_dep_map = rest.is_empty() && field == "dependencies"; + continue; + } + if let Some((dep, _, rest)) = parse_key_line(line, 6).filter(|_| in_dep_map) { + refs.push(if rest.starts_with("file:") { + rest.to_string() + } else if v54 { + format!("/{dep}/{rest}") + } else { + format!("/{dep}@{rest}") + }); + } + } + i = block.end; + } + for r in refs { + assert!(keys.contains(&r), "dangling dep ref `{r}`:\n{lock}"); + } + } + + /// The v9 `pc_round_trip` for the legacy grammars (#830). + async fn pc_round_trip(lock: &str, vendor_order: [&str; 2], revert_order: [&str; 2]) { + let v54 = lock.starts_with("lockfileVersion: 5.4"); + let fx = fixture_with(PC_PKG, lock).await; + for purl in vendor_order { + pc_vendor(&fx, purl).await; + } + for (step, purl) in revert_order.into_iter().enumerate() { + let mut state = crate::vendor::state::load_state(fx.root()).await.unwrap(); + let entry = state.entries.get(purl).cloned().unwrap(); + let outcome = revert_pnpm_legacy(&entry, fx.root(), false).await; + let label = format!("v54={v54} vendor {vendor_order:?}, revert {purl}"); + assert!(outcome.success, "{label}: {:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "{label}: {:?}", + outcome.warnings + ); + assert!(!outcome.kept_artifact, "{label}: artifact kept"); + state.entries.remove(purl); + crate::vendor::state::save_state(fx.root(), &state) + .await + .unwrap(); + let live = fx.read(PNPM_LOCK).await; + assert_pkg_refs_resolve(&live, v54); + if step == 0 { + let (name, version, uuid) = pc_parts(revert_order[1]); + let spec = format!("file:.socket/vendor/npm/{uuid}/{name}-{version}.tgz"); + assert!(live.contains(&format!(" {spec}:")), "{label}:\n{live}"); + let again = revert_pnpm_legacy(&entry, fx.root(), false).await; + assert!(again.success, "{label} re-run: {:?}", again.error); + assert!( + again.warnings.is_empty(), + "{label} re-run: {:?}", + again.warnings + ); + assert_eq!(fx.read(PNPM_LOCK).await, live, "{label}: re-run is a no-op"); + } + } + assert_eq!(fx.read(PNPM_LOCK).await, lock, "lock byte-restored"); + assert_eq!( + fx.read(PACKAGE_JSON).await, + PC_PKG, + "package.json byte-restored" + ); + for uuid in [PC_DEBUG_UUID, PC_MS_UUID] { + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{uuid}")) + .exists()); + } + } + + /// #830: unwinding the vendored parent alone keeps its vendored child's + /// dep ref; the child's ref record (keyed by the parent's now-gone + /// `file:` key) still restores it afterwards. Lockfile 5.4 and 6.0. + #[tokio::test] + async fn parent_first_revert_keeps_the_vendored_child_ref() { + for lock in [PC7_LOCK, PC8_LOCK] { + pc_round_trip(lock, [PC_DEBUG, PC_MS], [PC_DEBUG, PC_MS]).await; + } + } + + /// #830, reverse twin: child vendored first, then the parent; unwinding + /// the child alone restores the ref inside the parent's `file:` block. + #[tokio::test] + async fn child_first_vendored_child_revert_restores_the_ref_in_the_rekeyed_parent() { + for lock in [PC7_LOCK, PC8_LOCK] { + pc_round_trip(lock, [PC_MS, PC_DEBUG], [PC_MS, PC_DEBUG]).await; + } + } + + /// Guards: the two orders that already round-tripped stay clean. + #[tokio::test] + async fn parent_child_guard_orders_round_trip() { + for lock in [PC7_LOCK, PC8_LOCK] { + pc_round_trip(lock, [PC_DEBUG, PC_MS], [PC_MS, PC_DEBUG]).await; + pc_round_trip(lock, [PC_MS, PC_DEBUG], [PC_DEBUG, PC_MS]).await; + } + } } diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index 1412b1b3b..c8b2aecc6 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -557,6 +557,17 @@ async fn extract_pnpm(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { for rel in rush_lock_rels(ctx.root).await { extract_pnpm_lock(ctx, &rel, out).await; } + } else if let crate::utils::pnpm_workspace::MemberLocks::PerMember(rels) = + crate::utils::pnpm_workspace::member_locks(&ctx.view).await + { + // `sharedWorkspaceLockfile: false`: each workspace member installs + // from its own lock, which hosted mode pins beside the root's + // (#492). A member lock beside a shared root lock (one listing + // member importers) is a stale leftover pnpm never reads, and is + // not among `rels`. + for rel in rels { + extract_pnpm_lock(ctx, &rel, out).await; + } } } @@ -2500,6 +2511,37 @@ mod tests { assert!(out.diagnostics.is_empty(), "{:#?}", out.diagnostics); } + /// #492: under `sharedWorkspaceLockfile: false` each member's own lock + /// is read (pnpm 7 writes no root lock at all); beside a shared root + /// lock (one listing member importers) or without the setting, a + /// member lock is a stale leftover and never attests. + #[tokio::test] + async fn pnpm_member_locks_under_an_unshared_workspace() { + let lock = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n ms:\n specifier: 1.3.0\n version: 1.3.0\n\npackages:\n\n ms@1.3.0:\n resolution: {integrity: sha512-UP==}\n"; + let wired = hosted_rewrite("pnpm-lock.yaml", lock, &[pnpm_override("ms", UUID_B, None)]); + let p = Project::new(); + p.write("pnpm-workspace.yaml", "packages:\n - 'packages/*'\n"); + p.write("packages/a/package.json", "{}"); + p.write("packages/a/pnpm-lock.yaml", &wired); + assert!(run(&p).await.refs.is_empty(), "shared default: not read"); + p.write(".npmrc", "shared-workspace-lockfile=false\n"); + let out = run(&p).await; + assert_refs(&out, &[("pkg:npm/ms@1.3.0", UUID_B, WiringMode::Hosted)]); + assert_eq!( + out.refs[0].source_file, + std::path::PathBuf::from("packages/a/pnpm-lock.yaml") + ); + assert!(out.diagnostics.is_empty(), "{:#?}", out.diagnostics); + p.write( + "pnpm-lock.yaml", + "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n\n packages/a: {}\n", + ); + assert!( + run(&p).await.refs.is_empty(), + "shared root lock: stale member" + ); + } + /// `shrinkwrap.yaml` (pnpm <= 2) is read only when there is no /// `pnpm-lock.yaml`: beside one it is migration debris, and a stale /// Socket url left in it must not attest a reverted project. diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index 3e00f563f..9eb7d9843 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -628,7 +628,7 @@ fn strip_url_query_fragment(url: &str) -> &str { /// this form) — everything up to the last `@` before the first `/`, /// but only when a `:` follows it; otherwise the `@` belongs to the /// path (`host:repo@v1`) or the string is not a remote URL at all. -fn strip_url_userinfo(url: &str) -> String { +pub(crate) fn strip_url_userinfo(url: &str) -> String { if let Some(scheme_end) = url.find("://") { let (scheme, rest) = url.split_at(scheme_end + 3); let authority_end = rest.find('/').unwrap_or(rest.len()); diff --git a/crates/socket-patch-node/npm/index.d.ts b/crates/socket-patch-node/npm/index.d.ts index a6cadfba9..655b33ff1 100644 --- a/crates/socket-patch-node/npm/index.d.ts +++ b/crates/socket-patch-node/npm/index.d.ts @@ -2,7 +2,7 @@ export type Ecosystem = 'npm' | 'pypi' | 'cargo' | 'golang' | 'gem' | 'composer' export interface TreeEntryInput { path: string; mode: string; type: 'blob' | 'tree' | 'commit'; size?: number } export interface PathSelection { - roots: string[] // detected project roots, repo-relative ('' = repo root), sorted + roots: string[] // detected project roots, repo-relative ('' = repo root), sorted; includes a pnpm workspace root above member locks (the session decides which candidates are members) fetchText: string[] // stream these as UTF-8 text files fetchBinary: string[] // stream these as raw bytes (e.g. bun.lockb) presentOnly: string[] // engine only needs to know they exist (e.g. .pnp.cjs, rush repo-state.json) diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 1401cbb47..3c4c3e57f 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -78,11 +78,35 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. matching package instance is rewritten; an unsupported instance prevents confirming that dependency across the lockfile set. Rollback preserves the original resolution fragments. - For root 9.0 locks, the CLI configures `trustLockfile: true` in - `pnpm-workspace.yaml` unless opted out with `--no-trust-lockfile-config` or + A workspace with `sharedWorkspaceLockfile: false` (`shared-workspace-lockfile=false` + in `.npmrc` on pnpm 10 and older) installs each member from its own lock: + run from the workspace root, every `packages:` member's `pnpm-lock.yaml` is + pinned beside the root's (pnpm 7 writes no root lock at all), and `list`, + `vex` and `rollback` read the member locks too. Member locks beside a root + lock that lists member importers are stale and ignored. A member list the + CLI cannot read (including a `pnpm-workspace.yaml` with no `packages:` key) + is refused with `redirect_pnpm_member_locks_unresolved`. + With `gitBranchLockfile` on (`git-branch-lockfile=true` in `.npmrc` on + pnpm 10 and older), pnpm installs a branch from its own + `pnpm-lock..yaml` (in each member's directory too, when members + keep their own locks), which neither mode can pin: while such a lock + exists, hosted mode refuses the pnpm pins with + `redirect_pnpm_git_branch_lockfile` and vendored mode with + `vendor_pnpm_git_branch_lockfile`. Turn the setting off and run + `pnpm install --merge-git-branch-lockfiles`, then re-run. With no branch + lock, `pnpm-lock.yaml` is the lock pnpm installs from and is pinned as usual. + For 9.0 root or member locks, the CLI configures `trustLockfile: true` in + the root `pnpm-workspace.yaml` unless opted out with `--no-trust-lockfile-config` or explicitly disabled by the project. pnpm >=11 needs this for hosted URLs. This skips registry re-verification for the whole lock; tarball integrity - remains enforced. pnpm <=10 does not need the setting. + remains enforced. pnpm <=10 does not need the setting. A project with no + `pnpm-workspace.yaml` that pins pnpm 9.0–10.4 (`packageManager`, + `devEngines`, `engines.pnpm`, or the pnpm that last installed + `node_modules`) gets no file: there a root-only workspace makes + `pnpm add ` fail with `ERR_PNPM_ADDING_TO_ROOT`. Re-run the scan after + upgrading to pnpm >=11. When no pin says which pnpm runs, the file is + created, and pnpm 9.0–10.4 then need `pnpm add -w `. Vendored mode + follows the same rule for its `overrides:` mirror. **Reinstall after redirecting:** a successful warm-cache install can retain upstream bytes. Use a clean install tree and an empty store; `--force` is not a reliable substitute. Run `socket-patch vex` after installation to verify @@ -289,18 +313,37 @@ live at `common/config/rush/pnpm-lock.yaml` (plus one per subspace under `common/config/subspaces//`). - **Hosted** ✅ — `scan --mode hosted` discovers and repoints those locks in place - (subspaces included). + (subspaces included). On pnpm >=11 the install needs extra Rush settings (below). - **Agent** ✅ — works through the generated project symlink farm. - **Vendored** ❌ — refused (`vendor_rush_unsupported`): `rush install` copies the lock into `common/temp` and runs pnpm there, so vendor's relative `file:` specs can't survive the copy — the refusal routes you to hosted mode. Editing a Rush lock outside `rush update` desyncs the `pnpmShrinkwrapHash` in -`common/config/rush/repo-state.json`, so when `preventManualShrinkwrapChanges` is enabled +`common/config/rush/repo-state.json` (with subspaces enabled, in the +`common/config/subspaces//repo-state.json` beside each subspace lock), so when `preventManualShrinkwrapChanges` is enabled `rush install` fails until `rush update` refreshes it (a `redirect_rush_repo_state_stale` warning flags this; the redirect survives the refresh — pnpm keeps locked resolutions for unchanged specifiers). +On pnpm >=11 a repointed lock does not install under Rush's default flow: `rush install` +either fails (`ERR_PNPM_TARBALL_URL_MISMATCH` / +`ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION`) or, on pnpm 11, exits 0 after silently +re-resolving the patched entries to the upstream registry. Rush runs pnpm in `common/temp` +with a `pnpm-workspace.yaml` it generates, so the `trustLockfile` auto-config is not written +in a Rush repo; the `redirect_pnpm_trust_lockfile` warning gives the Rush remedy instead +(verified with Rush 5.180.0): + +- pnpm 12: install with `pnpm_config_trust_lockfile=true rush install` (set it in CI too). +- pnpm 11: also set `"usePnpmFrozenLockfileForRushInstall": true` in + `common/config/rush/experiments.json`, so `rush install` stops passing + `--no-prefer-frozen-lockfile`. +- pnpm <=10: nothing extra. + +Run `rush purge` before `rush install` so a warm store or old `node_modules` can't serve the +upstream files, then verify with `socket-patch vex`. Don't rebuild the lock +(`rush update --full`): that discards the hosted patches. + ## npm: vlt notes [vlt](https://www.vlt.sh) is supported in agent and hosted mode on every release from diff --git a/docs/testing/pnpm-compatibility.md b/docs/testing/pnpm-compatibility.md index d4dd35077..6b118043e 100644 --- a/docs/testing/pnpm-compatibility.md +++ b/docs/testing/pnpm-compatibility.md @@ -75,6 +75,16 @@ member `node_modules` trees. Keep the redirected lockfile and any generated installation error. VEX uses installed-file verification by default; exporting VEX does not automatically upload it or change Socket alert counts. +A generated `pnpm-workspace.yaml` (`packages: ['.']` plus `trustLockfile: true` +or the vendored `overrides:`) makes a single-package project a root-only +workspace. pnpm 9.0.0–10.4.x then refuse `pnpm add ` with +`ERR_PNPM_ADDING_TO_ROOT` unless given `-w`; 10.5.0 and later add normally. So +neither mode creates the file when every pnpm pin of the project (the +`node_modules/.modules.yaml` install record, package.json `packageManager`, +`devEngines.packageManager`, `engines.pnpm`) names 9.0–10.4, which read neither +setting from it. With no pin, or any pin that may be a later pnpm, the file is +created as before. + ## Run locally ```sh