diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index c10cda2b..1b703e9f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -54,6 +54,7 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--lock-timeout` | — | `SOCKET_LOCK_TIMEOUT` | (none) | seconds (u64) | How long to wait for `<.socket>/apply.lock`. Unset and `0` both mean a single non-blocking try; a positive value retries with a 100 ms backoff. Only meaningful on the mutating subcommands | | `--debug` | — | `SOCKET_DEBUG` | `false` | bool | Verbose debug logs to stderr | | `--no-telemetry` | — | `SOCKET_TELEMETRY_DISABLED` | `false` | bool | Disable anonymous usage telemetry | +| `--no-trust-lockfile-config` | — | `SOCKET_NO_TRUST_LOCKFILE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `trustLockfile: true` write to `pnpm-workspace.yaml` (see the pnpm trust-config note under the scan arguments) | The `--offline` semantics unified in v3.0. Previously `apply` enforced strict airgap, `repair` skipped network ops, and `rollback` failed when blobs were missing. All three now mean the same thing: never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --vendor` too: offline vendored staging is `vendor --offline`'s job. @@ -84,6 +85,8 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `repair` | `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Repair-specific cleanup mode (mutually exclusive with `--offline`; combining them is a usage error, exit 2) | | `setup` | `--check`, `--remove` (mutually exclusive); `--exclude` (CSV member paths); honors global `--ecosystems` | `SOCKET_SETUP_EXCLUDE`, `SOCKET_ECOSYSTEMS` | Wire / verify / revert the automatic-patching install hooks. `--exclude` skips + persists workspace members (property 9). See [Setup command contract](#setup-command-contract) | +**pnpm hosted-mode contract (v3.5)**: `scan --mode hosted` rewrites pnpm locks of every major since pnpm 7 — lockfileVersion 5.4, 6.0, and 9.0. Legacy grammars are spliced across **every** instance key of the dep (v5 `/name/ver` + `_peer`-suffixed, v6 `/name@ver(peer)` — each owns its own `resolution:`), one revert-ledger `redirect_pnpm_resolution` edit per instance; a partial rewrite is never possible. When a **9.0 root lock** was rewritten this run, the CLI also ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with the root-only `packages:` scaffold, or the single line appended to an existing file with all user bytes preserved) so pnpm ≥ 11's lockfile verification accepts the repointed tarballs with no flags and no CI changes (pnpm ≤ 10 ignores the key; the sha512 tarball pin still fails closed on tampered bytes). The write is recorded in the redirect ledger as a `redirect_pnpm_workspace_trust` edit (`created`/`added`), respects `--dry-run`, is skipped for legacy 5.4/6.0 locks and Rush repos, never overwrites an explicit user `trustLockfile:` value, and is disabled by `--no-trust-lockfile-config` (which restores the manual `--trust-lockfile` / committable-yaml guidance in the `redirect_pnpm_trust_lockfile` warning). Additive no-lockfile diagnostics: `redirect_pnpm_legacy_lockfile` (a pnpm ≤ 2-era `shrinkwrap.yaml` is present) and `redirect_pnpm_no_lockfile` (pnpm markers but no lock) replace the npm-flavored wording on marker-bearing projects; `redirect_pnpm_entry_vendored` names a dep whose lock entry is vendored (`socket-patch vendor --revert` to switch modes) instead of the misleading entry-not-found. **Takeover reconciliation (npm family)**: vendoring over a hosted-redirected purl drops that purl's records + package edits from `redirect-state.json` (the vendor wiring embeds the hosted-spliced fragments as `original`, so `vendor --revert` byte-restores the hosted lock); the `vendor_supersedes_redirect` warning fires exactly once, on the run that reconciles. + `scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + `updates` array only). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. **Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the hosted redirect ledger records scanned package(s) whose hosted lockfile wiring the live lock still proves (the agent run does not unwind hosted wiring; no npm/yarn hosted revert exists); the detail names the purls and the options (stay `--mode hosted`, or migrate via `scan --mode vendored`) and never advises hand-deleting the ledger. The warning keys on ledger *records* still live at scan time — a flow that pre-reverted the redirect (retiring the records) retires the warning with them, even while the append-only `edits` (revert originals) remain. The interactive path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. @@ -126,6 +129,8 @@ Contract details: * **Built from the post-run manifest**, verified against on-disk state (unless `--vex-no-verify`). Generated for real applies, `--dry-run`, and read-only `scan` alike. * **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. * `apply`'s no-manifest early exit (the "No .socket folder found" success no-op) does **not** trigger VEX generation — there is nothing to attest. +* **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly. +* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install; the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. ### VEX provenance markers (contract) @@ -488,7 +493,8 @@ to **six flavors**. | npm (package-lock) | deterministic patched tarball `[@scope/]-.tgz` | `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` | -| 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). lockfileVersion ≠ 9 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) | `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`) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`. `bun.lockb` (binary) refused with a `--save-text-lockfile` pointer | `bun install --frozen-lockfile`, cold cache (integrity-enforced) | | cargo | crate dir `-/` (no `.cargo-checksum.json`) | `.cargo/config.toml` `[patch.crates-io]` path entry **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed) | `cargo build --locked --offline` on a fresh checkout. Requires cargo ≥ 1.56 (`[patch]` in config files). Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | @@ -863,6 +869,8 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `already_vendored` | `skipped` | vendor: artifact + wiring already in sync for this patch uuid. | | `unsafe_coordinates` | `failed` | vendor: purl/uuid would escape `.socket/vendor/` (tampered manifest/state); refused before any write. | | `revert_failed` | `failed` | vendor --revert: a recorded entry could not be reverted. | +| `vendor_wiring_unknown_revert_blocked` | `skipped` (beside the `failed`/`revert_failed` event) | vendor --revert: the ledger entry was reconstructed by `repair` without wiring records and the live lockfile still resolves through the artifact — the revert refuses (fail-closed) instead of deleting a tarball the lock points at. Recovery: `socket-patch repair`, then restore the pre-vendor lock (or re-lock without the override) and re-run the revert. | +| `ecosystem_not_setup` | `skipped` | vex: the patch is applied and byte-verified but its ecosystem has no install hook configured and is not declared in the manifest's `setup.manual`, so it is omitted from the document (Property 7). Previously invisible in `--json`. | | `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager will still install UNPATCHED bytes; names the wired winner + the ignored locks. | | `vendor_yarn_berry_unsupported` / `vendor_bun_lockb_unsupported` | `failed` | vendor (npm): yarn-berry PnP / bun binary lockfile — pointer to `yarn patch` / `bun install --save-text-lockfile`. | | `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. | @@ -919,7 +927,7 @@ The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will mig - ⏳ `scan` — still emits the discovery + `apply.patches[*]` + `gc.*` shape documented in earlier drafts of this file. - ⏳ `get` — still emits per-patch action arrays. -- ⏳ `rollback` — still emits per-package result records. +- ⏳ `rollback` — still emits per-package result records. Additive (v3.5): a manifest entry with no matching installed package appears in `results[]` as a marker record `{ "purl", "path": null, "skipped": "package_not_installed" }` — no `success`/`error` keys, never counted in `rolledBack`/`failed`, never flips the status or exit code (rollback's job is "make the tree unpatched"; a not-installed package already satisfies that end state, deliberately asymmetric with apply's exit-1-on-unmatched). - ⏳ `setup` — still emits its own `{ status, updated, alreadyConfigured, errors, files }` shape (and the `--check` / `--remove` variants), now documented in full under [Setup command contract](#setup-command-contract). One command is **intentionally not** plain-envelope and will stay that way (not migration debt): diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 6485d585..dac683d9 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -279,6 +279,21 @@ pub struct GlobalArgs { value_parser = parse_bool_flag, )] pub no_telemetry: bool, + + /// Hosted mode (`scan --mode hosted`): do NOT auto-configure + /// `trustLockfile: true` in pnpm-workspace.yaml after a pnpm-lock.yaml + /// (lockfileVersion >= 9) is repointed at the hosted patch server. + /// pnpm >= 11 rejects the repointed lock without that trust grant, so + /// opting out means every install needs `pnpm install --trust-lockfile` + /// instead (the run's warning spells out both recoveries). Only `scan` + /// reads this; other subcommands accept it silently. + #[arg( + long = "no-trust-lockfile-config", + env = "SOCKET_NO_TRUST_LOCKFILE_CONFIG", + default_value_t = false, + value_parser = parse_bool_flag, + )] + pub no_trust_lockfile_config: bool, } impl GlobalArgs { @@ -361,6 +376,7 @@ pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_LOCK_TIMEOUT", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", ]; /// Every env var a **subcommand-local** flag binds (one per `env = "..."` @@ -450,6 +466,7 @@ impl Default for GlobalArgs { lock_timeout: None, debug: false, no_telemetry: false, + no_trust_lockfile_config: false, } } } diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index c4bdcd73..cf104f52 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -15,12 +15,17 @@ //! from the lockfile path itself (the contract's uuid-in-path rule), the //! record from the manifest (or the patch API, yielding a detached entry), //! and a fresh ledger entry is re-synthesized so sweep/GC/revert know the -//! artifact again. WIRING reconstruction is per-ecosystem: gem recognizes +//! artifact again — stamped with the npm lockfile FLAVOR the reference was +//! found in, so a later `vendor --revert` routes to the backend whose +//! unwired-revert guard probes the right lockfile. WIRING reconstruction is +//! per-ecosystem: gem recognizes //! its own Gemfile/lock wiring and rebuilds full revert-capable records //! ([`socket_patch_core::vendor::gem::reconstruct_gem_wiring`]); the other //! ecosystems' pre-vendor originals are registry integrity material no //! offline source can reproduce, so their entries keep empty wiring and the -//! gap is surfaced loudly (`vendor_wiring_unknown`) — a gem `--revert` of +//! gap is surfaced loudly (`vendor_wiring_unknown`, riding the envelope's +//! run-level `warnings[]` — the entry itself repaired fine, so it must not +//! ride `events[]` as a `skipped` consumers count) — a gem `--revert` of //! such an entry refuses instead of stranding the pair edit. Existing gem //! entries with EMPTY wiring (persisted by pre-reconstruction repairs) are //! backfilled the same way during the ledger-driven pass while healthy. @@ -46,7 +51,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; -use socket_patch_core::api::client::get_api_client_with_overrides; +use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::CrawlerOptions; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::copy_tree::remove_tree; @@ -68,7 +73,7 @@ use crate::commands::vendor::{ record_warning, PristineFetch, }; use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls}; -use crate::json_envelope::{Envelope, PatchAction, PatchEvent}; +use crate::json_envelope::{Envelope, PatchAction, PatchEvent, RunWarning}; /// One broken vendored unit queued for rebuild. struct Candidate { @@ -181,6 +186,69 @@ fn synth_entry(eco: &str, uuid: &str, artifact_path: &str, base_purl: &str) -> V } } +/// The npm lockfile FLAVOR whose lock carries the +/// `.socket/vendor/npm//` reference, for stamping onto a +/// re-synthesized ledger entry. The strings are `VendorEntry::flavor`'s +/// stable vocabulary (guarded by npm_flavor's `flavor_strings_are_stable` +/// test). Stamping matters: `revert_npm_any` routes by flavor, and each +/// backend's unwired-revert guard probes ITS OWN lockfile — a +/// pnpm-reconstructed entry left at flavor-None would be guarded against +/// package-lock.json instead of pnpm-lock.yaml. Locks are checked in the +/// vendor router's own precedence order (bun > pnpm > yarn > npm) for the +/// pathological multi-lock case; content sniffs mirror +/// `detect_npm_lock_flavor` (crate-private to core, so re-derived here). +/// `None` when genuinely unknowable — no recognizable lock carries the +/// reference, or the referencing lock's grammar is unrecognized — which +/// routes to the package-lock backend, whose guard also fails closed on +/// unwired entries. +async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) -> Option { + if eco != "npm" { + return None; + } + let needle = format!(".socket/vendor/npm/{uuid}/"); + let read = |name: &'static str| async move { + tokio::fs::read_to_string(project_root.join(name)) + .await + .ok() + }; + if read("bun.lock").await.is_some_and(|t| t.contains(&needle)) { + return Some("bun".to_string()); + } + if let Some(text) = read("pnpm-lock.yaml").await { + if text.contains(&needle) { + // Same version allowlist as core's `sniff_lock_grammar`. + return match text + .lines() + .find_map(|l| l.strip_prefix("lockfileVersion:")) + .map(|v| v.trim().trim_matches(['\'', '"'])) + { + Some("9.0") => Some("pnpm".to_string()), + Some("5.4") | Some("6.0") => Some("pnpm-legacy".to_string()), + _ => None, + }; + } + } + if let Some(text) = read("yarn.lock").await { + if text.contains(&needle) { + // Same head sniff as core's `sniff_yarn_lock`; berry wins. + let head: Vec<&str> = text.lines().take(30).collect(); + return if head.iter().any(|l| l.starts_with("__metadata:")) { + Some("yarn-berry".to_string()) + } else if head.iter().any(|l| l.trim() == "# yarn lockfile v1") { + Some("yarn-classic".to_string()) + } else { + None + }; + } + } + for name in ["npm-shrinkwrap.json", "package-lock.json"] { + if read(name).await.is_some_and(|t| t.contains(&needle)) { + return Some("package-lock".to_string()); + } + } + None +} + /// What wiring a re-synthesized ledger entry could recover. enum WiringReconstruction { /// The backend recognized its own wiring in the live project files: @@ -259,6 +327,22 @@ fn soft_restore_without_fingerprint( ); } +/// `vendor_wiring_unknown` advises about what a FUTURE `vendor --revert` +/// can restore — the entry itself was restored/verified fine, so the +/// advisory rides the envelope's run-level `warnings[]` (the documented +/// carrier for non-fatal advisories) rather than a per-purl `skipped` +/// event, which consumers count as work not done. The purl is baked into +/// `detail` by the callers so attribution survives the run-level move. +fn warn_wiring_unknown(env: &mut Envelope, common: &GlobalArgs, detail: String) { + if !common.silent && !common.json { + eprintln!("Warning (vendor_wiring_unknown): {detail}"); + } + env.warnings.push(RunWarning { + code: "vendor_wiring_unknown".to_string(), + detail, + }); +} + /// Best-effort removal of a vendored uuid dir — ahead of a rebuild (corrupt /// bytes must never blend into one) or after a failed post-verify (never /// leave unverifiable bytes behind). @@ -296,6 +380,9 @@ pub(crate) async fn repair_vendored_artifacts( }; // ── Pass 1: ledger-driven health check ─────────────────────────────── + // Shared across both passes so the API client (and its one-time + // token-shape stderr advisory) is constructed at most once per run. + let mut api_client: Option = None; let mut candidates: Vec = Vec::new(); let mut ledger_purls: Vec = state.entries.keys().cloned().collect(); ledger_purls.sort(); @@ -321,23 +408,25 @@ pub(crate) async fn repair_vendored_artifacts( } // Non-detached entry with no manifest at all: recover the // record from the API below, like a reconstruction. - (None, None) => match fetch_record_by_uuid(common, &entry.uuid).await { - Some((_, r)) => r, - None => { - fail( - env, - quiet, - purl, - "vendor_artifact_unrepairable", - format!( - "no manifest record for patch {} and the patch view could not \ + (None, None) => { + match fetch_record_by_uuid(common, &mut api_client, &entry.uuid).await { + Some((_, r)) => r, + None => { + fail( + env, + quiet, + purl, + "vendor_artifact_unrepairable", + format!( + "no manifest record for patch {} and the patch view could not \ be fetched (offline or API failure)", - entry.uuid - ), - ); - continue; + entry.uuid + ), + ); + continue; + } } - }, + } }; if record.uuid != entry.uuid { env.record( @@ -422,19 +511,16 @@ pub(crate) async fn repair_vendored_artifacts( rebuilt += 1; } Err(detail) => { - record_warning( + warn_wiring_unknown( env, - purl, - &VendorWarning::new( - "vendor_wiring_unknown", - format!( - "the ledger entry records no pre-vendor wiring \ - originals and they cannot be reconstructed from \ - the live files ({detail}); `vendor --revert` \ - cannot restore the project files for this entry" - ), - ), common, + format!( + "the ledger entry for {} records no pre-vendor wiring \ + originals and they cannot be reconstructed from the \ + live files ({detail}); `vendor --revert` cannot \ + restore the project files for this entry", + normalize_purl(purl) + ), ); } } @@ -492,7 +578,7 @@ pub(crate) async fn repair_vendored_artifacts( let (purl, record, detached) = match manifest.and_then(|m| m.patches.iter().find(|(_, r)| r.uuid == uuid)) { Some((p, r)) => (p.clone(), r.clone(), false), - None => match fetch_record_by_uuid(common, &uuid).await { + None => match fetch_record_by_uuid(common, &mut api_client, &uuid).await { Some((purl, r)) => (purl, r, true), None => { fail( @@ -512,6 +598,11 @@ pub(crate) async fn repair_vendored_artifacts( }, }; let mut entry = synth_entry(&eco, &uuid, &relpath, strip_purl_qualifiers(&purl)); + // Stamp the flavor the reference was found in (knowable right here: + // the scan above read specific lockfiles), so `vendor --revert` + // routes to the backend whose unwired-revert guard probes the RIGHT + // lockfile. Genuinely unknowable stays None (guarded fallback). + entry.flavor = detect_reference_flavor(&common.cwd, &eco, &uuid).await; entry.detached = detached; if detached { entry.record = Some(record.clone()); @@ -528,18 +619,15 @@ pub(crate) async fn repair_vendored_artifacts( } } WiringReconstruction::Unknown(detail) => { - record_warning( + warn_wiring_unknown( env, - &purl, - &VendorWarning::new( - "vendor_wiring_unknown", - format!( - "the ledger entry was reconstructed without pre-vendor wiring \ - originals ({detail}); `vendor --revert` cannot restore the \ - project files for this entry" - ), - ), common, + format!( + "the ledger entry for {} was reconstructed without pre-vendor \ + wiring originals ({detail}); `vendor --revert` cannot restore \ + the project files for this entry", + normalize_purl(&purl) + ), ); } } @@ -716,15 +804,14 @@ pub(crate) async fn repair_vendored_artifacts( } } - // ── Corrupt artifacts are deleted first ────────────────────────────── - // The backends' wired hot paths rebuild on MISSING; turning corrupt - // into missing gives every ecosystem one uniform rebuild trigger (and - // never leaves tampered bytes to be blended into a rebuild). - for c in &candidates { - if c.reason == "vendor_artifact_corrupt" { - remove_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await; - } - } + // NOTE: corrupt artifacts are NOT deleted here. Deletion waits until + // the rebuild loop below, where the patch sources and a pristine + // package source are both in hand — see the comment there. Destroying + // the corrupt copy before the rebuild-source ladder runs would, on any + // no-source outcome (--offline, node_modules gone, fetch failure), + // convert a corrupt-but-diagnosable integrity-mismatch state into a + // bare ENOENT on the next install (the lock still points at the + // artifact) and erase the forensic evidence of the tamper. // ── Patch content (in memory, like all vendor flows) ──────────────── let records_map: HashMap = candidates @@ -947,13 +1034,19 @@ pub(crate) async fn repair_vendored_artifacts( let Some(pkg_path) = all_packages.get(&c.purl).cloned() else { continue; // failed above }; - if c.soft { - // The healthy-by-members live tree is exactly what cannot be - // trusted; with a pristine source secured, clear it so the - // backend's wired hot path materialises a fresh copy — the - // fingerprint below then derives from the member-verified - // rebuild, never the live bytes. (Deleted only now, after the - // patch sources and the pristine source are both in hand.) + // Clear the live uuid dir only NOW — the patch sources and the + // pristine source are both in hand, so a rebuild WILL replace it. + // The backends' wired hot paths rebuild on MISSING (one uniform + // trigger for every ecosystem), and the live bytes must never + // blend into the rebuild: + // - corrupt: the recorded fingerprint already condemned them; + // - soft: the healthy-by-members live tree is exactly what cannot + // be trusted — the fingerprint below derives from the + // member-verified rebuild, never the live bytes. + // Deleting any earlier destroys evidence: with no rebuild source + // the corrupt copy is all a human has left to diagnose (and the + // lock still points at it — see the NOTE above the staging step). + if c.soft || c.reason == "vendor_artifact_corrupt" { remove_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await; } // For an unverified-source rebuild the rewired lockfile is the trust @@ -1210,12 +1303,29 @@ async fn fill_artifact_fingerprint(project_root: &Path, entry: &mut VendorEntry) } /// Fetch one patch view by uuid (proxy-aware) and shape it as a manifest -/// record; `None` offline or on any API failure. -async fn fetch_record_by_uuid(common: &GlobalArgs, uuid: &str) -> Option<(String, PatchRecord)> { +/// record; `None` offline or on any API failure. `client_cache` holds the +/// one API client the whole vendored-artifact phase shares — construction +/// re-prints the token-shape stderr advisory, so N uuid lookups must not +/// print it N times. Built lazily: a run with nothing to look up never +/// constructs (or warns) at all. +async fn fetch_record_by_uuid( + common: &GlobalArgs, + client_cache: &mut Option, + uuid: &str, +) -> Option<(String, PatchRecord)> { if common.offline { return None; } - let (client, _) = get_api_client_with_overrides(common.api_client_overrides()).await; + if client_cache.is_none() { + *client_cache = Some( + get_api_client_with_overrides(common.api_client_overrides()) + .await + .0, + ); + } + let client = client_cache + .as_ref() + .expect("client_cache was just initialized above"); let patch = client .fetch_patch(common.org.as_deref(), uuid) .await @@ -1284,6 +1394,105 @@ mod tests { ); } + /// The reconstruction stamps [`VendorEntry::flavor`] from whichever + /// lockfile carries the vendored reference, so `vendor --revert` routes + /// to the backend whose unwired-revert guard probes the RIGHT lockfile. + /// The strings must stay npm_flavor's stable vocabulary; unknowable + /// shapes stay `None` (the guarded package-lock fallback route). + #[tokio::test] + async fn detect_reference_flavor_maps_referencing_lock_to_stable_flavor() { + let uuid = "11111111-1111-4111-8111-111111111111"; + let mention = format!("resolved: file:.socket/vendor/npm/{uuid}/left-pad-1.3.0.tgz\n"); + let case = |files: Vec<(&'static str, String)>, want: Option<&'static str>| async move { + let tmp = tempfile::tempdir().unwrap(); + for (name, text) in &files { + tokio::fs::write(tmp.path().join(name), text).await.unwrap(); + } + assert_eq!( + detect_reference_flavor(tmp.path(), "npm", uuid).await, + want.map(str::to_string), + "files: {:?}", + files.iter().map(|(n, _)| n).collect::>() + ); + }; + + // Each flavor's lock, referenced → its stable string. + case( + vec![("package-lock.json", mention.clone())], + Some("package-lock"), + ) + .await; + case( + vec![("npm-shrinkwrap.json", mention.clone())], + Some("package-lock"), + ) + .await; + case( + vec![( + "pnpm-lock.yaml", + format!("lockfileVersion: '9.0'\n{mention}"), + )], + Some("pnpm"), + ) + .await; + case( + vec![("pnpm-lock.yaml", format!("lockfileVersion: 5.4\n{mention}"))], + Some("pnpm-legacy"), + ) + .await; + case( + vec![( + "pnpm-lock.yaml", + format!("lockfileVersion: '6.0'\n{mention}"), + )], + Some("pnpm-legacy"), + ) + .await; + case( + vec![("yarn.lock", format!("# yarn lockfile v1\n{mention}"))], + Some("yarn-classic"), + ) + .await; + case( + vec![("yarn.lock", format!("__metadata:\n version: 8\n{mention}"))], + Some("yarn-berry"), + ) + .await; + case(vec![("bun.lock", mention.clone())], Some("bun")).await; + + // Unknowable stays None: unrecognized grammars, or no referencing + // lock at all (an unreferenced lock must not claim the entry). + case( + vec![("pnpm-lock.yaml", format!("lockfileVersion: 5.3\n{mention}"))], + None, + ) + .await; + case(vec![("yarn.lock", mention.clone())], None).await; + case( + vec![("package-lock.json", "no reference here".to_string())], + None, + ) + .await; + case(vec![], None).await; + + // The referencing lock wins over an unreferenced sibling. + case( + vec![ + ("package-lock.json", "no reference here".to_string()), + ("yarn.lock", format!("# yarn lockfile v1\n{mention}")), + ], + Some("yarn-classic"), + ) + .await; + + // Non-npm ecosystems never carry an npm flavor. + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("package-lock.json"), &mention) + .await + .unwrap(); + assert_eq!(detect_reference_flavor(tmp.path(), "gem", uuid).await, None); + } + /// `base_purl` is stored VERBATIM percent-encoded (`pkg:npm/%40scope/…`, /// manifest/ledger key parity — see npm_common's coordinate tests), but /// the registry fetch and the berry cache-checksum recipe both need the diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 46a492ed..b717a26f 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -15,6 +15,14 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[ "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", + // pnpm-family MARKERS, never rewritten: `shrinkwrap.yaml` is the + // pnpm <=2-era lock (npm never emits that filename) and + // `node_modules/.modules.yaml` is pnpm's installer state file. The npm + // rewriter's no-lockfile diagnostic keys its family wording off their + // presence — without them a pnpm 1/2 project gets told "no + // package-lock.json present", npm advice that dead-ends. + "shrinkwrap.yaml", + "node_modules/.modules.yaml", "yarn.lock", // A berry lock's cache-config gate reads `.yarnrc.yml`; bun's text lock is // `bun.lock` (its binary `bun.lockb` is auto-migrated in `run_redirect`). @@ -81,6 +89,248 @@ fn parse_purl_simple(purl: &str) -> Option<(String, String, String)> { Some((typ.to_string(), name, version)) } +/// `scheme://[user[:pass]@]host[:port]/…` → `host[:port]`, NEVER userinfo. +/// For user-facing messages that name where a lockfile now points — the +/// hosted artifact host follows `--api-url`, so hardcoding `patch.socket.dev` +/// would misname it in custom-server environments. The port is kept (it is +/// part of the authority the lock records); credentials are stripped: a +/// credentialed artifact URL (`https://user:secret@host/…`) must never leak +/// `user:secret` into the warning text or the persisted `--json` envelope — +/// both land in CI logs. Split by hand because this crate has no URL-parser +/// dependency (reqwest is dev-only here); per RFC 3986 a raw `@` in the +/// authority can ONLY be the userinfo terminator (it is percent-encoded +/// everywhere else), so the tail after the LAST `@` is exactly host[:port]. +fn url_host(url: &str) -> Option<&str> { + let rest = url.split_once("://").map_or(url, |(_, r)| r); + let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); + let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + (!host.is_empty()).then_some(host) +} + +/// Repo-relative path of the pnpm workspace manifest the trustLockfile +/// auto-config edits (the same file the vendor backend's override surface +/// uses). +const PNPM_WORKSPACE_REL: &str = "pnpm-workspace.yaml"; + +/// `FileEdit.kind` recorded when the hosted flow ensures `trustLockfile: +/// true` in pnpm-workspace.yaml. `action: "created"` — the workspace file +/// itself was created (a revert deletes it); `action: "added"` — the single +/// `trustLockfile: true` line was appended to an existing file (a revert +/// removes exactly that line). Additive ledger vocabulary: older ledgers +/// without it load unchanged (kind is an opaque string to the loader). +const REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND: &str = "redirect_pnpm_workspace_trust"; + +/// The honest-tradeoff + don't-rebuild tail shared by every trustLockfile +/// warning variant. The tradeoff sentence is a security disclosure, not +/// prose garnish: `trustLockfile: true` disables pnpm's lockfile +/// re-verification for the WHOLE lock, so it must be stated wherever the +/// setting is written or recommended. +const PNPM_TRUST_TRADEOFF_AND_CAUTION: &str = + "Note: trustLockfile 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 follow pnpm's advice to rebuild the lockfile \ + (`pnpm clean --lockfile`): that silently discards the redirect and \ + reinstalls the vulnerable upstream artifact. pnpm <=10 ignores the \ + setting and installs work unchanged"; + +/// The policy preamble shared by every trustLockfile warning variant: +/// what was repointed, and how pnpm >=11 fails without trust. +fn pnpm_trust_policy_preamble(server: &str) -> String { + format!( + "pnpm-lock.yaml was repointed at {server}; pnpm >=11 rejects the \ + rewritten lock (pnpm 11: ERR_PNPM_TARBALL_URL_MISMATCH, pnpm 12: \ + ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION)" + ) +} + +/// The pre-auto-config guidance, kept verbatim for the runs where the +/// auto-config does not apply (legacy 5.x/6.0 locks, Rush nested locks, +/// `--no-trust-lockfile-config`): both verified recoveries, spelled exactly. +fn pnpm_trust_manual_guidance(server: &str) -> String { + format!( + "{}. Install with `pnpm install --trust-lockfile`, or commit \ + `trustLockfile: true` in pnpm-workspace.yaml so every install \ + accepts the patched artifacts. Do NOT follow pnpm's advice to \ + rebuild the lockfile (`pnpm clean --lockfile`): that silently \ + discards the redirect and reinstalls the vulnerable upstream \ + artifact. pnpm <=10 installs work unchanged", + pnpm_trust_policy_preamble(server), + ) +} + +/// 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 +/// no trust step exists or is needed. Deliberately NEVER mentions +/// `pnpm install --trust-lockfile`: pnpm 7/8 reject the flag as an unknown +/// option, so headlining it here would hand users a command that errors. +fn pnpm_trust_legacy_detail(server: &str) -> String { + format!( + "pnpm-lock.yaml was repointed at {server}. This is a legacy \ + (lockfileVersion 5.x/6.0) lock read by pnpm 7/8, which have no \ + lockfile trust policy: installs work unchanged on pnpm 7/8 and no \ + trust step exists or is needed. Do NOT regenerate the lockfile \ + (deleting it, or re-resolving on a newer pnpm): that silently \ + discards the redirect and reinstalls the vulnerable upstream \ + artifact. If the project later moves to pnpm >=9, re-run \ + `socket-patch scan --mode hosted` so the regenerated lock is \ + redirected (and trust-configured) again" + ) +} + +/// The unreadable-workspace fallback: pnpm-workspace.yaml EXISTS but could +/// not be read (permissions, invalid UTF-8, I/O error). Planning a Create +/// here would OVERWRITE the user's file with the root-only scaffold — +/// destroying their `packages:` globs — so the auto-config stands down and +/// the warning names the file, the error, and both manual recoveries. +fn pnpm_trust_workspace_unreadable_detail(server: &str, err: &std::io::Error) -> String { + format!( + "{}. {PNPM_WORKSPACE_REL} exists but could not be read ({err}); it \ + was left untouched — auto-configuring trust would risk overwriting \ + it. Fix the file, then install with `pnpm install --trust-lockfile` \ + or add `trustLockfile: true` to it yourself so every install \ + accepts the patched artifacts. Do NOT follow pnpm's advice to \ + rebuild the lockfile (`pnpm clean --lockfile`): that silently \ + discards the redirect and reinstalls the vulnerable upstream \ + artifact. pnpm <=10 installs work unchanged", + pnpm_trust_policy_preamble(server), + ) +} + +/// The pnpm-workspace.yaml read, classified for the trust auto-config: +/// `Ok(Some(text))` — read fine; `Ok(None)` — ABSENT (`ErrorKind::NotFound`, +/// the only state where planning a Create is safe); `Err(e)` — present but +/// unreadable, so the caller must fall back to warning-only guidance. It +/// was: a bare `.ok()` collapsed EVERY read error to `None`, so a +/// present-but-unreadable workspace file was planned as a Create and +/// OVERWRITTEN with the root-only scaffold, destroying the user's +/// `packages:` globs. +fn read_workspace_for_trust(path: &std::path::Path) -> std::io::Result> { + match std::fs::read_to_string(path) { + Ok(text) => Ok(Some(text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +/// HEAL-ON-RERUN probe: does this (unspliced) root pnpm-lock.yaml already +/// carry a granted hosted artifact URL from an EARLIER run? Same spelling +/// set as the confirmation probe (raw / `\/`-escaped via +/// `artifact_url_present`, plus the percent-encoded form) so a writer's +/// spelling can never be one this probe misses. Lets an idempotent re-scan +/// plan the trust config for a project that missed it once (opted-out first +/// run, or a crash between the lock write and the workspace write) — the +/// splice-only trigger skipped both forever on such projects. +fn pnpm_lock_carries_hosted_redirect( + lock_text: &str, + overrides: &[socket_patch_core::patch::redirect::DepOverride], +) -> bool { + overrides.iter().filter(|o| o.ecosystem == "npm").any(|o| { + let encoded = socket_patch_core::utils::uri::encode_uri_component(&o.artifact_url); + socket_patch_core::patch::redirect::artifact_url_present(lock_text, &o.artifact_url) + || lock_text.contains(encoded.as_str()) + }) +} + +/// 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 +/// `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). +fn pnpm_heal_root<'a>( + root_spliced: bool, + disk_root: Option<&'a String>, + overrides: &[socket_patch_core::patch::redirect::DepOverride], +) -> Option<&'a String> { + if root_spliced { + return None; + } + disk_root.filter(|text| { + pnpm_lock_version_major(text).is_some_and(|major| major >= 9) + && pnpm_lock_carries_hosted_redirect(text, overrides) + }) +} + +/// The auto-config variant: trust was (or, on `--dry-run`, would be) +/// configured in pnpm-workspace.yaml, so installs need no flags. +fn pnpm_trust_configured_detail(server: &str, created: bool, dry_run: bool) -> String { + let how = match (created, dry_run) { + (true, false) => "`trustLockfile: true` was written to a new", + (false, false) => "`trustLockfile: true` was merged into the existing", + (true, true) => "`trustLockfile: true` would be written to a new (--dry-run)", + (false, true) => "`trustLockfile: true` would be merged into the existing (--dry-run)", + }; + format!( + "{}, so {how} {PNPM_WORKSPACE_REL} — commit it alongside the lock; \ + installs need no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(server), + ) +} + +/// `lockfileVersion` major sniffed from a pnpm-lock.yaml head. pnpm 9-12 +/// emit `lockfileVersion: '9.0'` (single doc, first line — verified against +/// real 7/8/9/10/11/12-rc locks in the 2026-08-18 matrix); pnpm 8 emits +/// `'6.0'`, pnpm 7 an unquoted `5.4`. `None` when no parseable version line +/// exists — callers treat that as "not trust-policy era" and stay +/// hands-off (fail closed: never write config for a lock we can't read). +fn pnpm_lock_version_major(lock_text: &str) -> Option { + lock_text.lines().find_map(|line| { + let rest = line.strip_prefix("lockfileVersion:")?; + let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); + value.split('.').next()?.parse::().ok() + }) +} + +/// The planned pnpm-workspace.yaml `trustLockfile: true` edit. +enum TrustPlan { + /// No workspace file: create it (root-only `packages` scaffold — pnpm 9 + /// refuses a workspace file with no `packages` field — plus the trust + /// key; the same scaffold shape the vendor backend creates). + Create(String), + /// Workspace file exists without a `trustLockfile:` key: append exactly + /// one line after the last non-empty line, every other byte preserved. + Append(String), + /// Already `trustLockfile: true` — nothing to write. + AlreadyTrue, + /// The user explicitly set `trustLockfile: ` (non-true). Their + /// call is respected — flipping an explicit security setting behind the + /// user's back is worse than a failing install with a clear warning. + UserSet(String), +} + +/// Decide how to ensure `trustLockfile: true` in pnpm-workspace.yaml. +/// Line splices only (never a YAML library), mirroring the vendor backend's +/// workspace surgery: untouched lines stay byte-identical, so a revert can +/// remove exactly what was added. +fn plan_workspace_trust(existing: Option<&str>) -> TrustPlan { + let Some(text) = existing else { + return TrustPlan::Create("packages:\n - '.'\ntrustLockfile: true\n".to_string()); + }; + // Top-level key only: an indented `trustLockfile:` under some other + // mapping is not the setting pnpm reads. + for line in text.split('\n') { + if let Some(rest) = line.strip_prefix("trustLockfile:") { + let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); + if value == "true" { + return TrustPlan::AlreadyTrue; + } + return TrustPlan::UserSet(value.to_string()); + } + } + let mut lines: Vec = text.split('\n').map(str::to_string).collect(); + // After the last non-empty line (no blank separator): a revert removes + // exactly one line and the file's trailing bytes stay put. + let anchor = lines + .iter() + .rposition(|l| !l.trim().is_empty()) + .map(|i| i + 1) + .unwrap_or(lines.len()); + lines.insert(anchor, "trustLockfile: true".to_string()); + TrustPlan::Append(lines.join("\n")) +} + /// The hosted-mode JSON error envelope, for bail-outs that return before the /// success envelope at the bottom of [`run_redirect`] is built. When the /// classic scan object (`scan_result`, threaded in from `run`) is present it @@ -622,8 +872,10 @@ pub(super) async fn run_redirect( } } - let rewrite = rewrite_registry_redirect(&files, &overrides); - let rewritten: Vec = rewrite.files.keys().cloned().collect(); + // `mut`: the pnpm trustLockfile auto-config below may fold a + // pnpm-workspace.yaml write (plus its ledger edit) into the rewrite set so + // it rides the same atomic-write / ledger-first machinery as the locks. + let mut rewrite = rewrite_registry_redirect(&files, &overrides); // The lockb→text migration is only KEPT when the rewrite actually landed // in the migrated bun.lock. Otherwise nothing was redirected there and the @@ -684,30 +936,196 @@ pub(super) async fn run_redirect( // pnpm >=11 enforces a lockfile supply-chain policy: it compares each // resolution's tarball URL against the registry's published metadata and - // REFUSES the lock when they differ - // (`ERR_PNPM_TARBALL_URL_MISMATCH … has a tarball URL (https://patch.socket.dev/…) - // that does not match the registry's published metadata`). The hosted - // rewrite deliberately repoints tarball URLs at patch.socket.dev, so a - // pnpm >=11 install rejects the rewritten lock until the user opts in with - // `pnpm install --trust-lockfile` (which installs the patched artifact - // cleanly). Warn whenever the rewrite actually landed in ANY pnpm-lock.yaml - // — the plain root lock or a Rush nested/subspace lock (basename check). + // REFUSES a lock whose URLs differ. The failure spelling changed across + // majors (both observed against real installs): pnpm 11 fails with + // ERR_PNPM_TARBALL_URL_MISMATCH (ERR_PNPM_META_FETCH_FAIL when the + // registry is unreachable); pnpm 12 fails with + // ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION, and its OWN error text tells + // users to rebuild the lock (`pnpm clean --lockfile` + install) — which + // silently discards the redirect and reinstalls the vulnerable upstream, + // so the warning must pre-empt that advice. The recoveries verified on + // both majors are the per-run `pnpm install --trust-lockfile` flag and + // the committable pnpm-workspace.yaml `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 (pnpm 9-12 emit '9.0'; 5.x/6.0 locks mean + // pnpm 7/8, which have neither the policy nor the flag — those legacy + // locks get their own installs-work-unchanged guidance instead, never + // the `--trust-lockfile` headline pnpm 7/8 reject as an unknown option), + // the run auto-ensures `trustLockfile: true` in pnpm-workspace.yaml so + // CI needs no modification and installs need no flags. The same + // auto-config re-engages on a run that spliced NOTHING when the root v9 + // lock already carries a granted hosted artifact URL (see HEAL-ON-RERUN + // below), so a missed config is healed by re-running the scan. Verified against real installs (2026-08-18 matrix + + // tolerance spikes): pnpm 9.15.9 / 10.34.5 silently ignore the key + // (frozen installs stay green), pnpm 11.22.0 / 12.0.0-rc.7 accept the + // redirected lock with it, and the per-entry sha512 integrity pin still + // fails closed on tampered bytes. An explicit user `trustLockfile: + // ` is RESPECTED (never flipped — the warning explains the + // manual recoveries instead), and `--no-trust-lockfile-config` opts out + // entirely. Rush nested/subspace locks are excluded: rush runs pnpm in + // common/temp, which never reads the repo-root pnpm-workspace.yaml, so a + // root write would be config theater — those runs keep the manual + // guidance. The warning names the host(s) the lock now points at: the + // hosted artifact host follows --api-url, so it is not always + // patch.socket.dev. let mut pnpm_warnings: Vec = Vec::new(); - if rewrite.files.keys().any(|key| { - std::path::Path::new(key) - .file_name() - .and_then(|n| n.to_str()) - == Some("pnpm-lock.yaml") - }) { - pnpm_warnings.push(serde_json::json!({ - "code": "redirect_pnpm_trust_lockfile", - "detail": - "pnpm-lock.yaml was repointed at patch.socket.dev; pnpm >=11 rejects \ - the rewritten lock with ERR_PNPM_TARBALL_URL_MISMATCH (its tarball \ - URL no longer matches the registry's published metadata). Install \ - with `pnpm install --trust-lockfile` to accept the patched artifacts", - })); + // The pnpm-workspace.yaml content + ledger edit this run will fold into + // the rewrite set (decided inside the borrow scope, applied after it). + let mut trust_config_write: Option<(String, socket_patch_core::patch::redirect::FileEdit)> = + None; + { + // pnpm locks spliced THIS run (any depth — the rewriter is + // basename-generalized). + let mut pnpm_lock_texts: Vec<&String> = rewrite + .files + .iter() + .filter(|(key, _)| { + std::path::Path::new(key) + .file_name() + .and_then(|n| n.to_str()) + == Some("pnpm-lock.yaml") + }) + .map(|(_, content)| content) + .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. Without this, the idempotent no-op re-scan skipped both + // the config and the warning forever. 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, + ); + if let Some(text) = heal_root { + pnpm_lock_texts.push(text); + } + if !pnpm_lock_texts.is_empty() { + // Name only the hosts whose artifact URL actually landed in a + // touched pnpm lock's final text (spliced this run, or the + // already-redirected heal root): an npm override may have matched + // only a sibling lock (e.g. package-lock.json), and naming its host + // here would point users at a server the pnpm lock never references. + // Same presence predicate as the confirmation probe below (raw / + // `\/`-escaped via artifact_url_present, plus the percent-encoded + // spelling) so a writer's spelling can never be one this filter + // misses. + let mut hosts: Vec<&str> = overrides + .iter() + .filter(|o| o.ecosystem == "npm") + .filter(|o| { + let encoded = + socket_patch_core::utils::uri::encode_uri_component(&o.artifact_url); + pnpm_lock_texts.iter().any(|text| { + socket_patch_core::patch::redirect::artifact_url_present( + text, + &o.artifact_url, + ) || text.contains(encoded.as_str()) + }) + }) + .filter_map(|o| url_host(&o.artifact_url)) + .collect(); + hosts.sort_unstable(); + hosts.dedup(); + let server = if hosts.is_empty() { + "the hosted patch server".to_string() + } else { + format!("the hosted patch server ({})", hosts.join(", ")) + }; + // Root-lock gate (see the block comment above): 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.is_some() + || rewrite + .files + .get("pnpm-lock.yaml") + .and_then(|text| pnpm_lock_version_major(text)) + .is_some_and(|major| major >= 9); + // Every touched pnpm lock is a KNOWN legacy (5.x/6.0) format — + // pnpm 7/8 territory, where neither the trust policy nor the + // `--trust-lockfile` flag exists (the flag is rejected as an + // unknown option), so the manual guidance's headline would hand + // users a command that errors. An unparseable version stays on + // the manual guidance: never claim "no trust step needed" for a + // lock whose era is unknown. + let all_locks_legacy = pnpm_lock_texts + .iter() + .all(|text| pnpm_lock_version_major(text).is_some_and(|major| major < 9)); + let detail = if all_locks_legacy { + pnpm_trust_legacy_detail(&server) + } else if !root_lock_v9 || args.common.no_trust_lockfile_config { + pnpm_trust_manual_guidance(&server) + } else { + match read_workspace_for_trust(&args.common.cwd.join(PNPM_WORKSPACE_REL)) { + // 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. + Err(e) => pnpm_trust_workspace_unreadable_detail(&server, &e), + Ok(ws_existing) => match plan_workspace_trust(ws_existing.as_deref()) { + TrustPlan::Create(text) => { + trust_config_write = Some(( + text, + socket_patch_core::patch::redirect::FileEdit { + path: PNPM_WORKSPACE_REL.into(), + kind: REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND.into(), + action: "created".into(), + key: Some("trustLockfile".into()), + original: None, + new: Some(serde_json::json!("true")), + }, + )); + pnpm_trust_configured_detail(&server, true, args.common.dry_run) + } + TrustPlan::Append(text) => { + trust_config_write = Some(( + text, + socket_patch_core::patch::redirect::FileEdit { + path: PNPM_WORKSPACE_REL.into(), + kind: REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND.into(), + action: "added".into(), + key: Some("trustLockfile".into()), + original: None, + new: Some(serde_json::json!("true")), + }, + )); + pnpm_trust_configured_detail(&server, false, args.common.dry_run) + } + TrustPlan::AlreadyTrue => format!( + "{}, and {PNPM_WORKSPACE_REL} already carries `trustLockfile: \ + true` — keep it committed alongside the lock; installs need \ + no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(&server), + ), + TrustPlan::UserSet(value) => format!( + "{}. {PNPM_WORKSPACE_REL} explicitly sets `trustLockfile: \ + {value}`, which was respected and left untouched — install \ + with `pnpm install --trust-lockfile`, or set `trustLockfile: \ + true` yourself so every install accepts the patched \ + artifacts. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(&server), + ), + }, + } + }; + pnpm_warnings.push(serde_json::json!({ + "code": "redirect_pnpm_trust_lockfile", + "detail": detail, + })); + } } + if let Some((text, edit)) = trust_config_write { + rewrite.files.insert(PNPM_WORKSPACE_REL.to_string(), text); + // Appended last: `--revert` walks edits in reverse, so the trust key + // is unwound before the lock originals are restored. + rewrite.edits.push(edit); + } + let rewritten: Vec = rewrite.files.keys().cloned().collect(); // A dep counts as REDIRECTED only if its hosted-artifact URL (or its // per-dependency registry index URL) actually landed in the project's @@ -1056,8 +1474,372 @@ pub(super) async fn run_redirect( #[cfg(test)] mod tests { - use super::{build_redirect_json_envelope, parse_purl_simple, REDIRECT_CANDIDATE_FILES}; + use super::{ + build_redirect_json_envelope, parse_purl_simple, plan_workspace_trust, pnpm_heal_root, + pnpm_lock_carries_hosted_redirect, pnpm_lock_version_major, pnpm_trust_configured_detail, + pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, + pnpm_trust_workspace_unreadable_detail, read_workspace_for_trust, TrustPlan, + REDIRECT_CANDIDATE_FILES, + }; use socket_patch_core::constants::npm_family; + use socket_patch_core::patch::redirect::DepOverride; + + /// Lock-head version sniff against the byte-real heads the 2026-08-18 + /// matrix captured from pnpm 7/8/9-12: quoted `'9.0'` and `'6.0'`, + /// unquoted `5.4`; a headless/garbled lock yields `None` (hands-off). + #[test] + fn pnpm_lock_version_major_sniffs_real_lock_heads() { + assert_eq!( + pnpm_lock_version_major("lockfileVersion: '9.0'\n\nsettings:\n"), + Some(9), + "pnpm 9-12 emit a quoted '9.0'" + ); + assert_eq!( + pnpm_lock_version_major("lockfileVersion: '6.0'\n\nsettings:\n"), + Some(6), + "pnpm 8 emits a quoted '6.0'" + ); + assert_eq!( + pnpm_lock_version_major("lockfileVersion: 5.4\n\nspecifiers:\n"), + Some(5), + "pnpm 7 emits an unquoted 5.4" + ); + // Not necessarily the first line (a comment/BOM-damaged head). + assert_eq!( + pnpm_lock_version_major("# managed\nlockfileVersion: \"9.0\"\n"), + Some(9) + ); + assert_eq!( + pnpm_lock_version_major("importers:\n .:\n"), + None, + "no version line → None, callers stay hands-off" + ); + assert_eq!( + pnpm_lock_version_major("lockfileVersion: banana\n"), + None, + "unparseable version → None, never a guess" + ); + } + + /// No pnpm-workspace.yaml → create the root-only scaffold + trust key + /// (the exact bytes the vendor backend's scaffold precedent uses, with + /// `trustLockfile: true` in place of the override). + #[test] + fn plan_workspace_trust_creates_the_scaffold() { + match plan_workspace_trust(None) { + TrustPlan::Create(text) => { + assert_eq!(text, "packages:\n - '.'\ntrustLockfile: true\n"); + } + _ => panic!("no workspace file must plan a Create"), + } + } + + /// An existing workspace file gains exactly one line after its last + /// non-empty line; every other byte — including a trailing blank line and + /// comments — is preserved so a revert can remove exactly that line. + #[test] + fn plan_workspace_trust_appends_preserving_user_bytes() { + let user = "# team workspace\npackages:\n - 'apps/*'\n - 'libs/*'\n\ncatalog:\n react: ^18.0.0\n"; + match plan_workspace_trust(Some(user)) { + TrustPlan::Append(text) => { + assert_eq!( + text, + "# team workspace\npackages:\n - 'apps/*'\n - 'libs/*'\n\ncatalog:\n react: ^18.0.0\ntrustLockfile: true\n", + "one line appended after the last non-empty line, all user bytes intact" + ); + } + _ => panic!("a file without the key must plan an Append"), + } + // No trailing newline: the file's (lack of) trailing bytes stays put. + match plan_workspace_trust(Some("packages:\n - '.'")) { + TrustPlan::Append(text) => { + assert_eq!(text, "packages:\n - '.'\ntrustLockfile: true"); + } + _ => panic!("expected Append"), + } + } + + /// `trustLockfile: true` already present (any quoting) → nothing to do; + /// an explicit non-true value is the USER's security call and is + /// respected, never flipped. + #[test] + fn plan_workspace_trust_respects_existing_key() { + for spelled in [ + "packages:\n - '.'\ntrustLockfile: true\n", + "trustLockfile: 'true'\npackages:\n - '.'\n", + "trustLockfile: \"true\"\n", + ] { + assert!( + matches!(plan_workspace_trust(Some(spelled)), TrustPlan::AlreadyTrue), + "already-true must be a no-op for {spelled:?}" + ); + } + match plan_workspace_trust(Some("packages:\n - '.'\ntrustLockfile: false\n")) { + TrustPlan::UserSet(value) => assert_eq!(value, "false"), + _ => panic!("an explicit false must be respected as UserSet"), + } + // An INDENTED trustLockfile under some other mapping is not the + // top-level setting pnpm reads — it must not be mistaken for one. + match plan_workspace_trust(Some( + "catalogMode:\n trustLockfile: false\npackages:\n - '.'\n", + )) { + TrustPlan::Append(text) => assert!(text.ends_with("trustLockfile: true\n")), + _ => panic!("an indented key must not block the top-level append"), + } + } + + /// The warning variants: the configured text says trust is in place and + /// installs need no flags; the dry-run text says WOULD; both carry the + /// whole-lock tradeoff disclosure and the don't-rebuild caution; the + /// manual-guidance text keeps both verified recoveries. None may leak a + /// URL authority `@` (the userinfo-stripping contract). + #[test] + fn pnpm_trust_warning_variants_carry_the_load_bearing_sentences() { + let server = "the hosted patch server (patch.test)"; + for created in [true, false] { + let configured = pnpm_trust_configured_detail(server, created, false); + assert!(configured.contains("trustLockfile: true"), "{configured}"); + assert!(configured.contains("pnpm-workspace.yaml"), "{configured}"); + assert!( + configured.contains("commit it alongside the lock"), + "{configured}" + ); + assert!(configured.contains("no extra flags"), "{configured}"); + assert!(!configured.contains("would be"), "{configured}"); + let dry = pnpm_trust_configured_detail(server, created, true); + assert!(dry.contains("would be"), "{dry}"); + assert!(dry.contains("--dry-run"), "{dry}"); + for text in [&configured, &dry] { + assert!(text.contains("ALL lockfile entries"), "{text}"); + assert!(text.contains("minimumReleaseAge"), "{text}"); + assert!(text.contains("sha512 integrity pins are"), "{text}"); + assert!(text.contains("pnpm clean --lockfile"), "{text}"); + assert!(text.contains("pnpm <=10"), "{text}"); + assert!( + text.contains("ERR_PNPM_TARBALL_URL_MISMATCH") + && text.contains("ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION"), + "{text}" + ); + assert!(!text.contains('@'), "no URL authority may leak: {text}"); + assert!(!text.contains(".npmrc"), "{text}"); + } + } + let manual = pnpm_trust_manual_guidance(server); + assert!(manual.contains("--trust-lockfile"), "{manual}"); + assert!( + manual.contains("trustLockfile: true") && manual.contains("pnpm-workspace.yaml"), + "{manual}" + ); + assert!(manual.contains("pnpm clean --lockfile"), "{manual}"); + assert!(manual.contains("pnpm <=10"), "{manual}"); + assert!(!manual.contains('@'), "{manual}"); + } + + /// FINDING-10 regression: the legacy-lock (5.x/6.0 — pnpm 7/8) guidance + /// must NEVER mention `--trust-lockfile` (pnpm 7/8 reject the flag as an + /// unknown option) nor the `trustLockfile` setting (pnpm 7/8 ignore it); + /// it must say installs work unchanged with no trust step, keep the + /// don't-regenerate caution, and leak no URL authority. RED-verified: the + /// pre-fix manual guidance headlined `pnpm install --trust-lockfile` for + /// legacy locks, which errors out on pnpm 7/8. + #[test] + fn pnpm_trust_legacy_detail_never_recommends_the_trust_flag() { + let server = "the hosted patch server (patch.test)"; + let legacy = pnpm_trust_legacy_detail(server); + assert!( + !legacy.contains("trust-lockfile"), + "pnpm 7/8 reject --trust-lockfile as an unknown option: {legacy}" + ); + assert!( + !legacy.contains("trustLockfile"), + "pnpm 7/8 ignore the setting — recommending it is noise: {legacy}" + ); + assert!(legacy.contains("pnpm 7/8"), "{legacy}"); + assert!(legacy.contains("installs work unchanged"), "{legacy}"); + assert!(legacy.contains("no trust step"), "{legacy}"); + // The vulnerable-reinstall caution survives the split: regenerating + // the lock still silently discards the redirect. + assert!(legacy.contains("Do NOT regenerate"), "{legacy}"); + assert!(legacy.contains("vulnerable upstream"), "{legacy}"); + assert!(!legacy.contains('@'), "no URL authority may leak: {legacy}"); + } + + /// FINDING-5 regression: a PRESENT-but-unreadable pnpm-workspace.yaml + /// must classify as `Err` — never as `Ok(None)`, which plans a Create + /// that overwrites the user's file (destroying their `packages:` globs). + /// Absent stays `Ok(None)` (the only Create-safe state); readable stays + /// `Ok(Some)`. RED-verified: the pre-fix `.ok()` collapsed the + /// invalid-UTF-8 read error below to `None`. + #[test] + fn read_workspace_for_trust_distinguishes_unreadable_from_absent() { + let tmp = tempfile::tempdir().unwrap(); + // Absent → Ok(None). + assert!(matches!( + read_workspace_for_trust(&tmp.path().join("pnpm-workspace.yaml")), + Ok(None) + )); + // Readable → Ok(Some(text)). + let readable = tmp.path().join("readable.yaml"); + std::fs::write(&readable, "packages:\n - '.'\n").unwrap(); + assert!(matches!( + read_workspace_for_trust(&readable), + Ok(Some(text)) if text.contains("packages") + )); + // Invalid UTF-8 → Err(InvalidData), cross-platform. + let invalid = tmp.path().join("invalid.yaml"); + std::fs::write(&invalid, b"packages:\n - 'apps/*'\n\xff\xfe\x80").unwrap(); + let err = read_workspace_for_trust(&invalid) + .expect_err("invalid UTF-8 must classify as Err, never as absent→Create"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + // chmod 000 (unix): PermissionDenied → Err. Root ignores mode bits, + // so only the failing-read outcome is asserted strictly. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let locked = tmp.path().join("locked.yaml"); + std::fs::write(&locked, "packages:\n - '.'\n").unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + match read_workspace_for_trust(&locked) { + Err(e) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied), + // Running as root: mode bits don't apply; the invalid-UTF-8 + // case above already proved the Err classification. + Ok(Some(_)) => {} + Ok(None) => panic!("an unreadable file must never classify as absent"), + } + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).unwrap(); + } + // The fallback detail names the file, the error, and both manual + // recoveries — and never plans a write (it returns prose only). + let server = "the hosted patch server (patch.test)"; + let detail = pnpm_trust_workspace_unreadable_detail( + server, + &std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"), + ); + assert!(detail.contains("pnpm-workspace.yaml"), "{detail}"); + assert!(detail.contains("could not be read"), "{detail}"); + assert!(detail.contains("permission denied"), "{detail}"); + assert!(detail.contains("left untouched"), "{detail}"); + assert!( + detail.contains("--trust-lockfile") && detail.contains("trustLockfile: true"), + "{detail}" + ); + assert!(detail.contains("pnpm clean --lockfile"), "{detail}"); + } + + fn npm_override(artifact_url: &str) -> DepOverride { + DepOverride { + ecosystem: "npm".to_string(), + name: "in-proc-heal".to_string(), + namespace: None, + version: "1.0.0".to_string(), + token: "tok".to_string(), + patch_uuid: "11111111-1111-4111-8111-111111111111".to_string(), + artifact_url: artifact_url.to_string(), + berry_zip_url: None, + registry_override: None, + integrity: Default::default(), + } + } + + /// FINDING-6 regression (heal-on-rerun probe): a root lock ALREADY + /// carrying a granted hosted artifact URL from an earlier run — raw, + /// `\/`-escaped, or percent-encoded — is detected even when this run + /// spliced nothing, so the trust config can be (re)planned for a project + /// that missed it (opted-out first run, or a crash between the lock + /// write and the workspace write). A pristine lock, and a lock whose + /// only match is a NON-npm override's URL, must stay undetected. + #[test] + fn pnpm_lock_carries_hosted_redirect_detects_prior_run_splices() { + let url = "http://patch.test/patch/npm/in-proc-heal/1.0.0/tok/uuid/in-proc-heal-1.0.0.tgz"; + let mut cargo = npm_override("http://patch.test/crates/heal-1.0.0.crate"); + cargo.ecosystem = "cargo".to_string(); + let overrides = vec![npm_override(url), cargo]; + + // The exact splice shape an earlier run wrote (heal-on-rerun with a + // pre-redirected lock and a missing workspace file: this probe is + // what re-engages the trust planning on the re-scan). + let redirected = format!( + "lockfileVersion: '9.0'\n\npackages:\n in-proc-heal@1.0.0:\n \ + resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}\n" + ); + assert!(pnpm_lock_carries_hosted_redirect(&redirected, &overrides)); + + // The percent-encoded spelling counts too (same predicate set as the + // confirmation probe). + let encoded = socket_patch_core::utils::uri::encode_uri_component(url); + let encoded_lock = format!("lockfileVersion: '9.0'\npackages:\n x: {encoded}\n"); + assert!(pnpm_lock_carries_hosted_redirect(&encoded_lock, &overrides)); + + // Pristine lock: nothing to heal. + assert!(!pnpm_lock_carries_hosted_redirect( + "lockfileVersion: '9.0'\n\npackages:\n in-proc-heal@1.0.0:\n \ + resolution: {integrity: sha512-UPSTREAM==}\n", + &overrides + )); + + // A non-npm override's URL in the text is not a pnpm redirect. + assert!(!pnpm_lock_carries_hosted_redirect( + "lockfileVersion: '9.0'\n# http://patch.test/crates/heal-1.0.0.crate\n", + &overrides + )); + + // No grants at all → never engages. + assert!(!pnpm_lock_carries_hosted_redirect(&redirected, &[])); + } + + /// FINDING-6 regression (heal-on-rerun gate, the production + /// `pnpm_heal_root` wiring): a re-scan that spliced NOTHING over a + /// pre-redirected root v9 lock with a MISSING pnpm-workspace.yaml must + /// engage the trust block (heal → plan Create), while a legacy + /// pre-redirected lock, an unparseable-version lock, a pristine lock, + /// and a root lock this run DID splice all stay out of the heal path. + /// RED-verified by construction: the pre-fix trigger was + /// `!spliced.is_empty()` alone, i.e. this gate always answered None. + #[test] + fn pnpm_heal_root_re_engages_trust_planning_for_pre_redirected_v9_locks() { + let url = "http://patch.test/patch/npm/in-proc-heal/1.0.0/tok/uuid/in-proc-heal-1.0.0.tgz"; + let overrides = vec![npm_override(url)]; + let redirected_v9 = format!( + "lockfileVersion: '9.0'\n\npackages:\n in-proc-heal@1.0.0:\n \ + resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}\n" + ); + + // The heal scenario: nothing spliced this run, root lock already + // redirected, workspace file missing → the gate engages and the + // planning it feeds produces the Create the crashed/opted-out first + // run never wrote. + let healed = pnpm_heal_root(false, Some(&redirected_v9), &overrides) + .expect("a pre-redirected root v9 lock must re-engage the trust block"); + assert_eq!(healed, &redirected_v9); + assert!( + matches!(plan_workspace_trust(None), TrustPlan::Create(_)), + "with the workspace file missing, the healed run must plan the Create" + ); + + // Root lock spliced THIS run: the splice path covers it — no heal. + assert!(pnpm_heal_root(true, Some(&redirected_v9), &overrides).is_none()); + + // Pristine v9 lock (no redirect landed): nothing to heal. + let pristine = "lockfileVersion: '9.0'\n\npackages:\n in-proc-heal@1.0.0:\n \ + resolution: {integrity: sha512-UPSTREAM==}\n" + .to_string(); + assert!(pnpm_heal_root(false, Some(&pristine), &overrides).is_none()); + + // Legacy pre-redirected lock: pnpm 7/8 need no trust config — the + // heal gate must not drag a 5.x/6.0 lock into the v9 auto-config. + let redirected_v6 = format!( + "lockfileVersion: '6.0'\n\npackages:\n /in-proc-heal@1.0.0:\n \ + resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}\n" + ); + assert!(pnpm_heal_root(false, Some(&redirected_v6), &overrides).is_none()); + + // Unparseable version: fail closed, hands off. + let headless = format!("packages:\n x:\n resolution: {{tarball: {url}}}\n"); + assert!(pnpm_heal_root(false, Some(&headless), &overrides).is_none()); + + // No root lock at all (e.g. Rush): nothing to heal. + assert!(pnpm_heal_root(false, None, &overrides).is_none()); + } #[test] fn parse_purl_simple_percent_decodes_name_and_version() { diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index acbc88a3..f5f41a86 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -55,8 +55,12 @@ const DEFAULT_BATCH_SIZE: usize = 100; pub enum ScanMode { /// Rewrite lockfiles so ONLY patched dependencies resolve to Socket's /// hosted patch server (== `--redirect`): no artifact bytes land in the - /// repo, but installs must reach the patch server. - #[value(alias = "host")] + /// repo, but installs must reach the patch server. Hidden value aliases + /// mirror the legacy flag spellings symmetrically: `host` matches the + /// old mode name, `redirect` matches the `--redirect` boolean (vendored + /// accepts `vendor` for the same reason; `apply` is NOT an alias of + /// agent — applying is not a scan mode name anywhere else). + #[value(alias = "host", alias = "redirect")] Hosted, /// Commit patched artifacts to `.socket/vendor/` (== `--vendor`): /// hermetic, offline-safe installs at the cost of repo size. @@ -302,6 +306,13 @@ async fn embed_vex_into_json( "statements": summary.statements, "format": "openvex-0.2.0", }); + // Same additive `warnings` key the envelope's `VexSummary` + // carries (skip-if-empty): note_warning suppressed these on + // stderr under --json, so this is their only surviving channel. + if !summary.warnings.is_empty() { + result["vex"]["warnings"] = serde_json::to_value(&summary.warnings) + .expect("RunWarning is a plain string struct: serialization cannot fail"); + } 0 } Err(e) => { @@ -455,9 +466,13 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) - // mode's ledger on disk asserting wiring that is no longer live (and, for // vendored→hosted, the orphaned tarball behind). Anything auditing a ledger // as "what is live" (including `vex`) is then misled. Detect the overlap so -// each flow can warn; reconciliation (removing the stale ledger / orphaned -// artifacts) is deliberately deferred so neither mode silently mutates the -// other's ledger. +// each flow can warn. Reconciliation is per direction: the VENDORED flows +// clean the superseded redirect-ledger halves themselves (cargo via +// `revert_cargo_redirect_purl` before vendoring, npm-family via +// `note_vendor_supersedes_redirect` after — always announced by the +// takeover warning, never silent); the HOSTED direction stays warn-only +// (removing a vendored ledger entry means deleting committed artifacts — +// `remove `'s job, on the operator's say-so). // // The overlap alone only proves BOTH ledgers name the same package(s) — NOT // which one won. The takeover DIRECTION is decided by the ACTUAL current @@ -908,13 +923,15 @@ pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: boo "vendored artifacts superseded the hosted redirect ledger for: {list}. \ `.socket/vendor/redirect-state.json` still records a hosted redirect for \ these package(s), but the lockfile now points at the committed \ - `.socket/vendor/` files. Re-run `socket-patch vendor` (or `scan \ - --mode vendored`) to reconcile these package(s) automatically: it \ - reverts their stale hosted edits from the ledger and drops both \ - halves of each superseded entry — the `records` entry AND its \ - matching `edits`. To clean up by hand instead, delete only these \ - package(s)' entries under `records` AND their matching entries \ - under `edits`, so audits and VEX do not read superseded wiring. \ + `.socket/vendor/` files. The vendored flows (`socket-patch vendor`, \ + `scan --mode vendored`) reconcile npm-family and cargo package(s) \ + automatically on their next non-dry run, dropping both halves of \ + each superseded entry — the `records` entry AND its matching \ + `edits` (cargo additionally reverts the stale hosted edits on disk \ + first). For other package(s), or if the automatic reconciliation \ + could not run, clean up by hand: delete only these package(s)' \ + entries under `records` AND their matching entries under `edits`, \ + so audits and VEX do not read superseded wiring. \ Both halves matter: the leftover `edits` are that package's stale \ pre-redirect originals, which a later redirect revert would replay \ over the live vendored wiring — and an `edits` entry left behind \ @@ -928,12 +945,76 @@ pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: boo } } +/// Detail for the vendored-direction takeover warning on the run that +/// RECONCILED the ledger in place (non-dry-run, npm-family): past tense — +/// it states what was dropped and where the revert data now lives, so the +/// operator is told the takeover happened without being handed remediation +/// that is already done. The warning code stays `vendor_supersedes_redirect` +/// (envelope contract: codes are additive and stable; only the free-text +/// detail differs), and it fires exactly once — the reconciled ledger no +/// longer overlaps, so re-runs stay silent. +pub(super) fn mode_takeover_reconciled_detail(reconciled: &[String]) -> String { + let list = reconciled.join(", "); + format!( + "vendored artifacts superseded the hosted redirect ledger for: {list}; \ + reconciled automatically. Both halves of each superseded entry — the \ + package's `records` entry AND its matching `edits` — were dropped \ + from `.socket/vendor/redirect-state.json` (an emptied ledger is \ + deleted). The lockfile points at the committed `.socket/vendor/` \ + files, and the pre-vendor lock values (including the hosted-spliced \ + fragment) are preserved as the vendor ledger's wiring originals, so \ + `vendor --revert` still restores the hosted wiring losslessly. \ + Ledger data for other, still-redirected package(s) was left \ + untouched. No action needed." + ) +} + +/// Drop the superseded purls' `records` + `edits` from the redirect ledger +/// and persist it (atomic write; an emptied ledger is deleted — the same +/// delete-when-empty contract every other persist follows). Called ONLY with +/// purls [`classify_overlap_takeover`] proved vendored-live AND hosted-dead +/// against the LIVE lockfile: the gate that makes the warning truthful is +/// the one that makes the drop lossless (the vendor ledger's wiring +/// `original` embeds the hosted-spliced fragment, so `vendor --revert` needs +/// nothing from these records). `Ok(false)` when nothing matched (degenerate +/// — the caller falls back to the manual advisory rather than claiming a +/// reconciliation that did not happen); `Err` when the ledger could not be +/// read back or persisted (fail closed: the atomic writer leaves the on-disk +/// ledger either untouched or fully pre-drop, and the caller surfaces the +/// failure inside the warning). +async fn reconcile_superseded_redirect(cwd: &Path, purls: &[String]) -> Result { + let mut state = match socket_patch_core::patch::redirect::load_redirect_state(cwd).await { + Ok(Some(state)) => state, + Ok(None) => return Ok(false), + Err(corrupt) => return Err(corrupt.to_string()), + }; + let mut dropped = false; + for purl in purls { + dropped |= socket_patch_core::patch::redirect::drop_superseded_purl(&mut state, purl); + } + if !dropped { + return Ok(false); + } + socket_patch_core::patch::redirect::persist_redirect_state(cwd, &state) + .await + .map_err(|e| e.to_string())?; + Ok(true) +} + /// Cross-mode takeover advisory shared by every VENDORED flow (`vendor`, /// `scan --mode vendored`): when this ledger and a committed hosted redirect /// ledger both claim package(s) AND the live lockfile proves vendored won, /// the redirect ledger records for those package(s) are stale. Warn once at -/// the envelope level (JSON `warnings[]` and stderr) without deleting -/// anything — the per-package reconciliation lives in the vendor engine. +/// the envelope level (JSON `warnings[]` and stderr) — and, for npm-family +/// package(s) on a non-dry run, reconcile the ledger in place at the same +/// time (mirroring the cargo branch in `vendor.rs`, which reverts + drops +/// BEFORE vendoring because `[patch.crates-io]` cannot stack on the hosted +/// registry pin; npm-family needs no on-disk revert — vendoring already +/// overwrote the hosted splice and recorded it as the wiring `original`). +/// Without the drop, the stale records fed VEX/updates forever and this +/// warning re-fired on every subsequent run (`already_vendored` no-ops drop +/// nothing). The reverse direction (`redirect_supersedes_vendored`) is +/// deliberately untouched. pub(super) async fn note_vendor_supersedes_redirect( env: &mut crate::json_envelope::Envelope, cwd: &Path, @@ -948,14 +1029,59 @@ pub(super) async fn note_vendor_supersedes_redirect( if superseded.is_empty() { return; } - let detail = mode_takeover_detail(&superseded, /*current_is_hosted=*/ false); - if !common.silent && !common.json { - eprintln!("Warning ({VENDOR_SUPERSEDES_REDIRECT}): {detail}"); + fn push_warning(env: &mut crate::json_envelope::Envelope, common: &GlobalArgs, detail: String) { + if !common.silent && !common.json { + eprintln!("Warning ({VENDOR_SUPERSEDES_REDIRECT}): {detail}"); + } + env.warnings.push(crate::json_envelope::RunWarning { + code: VENDOR_SUPERSEDES_REDIRECT.to_string(), + detail, + }); + } + // Reconciliation is gated three ways, each fail-closed to the manual + // advisory: never under --dry-run (this advisory runs even on preview + // flows, and a dry run must not mutate the ledger); only npm-family + // purls (cargo goes through `revert_cargo_redirect_purl`'s on-disk + // revert in vendor.rs, and other ecosystems' vendor wiring has not been + // verified to embed the hosted originals); and only purls the live-lock + // classification above already proved no longer resolve the hosted URL. + let (reconcilable, manual): (Vec, Vec) = if common.dry_run { + (Vec::new(), superseded) + } else { + superseded + .into_iter() + .partition(|purl| purl.starts_with("pkg:npm/")) + }; + if !manual.is_empty() { + push_warning( + env, + common, + mode_takeover_detail(&manual, /*current_is_hosted=*/ false), + ); + } + if reconcilable.is_empty() { + return; + } + match reconcile_superseded_redirect(cwd, &reconcilable).await { + Ok(true) => push_warning(env, common, mode_takeover_reconciled_detail(&reconcilable)), + // Nothing matched to drop — do not claim a reconciliation that did + // not happen; hand out the manual remediation instead. + Ok(false) => push_warning( + env, + common, + mode_takeover_detail(&reconcilable, /*current_is_hosted=*/ false), + ), + Err(e) => push_warning( + env, + common, + format!( + "{} Automatic reconciliation failed ({e}); the ledger was left \ + as it was, so this warning will fire again until the cleanup \ + above succeeds.", + mode_takeover_detail(&reconcilable, /*current_is_hosted=*/ false) + ), + ), } - env.warnings.push(crate::json_envelope::RunWarning { - code: VENDOR_SUPERSEDES_REDIRECT.to_string(), - detail, - }); } /// Top-level `warnings[]` JSON for scan's envelope from `(code, detail)` diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 20a1efad..db41aee6 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -430,6 +430,9 @@ pub async fn run(args: VendorArgs) -> i32 { path: vex_path.display().to_string(), statements: summary.statements, format: "openvex-0.2.0".to_string(), + // note_warning suppressed these on stderr under + // --json; the envelope copy is their only + // surviving channel. warnings: summary.warnings, }); } @@ -544,6 +547,13 @@ async fn run_vendor( .await; if has_errors { + // A run where EVERY event failed still reads as "partialFailure": + // the envelope has no "completed with zero successes" status, and + // status=error is reserved for pre-event failures (it implies a + // top-level error payload and empty events[] — see json_envelope.rs). + // Escalating here without an envelope-level API broke that contract, + // and scan --vendor / vendor --revert report the same outcome as + // partialFailure, so this stays aligned with them. env.mark_partial_failure(); 1 } else { @@ -687,6 +697,11 @@ pub(crate) async fn vendor_records( service: Option<&VendorServiceConfig>, ) -> bool { let mut has_errors = false; + // Lockfile flavors the backends wired THIS run (from the returned ledger + // entries, not the whole ledger — an old pnpm entry must not re-flavor + // the hints of a run that vendored only cargo). Drives the human + // committable-files + reinstall hints below. + let mut wired_flavors: HashSet = HashSet::new(); let manifest_purls: Vec = records.keys().cloned().collect(); let partitioned = partition_purls(&manifest_purls, common.ecosystems.as_deref()); @@ -1208,6 +1223,9 @@ pub(crate) async fn vendor_records( record_warning(env, candidate, w, common); } if let Some(entry) = entry { + if let Some(flavor) = entry.flavor.as_deref() { + wired_flavors.insert(flavor.to_string()); + } has_errors |= persist_vendor_entry( common, env, &mut state, candidate, entry, detached, record, ) @@ -1248,11 +1266,16 @@ pub(crate) async fn vendor_records( HashSet::new() }; for purl in &unmatched { + // Honesty order: every purl here is first and foremost a crawler + // miss — nothing on disk matched — so the on-disk cause leads. + // The --offline note is strictly secondary and only stated when + // it is actually what blocked the fallback (the lockfile resolves + // the package, so a non-offline run would have auto-fetched it). let detail = if lock_resolvable.contains(purl) { - "no installed package found; --offline prevents fetching it from the \ - registry (the lockfile resolves it)" + "no installed package found on disk; the lockfile resolves it, but \ + --offline prevents fetching the pristine artifact from the registry" } else { - "no installed package found" + "no installed package found on disk" }; env.record( PatchEvent::new(PatchAction::Skipped, purl.clone()) @@ -1275,15 +1298,59 @@ pub(crate) async fn vendor_records( env.summary.applied, env.summary.skipped, env.summary.failed ); if env.summary.applied > 0 && !common.dry_run { - println!( - "Commit .socket/vendor/ and the updated lockfiles to make the patches portable." - ); + // pnpm >=11 reads `overrides` ONLY from pnpm-workspace.yaml (the + // 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. + if wired_flavors.contains("pnpm") { + println!( + "Commit .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)." + ); + } else { + println!( + "Commit .socket/vendor/ and the updated lockfiles to make the patches \ + portable." + ); + } + let mut installs: Vec<&str> = wired_flavors + .iter() + .filter_map(|f| flavor_install_command(f)) + .collect(); + installs.sort_unstable(); + for cmd in installs { + println!( + "Run `{cmd}` to update the installed tree — vendoring rewires the \ + lockfile only, so the current node_modules keeps the unpatched bytes \ + until reinstalled." + ); + } } } has_errors } +/// The install command that re-materializes the project tree from the wired +/// lockfile, per npm-family flavor. Vendoring edits ONLY the lockfile/config +/// wiring — the already-installed node_modules keeps its pre-vendor bytes +/// until the package manager reinstalls from the rewired lock (verified +/// against real pnpm installs) — so a successful vendor must say how to +/// update it. `None` for flavors whose consuming step is not an install. +fn flavor_install_command(flavor: &str) -> Option<&'static str> { + match flavor { + "package-lock" => Some("npm install"), + "yarn-classic" | "yarn-berry" => Some("yarn install"), + // pnpm-legacy (lockfileVersion 5.4/6.0): plain `pnpm install` is also + // the moved-checkout recovery — pnpm <= 8 absolutizes file: override + // specifiers, so `--frozen-lockfile` only passes at the vendoring path. + "pnpm" | "pnpm-legacy" => Some("pnpm install"), + "bun" => Some("bun install"), + _ => None, + } +} + /// Ledger entries whose patch is gone from the manifest — the stale test /// shared by [`reconcile_dropped`] and [`run_vendor_gc`]. Respects this /// run's --ecosystems scope: a `vendor --ecosystems npm` invocation must @@ -2047,12 +2114,27 @@ mod gc_tests { } /// (a) the patch is gone from the manifest: revert + drop the entry. + /// + /// The fixture entry carries EMPTY wiring (a synthetic ledger, not a + /// vendor-produced one), so the lock must no longer resolve through + /// the artifact for the revert to proceed: the unwired-revert guard + /// refuses to delete an artifact a live lock still points at (the + /// repair-reconstruction brick; pinned end-to-end in + /// repair_vendor_e2e / repair_vendor_flavors_e2e). Re-lock the + /// fixture to the registry — the realistic reclaim shape. #[tokio::test] async fn vendor_gc_reverts_manifest_dropped_entry() { let (tmp, common, manifest_path) = gc_fixture(false).await; write_manifest(&manifest_path, &PatchManifest::new()) .await .unwrap(); + tokio::fs::write( + tmp.path().join("package-lock.json"), + "{\"packages\":{\"node_modules/left-pad\":{\"resolved\":\ + \"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz\"}}}", + ) + .await + .unwrap(); let out = run_vendor_gc(&common, &manifest_path, false).await; assert_eq!(out.dropped_reverted, vec![PURL.to_string()], "{out:?}"); diff --git a/crates/socket-patch-cli/tests/apply_invariants.rs b/crates/socket-patch-cli/tests/apply_invariants.rs index fbb3c554..eea408d3 100644 --- a/crates/socket-patch-cli/tests/apply_invariants.rs +++ b/crates/socket-patch-cli/tests/apply_invariants.rs @@ -562,3 +562,129 @@ fn apply_with_unreadable_socket_dir_fails_closed() { "expected the manifest_unreadable envelope error; envelope: {v}" ); } + +/// Lay down a project with TWO npm manifest patches, BOTH with their +/// patched blobs staged (so the offline no-local-source guard never +/// fires and the only difference between them is matchability): +/// - `scopedpkg@1.0.0` is installed on disk and fully applicable; +/// - `__ghost_pkg__@9.9.9` matches nothing on disk. +fn write_partial_match_project(root: &Path) { + let before = git_sha256(SCOPED_ORIGINAL); + let after = git_sha256(SCOPED_PATCHED); + let ghost_after = git_sha256(b"ghost patched\n"); + + std::fs::write( + root.join("package.json"), + r#"{"name":"partial-match-test","version":"0.0.0"}"#, + ) + .expect("write package.json"); + + let pkg = root.join("node_modules").join("scopedpkg"); + std::fs::create_dir_all(&pkg).expect("create package dir"); + std::fs::write( + pkg.join("package.json"), + r#"{"name":"scopedpkg","version":"1.0.0"}"#, + ) + .expect("write pkg package.json"); + std::fs::write(pkg.join("index.js"), SCOPED_ORIGINAL).expect("write index.js"); + + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).expect("create blobs"); + std::fs::write(socket.join("blobs").join(&after), SCOPED_PATCHED).expect("write blob"); + std::fs::write(socket.join("blobs").join(&ghost_after), b"ghost patched\n") + .expect("write ghost blob"); + let before_ghost = git_sha256(b"ghost original\n"); + let manifest = format!( + r#"{{ + "patches": {{ + "{SCOPED_NPM_PURL}": {{ + "uuid": "33333333-3333-4333-8333-333333333333", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ + "package/index.js": {{ "beforeHash": "{before}", "afterHash": "{after}" }} + }}, + "vulnerabilities": {{}}, + "description": "installed npm patch with local sources", + "license": "MIT", + "tier": "free" + }}, + "pkg:npm/__ghost_pkg__@9.9.9": {{ + "uuid": "44444444-4444-4444-8444-444444444444", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ + "package/index.js": {{ "beforeHash": "{before_ghost}", "afterHash": "{ghost_after}" }} + }}, + "vulnerabilities": {{}}, + "description": "npm patch whose package is not installed", + "license": "MIT", + "tier": "free" + }} + }} +}}"# + ); + std::fs::write(socket.join("manifest.json"), manifest).expect("write manifest"); +} + +/// PINS the current (asymmetric) unmatched-purl exit semantics so any +/// change to them is deliberate. Today: +/// +/// - a manifest whose patches ALL miss (no installed package matches) +/// exits 1 with `status: partialFailure`, while +/// - a MIXED manifest (one patch applied, one unmatched) exits 0 with +/// `status: success` and only a per-purl `skipped` event +/// (`errorCode: package_not_installed`) recording the miss. +/// +/// The asymmetry is a known open decision, not an endorsement: CI that +/// gates on the exit code reads the mixed run as fully patched even when +/// a manifest-listed CVE patch was silently not applied (2026-08-18 pnpm +/// matrix, apply-pnpm8 leg), while install hooks want the all-miss case +/// to STOP exiting 1 (it fails `npm install` from the postinstall hook). +/// Resolving either direction changes a documented contract for the +/// other consumer — whoever does it must update BOTH halves of this test +/// and the hook/CI guidance together. +#[test] +fn unmatched_purl_exit_semantics_are_pinned() { + // Mixed manifest: applied + unmatched → exit 0, status success, + // with the miss visible only as a skipped event. + let tmp = tempfile::tempdir().expect("tempdir"); + write_partial_match_project(tmp.path()); + let (code, stdout) = run_apply(tmp.path(), &["--offline", "--silent"]); + let v: serde_json::Value = + serde_json::from_str(&stdout).expect("apply --json must emit valid JSON"); + assert_eq!(code, 0, "mixed manifest pins exit 0 today; envelope:\n{v}"); + assert_eq!( + v["status"], "success", + "mixed manifest pins status=success; {v}" + ); + assert_eq!(v["summary"]["applied"], 1, "{v}"); + let events = v["events"].as_array().expect("events array"); + assert!( + events + .iter() + .any(|e| e["action"] == "applied" && e["purl"] == SCOPED_NPM_PURL), + "installed patch must be applied; {events:?}" + ); + let ghost = events + .iter() + .find(|e| e["purl"] == "pkg:npm/__ghost_pkg__@9.9.9") + .unwrap_or_else(|| panic!("unmatched purl must surface as an event; {events:?}")); + assert_eq!(ghost["action"], "skipped", "{ghost}"); + assert_eq!( + ghost["errorCode"], "package_not_installed", + "the miss must be machine-identifiable; {ghost}" + ); + + // All-miss manifest (same ghost patch alone, blob still staged so the + // offline source guard is not what fires) → exit 1, partialFailure. + let tmp2 = tempfile::tempdir().expect("tempdir"); + write_partial_match_project(tmp2.path()); + std::fs::remove_dir_all(tmp2.path().join("node_modules")).expect("uninstall scopedpkg"); + let (code2, stdout2) = run_apply(tmp2.path(), &["--offline", "--silent"]); + let v2: serde_json::Value = + serde_json::from_str(&stdout2).expect("apply --json must emit valid JSON"); + assert_eq!( + code2, 1, + "all-miss manifest pins exit 1 today; envelope:\n{v2}" + ); + assert_eq!(v2["status"], "partialFailure", "{v2}"); +} diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index 6a111b75..b3d02e65 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -108,6 +108,9 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg ("--yes", None, |c| assert!(c.yes)), ("--debug", None, |c| assert!(c.debug)), ("--no-telemetry", None, |c| assert!(c.no_telemetry)), + ("--no-trust-lockfile-config", None, |c| { + assert!(c.no_trust_lockfile_config) + }), ("--lock-timeout", Some("30"), |c| { assert_eq!(c.lock_timeout, Some(30)) }), @@ -220,17 +223,18 @@ fn global_flag_cases_cover_every_global_field() { lock_timeout: _, debug: _, no_telemetry: _, + no_trust_lockfile_config: _, strict: _, vendor_source: _, vendor_url: _, patch_server_url: _, } = common; - // 23 fields ↔ 23 long-flag cases. Bump both this count and add a case when + // 24 fields ↔ 24 long-flag cases. Bump both this count and add a case when // the destructure above forces you to add a field. assert_eq!( global_flag_cases().len(), - 23, + 24, "every GlobalArgs field needs a long-flag case in global_flag_cases()", ); @@ -651,7 +655,7 @@ fn bool_env_vars_reject_zero_and_falsey() { #[serial_test::serial] fn empty_bool_env_var_resolves_to_false_not_crash() { // (env var, accessor) for every boolean global. - let bool_vars: [(&str, fn(&GlobalArgs) -> bool); 10] = [ + let bool_vars: [(&str, fn(&GlobalArgs) -> bool); 11] = [ ("SOCKET_OFFLINE", |c| c.offline), ("SOCKET_STRICT", |c| c.strict), ("SOCKET_GLOBAL", |c| c.global), @@ -662,6 +666,9 @@ fn empty_bool_env_var_resolves_to_false_not_crash() { ("SOCKET_YES", |c| c.yes), ("SOCKET_DEBUG", |c| c.debug), ("SOCKET_TELEMETRY_DISABLED", |c| c.no_telemetry), + ("SOCKET_NO_TRUST_LOCKFILE_CONFIG", |c| { + c.no_trust_lockfile_config + }), ]; let saved = save_and_clear_global_env(); diff --git a/crates/socket-patch-cli/tests/cli_parse_get.rs b/crates/socket-patch-cli/tests/cli_parse_get.rs index 7e01c06f..34cc1dda 100644 --- a/crates/socket-patch-cli/tests/cli_parse_get.rs +++ b/crates/socket-patch-cli/tests/cli_parse_get.rs @@ -54,6 +54,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_LOCK_TIMEOUT", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", // GetArgs-specific "SOCKET_SAVE_ONLY", "SOCKET_ONE_OFF", diff --git a/crates/socket-patch-cli/tests/cli_parse_repair.rs b/crates/socket-patch-cli/tests/cli_parse_repair.rs index 1c31261f..2d4443b9 100644 --- a/crates/socket-patch-cli/tests/cli_parse_repair.rs +++ b/crates/socket-patch-cli/tests/cli_parse_repair.rs @@ -61,6 +61,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_LOCK_TIMEOUT", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", // RepairArgs-specific "SOCKET_DOWNLOAD_ONLY", ]; diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index adb8c974..2247ae36 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -42,6 +42,7 @@ const SCAN_ENV_VARS: &[&str] = &[ "SOCKET_JSON", "SOCKET_LOCK_TIMEOUT", "SOCKET_MANIFEST_PATH", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", "SOCKET_OFFLINE", "SOCKET_ORG_SLUG", "SOCKET_PATCH_SERVER_URL", @@ -764,12 +765,15 @@ fn scrub_covers_every_scan_env_var_clap_consults() { } } -// --- hidden `--mode` value aliases ("vendor" / "host") ---------------------- +// --- hidden `--mode` value aliases ("vendor" / "host" / "redirect") --------- // -// `--mode vendor` and `--mode host` are UNDOCUMENTED spellings accepted for -// muscle-memory reasons (they match the boolean flag names). They are clap -// value aliases on the `ScanMode` variants, which clap keeps out of help -// output — the tests below lock in both the acceptance and the hiding. +// `--mode vendor`, `--mode host`, and `--mode redirect` are UNDOCUMENTED +// spellings accepted for muscle-memory reasons (they match the legacy +// boolean flag names / the old mode name). They are clap value aliases on +// the `ScanMode` variants, which clap keeps out of help output — the tests +// below lock in both the acceptance and the hiding. `--mode apply` is +// deliberately NOT an alias: applying is not a scan mode name anywhere +// (the canonical name is `agent`), so it must stay rejected. #[test] #[serial_test::serial] @@ -801,6 +805,46 @@ fn mode_alias_host_folds_to_redirect() { ); } +#[test] +#[serial_test::serial] +fn mode_alias_redirect_folds_to_hosted() { + // `redirect` is the legacy FLAG spelling (`--redirect`); pre-fix the + // value aliases were asymmetric — `--mode vendor`/`--mode host` parsed + // while `--mode redirect` was rejected, even though `--redirect` itself + // still folds to hosted. + assert_eq!( + parse_scan(&["--mode", "redirect"]).mode, + Some(ScanMode::Hosted) + ); + let folded = parse_and_resolve(&["--mode", "redirect"]).expect("fold ok"); + assert_eq!( + folded.mode, + Some(ScanMode::Hosted), + "--mode redirect (hidden alias) == --mode hosted" + ); + // The alias agrees with its own boolean: redundant, not contradictory. + let folded = parse_and_resolve(&["--mode", "redirect", "--redirect"]).expect("fold ok"); + assert_eq!(folded.mode, Some(ScanMode::Hosted)); +} + +#[test] +#[serial_test::serial] +fn mode_apply_stays_rejected() { + // `apply` is NOT a scan mode name (canonical: `agent`); accepting it + // would mint a fourth spelling nothing else recognizes. Clap must + // reject it at parse time like any unknown value. + let parsed = + with_clean_env(|| Cli::try_parse_from(["socket-patch", "scan", "--mode", "apply"])); + let Err(err) = parsed else { + panic!("--mode apply must be rejected"); + }; + let rendered = err.to_string(); + assert!( + rendered.contains("apply"), + "the error must echo the rejected value: {rendered}" + ); +} + #[test] #[serial_test::serial] fn mode_alias_host_with_vendor_boolean_errors_with_canonical_name() { @@ -852,7 +896,7 @@ fn mode_aliases_hidden_from_help() { "scan --help must itemize `{canonical}`; help was:\n{long}" ); } - for alias in ["host:", "vendor:"] { + for alias in ["host:", "vendor:", "redirect:"] { // "host:" is NOT a substring of "hosted:" (the canonical item has // 'e' after "host"), so any literal hit is a genuine leak. assert!( diff --git a/crates/socket-patch-cli/tests/cli_parse_vendor.rs b/crates/socket-patch-cli/tests/cli_parse_vendor.rs index 64d94830..33606eb8 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vendor.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vendor.rs @@ -58,6 +58,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_LOCK_TIMEOUT", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", // VendorArgs-specific "SOCKET_FORCE", "SOCKET_VENDOR_REVERT", diff --git a/crates/socket-patch-cli/tests/cli_parse_vex.rs b/crates/socket-patch-cli/tests/cli_parse_vex.rs index 951ae2fd..65543c6b 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vex.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vex.rs @@ -214,6 +214,7 @@ struct Snap { lock_timeout: Option, debug: bool, no_telemetry: bool, + no_trust_lockfile_config: bool, output: Option, product: Option, no_verify: bool, @@ -246,6 +247,7 @@ fn snapshot(a: &VexArgs) -> Snap { lock_timeout: a.common.lock_timeout, debug: a.common.debug, no_telemetry: a.common.no_telemetry, + no_trust_lockfile_config: a.common.no_trust_lockfile_config, output: a.output.clone(), product: a.product.clone(), no_verify: a.no_verify, @@ -285,6 +287,7 @@ fn expected_defaults() -> Snap { lock_timeout: None, debug: false, no_telemetry: false, + no_trust_lockfile_config: false, output: None, product: None, no_verify: false, diff --git a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs new file mode 100644 index 00000000..fee0f6e6 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs @@ -0,0 +1,1218 @@ +//! Real-install redirect capstone e2e for pnpm — the pnpm counterpart of +//! `tests/e2e_redirect_npm_build.rs`. +//! +//! `scan --mode hosted` never lands patched bytes in the repo: it splices the +//! patched package's `resolution:` in pnpm-lock.yaml to `{integrity: +//! , tarball: }` (a wiremock standing in for +//! patch.socket.dev) and records the patch in the redirect ledger. The +//! corepack legs prove every link of that chain against the REAL pnpm: +//! +//! 1. `corepack pnpm@ install left-pad@1.3.0` into a tempdir project +//! (network used for fixture setup only, private `--store-dir`). +//! 2. Build a PATCHED tarball from the actually-installed bytes (marker +//! comment prepended to `index.js`) and serve it from wiremock, alongside +//! the discovery / reference / view API mocks. +//! 3. `scan --mode hosted --json --yes` (the real binary): the lock's +//! `resolution:` now pins the wiremock tarball URL + the patched +//! tarball's sha512, the ledger holds the `redirect_pnpm_resolution` +//! edit + the patch record, and a second scan is idempotent (lock +//! byte-stable, no duplicate ledger edits). +//! 4. FRESH-CHECKOUT PROOF: only package.json + pnpm-lock.yaml + `.socket/` +//! travel, the `.npmrc` registry points at a DEAD port, the store is +//! empty — `pnpm install --frozen-lockfile` MUST land the marker bytes, +//! because the only reachable artifact URL is the hosted tarball. +//! +//! The tamper twin serves DIFFERENT bytes under the honest sha512 pin: the +//! fresh install must FAIL on the integrity check — the lockfile pin is +//! enforcement, not decoration. +//! +//! Version ladder (the `e2e_vendor_pnpm_build.rs` convention): pnpm@10 is the +//! PRIMARY leg (skips only when corepack/pnpm is unfetchable or the fixture +//! install cannot reach the registry); pnpm@7, pnpm@8, pnpm@9 and pnpm@11 are +//! opportunistic. The pnpm@7/@8 legs prove the LEGACY lock grammars end to +//! end: their pnpm-emitted v5.4 / v6 locks are spliced by the same rewrite +//! and both majors frozen-install the hosted tarball from an empty store +//! (verified live 2026-08-18, corepack pnpm@7.33.5 / pnpm@8.15.9). +//! +//! TRUST AUTO-CONFIG: a scan that rewrites a ROOT v9 lock also ensures +//! `trustLockfile: true` in pnpm-workspace.yaml (ledger edit kind +//! `redirect_pnpm_workspace_trust`; the workspace file joins +//! `rewrittenFiles`), because pnpm >=11's lockfile supply-chain policy +//! rejects the rewritten lock otherwise. Legacy 5.x/6.0 locks mean pnpm 7/8 +//! — no policy, no setting — so they rewrite ONLY the lock and keep the +//! manual `--trust-lockfile` guidance (the gate is lock-major >= 9). Two +//! pnpm@11 legs pin both sides empirically: the ZERO-TOUCH leg commits the +//! scan-written workspace file and the plain dead-registry frozen install +//! succeeds with NO flags; the `--no-trust-lockfile-config` control pins the +//! opt-out (no workspace write) plus the old behavior it restores — the +//! plain frozen install fails against a dead registry +//! (ERR_PNPM_META_FETCH_FAIL there — ERR_PNPM_TARBALL_URL_MISMATCH needs +//! reachable registry metadata) and the manual `--trust-lockfile` flag +//! recovers. +//! +//! Two synthetic legs need no pnpm at all (hermetic wiremock, never ignored), +//! pinning the rewrite grammar against byte-accurate locks captured from the +//! 2026-08-18 pnpm matrix sweep: a v5.4 lock (`/name/version:` key) and a v6 +//! plain key (`/name@version:`) each splice in place with sibling lines +//! byte-preserved. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use sha2::{Digest, Sha512}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/cache_env.rs"] +mod cache_env; + +const ORG: &str = "test-org"; +const DEP: &str = "left-pad"; +const DEP_VERSION: &str = "1.3.0"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +/// Canonical lowercase patch uuid (a dedicated path level of the hosted URL). +const UUID: &str = "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d"; +/// Access-token uuid segment of the hosted download URL (opaque to the CLI — +/// it just writes the URL the reference endpoint hands back). +const TOKEN: &str = "22222222-2222-4222-8222-222222222222"; +/// Marker prepended to the dep's entry point by the synthetic patch. +const MARKER: &str = "/* SOCKET-PATCHED */\n"; +const GHSA: &str = "GHSA-redirect-pnpm"; +/// Pinned pnpm majors via corepack — @10 is the required leg, the others are +/// opportunistic (the vendor capstone's ladder convention). @7 and @8 are the +/// legacy-lock legs: they emit lockfileVersion 5.4 / 6.0, proving the v5/v6 +/// rewrite installs for real. +const PNPM_PRIMARY: &str = "pnpm@10"; +const PNPM_SECONDARY: &str = "pnpm@9"; +const PNPM_TERTIARY: &str = "pnpm@11"; +const PNPM_LEGACY_V5: &str = "pnpm@7"; +const PNPM_LEGACY_V6: &str = "pnpm@8"; +/// left-pad@1.3.0's registry integrity, byte-accurate from the matrix legs' +/// pnpm-emitted locks — the synthetic legs' pristine `resolution:` value. +const UPSTREAM_SHA512: &str = "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="; +/// Synthetic patched integrity for the no-install legs (nothing downloads it, +/// so it only has to be distinct from the upstream value). +const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; + +// ── self-contained helpers ──────────────────────────────────────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +/// Probe corepack from a NEUTRAL temp dir (a `packageManager` field in an +/// ancestor package.json — e.g. this monorepo root — otherwise makes corepack +/// refuse a different manager). +fn has_corepack_pm(pm: &str) -> bool { + let Ok(probe) = tempfile::tempdir() else { + return false; + }; + // Isolated too: this probe is what actually downloads the package manager + // the first time, and corepack stores it under `COREPACK_HOME`. + let mut cmd = Command::new("corepack"); + cmd.args([pm, "--version"]) + .current_dir(probe.path()) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cache_env::isolate(&mut cmd); + cmd.stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Remove ambient `SOCKET_*` / `PNPM_*` / `npm_config_*` vars. +/// +/// Seed-then-scrub (mirrors e2e_vendor_pnpm_build.rs): pnpm lets EVERY +/// `.npmrc` setting be overridden by an `npm_config_*` env var (env outranks +/// the project npmrc), so an ambient `npm_config_node_linker=pnp` alone can +/// turn a capstone red. The explicit env_remove below clears the seed too, +/// but if the prefix scrub is ever dropped the seed (rather than a +/// developer's ambient shell, which this suite can't rely on) turns the test +/// red immediately. +fn scrub_socket_env(cmd: &mut Command) { + cmd.env("npm_config_node_linker", "pnp"); + for (k, _) in std::env::vars_os() { + let key = k.to_string_lossy(); + if (key.starts_with("SOCKET_") + || key.starts_with("PNPM_") + || key.to_ascii_lowercase().starts_with("npm_config_")) + && key != "SOCKET_NO_CONFIG" + { + cmd.env_remove(&k); + } + } + cmd.env_remove("VIRTUAL_ENV"); + cmd.env_remove("npm_config_node_linker"); +} + +fn corepack(cwd: &Path, pm: &str, args: &[&str]) -> Output { + let mut cmd = Command::new("corepack"); + cmd.arg(pm).args(args).current_dir(cwd); + scrub_socket_env(&mut cmd); + // After the scrub: it strips ambient `PNPM_*` / `npm_config_*`, which + // would otherwise take the sandbox values back out again. + cache_env::isolate(&mut cmd); + cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cmd.output().expect("failed to run corepack") +} + +fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + scrub_socket_env(&mut cmd); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// `scan --mode hosted` (the real binary) over the project at `root`. +/// `extra_args` rides at the end (e.g. `--no-trust-lockfile-config`). +fn scan_hosted(root: &Path, api_url: &str, extra_args: &[&str]) -> (i32, String, String) { + let mut args = vec![ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + root.to_str().unwrap(), + "--api-url", + api_url, + "--org", + ORG, + "--api-token", + "fake", + ]; + args.extend_from_slice(extra_args); + run_socket(root, &args) +} + +fn parse_envelope(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout).unwrap_or_else(|e| { + panic!("scan --mode hosted --json output is not JSON: {e}\nstdout:\n{stdout}") + }) +} + +fn warning_codes(env: &serde_json::Value) -> Vec { + env["redirect"]["warnings"] + .as_array() + .map(|ws| { + ws.iter() + .map(|w| w["code"].as_str().unwrap_or("").to_string()) + .collect() + }) + .unwrap_or_default() +} + +/// Standard-base64-encoded sha512 of `bytes` — the body of the npm-family +/// `sha512-…` SRI integrity string. +fn sha512_sri_b64(bytes: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes)) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +fn hosted_url_for(base: &str) -> String { + format!("{base}/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz") +} + +/// A patched npm tarball built from the ACTUALLY-installed package: every +/// installed file travels under the `package/` prefix, with the entry point +/// swapped for `patched_index`. Built with the tar crate rather than a system +/// `tar` so the suite has no external-binary dependency (pnpm installs from +/// tar-crate output fine — `e2e_redirect_rush_sim.rs` proved it). +fn make_tgz_from_installed(pkg_dir: &Path, patched_index: &[u8]) -> Vec { + // node_modules/ is a symlink into .pnpm under pnpm's layout, and a + // symlinked dir must be walked through its real path. + let pkg_dir = pkg_dir + .canonicalize() + .expect("installed package dir must resolve"); + let mut files: Vec = Vec::new(); + let mut stack = vec![pkg_dir.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + stack.push(p); + } else { + files.push(p); + } + } + } + files.sort(); + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + for p in &files { + let rel = p.strip_prefix(&pkg_dir).unwrap(); + // Tar entry names always use `/` regardless of host separator. + let name = format!( + "package/{}", + rel.components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/") + ); + let bytes = if rel == Path::new("index.js") { + patched_index.to_vec() + } else { + std::fs::read(p).unwrap() + }; + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, &name, bytes.as_slice()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + +/// Mount discovery + by-package + reference + view (same contract as +/// `tests/in_process_redirect_pnpm.rs` / `e2e_redirect_npm_build.rs`). +/// `before_hash`/`after_hash` are the view record's file hashes. +async fn mount_api_mocks( + server: &MockServer, + hosted_url: &str, + sri: &str, + before_hash: &str, + after_hash: &str, +) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "pnpm redirect capstone fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + UUID: { + "status": "granted", + "url": hosted_url, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": hosted_url, + "integrity": { "sha512": sri } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": before_hash, + "afterHash": after_hash, + } + }, + "vulnerabilities": { + GHSA: { + "cves": ["CVE-2026-2222"], + "summary": "pnpm redirect capstone vuln", + "severity": "high", + "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; +} + +/// The hosted tarball route pnpm hits at install time. Separate from the API +/// mocks because the tamper twin serves different bytes than the pinned sri. +async fn mount_tarball_route(server: &MockServer, served: Vec) { + Mock::given(method("GET")) + .and(path(format!( + "/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz" + ))) + .respond_with(ResponseTemplate::new(200).set_body_raw(served, "application/octet-stream")) + .mount(server) + .await; +} + +/// Everything the post-redirect legs need. `tmp` owns the whole tree; +/// `_server` keeps the hosted-tarball route alive through the fresh installs. +struct PnpmRedirectFixture { + tmp: tempfile::TempDir, + proj: PathBuf, + patched: Vec, + _server: MockServer, +} + +/// Steps 1–3 of the module doc against the REAL `corepack `: fixture +/// install, patched tarball + API mocks, `scan --mode hosted`, and the +/// envelope/lockfile/ledger/idempotency assertions. When +/// `tamper_served_tarball` is set, the tarball route serves DIFFERENT bytes +/// than the sha512 pinned into the lockfile — the negative twin's premise. +/// `no_trust_config` runs every scan with `--no-trust-lockfile-config` and +/// flips the trust-auto-config expectations to the opted-out contract. +/// `None` = skip (message already printed). +async fn redirect_scanned_pnpm_project( + pm: &str, + tag: &str, + tamper_served_tarball: bool, + no_trust_config: bool, +) -> Option { + if !has_corepack_pm(pm) { + println!("SKIP e2e_redirect_pnpm_build ({tag}): `corepack {pm}` unavailable"); + return None; + } + + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("package.json"), + format!( + r#"{{ "name": "pnpm-redirect-capstone", "version": "0.0.0", "private": true, "dependencies": {{ "{DEP}": "{DEP_VERSION}" }} }}"# + ), + ) + .unwrap(); + + // 1. REAL fixture: pnpm install (network allowed here, private store). + let store = tmp.path().join("pnpm-store"); + let install = corepack( + &proj, + pm, + &["install", "--store-dir", store.to_str().unwrap()], + ); + if !install.status.success() { + println!( + "SKIP e2e_redirect_pnpm_build ({tag}): fixture `{pm} install` failed \ + (registry unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return None; + } + + let orig = std::fs::read(proj.join("node_modules").join(DEP).join("index.js")) + .expect("installed index.js"); + assert!( + !orig.starts_with(MARKER.as_bytes()), + "pristine install must not carry the marker" + ); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + + // 2. Patched tarball from the ACTUAL installed bytes. The lockfile pin is + // ALWAYS the real tarball's sha512; the negative twin only tampers + // what the route SERVES, so the pin is what catches the swap. + let tgz = make_tgz_from_installed(&proj.join("node_modules").join(DEP), &patched); + let sri = format!("sha512-{}", sha512_sri_b64(&tgz)); + let served: Vec = if tamper_served_tarball { + let tampered: Vec = [b"/* SOCKET-TAMPERED */\n", orig.as_slice()].concat(); + make_tgz_from_installed(&proj.join("node_modules").join(DEP), &tampered) + } else { + tgz.clone() + }; + + // 3. API mocks + the hosted tarball route the fresh installs will hit. + let server = MockServer::start().await; + let hosted_url = hosted_url_for(&server.uri()); + mount_api_mocks( + &server, + &hosted_url, + &sri, + &compute_git_sha256_from_bytes(&orig), + &compute_git_sha256_from_bytes(&patched), + ) + .await; + mount_tarball_route(&server, served).await; + + let lock_path = proj.join("pnpm-lock.yaml"); + let lock_before = std::fs::read_to_string(&lock_path).expect("pnpm-lock.yaml after install"); + let pkg_before = std::fs::read(proj.join("package.json")).unwrap(); + // Whether the fixture install left a workspace file behind decides the + // trust edit's action: "created" (new file) vs "added" (line appended). + let ws_path = proj.join("pnpm-workspace.yaml"); + let ws_existed_before = ws_path.exists(); + // The pristine resolution line — captured (not hardcoded) so "the upstream + // integrity is gone" can be asserted against whatever the registry served. + let upstream_resolution = lock_before + .lines() + .find(|l| l.trim_start().starts_with("resolution: {integrity:")) + .expect("pristine lock must carry an inline resolution") + .to_string(); + + let scan_extra: &[&str] = if no_trust_config { + &["--no-trust-lockfile-config"] + } else { + &[] + }; + let (code, stdout, stderr) = scan_hosted(&proj, &server.uri(), scan_extra); + assert_eq!( + code, 0, + "scan --mode hosted failed ({tag}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "exactly one dep redirected: {env}" + ); + // The zero-touch trustLockfile auto-config fires only for root v9 locks + // (and not under `--no-trust-lockfile-config`): pnpm 7/8 (5.x/6.0) have + // 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; + let expected_rewrites = if auto_trust { + serde_json::json!(["pnpm-lock.yaml", "pnpm-workspace.yaml"]) + } else { + serde_json::json!(["pnpm-lock.yaml"]) + }; + assert_eq!( + env["redirect"]["rewrittenFiles"], expected_rewrites, + "the rewritten set must match the lock's grammar + trust config ({tag}): {env}" + ); + // The install-guidance warning: assert the CODE and the recovery's stable + // spelling only — the detail prose is not part of the contract. + assert!( + warning_codes(&env).contains(&"redirect_pnpm_trust_lockfile".to_string()), + "a landed pnpm rewrite must warn about pnpm >=11 installs: {env}" + ); + let trust_detail = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_default() + .to_string(); + if auto_trust { + assert!( + trust_detail.contains("trustLockfile: true"), + "the v9 warning must name the auto-configured trustLockfile key; got: {trust_detail}" + ); + } else if v9_lock { + // Opted out on a v9 lock: the manual two-recovery guidance stands. + assert!( + trust_detail.contains("trust-lockfile"), + "the opted-out v9 warning must name the manual trust-lockfile recovery; \ + got: {trust_detail}" + ); + } else { + // Legacy (5.x/6.0) lock: pnpm 7/8 reject `--trust-lockfile` as an + // unknown option, so the guidance must never mention it — installs + // work unchanged and no trust step exists on those majors. + assert!( + !trust_detail.contains("trust-lockfile"), + "the legacy-lock warning must not recommend --trust-lockfile (pnpm 7/8 \ + reject the flag); got: {trust_detail}" + ); + assert!( + trust_detail.contains("pnpm 7/8") && trust_detail.contains("installs work unchanged"), + "the legacy-lock warning must say installs work unchanged on pnpm 7/8; \ + got: {trust_detail}" + ); + } + + // Trust auto-config surface: the workspace file itself. Auto runs write + // `trustLockfile: true`; legacy / opted-out runs must leave the file + // exactly as the fixture install left it (absent, for these fixtures). + let ws_after_scan = if auto_trust { + let ws = std::fs::read_to_string(&ws_path) + .expect("a v9 rewrite must auto-write pnpm-workspace.yaml"); + assert!( + ws.contains("trustLockfile: true"), + "the scan-written workspace file must carry the trust key ({tag}); got:\n{ws}" + ); + Some(ws) + } else { + assert_eq!( + ws_path.exists(), + ws_existed_before, + "a legacy-lock or --no-trust-lockfile-config run must not create \ + pnpm-workspace.yaml ({tag})" + ); + None + }; + + // Lock splice: `{integrity: , tarball: }` with + // the upstream resolution line fully replaced; package.json untouched + // (hosted mode edits only the lock). + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + assert!( + lock_after.contains(&format!( + "resolution: {{integrity: {sri}, tarball: {hosted_url}}}" + )), + "resolution must be spliced to the patched sri + hosted tarball; got:\n{lock_after}" + ); + assert!( + !lock_after.contains(&upstream_resolution), + "the upstream resolution line must be replaced; got:\n{lock_after}" + ); + assert_eq!( + std::fs::read(proj.join("package.json")).unwrap(), + pkg_before, + "hosted mode must not edit package.json" + ); + + // Ledger: the lock edit (with the original resolution preserved for + // revert) + the embedded patch record a post-install `vex` verifies. + let ledger_path = proj.join(".socket/vendor/redirect-state.json"); + let ledger: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); + let edits = ledger["edits"].as_array().unwrap().clone(); + assert!( + edits.iter().any(|e| e["kind"] == "redirect_pnpm_resolution" + && e["key"] == format!("{DEP}@{DEP_VERSION}") + && e["path"] == "pnpm-lock.yaml"), + "the ledger must record the redirect_pnpm_resolution edit: {ledger}" + ); + let trust_edits: Vec<&serde_json::Value> = edits + .iter() + .filter(|e| e["kind"] == "redirect_pnpm_workspace_trust") + .collect(); + if auto_trust { + // Exactly one trust edit, so `--revert` unwinds exactly one write. + assert_eq!( + trust_edits.len(), + 1, + "a v9 rewrite must record exactly one workspace trust edit: {ledger}" + ); + let edit = trust_edits[0]; + assert_eq!(edit["path"], "pnpm-workspace.yaml", "trust edit: {edit}"); + assert_eq!(edit["key"], "trustLockfile", "trust edit: {edit}"); + // "created" = new file (revert deletes it); "added" = line appended + // to a pre-existing file (revert removes only that line). + let expected_action = if ws_existed_before { + "added" + } else { + "created" + }; + assert_eq!(edit["action"], expected_action, "trust edit: {edit}"); + } else { + assert!( + trust_edits.is_empty(), + "legacy-lock / --no-trust-lockfile-config runs must record no workspace \ + trust edit: {ledger}" + ); + } + assert!( + ledger["records"][PURL]["vulnerabilities"][GHSA].is_object(), + "the ledger must embed the patch record + vulnerability: {ledger}" + ); + + // Idempotency: the second scan still counts the dep as redirected (the + // hosted URL is already in the lock) but rewrites nothing — lock AND + // workspace file byte-stable — and appends no duplicate edits (which + // would poison a revert). + let (code, stdout, stderr) = scan_hosted(&proj, &server.uri(), scan_extra); + assert_eq!( + code, 0, + "second scan --mode hosted failed ({tag}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env2 = parse_envelope(&stdout); + assert_eq!( + env2["redirect"]["redirected"], 1, + "an already-redirected dep still counts: {env2}" + ); + assert_eq!( + env2["redirect"]["rewrittenFiles"], + serde_json::json!([]), + "the re-run must rewrite nothing: {env2}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + lock_after, + "the re-run must leave the lock byte-stable" + ); + if let Some(ws) = &ws_after_scan { + assert_eq!( + &std::fs::read_to_string(&ws_path).unwrap(), + ws, + "the re-run must leave pnpm-workspace.yaml byte-stable" + ); + } + let ledger2: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); + assert_eq!( + edits.len(), + ledger2["edits"].as_array().unwrap().len(), + "a re-run must not append duplicate ledger edits: {ledger2}" + ); + + Some(PnpmRedirectFixture { + tmp, + proj, + patched, + _server: server, + }) +} + +/// New dir holding ONLY what a git checkout would carry — package.json, +/// pnpm-lock.yaml, `.socket/` (plus, when `with_workspace_yaml`, the +/// `trustLockfile: true` pnpm-workspace.yaml the scan wrote — the zero-touch +/// committed checkout) — with the registry pointed at a DEAD port and an +/// EMPTY store, then `corepack install --frozen-lockfile` (+ +/// `extra_args`). Returns the fresh dir and the pnpm output (asserted by each +/// leg: success for the real tarball, integrity failure for the tampered +/// one, policy failure for pnpm 11 without the trust config or flag). +fn fresh_checkout_install( + fx: &PnpmRedirectFixture, + pm: &str, + label: &str, + extra_args: &[&str], + with_workspace_yaml: bool, +) -> (PathBuf, Output) { + let fresh = fx.tmp.path().join(format!("fresh-{label}")); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(fx.proj.join("package.json"), fresh.join("package.json")).unwrap(); + std::fs::copy(fx.proj.join("pnpm-lock.yaml"), fresh.join("pnpm-lock.yaml")).unwrap(); + if with_workspace_yaml { + std::fs::copy( + fx.proj.join("pnpm-workspace.yaml"), + fresh.join("pnpm-workspace.yaml"), + ) + .expect("the v9 scan must have written pnpm-workspace.yaml"); + } + copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + // Dead registry: the only reachable artifact URL is the wiremock hosted + // tarball, so a successful install can only have come from it. The retry + // clamps keep the negative legs from pnpm's default 10s + 60s retry + // ladder against the dead port (the max/min timeouts back the retry + // count up, should a pnpm major ever treat 0 as unset). + std::fs::write( + fresh.join(".npmrc"), + "registry=http://127.0.0.1:1/\n\ + fetch-retries=0\n\ + fetch-retry-mintimeout=100\n\ + fetch-retry-maxtimeout=500\n", + ) + .unwrap(); + let fresh_store = fx.tmp.path().join(format!("fresh-store-{label}")); + let mut args = vec![ + "install", + "--frozen-lockfile", + "--store-dir", + fresh_store.to_str().unwrap(), + ]; + args.extend_from_slice(extra_args); + let out = corepack(&fresh, pm, &args); + (fresh, out) +} + +fn assert_marker_landed(fresh: &Path, patched: &[u8], ci: &Output, tag: &str) { + assert!( + ci.status.success(), + "fresh-checkout `pnpm install --frozen-lockfile` must succeed from the hosted \ + patch tarball ({tag}).\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert!( + installed.starts_with(MARKER.as_bytes()), + "pnpm must install the PATCHED bytes from the hosted patch ({tag}); got:\n{}", + String::from_utf8_lossy(&installed[..installed.len().min(120)]) + ); + assert_eq!( + installed, patched, + "fresh install must be byte-identical to the patched content ({tag})" + ); +} + +// ── corepack legs (gating mirrors e2e_redirect_rush_sim.rs) ─────────── + +// multi_thread: the CLI/pnpm subprocesses block a worker thread while +// wiremock keeps serving the API + tarball routes on the others. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm10_redirect_fresh_checkout_frozen_install_lands_patched_bytes() { + let Some(fx) = redirect_scanned_pnpm_project(PNPM_PRIMARY, "pnpm10", false, false).await else { + return; + }; + + // 4. FRESH-CHECKOUT PROOF: pnpm pulls the patched bytes from the hosted + // patch server because the committed lockfile says so. + let (fresh, ci) = fresh_checkout_install(&fx, PNPM_PRIMARY, "pnpm10", &[], false); + assert_marker_landed(&fresh, &fx.patched, &ci, "pnpm10"); +} + +/// Negative twin: the hosted route serves TAMPERED bytes while the lockfile +/// pins the REAL tarball's sha512 — the fresh frozen install must refuse to +/// install and must not land the marker. This is what makes the redirect +/// safe to commit: a compromised or swapped hosted artifact cannot slip past +/// the pin. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm10_redirect_tampered_hosted_tarball_fails_fresh_frozen_install() { + let Some(fx) = + redirect_scanned_pnpm_project(PNPM_PRIMARY, "pnpm10-tampered", true, false).await + else { + return; + }; + + let (fresh, ci) = fresh_checkout_install(&fx, PNPM_PRIMARY, "pnpm10-tampered", &[], false); + assert!( + !ci.status.success(), + "pnpm MUST fail when the served tarball does not match the pinned sha512.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let chatter = format!( + "{}\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr) + ); + assert!( + chatter.to_lowercase().contains("integrity") + || chatter.to_lowercase().contains("checksum") + || chatter.contains("ERR_PNPM"), + "the failure must be the integrity check, not something incidental:\n{chatter}" + ); + // The marker bytes must not have landed anywhere pnpm links from. + if let Ok(bytes) = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")) { + assert!( + !bytes.starts_with(MARKER.as_bytes()), + "no marker bytes may land from a tampered tarball" + ); + } +} + +/// Opportunistic pnpm@9 leg (the vendor capstone's secondary convention): +/// same positive chain, no tamper twin needed — @10 already carries it. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm9_redirect_fresh_checkout_frozen_install_lands_patched_bytes() { + let Some(fx) = redirect_scanned_pnpm_project(PNPM_SECONDARY, "pnpm9", false, false).await + else { + return; + }; + let (fresh, ci) = fresh_checkout_install(&fx, PNPM_SECONDARY, "pnpm9", &[], false); + assert_marker_landed(&fresh, &fx.patched, &ci, "pnpm9"); +} + +/// pnpm@11 ZERO-TOUCH leg: pnpm 11's lockfile supply-chain policy verifies +/// each resolution's tarball URL against registry metadata and would reject +/// the rewritten lock, but the scan auto-writes `trustLockfile: true` into +/// pnpm-workspace.yaml — so a fresh checkout that carries the scan's outputs +/// (the workspace file is scan-written and commit-intended, exactly like the +/// lock) frozen-installs against the DEAD registry with NO FLAGS and lands +/// the marker bytes. This is the shipped headline: CI needs no modification. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm11_zero_touch_frozen_install_lands_patched_bytes_via_auto_trust_config() { + let Some(fx) = redirect_scanned_pnpm_project(PNPM_TERTIARY, "pnpm11", false, false).await + else { + return; + }; + let (fresh, ci) = fresh_checkout_install(&fx, PNPM_TERTIARY, "pnpm11-zero-touch", &[], true); + assert_marker_landed(&fresh, &fx.patched, &ci, "pnpm11 zero-touch"); +} + +/// `--no-trust-lockfile-config` control: pins the opt-out (the scan writes +/// no pnpm-workspace.yaml — asserted inside the fixture helper) AND the old +/// behavior it restores. Without the trust config the PLAIN frozen install +/// fails against the dead registry — with ERR_PNPM_META_FETCH_FAIL, not the +/// live-registry-only ERR_PNPM_TARBALL_URL_MISMATCH, so only the ERR_PNPM +/// family and the non-zero exit are asserted (and no claim is made about the +/// marker: pnpm downloads the hosted tarball before the policy check fails). +/// The manual `--trust-lockfile` flag recovery must then succeed against the +/// same dead registry and land the marker bytes. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm11_no_trust_config_opt_out_frozen_install_needs_manual_trust_lockfile() { + let Some(fx) = + redirect_scanned_pnpm_project(PNPM_TERTIARY, "pnpm11-opt-out", false, true).await + else { + return; + }; + + let (_fresh, plain) = + fresh_checkout_install(&fx, PNPM_TERTIARY, "pnpm11-opt-out-plain", &[], false); + assert!( + !plain.status.success(), + "pnpm 11's lockfile policy must reject the plain frozen install against a dead \ + registry when the scan was opted out of the trustLockfile config.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&plain.stdout), + String::from_utf8_lossy(&plain.stderr), + ); + let chatter = format!( + "{}\n{}", + String::from_utf8_lossy(&plain.stdout), + String::from_utf8_lossy(&plain.stderr) + ); + assert!( + chatter.contains("ERR_PNPM"), + "the plain-install failure must be a pnpm error, not something incidental:\n{chatter}" + ); + + // Manual recovery: the per-run flag, exactly as the warning detail says. + let (fresh, trusted) = fresh_checkout_install( + &fx, + PNPM_TERTIARY, + "pnpm11-opt-out-trust", + &["--trust-lockfile"], + false, + ); + assert_marker_landed(&fresh, &fx.patched, &trusted, "pnpm11 --trust-lockfile"); +} + +/// Legacy pnpm@7 leg: the fixture install emits a lockfileVersion 5.4 lock +/// (`/name/version:` path-style key), the scan splices its resolution like +/// any other grammar, and the fresh dead-registry frozen install proves +/// pnpm 7 fetches the hosted tarball from the spliced entry and enforces the +/// sha512 pin (empty store, marker bytes land). +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm7_v5_lock_redirect_fresh_checkout_frozen_install_lands_patched_bytes() { + let Some(fx) = redirect_scanned_pnpm_project(PNPM_LEGACY_V5, "pnpm7", false, false).await + else { + return; + }; + let lock = std::fs::read_to_string(fx.proj.join("pnpm-lock.yaml")).unwrap(); + assert!( + lock.starts_with("lockfileVersion: 5.4") + && lock.contains(&format!("/{DEP}/{DEP_VERSION}:")), + "anchor: pnpm@7 must have emitted a v5.4 path-style lock; got:\n{lock}" + ); + let (fresh, ci) = fresh_checkout_install(&fx, PNPM_LEGACY_V5, "pnpm7", &[], false); + assert_marker_landed(&fresh, &fx.patched, &ci, "pnpm7"); +} + +/// Legacy pnpm@8 leg: same chain over the lockfileVersion 6.0 grammar +/// (`/name@version:` key). pnpm 8 has no lockfile supply-chain policy, so the +/// plain frozen install must succeed against the dead registry. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm8_v6_lock_redirect_fresh_checkout_frozen_install_lands_patched_bytes() { + let Some(fx) = redirect_scanned_pnpm_project(PNPM_LEGACY_V6, "pnpm8", false, false).await + else { + return; + }; + let lock = std::fs::read_to_string(fx.proj.join("pnpm-lock.yaml")).unwrap(); + assert!( + lock.starts_with("lockfileVersion: '6.0'") + && lock.contains(&format!("/{DEP}@{DEP_VERSION}:")), + "anchor: pnpm@8 must have emitted a v6 lock; got:\n{lock}" + ); + let (fresh, ci) = fresh_checkout_install(&fx, PNPM_LEGACY_V6, "pnpm8", &[], false); + assert_marker_landed(&fresh, &fx.patched, &ci, "pnpm8"); +} + +// ── synthetic legs (hermetic — no pnpm binary, never ignored) ───────── + +/// A project whose only lockfile is the synthesized `lock`, with an installed +/// node_modules stub so the crawler discovers the dep (a real pnpm project +/// always has one). +fn write_synthetic_project(root: &Path, lock: &str) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{DEP}": "{DEP_VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(DEP); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{DEP}", "version": "{DEP_VERSION}" }}"#), + ) + .unwrap(); + std::fs::write(root.join("pnpm-lock.yaml"), lock).unwrap(); +} + +/// Byte-accurate pnpm 7 (lockfileVersion 5.4) lock, copied from the matrix +/// sweep's hosted-pnpm7 fixture: unquoted `5.4`, `/name/version:` package +/// key, `specifiers:` section, `dev: false` flag. +fn v5_lock() -> String { + format!( + "lockfileVersion: 5.4 + +specifiers: + {DEP}: {DEP_VERSION} + +dependencies: + {DEP}: {DEP_VERSION} + +packages: + + /{DEP}/{DEP_VERSION}: + resolution: {{integrity: {UPSTREAM_SHA512}}} + deprecated: use String.prototype.padStart() + dev: false +" + ) +} + +/// Byte-accurate pnpm 8 (lockfileVersion '6.0') lock from the matrix sweep's +/// hosted-pnpm8 fixture: quoted `'6.0'`, `/name@version:` package key. +fn v6_lock() -> String { + format!( + "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + {DEP}: + specifier: {DEP_VERSION} + version: {DEP_VERSION} + +packages: + + /{DEP}@{DEP_VERSION}: + resolution: {{integrity: {UPSTREAM_SHA512}}} + deprecated: use String.prototype.padStart() + dev: false +" + ) +} + +/// pnpm v5.x lock keys (`/name/version:`) are inside the redirect grammar: +/// the resolution is spliced in place with the path-style key and every +/// sibling line (`deprecated:`, `dev:`) byte-preserved — proven installable +/// by the gated pnpm@7 leg above (matrix: hosted-pnpm7, live splice-install +/// verification 2026-08-18). +#[tokio::test(flavor = "multi_thread")] +async fn pnpm_v5_lock_key_rewrite_splices_in_place() { + let server = MockServer::start().await; + let hosted_url = hosted_url_for("http://patch.test"); + mount_api_mocks( + &server, + &hosted_url, + PATCHED_SHA512, + &"a".repeat(64), + &"b".repeat(64), + ) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_synthetic_project(tmp.path(), &v5_lock()); + let lock_path = tmp.path().join("pnpm-lock.yaml"); + + let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &[]); + assert_eq!( + code, 0, + "scan --mode hosted failed on the v5 lock.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "the v5 path-style key must be redirectable: {env}" + ); + assert_eq!( + env["redirect"]["rewrittenFiles"], + serde_json::json!(["pnpm-lock.yaml"]), + "the v5 lock must be the rewritten file: {env}" + ); + assert!( + warning_codes(&env).contains(&"redirect_pnpm_trust_lockfile".to_string()), + "a landed v5 rewrite must still carry the install guidance: {env}" + ); + + // The spliced block, byte-exact: the `/name/version:` key keeps its + // path-style shape, the resolution carries {integrity, tarball}, and the + // sibling lines survive untouched. + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + let spliced = format!( + " /{DEP}/{DEP_VERSION}:\n resolution: {{integrity: {PATCHED_SHA512}, tarball: {hosted_url}}}\n deprecated: use String.prototype.padStart()\n dev: false\n" + ); + assert!( + lock_after.contains(&spliced), + "the v5 packages entry must be spliced in place; want:\n{spliced}\ngot:\n{lock_after}" + ); + assert!( + !lock_after.contains(UPSTREAM_SHA512), + "the upstream integrity must be replaced; got:\n{lock_after}" + ); + + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + let edit = ledger["edits"] + .as_array() + .unwrap() + .iter() + .find(|e| { + e["kind"] == "redirect_pnpm_resolution" && e["key"] == format!("{DEP}@{DEP_VERSION}") + }) + .unwrap_or_else(|| panic!("the ledger must record the v5 redirect edit: {ledger}")); + assert!( + edit["original"] + .as_str() + .unwrap_or_default() + .contains(UPSTREAM_SHA512), + "the ledger must preserve the original upstream integrity for revert: {edit}" + ); +} + +/// pnpm v6 PLAIN lock keys (`/name@version:` with no peer suffix) stay inside +/// the redirect grammar — verified against a real pnpm 8 install in the +/// matrix sweep (hosted-pnpm8): the resolution is spliced in place with its +/// sibling lines (`deprecated:`, `dev:`) byte-preserved. +#[tokio::test(flavor = "multi_thread")] +async fn pnpm_v6_plain_lock_key_rewrite_stays_supported() { + let server = MockServer::start().await; + let hosted_url = hosted_url_for("http://patch.test"); + mount_api_mocks( + &server, + &hosted_url, + PATCHED_SHA512, + &"a".repeat(64), + &"b".repeat(64), + ) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_synthetic_project(tmp.path(), &v6_lock()); + let lock_path = tmp.path().join("pnpm-lock.yaml"); + + let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &[]); + assert_eq!( + code, 0, + "scan --mode hosted failed on the v6 lock.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "the plain v6 key must stay redirectable: {env}" + ); + assert_eq!( + env["redirect"]["rewrittenFiles"], + serde_json::json!(["pnpm-lock.yaml"]), + "the v6 lock must be the rewritten file: {env}" + ); + assert!( + warning_codes(&env).contains(&"redirect_pnpm_trust_lockfile".to_string()), + "a landed v6 rewrite must still carry the install guidance: {env}" + ); + + // The trust AUTO-CONFIG must NOT fire for a legacy lock: the gate is + // lock-major >= 9, and a 6.0 lock means pnpm 8 — no lockfile policy, no + // trustLockfile setting (pnpm 7/8 reject the flag spelling too, so the + // warning must NOT recommend `--trust-lockfile`: it gets the legacy + // installs-work-unchanged guidance instead). No workspace file appears. + assert!( + !tmp.path().join("pnpm-workspace.yaml").exists(), + "a v6-lock scan must not auto-write pnpm-workspace.yaml: {env}" + ); + let v6_detail = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_default(); + assert!( + !v6_detail.contains("trust-lockfile"), + "the legacy-lock warning must not recommend --trust-lockfile (pnpm 7/8 \ + reject the flag as unknown); got: {v6_detail}" + ); + assert!( + v6_detail.contains("pnpm 7/8") && v6_detail.contains("installs work unchanged"), + "the legacy-lock warning must say installs work unchanged on pnpm 7/8; \ + got: {v6_detail}" + ); + + // The spliced block, byte-exact: the `/name@version:` key keeps its + // shape, the resolution carries {integrity, tarball}, and the sibling + // lines survive untouched. + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + let spliced = format!( + " /{DEP}@{DEP_VERSION}:\n resolution: {{integrity: {PATCHED_SHA512}, tarball: {hosted_url}}}\n deprecated: use String.prototype.padStart()\n dev: false\n" + ); + assert!( + lock_after.contains(&spliced), + "the v6 packages entry must be spliced in place; want:\n{spliced}\ngot:\n{lock_after}" + ); + assert!( + !lock_after.contains(UPSTREAM_SHA512), + "the upstream integrity must be replaced; got:\n{lock_after}" + ); + + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + let edit = ledger["edits"] + .as_array() + .unwrap() + .iter() + .find(|e| { + e["kind"] == "redirect_pnpm_resolution" && e["key"] == format!("{DEP}@{DEP_VERSION}") + }) + .unwrap_or_else(|| panic!("the ledger must record the v6 redirect edit: {ledger}")); + assert!( + edit["original"] + .as_str() + .unwrap_or_default() + .contains(UPSTREAM_SHA512), + "the ledger must preserve the original upstream integrity for revert: {edit}" + ); + assert!( + !ledger["edits"] + .as_array() + .unwrap() + .iter() + .any(|e| e["kind"] == "redirect_pnpm_workspace_trust"), + "a v6-lock scan must record no workspace trust edit: {ledger}" + ); +} 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 2d7e1635..47975d12 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -1,8 +1,9 @@ //! Real-pnpm capstone e2e for `socket-patch vendor` — the committability //! proof for the pnpm (lockfileVersion 9.0) flavor. //! -//! Drives the REAL `corepack pnpm@10` (and pnpm@9 when fetchable — both emit -//! byte-identical 9.0 locks, spike P1/P2): +//! Drives the REAL `corepack pnpm@10` (and pnpm@9 / pnpm@11 when fetchable — +//! all three emit byte-identical single-document 9.0 locks, spike P1/P2 + +//! matrix leg vendor-pnpm11): //! 1. `pnpm install` of left-pad@1.3.0 into a tempdir (private `--store-dir`). //! 2. Hand-stage a `.socket/` manifest + blob from the ACTUAL installed //! bytes (a marker comment prepended to `index.js`). @@ -25,6 +26,17 @@ //! LOCAL capstone (not behind docker-e2e): skips with a `println` + return //! when `corepack pnpm@10` is unavailable or the fixture install cannot reach //! the registry; every assertion after that is HARD. +//! +//! Below the capstone: the LEGACY grammars — pnpm 7 (`lockfileVersion: 5.4`) +//! and pnpm 8 (`'6.0'`), wired by the pnpm-legacy backend. HERMETIC legs +//! (no corepack, no network, always run) prove the splice reproduces the +//! pnpm-captured end state byte-for-byte, is idempotent, and reverts +//! byte-identical; gated `pnpm7_real_*`/`pnpm8_real_*` legs run the full +//! lifecycle against the real pinned pnpm, including the SAME-PATH +//! frozen+offline empty-store proof and the MOVED-CHECKOUT `--offline` +//! proof (pnpm <= 8 absolutizes file: override specifiers, so frozen +//! installs are path-bound — the moved-checkout frozen FAILURE is asserted +//! as the documented limitation). use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; @@ -38,10 +50,12 @@ const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; const MARKER: &str = "/* SOCKET-PATCHED */\n"; const DEP: &str = "left-pad"; const DEP_VERSION: &str = "1.3.0"; -/// Pinned pnpm majors via corepack — @10 is required, @9 is run too when -/// fetchable (the spike proved both emit byte-identical 9.0 locks). +/// Pinned pnpm majors via corepack — @10 is required, @9 and @11 are run too +/// when fetchable (the spike proved @9/@10 emit byte-identical 9.0 locks; the +/// matrix proved @11 (11.22.0) does as well — single-document, same grammar). const PNPM_PRIMARY: &str = "pnpm@10"; const PNPM_SECONDARY: &str = "pnpm@9"; +const PNPM_TERTIARY: &str = "pnpm@11"; // ── self-contained helpers ──────────────────────────────────────────── @@ -192,6 +206,22 @@ fn pnpm_vendor_fresh_checkout_frozen_offline_install_and_revert() { } else { eprintln!("note: {PNPM_SECONDARY} not fetchable; ran {PNPM_PRIMARY} only"); } + + // Ladder top: pnpm 11 (matrix leg vendor-pnpm11, 11.22.0) emits the same + // single-document 9.0 lock as 10 and its supply-chain verification accepts + // the vendored file: tarball, so the whole lifecycle carries hard + // assertions here too. pnpm >= 11 reads `overrides` ONLY from + // pnpm-workspace.yaml (the package.json `pnpm` table is ignored), which + // makes the workspace-file assertions inside run_pnpm_capstone the + // load-bearing wiring surface on this leg — the fresh-checkout + // frozen+offline install below would resolve the unpatched registry + // tarball without it. + if has_corepack_pm(PNPM_TERTIARY) { + eprintln!("--- also exercising {PNPM_TERTIARY} ---"); + run_pnpm_capstone(PNPM_TERTIARY); + } else { + eprintln!("note: {PNPM_TERTIARY} not fetchable; skipped"); + } } fn run_pnpm_capstone(pm: &str) { @@ -507,3 +537,583 @@ fn run_pnpm_capstone(pm: &str) { ); eprintln!("REVERT OK ({pm})"); } + +// ── pre-9.0 LEGACY lock legs (hermetic splice-shape + gated real-pnpm) ──── +// +// pnpm 7 (lockfileVersion 5.4) and pnpm 8 ('6.0') are wired by the +// pnpm-legacy vendor backend since 2026-08-18 (they used to refuse). The +// hermetic legs below prove the splice reproduces the captured pnpm-emitted +// shape byte-for-byte with no corepack/network; the `pnpm7_real_*` / +// `pnpm8_real_*` legs run the full lifecycle against the REAL pinned pnpm +// when corepack can fetch it (mirroring the @9/@11 opportunistic pattern — +// @10 stays the primary capstone above). + +/// Pinned legacy majors (the exact versions the vendor-legacy spike +/// captured the lock grammars from). +const PNPM_LEGACY_7: &str = "pnpm@7.33.5"; +const PNPM_LEGACY_8: &str = "pnpm@8.15.9"; + +/// Byte-accurate lock captured from a REAL `pnpm@7.33.5 install` of +/// left-pad@1.3.0 (matrix leg vendor-pnpm7): unquoted `lockfileVersion: 5.4`, +/// `specifiers:` section, `/left-pad/1.3.0:` packages key. +const PNPM7_LOCK: &str = "lockfileVersion: 5.4 + +specifiers: + left-pad: 1.3.0 + +dependencies: + left-pad: 1.3.0 + +packages: + + /left-pad/1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + dev: false +"; + +/// Byte-accurate lock captured from a REAL `pnpm@8.15.9 install` of the same +/// fixture (matrix leg vendor-pnpm8): quoted `lockfileVersion: '6.0'`, +/// `settings:` section, `/left-pad@1.3.0:` packages key (the `@` rekeying +/// pnpm 8 introduced). +const PNPM8_LOCK: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + + /left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + dev: false +"; + +/// Expected pnpm-7-shaped lock AFTER vendoring, exactly as `pnpm@7.33.5 +/// install` itself serialized the same end state (spike p7): `overrides:` +/// at the ROOT_KEYS_ORDER slot, the SPECIFIER absolutized against the +/// project root (pnpm <= 8 absolutizes file: overrides itself — the +/// documented portability caveat), the dep value + rekeyed packages entry +/// relative, `name:`/`version:` spelled out, `deprecated:` dropped. +/// `{ABS}` / `{INT}` are substituted per run. +const PNPM7_AFTER_TEMPLATE: &str = "lockfileVersion: 5.4 + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz + +specifiers: + left-pad: file:{ABS}/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz + +dependencies: + left-pad: file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz + +packages: + + file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz: + resolution: {integrity: {INT}, tarball: file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz} + name: left-pad + version: 1.3.0 + dev: false +"; + +/// Expected pnpm-8-shaped lock AFTER vendoring (spike p8) — the nested +/// specifier/version grammar, same override/rekey shape. +const PNPM8_AFTER_TEMPLATE: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz + +dependencies: + left-pad: + specifier: file:{ABS}/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz + version: file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz + +packages: + + file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz: + resolution: {integrity: {INT}, tarball: file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz} + name: left-pad + version: 1.3.0 + dev: false +"; + +#[test] +fn pnpm7_lock_v54_hermetic_splice_idempotency_and_revert() { + run_legacy_hermetic(PNPM7_LOCK, PNPM7_AFTER_TEMPLATE, "5.4"); +} + +#[test] +fn pnpm8_lock_v60_hermetic_splice_idempotency_and_revert() { + run_legacy_hermetic(PNPM8_LOCK, PNPM8_AFTER_TEMPLATE, "6.0"); +} + +/// The tarball's SRI (`sha512-`), the integrity spelling pnpm locks +/// record. +fn tarball_integrity(tgz: &Path) -> String { + use base64::Engine as _; + use sha2::Sha512; + let bytes = std::fs::read(tgz).expect("vendored tarball"); + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&bytes)) + ) +} + +/// Hermetic (no corepack, no network) proof that the legacy splice +/// reproduces the pnpm-captured end state byte-for-byte, is idempotent +/// (`skipped`/`already_vendored`, byte-stable), and reverts byte-identical. +/// The absolute-specifier portability caveat must be surfaced as the +/// `vendor_pnpm_legacy_absolute_specifier` run warning, and NO +/// pnpm-workspace.yaml may appear (pnpm <= 8 reads overrides only from +/// package.json). +fn run_legacy_hermetic(lock_text: &str, after_template: &str, version: &str) { + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + let dep_dir = proj.join("node_modules").join(DEP); + std::fs::create_dir_all(&dep_dir).unwrap(); + std::fs::write( + dep_dir.join("package.json"), + format!("{{\"name\":\"{DEP}\",\"version\":\"{DEP_VERSION}\"}}\n"), + ) + .unwrap(); + let orig = b"module.exports = function leftPad(str) { return str; };\n".to_vec(); + std::fs::write(dep_dir.join("index.js"), &orig).unwrap(); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); + stage_patch(&proj, &purl, "package/index.js", &orig, &patched); + + let pkg_doc = serde_json::json!({ + "name": "pnpm-legacy-hermetic", + "version": "0.0.0", + "private": true, + "dependencies": { DEP: DEP_VERSION }, + }); + let pkg_path = proj.join("package.json"); + let lock_path = proj.join("pnpm-lock.yaml"); + std::fs::write( + &pkg_path, + format!("{}\n", serde_json::to_string_pretty(&pkg_doc).unwrap()), + ) + .unwrap(); + std::fs::write(&lock_path, lock_text).unwrap(); + let pkg_before = std::fs::read(&pkg_path).unwrap(); + let lock_before = std::fs::read(&lock_path).unwrap(); + + // 1. Vendor: exits 0 and wires both surfaces. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor must wire a {version} lock.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + // The path-bound frozen-install caveat is machine-readable (vendor + // warnings surface as non-counting skipped events carrying the code). + assert!( + env["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["errorCode"] == "vendor_pnpm_legacy_absolute_specifier"), + "the absolute-specifier caveat must be surfaced: {env}" + ); + + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); + assert!( + proj.join(&tgz_rel).is_file(), + "tarball missing at {tgz_rel}" + ); + + // package.json gained the versioned override. + let pkg_json: serde_json::Value = + serde_json::from_slice(&std::fs::read(&pkg_path).unwrap()).unwrap(); + assert_eq!( + pkg_json["pnpm"]["overrides"][format!("{DEP}@{DEP_VERSION}")].as_str(), + Some(format!("file:{tgz_rel}").as_str()), + "package.json must gain pnpm.overrides: {pkg_json}" + ); + + // The lock is byte-identical to what the REAL pnpm serialized for this + // end state (spike p7/p8), with the live absolute root + integrity. + let abs = socket_patch_core::vendor::pnpm_lock_legacy::normalize_canonical_root( + &std::fs::canonicalize(&proj).unwrap().display().to_string(), + ); + let expected = after_template + .replace("{UUID}", UUID) + .replace("{ABS}", &abs) + .replace("{INT}", &tarball_integrity(&proj.join(&tgz_rel))); + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + assert_eq!( + lock_after, expected, + "{version} lock must match the pnpm-captured after shape byte-for-byte" + ); + + // pnpm <= 8 reads overrides only from package.json — creating a + // workspace file would flip the project into workspace mode. + assert!( + !proj.join("pnpm-workspace.yaml").exists(), + "legacy wiring must not create pnpm-workspace.yaml" + ); + + // 2. Idempotency: re-run skips (`already_vendored`), all bytes stable. + let pkg_wired = std::fs::read(&pkg_path).unwrap(); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "re-vendor.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env2 = parse_envelope(&stdout); + assert!( + env2["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "skipped" && e["errorCode"] == "already_vendored"), + "in-sync rerun must report already_vendored: {env2}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + lock_after, + "re-vendor must leave the lock byte-identical" + ); + assert_eq!( + std::fs::read(&pkg_path).unwrap(), + pkg_wired, + "re-vendor must leave package.json byte-identical" + ); + + // 3. Revert: both files byte-restored, artifact gone. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--revert", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "revert.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert_eq!( + std::fs::read(&pkg_path).unwrap(), + pkg_before, + "revert must restore package.json byte-identical" + ); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_before, + "revert must restore pnpm-lock.yaml byte-identical" + ); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); +} + +// ── gated real-pnpm legacy lifecycle legs ───────────────────────────── + +#[test] +fn pnpm7_real_lifecycle_same_path_frozen_and_moved_checkout_offline() { + if !has_corepack_pm(PNPM_LEGACY_7) { + println!("SKIP: `corepack {PNPM_LEGACY_7}` unavailable"); + return; + } + run_legacy_capstone(PNPM_LEGACY_7, "lockfileVersion: 5.4"); +} + +#[test] +fn pnpm8_real_lifecycle_same_path_frozen_and_moved_checkout_offline() { + if !has_corepack_pm(PNPM_LEGACY_8) { + println!("SKIP: `corepack {PNPM_LEGACY_8}` unavailable"); + return; + } + run_legacy_capstone(PNPM_LEGACY_8, "lockfileVersion: '6.0'"); +} + +/// Full lifecycle against the REAL pinned legacy pnpm, spike-proven flags: +/// +/// 1. online fixture install (skip when the registry is unreachable); +/// 2. vendor --json --offline wires package.json + the legacy lock (no +/// pnpm-workspace.yaml); +/// 3. SAME-PATH strict proof: wipe node_modules, EMPTY store, +/// `install --frozen-lockfile --offline` → marker bytes (the absolute +/// specifier matches this checkout, spike probe A); +/// 4. MOVED-CHECKOUT proof: committables copied to a different dir with a +/// dead-registry .npmrc and an EMPTY store — `--frozen-lockfile` MUST +/// fail (pnpm <= 8's path-bound frozen check, spike probe B pins the +/// documented limitation) and plain `install --offline` MUST land the +/// marker bytes (probe C); +/// 5. idempotent re-vendor (byte-stable, already_vendored); +/// 6. revert restores both files byte-identical and removes .socket/vendor. +fn run_legacy_capstone(pm: &str, lock_head: &str) { + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + let pkg_doc = serde_json::json!({ + "name": "pnpm-legacy-capstone", + "version": "0.0.0", + "private": true, + "dependencies": { DEP: DEP_VERSION }, + }); + std::fs::write( + proj.join("package.json"), + format!("{}\n", serde_json::to_string_pretty(&pkg_doc).unwrap()), + ) + .unwrap(); + + // 1. REAL fixture install (network allowed here, private store). + let store = tmp.path().join("pnpm-store"); + let install = corepack( + &proj, + pm, + &["install", "--store-dir", store.to_str().unwrap()], + ); + if !install.status.success() { + println!( + "SKIP legacy capstone ({pm}): fixture `pnpm install` failed (registry \ + unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return; + } + + 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}@{DEP_VERSION}"); + stage_patch(&proj, &purl, "package/index.js", &orig, &patched); + + let lock_path = proj.join("pnpm-lock.yaml"); + let pkg_path = proj.join("package.json"); + let lock_before = std::fs::read(&lock_path).expect("lock after pnpm install"); + let pkg_before = std::fs::read(&pkg_path).unwrap(); + let lock_before_str = String::from_utf8(lock_before.clone()).unwrap(); + assert!( + lock_before_str.starts_with(lock_head), + "fixture must be a {lock_head} lock:\n{lock_before_str}" + ); + + // 2. Vendor (offline). + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "{env}"); + let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); + assert!(proj.join(&tgz_rel).is_file()); + assert!( + !proj.join("pnpm-workspace.yaml").exists(), + "legacy wiring must not create pnpm-workspace.yaml ({pm})" + ); + let lock_after = std::fs::read_to_string(&lock_path).unwrap(); + let abs = socket_patch_core::vendor::pnpm_lock_legacy::normalize_canonical_root( + &std::fs::canonicalize(&proj).unwrap().display().to_string(), + ); + assert!( + lock_after.contains(&format!("{DEP}@{DEP_VERSION}: file:{tgz_rel}")), + "lock overrides must point at the vendored tarball ({pm}):\n{lock_after}" + ); + assert!( + lock_after.contains(&format!("file:{abs}/{tgz_rel}")), + "lock specifier must carry pnpm <= 8's absolutized spelling ({pm}):\n{lock_after}" + ); + eprintln!("VENDOR OK ({pm})"); + + // 3. SAME-PATH strict proof: wipe node_modules, EMPTY store, the + // spike-proven strictest invocation. + std::fs::remove_dir_all(proj.join("node_modules")).unwrap(); + let same_store = tmp.path().join("store-same"); + let ci = corepack( + &proj, + pm, + &[ + "install", + "--frozen-lockfile", + "--offline", + "--store-dir", + same_store.to_str().unwrap(), + ], + ); + assert!( + ci.status.success(), + "same-path `install --frozen-lockfile --offline` must succeed from the vendored \ + tarball ({pm}).\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let same_installed = std::fs::read(&installed_index).unwrap(); + assert_eq!( + same_installed, patched, + "same-path frozen+offline install must land the patched bytes ({pm})" + ); + eprintln!("SAME-PATH FROZEN INSTALL OK ({pm})"); + + // The lock must be byte-stable under pnpm's own re-serialization. + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + lock_after, + "pnpm's own install must leave the spliced lock byte-identical ({pm})" + ); + + // 4. MOVED-CHECKOUT proof (different absolute path, dead registry, + // EMPTY store): frozen fails — the documented pnpm <= 8 limitation — + // and plain --offline lands the marker. + let fresh = tmp.path().join("fresh"); + 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(); + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + std::fs::write(fresh.join(".npmrc"), "registry=http://127.0.0.1:1/\n").unwrap(); + + let fresh_store = tmp.path().join("store-fresh"); + let frozen = corepack( + &fresh, + pm, + &[ + "install", + "--frozen-lockfile", + "--offline", + "--store-dir", + fresh_store.to_str().unwrap(), + ], + ); + assert!( + !frozen.status.success(), + "pnpm <= 8's frozen check is path-bound (absolute specifier): a moved checkout \ + passing --frozen-lockfile would invalidate the documented caveat ({pm})" + ); + + // `--no-frozen-lockfile` is load-bearing, not belt-and-braces: pnpm + // defaults --frozen-lockfile ON when CI=true, and the moved-checkout + // recovery WORKS by re-resolving the path-bound absolute specifier — + // frozen semantics skip that re-resolution and fail (observed on CI: + // ERR_PNPM_OUTDATED_LOCKFILE on pnpm 8, stale-path install on pnpm 7). + let plain = corepack( + &fresh, + pm, + &[ + "install", + "--offline", + "--no-frozen-lockfile", + "--store-dir", + fresh_store.to_str().unwrap(), + ], + ); + assert!( + plain.status.success(), + "moved-checkout `install --offline --no-frozen-lockfile` must succeed from the \ + vendored tarball ({pm}).\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&plain.stdout), + String::from_utf8_lossy(&plain.stderr), + ); + let fresh_installed = + std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert_eq!( + fresh_installed, patched, + "moved-checkout install must land the patched bytes ({pm})" + ); + eprintln!("MOVED-CHECKOUT OFFLINE INSTALL OK ({pm})"); + + // 5. Idempotency in the original project. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "re-vendor failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env2 = parse_envelope(&stdout); + assert!( + env2["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "skipped" && e["errorCode"] == "already_vendored"), + "in-sync rerun must report already_vendored ({pm}): {env2}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + lock_after, + "re-vendor must be byte-stable ({pm})" + ); + + // 6. Revert restores the pair byte-identical. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--revert", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "revert failed ({pm}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let renv = parse_envelope(&stdout); + assert_eq!(renv["summary"]["removed"], 1, "{renv}"); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_before, + "revert must restore pnpm-lock.yaml byte-identical ({pm})" + ); + assert_eq!( + std::fs::read(&pkg_path).unwrap(), + pkg_before, + "revert must restore package.json byte-identical ({pm})" + ); + assert!(!proj.join(".socket/vendor").exists()); + eprintln!("REVERT OK ({pm})"); +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index e2564d15..a0ba4570 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1564,6 +1564,14 @@ async fn scan_redirect_rewrites_rush_common_and_subspace_locks() { !tmp.path().join("pnpm-lock.yaml").exists(), "the rewrite must edit nested locks in place, not create a root lock" ); + // And no root pnpm-workspace.yaml either: rush runs pnpm in common/temp, + // which never reads the repo root, so the trustLockfile auto-config + // (root-lock-gated) must stay hands-off here — writing it would be + // config theater. + assert!( + !tmp.path().join("pnpm-workspace.yaml").exists(), + "rush nested-lock redirects must not create a root pnpm-workspace.yaml" + ); // repo-state.json present → the stale-hash warning fires. let out = std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")) @@ -1580,6 +1588,13 @@ async fn scan_redirect_rewrites_rush_common_and_subspace_locks() { /// hosting test can't read back. No package-manager binary is needed: the /// rewrite is pure text over the fixture locks. fn run_redirect_subprocess(cwd: &Path, api_url: &str) -> serde_json::Value { + run_redirect_subprocess_with(cwd, api_url, &[]) +} + +/// [`run_redirect_subprocess`] with extra CLI flags appended (e.g. the +/// `--no-trust-lockfile-config` opt-out), so flag-dependent envelope shapes +/// are exercised through the real clap parse. +fn run_redirect_subprocess_with(cwd: &Path, api_url: &str, extra: &[&str]) -> serde_json::Value { let out = scrubbed_cli() .args([ "scan", @@ -1595,6 +1610,7 @@ fn run_redirect_subprocess(cwd: &Path, api_url: &str) -> serde_json::Value { "--api-token", "fake", ]) + .args(extra) .output() .expect("run socket-patch"); assert_eq!( @@ -1704,6 +1720,96 @@ async fn redirect_inbundle_only_dep_is_skipped_not_confirmed() { ); } +/// A pnpm v6 lock resolving the patched dep through BOTH a plain key and a +/// nested-peer-paren key (`/pkg@1.0.0(react@18.2.0(scheduler@0.23.2)):` — +/// a spelling the splice grammar cannot parse) must be refused whole: +/// `redirected: 0`, the lock byte-untouched, the hosted URL nowhere (a +/// partial splice would have landed it in the lock, and the confirmation +/// probe would then confirm + VEX-attest the dep while dependents through +/// the nested-peer instance keep installing the unpatched upstream tarball), +/// a `redirect_pnpm_unsupported_lock_key` warning naming the residual key, +/// and no redirect-ledger record claiming the purl. Subprocess so the +/// `--json` envelope's `redirected` count and `warnings[]` can be read back. +#[tokio::test] +#[serial] +async fn redirect_pnpm_nested_peer_residual_refuses_dep_not_confirmed() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().expect("create the fixture tempdir"); + std::fs::write( + tmp.path().join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .expect("write the consumer package.json"); + let pkg = tmp.path().join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).expect("create the installed package dir"); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .expect("write the installed package.json"); + let lock = format!( + "lockfileVersion: '6.0' + +dependencies: + {NAME}: + specifier: {VERSION} + version: {VERSION} + +packages: + + /{NAME}@{VERSION}: + resolution: {{integrity: sha512-UPSTREAMupstream==}} + dev: false + + /{NAME}@{VERSION}(react@18.2.0(scheduler@0.23.2)): + resolution: {{integrity: sha512-UPSTREAMupstream==}} + dev: false +" + ); + std::fs::write(tmp.path().join("pnpm-lock.yaml"), &lock) + .expect("write the mixed plain + nested-peer pnpm lock"); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!( + env["redirect"]["redirected"], 0, + "a residual-instance dep must NOT be counted redirected: {env}" + ); + let codes = warning_codes(&env); + assert!( + codes.contains(&"redirect_pnpm_unsupported_lock_key".to_string()), + "the residual refusal warning must reach the envelope: {env}" + ); + let after = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")) + .expect("read back the pnpm-lock.yaml the run must have left alone"); + assert_eq!(after, lock, "the lockfile must be byte-untouched"); + assert!( + !after.contains(HOSTED_URL), + "the hosted URL must never appear (it would confirm + attest): {after}" + ); + // No ledger half-claims the purl either: an unconfirmed dep must fetch + // no record and record no edits. + let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); + if let Ok(text) = std::fs::read_to_string(&ledger_path) { + let ledger: serde_json::Value = + serde_json::from_str(&text).expect("the redirect ledger must be valid JSON"); + assert!( + ledger["records"].get(PURL).is_none(), + "an unconfirmed dep must not be recorded: {ledger}" + ); + let claimed = ledger["edits"] + .as_array() + .into_iter() + .flatten() + .any(|e| e["key"].as_str().is_some_and(|k| k.contains(NAME))); + assert!(!claimed, "no edit may claim the refused dep: {ledger}"); + } +} + /// The rewriters' own warnings must reach HUMAN mode too, not just the /// `--json` envelope: they carry the load-bearing "why nothing happened / /// what you must do" guidance (`redirect_npm_no_lockfile`, @@ -1985,17 +2091,26 @@ fn write_pnpm_project(root: &Path) { std::fs::write(root.join("pnpm-lock.yaml"), rush_pnpm_lock(NAME)).unwrap(); } -/// A hosted redirect that rewrites a `pnpm-lock.yaml` must warn that pnpm >=11's -/// lockfile supply-chain policy will REJECT the rewritten lock -/// (`ERR_PNPM_TARBALL_URL_MISMATCH` — the repointed tarball URL no longer -/// matches the registry's published metadata) and name the documented -/// `pnpm install --trust-lockfile` opt-out — the same way the Rush repo-state -/// case surfaces its own post-rewrite install caveat. The npm twin +/// A hosted redirect that rewrites the root v9 `pnpm-lock.yaml` AUTO-CONFIGURES +/// `trustLockfile: true` in pnpm-workspace.yaml (zero-touch: pnpm >=11's +/// lockfile supply-chain policy rejects the rewritten lock otherwise, and the +/// committable workspace key is the verified recovery both majors honor while +/// pnpm 9/10 silently ignore it), and the `redirect_pnpm_trust_lockfile` +/// warning must say so: trust is configured, commit the file alongside the +/// lock, installs need no flags — naming BOTH failure spellings (pnpm 11: +/// `ERR_PNPM_TARBALL_URL_MISMATCH`, pnpm 12: +/// `ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION`), disclosing the whole-lock +/// tradeoff (trustLockfile skips pnpm's re-verification for ALL entries), and +/// pre-empting pnpm 12's own rebuild-the-lock advice, which silently +/// reinstates the vulnerable upstream. The named host must be the SPLICED +/// artifact host (here the fixture's `patch.test`), never a hardcoded +/// `patch.socket.dev` — the hosted host follows `--api-url`. The npm twin /// (package-lock.json, no pnpm lock) rewrites identically but emits no such -/// warning. Subprocess so the `--json` `warnings[]` array can be read back. +/// warning and no workspace file. Subprocess so the `--json` `warnings[]` +/// array can be read back. #[tokio::test] #[serial] -async fn pnpm_lock_redirect_warns_to_trust_lockfile() { +async fn pnpm_lock_redirect_autoconfigures_trust_lockfile_and_says_so() { let server = MockServer::start().await; mock_discovery(&server).await; mock_reference(&server).await; @@ -2010,12 +2125,28 @@ async fn pnpm_lock_redirect_warns_to_trust_lockfile() { env["redirect"]["redirected"], 1, "anchor: the pnpm lock must have been redirected: {env}" ); + // The zero-touch write itself: a fresh pnpm-workspace.yaml with the + // root-only scaffold + the trust key, and it counts as a rewritten file + // (a CI consumer committing rewrittenFiles must not miss it). + let ws = std::fs::read_to_string(pnpm.path().join("pnpm-workspace.yaml")) + .expect("the run must create pnpm-workspace.yaml"); + assert_eq!( + ws, "packages:\n - '.'\ntrustLockfile: true\n", + "created workspace file must be the scaffold + trust key" + ); + assert!( + env["redirect"]["rewrittenFiles"] + .as_array() + .unwrap() + .iter() + .any(|f| f == "pnpm-workspace.yaml"), + "pnpm-workspace.yaml must be listed in rewrittenFiles: {env}" + ); assert!( warning_codes(&env).contains(&"redirect_pnpm_trust_lockfile".to_string()), "a rewritten pnpm-lock.yaml must warn about the pnpm >=11 policy; got warnings {:?}", warning_codes(&env) ); - // The warning must NAME the documented opt-out flag. let detail = env["redirect"]["warnings"] .as_array() .unwrap() @@ -2023,12 +2154,68 @@ async fn pnpm_lock_redirect_warns_to_trust_lockfile() { .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") .and_then(|w| w["detail"].as_str()) .unwrap_or_default(); + // The new reality: trust is configured; commit it; no flags needed. assert!( - detail.contains("--trust-lockfile"), - "the warning must name `pnpm install --trust-lockfile`; got: {detail}" + detail.contains("trustLockfile: true") && detail.contains("pnpm-workspace.yaml"), + "the warning must name the configured pnpm-workspace.yaml \ + `trustLockfile: true` key; got: {detail}" + ); + assert!( + detail.contains("commit it alongside the lock") && detail.contains("no extra flags"), + "the warning must say trust is configured and installs need no flags; got: {detail}" + ); + // The security tradeoff, stated honestly: the skip covers the WHOLE lock. + assert!( + detail.contains("ALL lockfile entries") && detail.contains("minimumReleaseAge"), + "the warning must disclose the whole-lock re-verification skip; got: {detail}" + ); + // The `.npmrc` `trust-lockfile=true` spelling is IGNORED by pnpm and + // must never be recommended. + assert!( + !detail.contains(".npmrc"), + "the warning must not point at .npmrc (pnpm ignores that spelling); got: {detail}" + ); + // Both major-specific failure spellings. + assert!( + detail.contains("ERR_PNPM_TARBALL_URL_MISMATCH") + && detail.contains("ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION"), + "the warning must name the pnpm 11 AND pnpm 12 error codes; got: {detail}" + ); + // pnpm 12's error text steers users to rebuild the lock, which discards + // the redirect — the warning must pre-empt it and scope the blast radius. + assert!( + detail.contains("pnpm clean --lockfile"), + "the warning must pre-empt pnpm's rebuild-the-lock advice; got: {detail}" + ); + assert!( + detail.contains("pnpm <=10"), + "the warning must scope the failure to pnpm >=11; got: {detail}" + ); + // The host is derived from the spliced tarball URL (the fixture's + // HOSTED_URL host), never hardcoded. + assert!( + detail.contains("patch.test") && !detail.contains("patch.socket.dev"), + "the warning must name the actual spliced host, not patch.socket.dev; \ + got: {detail}" + ); + + // The ledger records the workspace-trust edit (created ⇒ revert deletes). + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(pnpm.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + assert!( + ledger["edits"].as_array().unwrap().iter().any(|e| { + e["kind"] == "redirect_pnpm_workspace_trust" + && e["action"] == "created" + && e["path"] == "pnpm-workspace.yaml" + && e["key"] == "trustLockfile" + }), + "the ledger must record the created workspace-trust edit: {ledger}" ); - // npm twin: only a package-lock.json is rewritten → no pnpm warning. + // npm twin: only a package-lock.json is rewritten → no pnpm warning and + // no workspace file materializes. let npm = tempfile::tempdir().unwrap(); write_project(npm.path()); let env = run_redirect_subprocess(npm.path(), &server.uri()); @@ -2038,6 +2225,348 @@ async fn pnpm_lock_redirect_warns_to_trust_lockfile() { "an npm-only redirect must not emit the pnpm trust-lockfile warning; got warnings {:?}", warning_codes(&env) ); + assert!( + !npm.path().join("pnpm-workspace.yaml").exists(), + "an npm-only redirect must not create pnpm-workspace.yaml" + ); +} + +/// `--no-trust-lockfile-config` (the opt-out for users who refuse the +/// whole-lock trust tradeoff): the redirect still lands, but nothing touches +/// pnpm-workspace.yaml, the ledger records no workspace-trust edit, and the +/// warning falls back to the OLD two-recovery guidance (per-run +/// `pnpm install --trust-lockfile`; committable `trustLockfile: true`). +#[tokio::test] +#[serial] +async fn pnpm_trust_opt_out_writes_nothing_and_keeps_manual_guidance() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let env = + run_redirect_subprocess_with(tmp.path(), &server.uri(), &["--no-trust-lockfile-config"]); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "the opt-out must not stop the redirect itself: {env}" + ); + assert!( + !tmp.path().join("pnpm-workspace.yaml").exists(), + "the opt-out must not create pnpm-workspace.yaml" + ); + assert_eq!( + env["redirect"]["rewrittenFiles"], + serde_json::json!(["pnpm-lock.yaml"]), + "only the lock may be rewritten under the opt-out: {env}" + ); + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + assert!( + !ledger.to_string().contains("redirect_pnpm_workspace_trust"), + "the opt-out must record no workspace-trust edit: {ledger}" + ); + let detail = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_default(); + assert!( + detail.contains("--trust-lockfile") + && detail.contains("trustLockfile: true") + && detail.contains("pnpm-workspace.yaml"), + "the opt-out warning must keep BOTH manual recoveries; got: {detail}" + ); + assert!( + detail.contains("pnpm clean --lockfile") && detail.contains("pnpm <=10"), + "the opt-out warning keeps the rebuild caution and version scoping; got: {detail}" + ); + assert!( + !detail.contains("no extra flags"), + "the opt-out warning must not claim trust was configured; got: {detail}" + ); +} + +/// An EXPLICIT user `trustLockfile: ` in pnpm-workspace.yaml is a +/// security decision the auto-config must never flip: the file stays +/// byte-identical, no workspace-trust edit is recorded, and the warning says +/// the setting was respected while spelling out the manual recoveries. +#[tokio::test] +#[serial] +async fn pnpm_trust_respects_an_explicit_user_false() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let user_ws = "packages:\n - '.'\ntrustLockfile: false\n"; + std::fs::write(tmp.path().join("pnpm-workspace.yaml"), user_ws).unwrap(); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "the redirect itself must still land: {env}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap(), + user_ws, + "an explicit trustLockfile: false must be left byte-identical" + ); + let detail = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_default(); + assert!( + detail.contains("respected") && detail.contains("trustLockfile: false"), + "the warning must say the explicit user setting was respected; got: {detail}" + ); + assert!( + detail.contains("--trust-lockfile"), + "the warning must fall back to the per-run flag recovery; got: {detail}" + ); + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + assert!( + !ledger.to_string().contains("redirect_pnpm_workspace_trust"), + "no workspace-trust edit may be recorded for a respected user setting: {ledger}" + ); +} + +/// Two hardening pins on the trust-lockfile warning's host list, which lands +/// in CI logs via both stderr and the persisted `--json` envelope: +/// +/// 1. USERINFO NEVER LEAKS. A credentialed artifact URL +/// (`http://alice:s3cret@patch.test/…`) is spliced into the lock verbatim, +/// but the warning must name `host[:port]` only — printing the URL +/// authority wholesale leaked `alice:s3cret` into CI logs. +/// 2. ONLY SPLICED HOSTS ARE NAMED. The host list must come from the URLs +/// actually present in a REWRITTEN pnpm-lock.yaml's final text, not from +/// every npm override: a sibling override that landed solely in +/// package-lock.json (here `other-host.test`) must not be named, or the +/// warning points users at a server the pnpm lock never references. +/// +/// Fixture: two granted npm patches — the credentialed one resolved ONLY by +/// the root pnpm-lock.yaml, the sibling resolved ONLY by package-lock.json. +#[tokio::test] +#[serial] +async fn pnpm_warning_strips_userinfo_and_names_only_spliced_hosts() { + const SIBLING_NAME: &str = "sibling-npm-only"; + const SIBLING_VERSION: &str = "2.0.0"; + const SIBLING_PURL: &str = "pkg:npm/sibling-npm-only@2.0.0"; + const SIBLING_UUID: &str = "33333333-3333-4333-8333-333333333333"; + const SIBLING_URL: &str = "http://other-host.test/patch/npm/sibling-npm-only/2.0.0/44444444-4444-4444-8444-444444444444/33333333-3333-4333-8333-333333333333/sibling-npm-only-2.0.0.tgz"; + const CRED_URL: &str = "http://alice:s3cret@patch.test/patch/npm/in-proc-redirect/1.0.0/22222222-2222-4222-8222-222222222222/11111111-1111-4111-8111-111111111111/in-proc-redirect-1.0.0.tgz"; + + let server = MockServer::start().await; + // Discovery: BOTH installed packages have a granted patch. + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [ + { + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "credentialed redirect fixture" + }] + }, + { + "purl": SIBLING_PURL, + "patches": [{ + "uuid": SIBLING_UUID, "purl": SIBLING_PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "sibling redirect fixture" + }] + } + ], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + // Per-package search, one mock per package name (the names share no + // substring, so the regexes cannot cross-match). + for (pkg_name, uuid, purl) in [ + (NAME, UUID, PURL), + (SIBLING_NAME, SIBLING_UUID, SIBLING_PURL), + ] { + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.*{pkg_name}.*$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_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; + } + // Grants: the pnpm-locked package's tarball URL carries userinfo; the + // sibling's points at a DIFFERENT host that must never reach the warning. + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + UUID: { + "status": "granted", + "url": CRED_URL, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": CRED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + }, + SIBLING_UUID: { + "status": "granted", + "url": SIBLING_URL, + "purl": SIBLING_PURL, + "artifacts": [{ + "kind": "tarball", + "url": SIBLING_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(&server) + .await; + // Patch views for BOTH confirmed redirects (ledger records for VEX). + for (uuid, purl) in [(UUID, PURL), (SIBLING_UUID, SIBLING_PURL)] { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": uuid, + "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": "a".repeat(64), + "afterHash": "b".repeat(64), + } + }, + "vulnerabilities": { + GHSA: { + "cves": ["CVE-2024-9"], + "summary": "redirect vex fixture", + "severity": "high", + "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(&server) + .await; + } + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}", "{SIBLING_NAME}": "{SIBLING_VERSION}" }} }}"# + ), + ) + .unwrap(); + write_installed(tmp.path(), NAME, VERSION, b"unpatched installed bytes\n"); + write_installed( + tmp.path(), + SIBLING_NAME, + SIBLING_VERSION, + b"unpatched sibling bytes\n", + ); + // pnpm-lock.yaml resolves ONLY the credentialed package… + std::fs::write(tmp.path().join("pnpm-lock.yaml"), rush_pnpm_lock(NAME)).unwrap(); + // …while package-lock.json resolves ONLY the sibling. + std::fs::write( + tmp.path().join("package-lock.json"), + format!( + r#"{{ + "name": "consumer", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": {{ + "": {{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{SIBLING_NAME}": "{SIBLING_VERSION}" }} }}, + "node_modules/{SIBLING_NAME}": {{ + "version": "{SIBLING_VERSION}", + "resolved": "https://registry.npmjs.org/{SIBLING_NAME}/-/{SIBLING_NAME}-{SIBLING_VERSION}.tgz", + "integrity": "sha512-UPSTREAMupstream==" + }} + }} +}} +"# + ), + ) + .unwrap(); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 2, + "anchor: both locks must have been redirected: {env}" + ); + // Anchors: the credentialed URL really was spliced into the pnpm lock + // (so the no-leak assertion below is exercising a real splice), and the + // sibling's URL landed only in package-lock.json. + let pnpm_lock = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); + assert!( + pnpm_lock.contains(CRED_URL) && !pnpm_lock.contains("other-host.test"), + "pnpm-lock.yaml must carry the credentialed URL and nothing from the \ + sibling host; got:\n{pnpm_lock}" + ); + let npm_lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!( + npm_lock.contains(SIBLING_URL), + "package-lock.json must carry the sibling URL; got:\n{npm_lock}" + ); + + let detail = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_default() + .to_string(); + // (1) Host only, never userinfo. The `@` check pins the whole authority + // form: no spelling of `user:pass@host` can survive it. + assert!( + detail.contains("patch.test"), + "the warning must name the spliced host; got: {detail}" + ); + assert!( + !detail.contains("alice") && !detail.contains("s3cret") && !detail.contains('@'), + "the warning must never leak URL userinfo (credentials) into CI logs; \ + got: {detail}" + ); + // (2) Only hosts spliced into a rewritten pnpm lock — the sibling landed + // solely in package-lock.json, so its host must not be named. + assert!( + !detail.contains("other-host.test"), + "the warning must name only hosts the pnpm lock actually points at; \ + got: {detail}" + ); } /// A clean, fully-successful hosted run must carry an EXACT warning set — not @@ -2435,7 +2964,10 @@ async fn redirect_ledger_write_failure_leaves_project_files_untouched() { perms.set_readonly(true); std::fs::set_permissions(&vendor_dir, perms.clone()).unwrap(); let out = run_hosted_json_scan(tmp.path(), &server).await; - // Restore writability so the tempdir can be cleaned up. + // Restore writability so the tempdir can be cleaned up. The blanket + // group-write concern behind the lint doesn't apply: this un-readonlies a + // private tempdir moments before its deletion. + #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false); std::fs::set_permissions(&vendor_dir, perms).unwrap(); assert_write_failure_envelope(&out, "ledger-write failure"); @@ -2897,7 +3429,9 @@ async fn composer_redirect_is_confirmed_and_recorded() { } /// Mount the full cargo hosted-mock set (discovery + reference + view) for -/// one patch over `purl`. +/// one patch over `purl`. Eight positional fixture knobs beat a one-off +/// params struct for a test-local mock helper. +#[allow(clippy::too_many_arguments)] async fn mock_cargo_patch( server: &MockServer, purl: &str, 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 8229322d..73b9a375 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -238,8 +238,30 @@ async fn hosted_rewrites_pnpm_root_lock_resolution() { "the ledger must preserve the original upstream integrity for revert: {first}" ); + // Zero-touch trust config: a rewritten root v9 lock auto-creates + // pnpm-workspace.yaml with the root-only scaffold + `trustLockfile: true` + // (pnpm >=11 rejects the redirected lock without it; 9/10 ignore the + // key), and the ledger records the created-file edit for revert. + let ws_path = tmp.path().join("pnpm-workspace.yaml"); + let ws = std::fs::read_to_string(&ws_path) + .expect("the redirect must auto-create pnpm-workspace.yaml"); + assert_eq!( + ws, "packages:\n - '.'\ntrustLockfile: true\n", + "created workspace file must be the scaffold + trust key" + ); + assert!( + edits.iter().any(|e| { + e["kind"] == "redirect_pnpm_workspace_trust" + && e["action"] == "created" + && e["path"] == "pnpm-workspace.yaml" + && e["key"] == "trustLockfile" + }), + "the ledger must record the workspace-trust creation: {first}" + ); + // Idempotency: a second run rewrites nothing new — an already-redirected - // resolution must not append duplicate edits (which would poison a revert). + // resolution must not append duplicate edits (which would poison a revert), + // and the auto-created workspace file must stay byte-stable. let code = run(hosted_args(tmp.path(), server.uri())).await; assert_eq!(code, 0, "second scan --mode hosted should succeed"); let second: serde_json::Value = @@ -254,6 +276,162 @@ async fn hosted_rewrites_pnpm_root_lock_resolution() { lock, lock_after_rerun, "the re-run must leave the lock byte-stable" ); + assert_eq!( + std::fs::read_to_string(&ws_path).unwrap(), + ws, + "the re-run must leave pnpm-workspace.yaml byte-stable" + ); +} + +/// MERGE case: a pre-existing pnpm-workspace.yaml (comments, multi-glob +/// packages, catalog — none of it ours) gains EXACTLY one appended +/// `trustLockfile: true` line after its last non-empty line; every other +/// byte survives verbatim, and the ledger records the `added` (not +/// `created`) action so a revert removes just that line. +#[tokio::test] +#[serial] +async fn hosted_merges_trust_key_into_existing_workspace_yaml_byte_exactly() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let user_ws = + "# team workspace\npackages:\n - '.'\n - 'tools/*'\n\ncatalog:\n react: ^18.0.0\n"; + std::fs::write(tmp.path().join("pnpm-workspace.yaml"), user_ws).unwrap(); + + let code = run(hosted_args(tmp.path(), server.uri())).await; + assert_eq!(code, 0, "scan --mode hosted should succeed"); + + let ws = std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap(); + assert_eq!( + ws, + "# team workspace\npackages:\n - '.'\n - 'tools/*'\n\ncatalog:\n react: ^18.0.0\ntrustLockfile: true\n", + "the merge must preserve every user byte and append exactly one line" + ); + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + assert!( + ledger["edits"].as_array().unwrap().iter().any(|e| { + e["kind"] == "redirect_pnpm_workspace_trust" + && e["action"] == "added" + && e["path"] == "pnpm-workspace.yaml" + }), + "the ledger must record the merged (added) trust edit: {ledger}" + ); +} + +/// `--dry-run` previews: NOTHING lands on disk — no lock rewrite, no +/// pnpm-workspace.yaml, no ledger — while the envelope still reports both +/// files as would-be-rewritten (`dryRun: true`). +#[tokio::test] +#[serial] +async fn hosted_dry_run_writes_neither_lock_nor_workspace_trust() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_pnpm_project(tmp.path()); + let lock_before = std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(); + + let mut args = hosted_args(tmp.path(), server.uri()); + args.common.dry_run = true; + let code = run(args).await; + assert_eq!(code, 0, "dry-run scan --mode hosted should succeed"); + + assert_eq!( + std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(), + lock_before, + "dry-run must leave the lock byte-identical" + ); + assert!( + !tmp.path().join("pnpm-workspace.yaml").exists(), + "dry-run must not create pnpm-workspace.yaml" + ); + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "dry-run must not write the redirect ledger" + ); +} + +/// LEGACY lock (pnpm 8's lockfileVersion '6.0', byte-real matrix shape): the +/// plain `/name@version:` key stays redirectable, but the trustLockfile +/// auto-config must NOT fire — pnpm 7/8 have neither the >=11 policy nor the +/// flag, so writing trust config for them would be pure noise. No +/// pnpm-workspace.yaml appears and the ledger carries no workspace-trust +/// edit. +#[tokio::test] +#[serial] +async fn hosted_legacy_v6_lock_gets_no_workspace_trust_config() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + // The v6 shape pnpm 8 emits (matrix hosted-pnpm8): quoted '6.0', + // `/name@version:` package key. + std::fs::write( + root.join("pnpm-lock.yaml"), + format!( + "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + {NAME}: + specifier: {VERSION} + version: {VERSION} + +packages: + + /{NAME}@{VERSION}: + resolution: {{integrity: {UPSTREAM_SHA512}}} + dev: false +" + ), + ) + .unwrap(); + + let code = run(hosted_args(root, server.uri())).await; + assert_eq!(code, 0, "scan --mode hosted should succeed on a v6 lock"); + + let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + assert!( + lock.contains(&format!("tarball: {HOSTED_URL}")), + "anchor: the v6 lock must still be redirected; got:\n{lock}" + ); + assert!( + !root.join("pnpm-workspace.yaml").exists(), + "a legacy v6 lock must not trigger the trustLockfile auto-config" + ); + let ledger = std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")).unwrap(); + assert!( + !ledger.contains("redirect_pnpm_workspace_trust"), + "no workspace-trust edit may be recorded for a legacy lock: {ledger}" + ); } /// (a2) SCOPED package: pnpm lockfileVersion 9 single-quotes `packages:` keys @@ -445,3 +623,251 @@ async fn hosted_pnpm_vex_emits_redirected_attestation() { "the attestation must carry the (redirected) marker: {impact}" ); } + +/// The CLI binary with ambient SOCKET_* env scrubbed (same hermeticity rule +/// as `in_process_redirect.rs`'s `scrubbed_cli`; duplicated because test +/// binaries cannot share helpers). Subprocess (not in-process `run`) so the +/// `--json` stdout envelope can be read back — the diagnostics under test +/// ARE the envelope's `redirect.warnings`. +fn scrubbed_cli() -> std::process::Command { + let cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); + let mut cmd = cmd; + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { + cmd.env_remove(&key); + } + } + cmd +} + +/// Run `scan --mode hosted --json` as a subprocess against `cwd` and parse +/// the stdout envelope (exit code, parsed JSON). +fn run_hosted_json(cwd: &Path, api_url: &str) -> (Option, serde_json::Value) { + let out = scrubbed_cli() + .args([ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + cwd.to_str().unwrap(), + "--api-url", + api_url, + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let doc = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("stdout must be the JSON envelope ({e});\nstdout=\n{stdout}\nstderr=\n{stderr}") + }); + (out.status.code(), doc) +} + +/// (c) pnpm 1/2 legacy project (the 2026-08-18 legacy-matrix layout: +/// `shrinkwrap.yaml` + `node_modules/.modules.yaml`, no pnpm-lock.yaml and +/// no package-lock.json): the no-lockfile diagnostic must be PNPM-flavored +/// — `redirect_pnpm_legacy_lockfile` naming shrinkwrap.yaml and the pnpm +/// upgrade path — never the npm `redirect_npm_no_lockfile` wording that +/// dead-ends on a pnpm project. The legacy lock also feeds the +/// lockfile-only supplement (shrinkwrap.yaml IS the v5 grammar under the +/// pre-rename filename), and the run stays fail-closed: shrinkwrap.yaml is +/// byte-untouched with zero redirects. +#[tokio::test] +#[serial] +async fn hosted_legacy_shrinkwrap_project_diagnoses_pnpm_not_npm() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + // pnpm layout marker (pnpm 1/2 writes it, modern pnpm still does). + std::fs::write( + root.join("node_modules/.modules.yaml"), + "packageManager: pnpm@2.17.0\n", + ) + .unwrap(); + // shrinkwrapVersion-3 grammar (real shape from the legacy matrix): + // v5-style `/name/version` keys, BLOCK-mapped resolution. One entry is + // installed (NAME), one is lock-only — the supplement must see it. + let shrinkwrap = format!( + "dependencies: + {NAME}: {VERSION} +packages: + /{NAME}/{VERSION}: + dev: false + resolution: + integrity: {UPSTREAM_SHA512} + /legacy-only-dep/2.0.0: + dev: false + resolution: + integrity: sha512-LEGACYONLYlegacyonly== +registry: 'https://registry.npmjs.org/' +shrinkwrapMinorVersion: 9 +shrinkwrapVersion: 3 +specifiers: + {NAME}: {VERSION} +" + ); + std::fs::write(root.join("shrinkwrap.yaml"), &shrinkwrap).unwrap(); + + let (code, doc) = run_hosted_json(root, &server.uri()); + assert_eq!(code, Some(0), "fail-closed diagnostics still exit 0: {doc}"); + + let warnings = doc["redirect"]["warnings"].as_array().unwrap(); + assert_eq!( + warnings.len(), + 1, + "exactly the pnpm-legacy diagnostic, no npm noise: {doc}" + ); + assert_eq!( + warnings[0]["code"], "redirect_pnpm_legacy_lockfile", + "the family selection must be marker-aware: {doc}" + ); + let detail = warnings[0]["detail"].as_str().unwrap(); + assert!( + detail.contains("shrinkwrap.yaml") && detail.contains("pnpm"), + "detail must name the legacy lock and pnpm: {detail}" + ); + assert!( + !doc.to_string().contains("redirect_npm_no_lockfile"), + "the npm wording must be gone: {doc}" + ); + assert_eq!(doc["redirect"]["redirected"], 0, "nothing redirects: {doc}"); + + // The lockfile-only supplement reads shrinkwrap.yaml: the uninstalled + // `/legacy-only-dep/2.0.0` entry surfaces (it was 0 before the fix). + assert_eq!( + doc["lockfileOnlyPackages"], 1, + "shrinkwrap.yaml must feed the lockfile-only supplement: {doc}" + ); + + assert_eq!( + std::fs::read_to_string(root.join("shrinkwrap.yaml")).unwrap(), + shrinkwrap, + "shrinkwrap.yaml must be byte-untouched (fail-closed)" + ); +} + +/// (d) hosted over a VENDORED pnpm lock (the mode-conversion matrix's projB +/// shape: overrides + `@file:.socket/vendor/…` packages/snapshots +/// keys): the per-dep warning must be `redirect_pnpm_entry_vendored` +/// pointing at `vendor --revert` — not the `redirect_pnpm_entry_not_found` +/// wording that reads as "not locked" and invites a `pnpm install` +/// wild-goose chase. Fail-closed unchanged: zero redirects, lock untouched, +/// no redirect ledger. +#[tokio::test] +#[serial] +async fn hosted_over_vendored_pnpm_lock_diagnoses_vendored_not_missing() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + // Byte-real vendored v9 shape (mode-conversion snap-B, renamed to the + // fixture package). + let vendored_spec = format!( + "file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/{NAME}-{VERSION}.tgz" + ); + let lock = format!( + "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + {NAME}@{VERSION}: {vendored_spec} + +importers: + + .: + dependencies: + {NAME}: + specifier: {vendored_spec} + version: {vendored_spec} + +packages: + + {NAME}@{vendored_spec}: + resolution: {{integrity: {UPSTREAM_SHA512}, tarball: {vendored_spec}}} + version: {VERSION} + +snapshots: + + {NAME}@{vendored_spec}: {{}} +" + ); + std::fs::write(root.join("pnpm-lock.yaml"), &lock).unwrap(); + + let (code, doc) = run_hosted_json(root, &server.uri()); + assert_eq!(code, Some(0), "fail-closed diagnostics still exit 0: {doc}"); + + let warnings = doc["redirect"]["warnings"].as_array().unwrap(); + assert!( + warnings + .iter() + .any(|w| w["code"] == "redirect_pnpm_entry_vendored"), + "the vendored state must be named: {doc}" + ); + let detail = warnings + .iter() + .find(|w| w["code"] == "redirect_pnpm_entry_vendored") + .and_then(|w| w["detail"].as_str()) + .unwrap(); + assert!( + detail.contains("vendor --revert"), + "detail must give the mode-switch path: {detail}" + ); + assert!( + !doc.to_string().contains("redirect_pnpm_entry_not_found"), + "the not-locked wording must be gone for a vendored dep: {doc}" + ); + assert_eq!(doc["redirect"]["redirected"], 0, "nothing redirects: {doc}"); + assert_eq!( + std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(), + lock, + "the vendored lock must be byte-untouched (fail-closed)" + ); + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "a zero-redirect run must not write a redirect ledger" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 2753d003..0e2d1d72 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1473,6 +1473,17 @@ fn vendor_missing_file_fails_closed_without_force() { let (code, env) = vendor_cli(fx.root(), &[]); assert_ne!(code, 0, "missing patch target must fail: {env:#}"); + // CONTRACT: even a run where EVERY outcome failed reports + // "partialFailure". The envelope has no "completed with zero successes" + // status, and status=error is reserved for pre-event failures (it implies + // a top-level error payload and empty events[] — json_envelope.rs), so + // escalating this run to "error" would violate the contract and diverge + // from scan --vendor and vendor --revert, which report the same outcome + // as partialFailure. Exit code 1 carries the failure signal. + assert_eq!( + env["status"], "partialFailure", + "all-failed vendor runs report partialFailure per the envelope contract: {env:#}" + ); let failed = find_event(&env, "failed", None); assert!( failed["error"] @@ -1597,6 +1608,93 @@ async fn dry_run_vex_is_skipped_not_generated() { assert!(!fx.vendor_dir().exists(), "no artifacts staged"); } +/// Embedded-VEX run advisories must ride the envelope's `vex.warnings` +/// under `--json`: `note_warning` silences stderr there, so without this +/// key the advisory has no channel at all. The vendor host populates +/// `VexSummary.warnings` (the scan host's raw-json arm mirrors it); a +/// clean product pins the skip-if-empty contract — no `warnings` key. +#[tokio::test] +async fn vendor_json_vex_warnings_ride_in_envelope() { + // The stock fixture manifest has no vulnerability metadata, and VEX + // refuses to attest a metadata-less patch (`no_applicable_patches`) — + // inject one so the embedded generation actually runs. + fn add_vulnerability(root: &Path) { + let manifest_path = root.join(".socket/manifest.json"); + let mut m: Value = serde_json::from_str( + &std::fs::read_to_string(&manifest_path).expect("read fixture manifest"), + ) + .expect("parse fixture manifest"); + for (_, patch) in m["patches"].as_object_mut().expect("patches map") { + patch["vulnerabilities"] = serde_json::json!({ + "GHSA-embd-warn-test": { + "cves": ["CVE-2024-77777"], + "summary": "embedded-vex warning fixture", + "severity": "high", + "description": "d", + } + }); + } + std::fs::write( + &manifest_path, + serde_json::to_string_pretty(&m).expect("serialize manifest"), + ) + .expect("write fixture manifest"); + } + + let fx = npm_fixture(); + add_vulnerability(fx.root()); + let vex_path = fx.root().join("vendored.vex.json"); + let (code, env) = vendor_cli( + fx.root(), + &[ + "--vex", + vex_path.to_str().expect("vex path is UTF-8"), + "--vex-product", + "not an iri at all", + ], + ); + assert_eq!( + code, 0, + "a non-IRI product warns, never hard-rejects: {env}" + ); + let warnings = env["vex"]["warnings"] + .as_array() + .unwrap_or_else(|| panic!("vex.warnings must be present for a non-IRI product: {env}")); + assert!( + warnings.iter().any(|w| w["code"] == "product_not_iri"), + "vendor vex.warnings must carry product_not_iri, got {warnings:?}" + ); + + // Clean-product control on a fresh fixture: no product advisory — but + // the tree-sync disclosure legitimately fires (vendor rewires the + // lockfile only; the live node_modules keeps pre-vendor bytes until a + // reinstall), which doubles as a pin that the host folds EVERY + // advisory kind in, not just product_not_iri. The skip-if-empty + // contract (no key at all on a warning-free run) is pinned by the + // apply-side control in e2e_embedded_vex.rs. + let fx2 = npm_fixture(); + add_vulnerability(fx2.root()); + let vex2 = fx2.root().join("vendored2.vex.json"); + let (code2, env2) = vendor_cli( + fx2.root(), + &["--vex", vex2.to_str().expect("vex path is UTF-8")], + ); + assert_eq!(code2, 0, "clean embedded vex run must succeed: {env2}"); + let warnings2 = env2["vex"]["warnings"] + .as_array() + .unwrap_or_else(|| panic!("out-of-sync disclosure must ride the envelope: {env2}")); + assert!( + warnings2.iter().all(|w| w["code"] != "product_not_iri"), + "auto-detected product must not warn: {warnings2:?}" + ); + assert!( + warnings2 + .iter() + .any(|w| w["code"] == "vendored_tree_out_of_sync"), + "the pre-reinstall tree must surface the sync disclosure: {warnings2:?}" + ); +} + // ───────────────────────────────────────────────────────────────────── // 12. fail-closed --vendor-source=service refuses --offline // ───────────────────────────────────────────────────────────────────── @@ -1957,6 +2055,373 @@ async fn scan_vendor_gem_detached_writes_no_manifest_and_reverts() { assert!(!fx.root().join(".socket/vendor").exists()); } +// ───────────────────────────────────────────────────────────────────── +// hosted → vendored mode conversion (takeover reconciliation, pnpm v9) +// ───────────────────────────────────────────────────────────────────── +// +// The full migration a real project performs: `scan --mode hosted` first +// (wiremock API, v9 pnpm root lock — the shapes of +// `in_process_redirect_pnpm.rs`), then `vendor` over the hosted-redirected +// lock. Pins the npm-family takeover pre-revert (the pnpm twin of +// `mode_migration_npm.rs`): +// +// * vendor PRE-REVERTS the live hosted redirect (surfaced as the +// `vendor_takeover_reverted_redirect` advisory) before rewiring, so the +// redirect ledger loses the converted purl's `records` entry AND its +// `redirect_pnpm_resolution` edits (stale halves fed VEX/updates and +// re-fired the takeover warning forever pre-fix) — the +// `vendor_supersedes_redirect` warning can never fire, on the takeover +// run or any later one, +// * the vendor wiring `original` embeds the PRISTINE registry fragment — +// not the grant-tokenized hosted splice — and a re-vendor +// (`already_vendored`) is silent, +// * `vendor --revert` therefore restores the REGISTRY lock byte-exactly +// (pre-#206 it restored an expiring hosted URL with no CLI path back to +// registry state). +mod hosted_to_vendor_conversion { + use super::*; + use serial_test::serial; + use socket_patch_cli::commands::scan::{run as scan_run, ScanArgs, ScanMode}; + use wiremock::matchers::{method, path, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const ORG: &str = "test-org"; + const CONV_NAME: &str = "conv-pnpm-takeover"; + const CONV_VERSION: &str = "1.0.0"; + const CONV_PURL: &str = "pkg:npm/conv-pnpm-takeover@1.0.0"; + const CONV_UUID: &str = "44444444-4444-4444-8444-444444444444"; + const HOSTED_URL: &str = "http://patch.test/patch/npm/conv-pnpm-takeover/1.0.0/55555555-5555-4555-8555-555555555555/44444444-4444-4444-8444-444444444444/conv-pnpm-takeover-1.0.0.tgz"; + const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; + const UPSTREAM_SHA512: &str = "sha512-UPSTREAMupstream=="; + + async fn mock_hosted_api(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": CONV_PURL, + "patches": [{ + "uuid": CONV_UUID, "purl": CONV_PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "conversion fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": CONV_UUID, "purl": CONV_PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { + CONV_UUID: { + "status": "granted", + "url": HOSTED_URL, + "purl": CONV_PURL, + "artifacts": [{ + "kind": "tarball", + "url": HOSTED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; + // `view/{uuid}` — the record the redirect ledger persists (real file + // hashes, so the ledger record mirrors the manifest the vendor run + // uses later). + let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{CONV_UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": CONV_UUID, + "purl": CONV_PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": before_hash, + "afterHash": after_hash, + } + }, + "vulnerabilities": {}, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; + } + + /// The `in_process_redirect_pnpm.rs` project shape: v9 root pnpm lock + /// resolving the package under `packages:`, plus the installed + /// node_modules copy (with the real patch-target file, so the later + /// vendor run can pack the artifact). + fn write_pnpm_project(root: &Path) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{CONV_NAME}": "{CONV_VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(CONV_NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{CONV_NAME}", "version": "{CONV_VERSION}" }}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + std::fs::write( + root.join("pnpm-lock.yaml"), + format!( + "lockfileVersion: '9.0' + +importers: + .: + dependencies: + {CONV_NAME}: + specifier: {CONV_VERSION} + version: {CONV_VERSION} + +packages: + {CONV_NAME}@{CONV_VERSION}: + resolution: {{integrity: {UPSTREAM_SHA512}}} + +snapshots: + {CONV_NAME}@{CONV_VERSION}: {{}} +" + ), + ) + .unwrap(); + } + + /// `--mode hosted` args against the wiremock API (the + /// `in_process_redirect_pnpm.rs` shape). + fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { + ScanArgs { + common: GlobalArgs { + cwd: cwd.to_path_buf(), + org: Some(ORG.to_string()), + api_token: Some("fake".to_string()), + api_url: Some(api_url), + json: true, + yes: true, + ..GlobalArgs::default() + }, + batch_size: 100, + apply: false, + prune: false, + sync: false, + vendor: false, + detached: false, + redirect: false, + mode: Some(ScanMode::Hosted), + all_releases: false, + vex: Default::default(), + } + } + + /// The manifest + staged blob the vendor run needs (vendor is driven + /// offline; hosted mode wrote no manifest — the ledger is its store). + fn seed_manifest_and_blob(root: &Path) { + let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + let manifest = json!({ + "patches": { + CONV_PURL: { + "uuid": CONV_UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": before_hash, + "afterHash": after_hash + } + }, + "vulnerabilities": {}, + "description": "conversion fixture", + "license": "MIT", + "tier": "free" + } + } + }); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let mut bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + bytes.push(b'\n'); + std::fs::write(socket.join("manifest.json"), &bytes).unwrap(); + std::fs::write(socket.join("blobs").join(after_hash), PATCHED_INDEX).unwrap(); + } + + fn takeover_warnings(envelope: &Value) -> Vec<&str> { + envelope["warnings"] + .as_array() + .map(|w| { + w.iter() + .filter(|e| e["code"] == "vendor_supersedes_redirect") + .map(|e| e["detail"].as_str().unwrap_or("")) + .collect() + }) + .unwrap_or_default() + } + + #[tokio::test] + #[serial] + async fn hosted_then_vendor_takeover_pre_reverts_redirect_and_round_trips() { + let server = MockServer::start().await; + mock_hosted_api(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_pnpm_project(root); + let pristine_lock = std::fs::read(root.join("pnpm-lock.yaml")).unwrap(); + + // 1. Hosted redirect: the lock's resolution is spliced to the hosted + // tarball and the redirect ledger claims the purl. + let code = scan_run(hosted_args(root, server.uri())).await; + assert_eq!(code, 0, "scan --mode hosted must succeed"); + let hosted_lock_text = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + assert!( + hosted_lock_text.contains(&format!("tarball: {HOSTED_URL}")), + "hosted splice missing:\n{hosted_lock_text}" + ); + let ledger_path = root.join(".socket/vendor/redirect-state.json"); + let ledger: Value = + serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); + assert!( + ledger["records"].get(CONV_PURL).is_some(), + "hosted run must record the purl: {ledger}" + ); + + // 2. Vendor over the hosted-redirected lock (offline, staged blob). + // The takeover PRE-REVERTS the hosted edits first — surfaced as + // the `vendor_takeover_reverted_redirect` advisory — then vendors + // from the clean registry baseline. + seed_manifest_and_blob(root); + let (code, env1) = vendor_cli(root, &[]); + assert_eq!( + code, 0, + "vendor over the hosted lock must succeed: {env1:#}" + ); + find_event(&env1, "applied", None); + find_event(&env1, "skipped", Some("vendor_takeover_reverted_redirect")); + + // The pre-revert leaves nothing to supersede, so the + // vendor_supersedes_redirect warning must not fire — not on this run + // (pre-#206 it fired here) and not on any later one. + assert!( + takeover_warnings(&env1).is_empty(), + "the pre-revert must preempt vendor_supersedes_redirect: {env1:#}" + ); + + // The lock is FULLY vendored: local wiring present, no hosted + // residue. + let vendored_lock_text = + std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + assert!( + !vendored_lock_text.contains(HOSTED_URL), + "the hosted splice must be gone from the vendored lock:\n{vendored_lock_text}" + ); + assert!( + vendored_lock_text.contains(".socket/vendor/"), + "the vendored wiring must be present:\n{vendored_lock_text}" + ); + + // The redirect ledger no longer carries the purl's halves: the + // takeover dropped its `records` entry and its + // `redirect_pnpm_resolution` edits (a residual non-package edit like + // the workspace-trust one may remain — it is the hosted flow's own + // config surface). + match std::fs::read_to_string(&ledger_path) { + Ok(text) => { + let after: Value = serde_json::from_str(&text).unwrap(); + assert!( + after["records"].get(CONV_PURL).is_none(), + "the superseded record must be dropped: {after}" + ); + let leftover: Vec<&Value> = after["edits"] + .as_array() + .map(|edits| { + edits + .iter() + .filter(|e| e["key"].as_str().is_some_and(|k| k.contains(CONV_NAME))) + .collect() + }) + .unwrap_or_default(); + assert!( + leftover.is_empty(), + "the superseded package edits must be dropped: {after}" + ); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Fully emptied ledgers are deleted — also a valid outcome. + } + Err(e) => panic!("unreadable redirect ledger: {e}"), + } + + // The vendor ledger's wiring `original` embeds the PRISTINE registry + // fragment the pre-revert restored — never the grant-tokenized + // hosted splice (which would make `--revert` restore an expiring + // hosted URL with no CLI path back to registry state). + let state: Value = serde_json::from_str( + &std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + let wiring = state["entries"][CONV_PURL]["wiring"].to_string(); + assert!( + wiring.contains(UPSTREAM_SHA512), + "vendor wiring must embed the pristine registry original: {state:#}" + ); + assert!( + !wiring.contains(HOSTED_URL), + "vendor wiring must NOT record the hosted fragment: {state:#}" + ); + + // 3. Re-vendor: an `already_vendored` no-op with NO takeover event + // and NO supersede warning (pre-fix the stale ledger re-fired the + // warning on every run). + let (code, env2) = vendor_cli(root, &[]); + assert_eq!(code, 0, "re-vendor must succeed: {env2:#}"); + find_event(&env2, "skipped", Some("already_vendored")); + assert!( + takeover_warnings(&env2).is_empty(), + "a reconciled ledger must not re-fire the warning: {env2:#}" + ); + assert!( + events(&env2) + .iter() + .all(|e| e["errorCode"] != "vendor_takeover_reverted_redirect"), + "a reconciled ledger must not re-fire the takeover: {env2:#}" + ); + + // 4. `vendor --revert` restores the REGISTRY lock byte-exactly — the + // pre-redirect resolution, not the hosted splice. + let (code, renv) = vendor_cli(root, &["--revert"]); + assert_eq!(code, 0, "revert must succeed: {renv:#}"); + assert_eq!( + std::fs::read(root.join("pnpm-lock.yaml")).unwrap(), + pristine_lock, + "revert must byte-restore the pristine registry lock" + ); + } +} + // ───────────────────────────────────────────────────────────────────── // 12. drift-skipped revert keeps artifacts + ledger (residual #131) // ───────────────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs index dee42141..41a5d869 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs @@ -241,6 +241,11 @@ fn events_of(v: &serde_json::Value) -> Vec { v["events"].as_array().cloned().unwrap_or_default() } +/// Run-level `warnings[]` (`{code, detail}`); empty when omitted. +fn warnings_of(v: &serde_json::Value) -> Vec { + v["warnings"].as_array().cloned().unwrap_or_default() +} + /// 1. Deleted tarball → `repair` rebuilds it byte-identically (installed /// copy + view-fetched patch content), lockfile and ledger untouched. #[tokio::test] @@ -357,6 +362,92 @@ async fn repair_rebuilds_corrupt_vendored_tarball() { ); } +/// 3b. Corrupt tarball with NO rebuild source: the per-entry failure must +/// PRESERVE the corrupt-but-diagnosable bytes. Deleting them (as the +/// old delete-corrupt-first pass did) converts an integrity-mismatch +/// state into a bare ENOENT on the next install — the lock still +/// points at the tarball — and destroys the forensic evidence of the +/// tamper. Both no-source rungs are pinned: patch content missing +/// (staging unavailable) and patch content present but the pristine +/// package unreachable (--offline, node_modules gone). RED before the +/// rebuild-source-first ordering: the uuid dir was emptied up front +/// and both arms left it bare. +#[tokio::test] +async fn repair_keeps_corrupt_artifact_when_no_rebuild_source_exists() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture( + tmp.path(), + "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "sha512-orig==", + ); + let tgz = vendor_project(tmp.path(), &mock.uri(), &[]); + let tgz_bytes = std::fs::read(&tgz).unwrap(); + + const GARBAGE: &[u8] = b"\x1f\x8bgarbage"; + std::fs::write(&tgz, GARBAGE).unwrap(); + std::fs::remove_dir_all(tmp.path().join("node_modules")).unwrap(); + + // Arm 1: no local patch sources either — the staging step itself has + // nothing to rebuild from. + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair", "--offline"]); + assert_eq!(code, 1, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v).iter().any(|e| e["action"] == "failed" + && e["purl"] == PURL + && e["error"].as_str().unwrap_or("").contains("--offline")), + "the failure names the purl and the offline cause: {v}" + ); + assert_eq!( + std::fs::read(&tgz).unwrap(), + GARBAGE, + "arm 1: an unrebuildable corrupt artifact must not be destroyed" + ); + + // Arm 2: patch content IS local (seeded after-blob), but the pristine + // package ladder still has no source — the corrupt copy must survive + // the deeper rung too. + let blobs = tmp.path().join(".socket/blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + std::fs::write(blobs.join(git_sha256(AFTER)), AFTER).unwrap(); + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair", "--offline"]); + assert_eq!(code, 1, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v).iter().any(|e| e["action"] == "failed" + && e["purl"] == PURL + && e["error"].as_str().unwrap_or("").contains("--offline")), + "the failure names the purl and the offline cause: {v}" + ); + assert_eq!( + std::fs::read(&tgz).unwrap(), + GARBAGE, + "arm 2: an unrebuildable corrupt artifact must not be destroyed" + ); + + // Heal: with the installed copy restored, the same repair rebuilds the + // recorded bytes — retention never wedges the corrupt→rebuild path. + let pkg = tmp.path().join("node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), BEFORE).unwrap(); + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair", "--offline"]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert_eq!(v["summary"]["rebuilt"], 1, "envelope={v}"); + assert_eq!( + std::fs::read(&tgz).unwrap(), + tgz_bytes, + "rebuild restores the recorded bytes once a source exists" + ); +} + /// 4. A tampered ledger sha can never be satisfied: the rebuild is removed /// and the run fails loudly rather than leaving unverifiable bytes. #[tokio::test] @@ -499,6 +590,24 @@ async fn repair_reconstructs_ledger_from_lockfile_references() { lock1, "lockfile untouched" ); + // npm wiring originals are registry integrity material repair cannot + // reconstruct offline: the gap is a run-level ADVISORY (the entry + // itself repaired fine), so it rides `warnings[]` naming the purl — + // never `events[]` as a `skipped` a consumer would count as work not + // done. + assert!( + warnings_of(&v) + .iter() + .any(|w| w["code"] == "vendor_wiring_unknown" + && w["detail"].as_str().unwrap_or("").contains(PURL)), + "the wiring gap rides run-level warnings[] with the purl: {v}" + ); + assert!( + !events_of(&v) + .iter() + .any(|e| e["errorCode"] == "vendor_wiring_unknown"), + "no skipped event for the run-level advisory: {v}" + ); // The re-synthesized ledger entry: same uuid, fingerprint of the // rebuilt bytes, NOT detached (the manifest still has the record). @@ -515,11 +624,23 @@ async fn repair_reconstructs_ledger_from_lockfile_references() { "recomputed fingerprint matches the rebuilt artifact: {state}" ); - // Revert degrades gracefully (no recorded originals): exit 0, artifact - // removed, the drifted-entry guidance surfaced. + // Revert fails CLOSED: there are no recorded originals to replay and + // the rewired lock still resolves through the artifact — removing it + // would brick every later `npm ci` (see test 12 for the full recovery + // arc). let (code, stdout, _) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); - assert_eq!(code, 0, "revert of a reconstructed entry: {stdout}"); - assert!(!tgz.exists(), "revert removed the artifact"); + assert_ne!( + code, 0, + "revert of a still-wired reconstructed entry must refuse: {stdout}" + ); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["errorCode"] == "vendor_wiring_unknown_revert_blocked"), + "envelope={v}" + ); + assert!(tgz.exists(), "the artifact the lock references survives"); } /// 7b. Only `state.json` was lost; the committed artifact survived INTACT. @@ -906,6 +1027,122 @@ async fn repair_dry_run_previews_rebuild() { assert!(!tgz.exists(), "dry run writes nothing"); } +/// 12. The flavor-None brick, exactly as observed empirically (real project, +/// 2026-08-18): only `state.json` is lost, the artifact and the rewired +/// `package-lock.json` survive. `repair` reconstructs the entry with +/// EMPTY wiring (npm pre-vendor lock fragments are not +/// offline-recoverable) — and `vendor --revert` of that entry used to +/// exit 0 while DELETING the tarball the lock still resolves through, +/// silently bricking every later `npm ci` (ENOENT on the file: spec). +/// Now: the revert fails closed (`vendor_wiring_unknown_revert_blocked`), +/// the artifact and lock survive, `repair` stays idempotent, and once +/// the pre-vendor lock is restored a normal revert removes the orphaned +/// artifact cleanly. The reconstruction also stamps the flavor it found +/// the reference in (`package-lock`), so revert routes to the backend +/// whose guard probes the RIGHT lockfile. +#[tokio::test] +async fn revert_of_reconstructed_package_lock_entry_fails_closed_then_recovers() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture( + tmp.path(), + "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "sha512-orig==", + ); + let lock_pre = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + let tgz = vendor_project(tmp.path(), &mock.uri(), &[]); + let lock_vendored = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + + // Ledger gone; artifact + rewired lock intact (the empirical shape). + // The anchored reconstruction restores the entry with wiring: []. + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + mount_blob(&mock).await; + let (code, stdout, stderr) = run_cli( + tmp.path(), + &mock.uri(), + &["repair", "--download-mode", "file"], + ); + assert_eq!(code, 0, "reconstruction: stdout={stdout} stderr={stderr}"); + let state_path = tmp.path().join(".socket/vendor/state.json"); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + assert_eq!( + state["entries"][PURL]["wiring"].as_array().map(Vec::len), + Some(0), + "npm wiring is not offline-recoverable: {state}" + ); + let stamped_flavor = state["entries"][PURL]["flavor"].clone(); + + // Nothing to replay + the lock still resolves through the artifact: + // revert must refuse loudly instead of silently removing the tarball. + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); + assert_ne!( + code, 0, + "revert of an empty-wiring entry the lock still references must fail closed: \ + stdout={stdout} stderr={stderr}" + ); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["errorCode"] == "vendor_wiring_unknown_revert_blocked"), + "envelope={v}" + ); + assert!( + tgz.is_file(), + "the artifact the lock still references must survive the refusal" + ); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + lock_vendored, + "the lock stays untouched by the refusal" + ); + + // The reconstruction identified the referencing lockfile, so the entry + // carries the detected flavor — not None (which would depend on the + // flavor-None fallback route for its guard). + assert_eq!( + stamped_flavor, + serde_json::json!("package-lock"), + "reconstruction stamps the detected flavor" + ); + + // Recovery, exactly as the refusal advises: `repair` keeps the vendored + // artifact healthy (idempotent — the entry and tarball survive)... + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!( + code, 0, + "repair after refusal: stdout={stdout} stderr={stderr}" + ); + assert!(tgz.is_file(), "repair keeps the artifact"); + + // ...and once the pre-vendor lock is restored (the manual-restore arm — + // the wiring originals are unrecoverable by design), a normal revert + // removes the now-orphaned artifact cleanly. + std::fs::write(tmp.path().join("package-lock.json"), &lock_pre).unwrap(); + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); + assert_eq!(code, 0, "final revert: stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "removed" && e["purl"] == PURL), + "envelope={v}" + ); + assert!( + !tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "the orphaned artifact dir is removed" + ); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + lock_pre, + "clean end state: the restored pre-vendor lock is untouched" + ); +} + // ────────────────────────────── gem rows ────────────────────────────── const GEM_UUID: &str = "22222222-2222-4222-8222-222222222222"; @@ -1129,7 +1366,10 @@ async fn repair_reconstructs_gem_wiring_and_revert_byte_restores() { assert!( !events_of(&v) .iter() - .any(|e| e["errorCode"] == "vendor_wiring_unknown"), + .any(|e| e["errorCode"] == "vendor_wiring_unknown") + && !warnings_of(&v) + .iter() + .any(|w| w["code"] == "vendor_wiring_unknown"), "gem wiring IS reconstructable — no unknown-wiring warning: {v}" ); // THE oracle: the reconstructed ledger equals the original, wiring, diff --git a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs index d7eae377..a14c1b59 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs @@ -587,6 +587,136 @@ async fn repair_reconstructs_pnpm_ledger_from_lockfile() { ledger_gone_reconstructs_from_lock(Flavor::Pnpm).await; } +// ── (f) reconstructed empty-wiring entry: revert must not brick installs ──── +// +// Empirically confirmed brick (real pnpm@10.34.5 project, 2026-08-18): after +// `repair` reconstructs a ledger-gone vendored entry from the lockfile, the +// entry carries EMPTY wiring (npm-family pre-vendor lock fragments are not +// offline-recoverable). A subsequent `vendor --revert` used to exit 0 with a +// bare {"action":"removed"} event — zero warnings — while DELETING the +// vendored tarball pnpm-lock.yaml still resolves through in several places; +// every later `pnpm install` then fails ENOENT on the missing file: tarball. +// The npm-family backends now fail closed +// (`vendor_wiring_unknown_revert_blocked`) when there is nothing to replay +// and the lock still references the artifact, and still remove genuinely +// orphaned artifacts once the lock no longer does. `repair`'s reconstruction +// also stamps the flavor it found the reference in (asserted below), so the +// revert routes to the backend whose guard probes the RIGHT lockfile; a +// flavor-None entry falls back to the package-lock backend, which is +// guarded too (repair_vendor_e2e.rs test 12). + +#[tokio::test] +async fn revert_of_reconstructed_pnpm_entry_fails_closed_then_recovers() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture(tmp.path(), Flavor::Pnpm); + let lock_pre = std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(); + let pkg_pre = std::fs::read(tmp.path().join("package.json")).unwrap(); + let tgz = vendor_project(tmp.path(), &mock.uri()); + let lock_vendored = std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(); + + // Ledger gone; artifact + rewired lock intact (the empirical shape). + // The anchored reconstruction restores the entry with wiring: []. + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 0, "reconstruction: stdout={stdout} stderr={stderr}"); + let state_path = tmp.path().join(".socket/vendor/state.json"); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + assert_eq!( + state["entries"][PURL]["wiring"].as_array().map(Vec::len), + Some(0), + "npm wiring is not offline-recoverable: {state}" + ); + // The reconstruction found the reference in pnpm-lock.yaml (v9), so the + // entry is stamped with the pnpm flavor — revert routes to the pnpm + // backend and its guard probes pnpm-lock.yaml, not package-lock.json. + assert_eq!( + state["entries"][PURL]["flavor"], + serde_json::json!("pnpm"), + "reconstruction stamps the detected flavor: {state}" + ); + + // Nothing to replay + the lock still resolves through the artifact: + // revert must refuse loudly instead of silently removing the tarball. + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); + assert_ne!( + code, 0, + "revert of an empty-wiring entry the lock still references must fail closed: \ + stdout={stdout} stderr={stderr}" + ); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["errorCode"] == "vendor_wiring_unknown_revert_blocked"), + "envelope={v}" + ); + assert!( + tgz.is_file(), + "the artifact the lock still references must survive the refusal" + ); + assert_eq!( + std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(), + lock_vendored, + "the lock stays untouched by the refusal" + ); + + // Recovery, exactly as the refusal advises: `repair` keeps the vendored + // artifact healthy (idempotent — the entry and tarball survive)... + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!( + code, 0, + "repair after refusal: stdout={stdout} stderr={stderr}" + ); + assert!(tgz.is_file(), "repair keeps the artifact"); + + // ...and once the pre-vendor surfaces are restored (the manual-restore + // arm — the wiring originals are unrecoverable by design), a normal + // revert removes the now-orphaned artifact cleanly. + std::fs::write(tmp.path().join("pnpm-lock.yaml"), &lock_pre).unwrap(); + std::fs::write(tmp.path().join("package.json"), &pkg_pre).unwrap(); + let ws = tmp.path().join("pnpm-workspace.yaml"); + if ws.exists() { + std::fs::remove_file(&ws).unwrap(); + } + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); + assert_eq!(code, 0, "final revert: stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "removed" && e["purl"] == PURL), + "envelope={v}" + ); + assert!( + !tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "the orphaned artifact dir is removed" + ); + assert_eq!( + std::fs::read(tmp.path().join("pnpm-lock.yaml")).unwrap(), + lock_pre, + "clean end state: the restored pre-vendor lock is untouched" + ); + // The ledger entry is gone — either the state file was removed with its + // last entry, or it persists with an empty entries map. + match std::fs::read_to_string(&state_path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Ok(text) => { + let state: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!( + state["entries"].as_object().map(serde_json::Map::len), + Some(0), + "ledger entry gone: {state}" + ); + } + Err(e) => panic!("unreadable state.json: {e}"), + } +} + #[tokio::test] async fn repair_reconstructs_yarn_berry_ledger_from_lockfile() { ledger_gone_reconstructs_from_lock(Flavor::YarnBerry).await; diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 80d51f60..c704c40c 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -28,8 +28,8 @@ pub mod golang_local; mod state; mod takeover; pub use state::{ - load_redirect_state, persist_redirect_state, save_redirect_state, CorruptRedirectState, - RedirectState, REDIRECT_STATE_REL, + drop_superseded_purl, load_redirect_state, persist_redirect_state, save_redirect_state, + CorruptRedirectState, RedirectState, REDIRECT_STATE_REL, }; pub use takeover::{ redirect_revert_supported, revert_cargo_redirect_purl, revert_npm_redirect_purl, @@ -238,10 +238,40 @@ fn rewrite_npm_lock( || k.ends_with("/pnpm-lock.yaml") }); if !sibling_lock_present { - result.warnings.push(RewriteWarning { - code: "redirect_npm_no_lockfile".into(), - detail: "no package-lock.json / npm-shrinkwrap.json present".into(), - }); + // Family selection is marker-aware: `shrinkwrap.yaml` is the + // pnpm <=2-era lock (pnpm 3 renamed it to pnpm-lock.yaml; npm + // never emits that filename), and `node_modules/.modules.yaml` + // is pnpm's installer state file — either one proves the + // project is pnpm, where the npm "no package-lock.json" + // wording sends users to the wrong package manager. Both are + // read-only markers handed in via the CLI's candidate list; no + // rewriter edits them. The shrinkwrap check runs first so a + // fresh clone (shrinkwrap.yaml committed, node_modules absent) + // still names the legacy lock. + let warning = if files.contains_key("shrinkwrap.yaml") { + RewriteWarning { + code: "redirect_pnpm_legacy_lockfile".into(), + detail: "shrinkwrap.yaml is the pnpm <=2-era lockfile; the \ + redirect only rewrites pnpm-lock.yaml — upgrade pnpm \ + (>=3) and reinstall so it emits pnpm-lock.yaml, then \ + re-run" + .into(), + } + } else if files.contains_key("node_modules/.modules.yaml") { + RewriteWarning { + code: "redirect_pnpm_no_lockfile".into(), + detail: "pnpm project (node_modules/.modules.yaml present) but \ + no pnpm-lock.yaml; run `pnpm install` to generate one, \ + then re-run" + .into(), + } + } else { + RewriteWarning { + code: "redirect_npm_no_lockfile".into(), + detail: "no package-lock.json / npm-shrinkwrap.json present".into(), + } + }; + result.warnings.push(warning); } return; }; @@ -1482,6 +1512,82 @@ fn plan_cargo_config( } // ── pnpm-lock.yaml ─────────────────────────────────────────────────────────── + +/// LOOSE post-splice residual probe for ONE dep over one pnpm lock text: the +/// lock instance keys of `@` — in ANY grammar pnpm has +/// shipped (v9 `name@version`, quoted scoped spellings, v6 +/// `/name@version(peers…)` including NESTED peer parens the splice regex +/// provably cannot match, v5 `/name/version` with `_` suffixes) and with ANY +/// suffix spelling, anticipated or not — whose own `resolution:` block does +/// NOT reference `artifact_url`. +/// +/// The splice regex is the WRITER and must stay strict (it rebuilds the +/// resolution byte-surgically). This probe is the AUDITOR: it only answers +/// "does an instance of this exact name@version remain pointed somewhere +/// else?", so it is deliberately looser than the writer — an instance key +/// the writer's grammar cannot even parse still shows up here, and the +/// caller then refuses the dep instead of shipping a partial rewrite (the +/// fail-open the original pre-splice `redirect_pnpm_unsupported_lock_key` +/// refusal guarded against). +/// +/// Keys are recognized version-exactly: `@` / v5 +/// `/` followed by nothing or by a character that cannot +/// extend a version (so `left-pad@1.3.0` never claims `left-pad@1.3.01`). +/// v9 `snapshots:` instance keys carry no `resolution:` line and pin +/// nothing, so a key with no resolution in its block does not count. +fn pnpm_unrewritten_instances( + content: &str, + fname: &str, + version: &str, + artifact_url: &str, +) -> Vec { + let at_form = format!("{fname}@{version}"); + let slash_form = format!("{fname}/{version}"); + // A character that could extend `version` into a LONGER version string + // (semver body chars) — anything else marks a suffix boundary. + let extends_version = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'); + let mut residual: Vec = Vec::new(); + let lines: Vec<&str> = content.lines().collect(); + for (i, line) in lines.iter().enumerate() { + // Lock instance keys are 2-space-indented mapping keys: ` :`. + let Some(rest) = line.strip_prefix(" ") else { + continue; + }; + if rest.starts_with(' ') { + continue; + } + let Some(raw_key) = rest.strip_suffix(':') else { + continue; + }; + let unquoted = raw_key.trim_matches(|c| c == '\'' || c == '"'); + let key = unquoted.strip_prefix('/').unwrap_or(unquoted); + let Some(suffix) = key + .strip_prefix(&at_form) + .or_else(|| key.strip_prefix(&slash_form)) + else { + continue; + }; + if suffix.chars().next().is_some_and(extends_version) { + continue; + } + // The entry's block: the following deeper-indented lines. A + // `resolution:` pointing anywhere but the hosted artifact is a + // residual; no resolution at all (v9 `snapshots:` keys) pins nothing. + for entry_line in &lines[i + 1..] { + if !entry_line.trim().is_empty() && !entry_line.starts_with(" ") { + break; + } + if entry_line.trim_start().starts_with("resolution:") + && !entry_line.contains(artifact_url) + { + residual.push(raw_key.to_string()); + break; + } + } + } + residual +} + fn rewrite_pnpm_lock( files: &BTreeMap, overrides: &[DepOverride], @@ -1533,115 +1639,230 @@ fn rewrite_pnpm_lock( }); continue; }; - // `(^ {2}(?:''|/?):\n(?: {4,}.*\n)*? {4,}resolution: )\{([^}\n]*)\}` - // where `` is `@`. lockfileVersion 9 single-quotes keys - // that begin with `@` (`'@scope/name@1.0.0':` — YAML forbids a plain - // scalar starting with `@`); v6 keys start with `/` and are unquoted. + // One `packages:`-section entry per INSTANCE of the dep, across every + // lock grammar pnpm has shipped (each carries its own `resolution:` + // block — verified against locks emitted by corepack pnpm@7.33.5 and + // pnpm@8.15.9, 2026-08-18): + // v9: `@:` — single-quoted when the name starts with `@` + // (YAML forbids a plain scalar starting with `@`); resolved + // peers live in `snapshots:` keys, which carry no resolution. + // v6: `/@:` plus one `/@(peerA@x)(peerB@y):` + // per resolved-peer combination. + // v5.x: `//:` plus `//_:` per + // combination (`_react@18.2.0`, or a hash for long sets). + // EVERY matching instance is spliced. Rewriting only the first would + // fail open: a v6 lock holding both `/pkg@1.0.0:` and + // `/pkg@1.0.0(peer@2.0.0):` would confirm and attest the dep while + // every dependent resolving through the peered entry still installs + // the unpatched upstream tarball. let key = regex::escape(&fname) + "@" + ®ex::escape(&dep.version); - // Legacy pnpm lock grammars this rewriter cannot repoint: lockfile- - // Version 6 embeds resolved peers in the `packages:` key itself - // (`/name@1.0.0(peer@2.0.0):`) and v5.x separates the version with a - // slash (`/name/1.0.0:`, peers suffixed `_peer@2.0.0`). Both carry - // their own `resolution:` block the pattern below never matches. - // Rewriting AROUND them is fail-open: a v6 lock holding both - // `/pkg@1.0.0:` and `/pkg@1.0.0(peer@2.0.0):` would get the plain - // entry rewritten — confirming and attesting the dep — while every - // dependent resolving through the peered entry still installs the - // unpatched upstream tarball. So when ANY such key exists for this - // dep in ANY lock, refuse the dep outright (no rewrite anywhere), - // naming the unmatched keys. v9 is unaffected: its peer-suffixed - // `snapshots:` keys never start with `/` (and carry no resolution). - let legacy_pat = String::from(r"(?m)^ {2}(/") + let pat = String::from(r"(?m)(^ {2}('") + + &key + + r"'|/?" + &key - + r"\([^:\n]*|/" + + r"(?:\([^)\n]*\))*|/" + ®ex::escape(&fname) + "/" + ®ex::escape(&dep.version) - + r"(?:[_(][^:\n]*)?):"; - let legacy_re = Regex::new(&legacy_pat) - .expect("legacy-key regex from the escaped name and version is valid"); - let mut legacy_keys: Vec = Vec::new(); - for (lock_key, content, _) in &contents { - for caps in legacy_re.captures_iter(content) { - legacy_keys.push(format!("{} in {lock_key}", &caps[1])); - } - } - if !legacy_keys.is_empty() { - result.warnings.push(RewriteWarning { - code: "redirect_pnpm_unsupported_lock_key".into(), - detail: format!( - "{fname}@{} resolves through pnpm v5/v6 lock key(s) the \ - redirect grammar cannot repoint: {}; left unredirected — \ - regenerate the lock with pnpm >=9 (lockfileVersion 9) \ - and re-run", - dep.version, - legacy_keys.join(", ") - ), - }); - continue; - } - let pat = String::from(r"(?m)(^ {2}(?:'") - + &key - + r"'|/?" - + &key - + r"):\n(?: {4,}.*\n)*? {4,}resolution: )\{([^}\n]*)\}"; + + r"(?:_[^:\n]*)?):\n(?: {4,}.*\n)*? {4,}resolution: )\{([^}\n]*)\}"; let re = Regex::new(&pat).expect("resolution regex from the escaped name@version key is valid"); let mut matched_any = false; - for (key, content, changed) in &mut contents { - let Some(caps) = re.captures(content) else { - continue; - }; - matched_any = true; - let whole = caps - .get(0) - .expect("group 0 is the whole match") - .as_str() - .to_string(); - let prefix = caps - .get(1) - .expect("resolution regex always captures group 1 (prefix)") - .as_str() - .to_string(); - let inner = caps - .get(2) - .expect("resolution regex always captures group 2 (inner)") - .as_str() - .to_string(); - let original = format!("{{{inner}}}"); - let mut fields: Vec = vec![ - format!("integrity: {sha512}"), - format!("tarball: {}", dep.artifact_url), - ]; - for f in inner.split(',') { - let t = f.trim(); - if !t.is_empty() && !t.starts_with("integrity:") && !t.starts_with("tarball:") { - fields.push(t.to_string()); + // Per-lock rewrites are PLANNED first and committed only after the + // residual gate below proves no instance of this dep escaped the + // splice grammar in ANY lock — committing lock-by-lock as we go + // would ship exactly the partial rewrite the gate exists to refuse. + let mut planned: Vec<(usize, String, Vec)> = Vec::new(); + let mut residuals: Vec<(&str, Vec)> = Vec::new(); + for (idx, (lock_key, content, _)) in contents.iter().enumerate() { + // (byte range to replace, replacement text) per instance, plus + // one FileEdit per instance keyed by the canonical instance key — + // per-instance edits keep the revert ledger lossless when several + // instances of one dep live in the same lock. + let mut splices: Vec<(std::ops::Range, String)> = Vec::new(); + let mut instance_edits: Vec = Vec::new(); + for caps in re.captures_iter(content) { + matched_any = true; + let whole = caps.get(0).expect("group 0 is the whole match"); + let prefix = caps + .get(1) + .expect("resolution regex always captures group 1 (prefix)") + .as_str(); + let key_text = caps + .get(2) + .expect("resolution regex always captures group 2 (the lock key)") + .as_str(); + let inner = caps + .get(3) + .expect("resolution regex always captures group 3 (inner)") + .as_str(); + let original = format!("{{{inner}}}"); + let mut fields: Vec = vec![ + format!("integrity: {sha512}"), + format!("tarball: {}", dep.artifact_url), + ]; + for f in inner.split(',') { + let t = f.trim(); + if !t.is_empty() && !t.starts_with("integrity:") && !t.starts_with("tarball:") { + fields.push(t.to_string()); + } } + let rebuilt = format!("{{{}}}", fields.join(", ")); + // Already redirected (re-run): no edit, no ledger growth. + if rebuilt == original { + continue; + } + // Canonical instance key: quotes and the leading `/` are lock + // spelling, not identity, and v5's `//` is + // respelled `@` — so a plain instance's key + // is `@` in every grammar (the shape the golden + // fixtures pin) and peered instances stay distinct. + let instance_key = if let Some(quoted) = key_text + .strip_prefix('\'') + .and_then(|k| k.strip_suffix('\'')) + { + quoted.to_string() + } else if let Some(slashed) = key_text.strip_prefix('/') { + match slashed.strip_prefix(&format!("{fname}/")) { + Some(rest) => format!("{fname}@{rest}"), + None => slashed.to_string(), + } + } else { + key_text.to_string() + }; + splices.push((whole.range(), format!("{prefix}{rebuilt}"))); + instance_edits.push(FileEdit { + path: (*lock_key).clone(), + kind: "redirect_pnpm_resolution".into(), + action: "rewritten".into(), + key: Some(instance_key), + original: Some(Value::String(original)), + new: Some(Value::String(rebuilt)), + }); } - let rebuilt = format!("{{{}}}", fields.join(", ")); - // Already redirected (re-run): no edit, no ledger growth. - if rebuilt == original { + // Splice by byte range (captures_iter yields non-overlapping + // matches in order) — a string replace could hit the wrong + // instance when two entries share identical surrounding bytes. + let candidate: Option = if splices.is_empty() { + None + } else { + let mut out = String::with_capacity(content.len()); + let mut cursor = 0usize; + for (range, replacement) in splices { + out.push_str(&content[cursor..range.start]); + out.push_str(&replacement); + cursor = range.end; + } + out.push_str(&content[cursor..]); + Some(out) + }; + // Residual gate, run over the POST-splice text: any instance of + // this exact name@version still resolving somewhere other than + // the hosted artifact — in a spelling the splice grammar cannot + // parse (e.g. v6 NESTED peer parens) — makes this a partial + // rewrite. Shipping it would confirm and VEX-attest the dep while + // dependents through the unmatched instance keep installing the + // unpatched upstream tarball, so the dep is refused instead. + let leftover = pnpm_unrewritten_instances( + candidate.as_deref().unwrap_or(content), + &fname, + &dep.version, + &dep.artifact_url, + ); + if !leftover.is_empty() { + residuals.push(((*lock_key).as_str(), leftover)); continue; } - *content = content.replacen(&whole, &format!("{prefix}{rebuilt}"), 1); + if let Some(out) = candidate { + planned.push((idx, out, instance_edits)); + } + } + // ANY residual anywhere refuses the dep across the WHOLE lock set — + // nothing rewritten, nothing recorded, nothing confirmed (the same + // fail-closed contract the pre-splice v5/v6 refusal had): a rewrite + // committed in one lock while another still resolves the dep + // upstream would confirm the dep set-wide. + if !residuals.is_empty() { + for (lock_key, keys) in &residuals { + result.warnings.push(RewriteWarning { + code: "redirect_pnpm_unsupported_lock_key".into(), + detail: format!( + "{fname}@{} still resolves through pnpm lock key(s) whose \ + resolution the redirect grammar cannot repoint: {} in \ + {lock_key}; the dep is left unredirected in EVERY lock \ + (nothing rewritten, nothing confirmed) — regenerate the \ + lock with a current pnpm (lockfileVersion 9) and re-run", + dep.version, + keys.join(", ") + ), + }); + } + continue; + } + for (idx, out, mut instance_edits) in planned { + let (_, content, changed) = &mut contents[idx]; + *content = out; *changed = true; - result.edits.push(FileEdit { - path: (*key).clone(), - kind: "redirect_pnpm_resolution".into(), - action: "rewritten".into(), - key: Some(format!("{fname}@{}", dep.version)), - original: Some(Value::String(original)), - new: Some(Value::String(rebuilt)), - }); + result.edits.append(&mut instance_edits); } // The entry-not-found warning fires only when the dep matched in NO - // pnpm lock across the whole set, not once per lock. + // pnpm lock across the whole set, not once per lock. A VENDORED dep + // is named as such: `socket-patch vendor` removes the registry + // resolution this grammar looks for (v9 respells the packages key + // `@file:.socket/vendor/…`; v5/v6 rekey it to a bare `file:` + // key but keep the `@: file:…` overrides line), so + // the generic not-locked wording would send users on a wild-goose + // `pnpm install` when the real path is a mode switch. Fail-closed + // either way: nothing is rewritten for the dep. if !matched_any { - result.warnings.push(RewriteWarning { - code: "redirect_pnpm_entry_not_found".into(), - detail: format!("no inline resolution for {fname}@{}", dep.version), + let v9_vendored_key = format!("{fname}@file:"); + let override_key = format!("{fname}@{}", dep.version); + let vendored = contents.iter().any(|(_, content, _)| { + content.lines().any(|line| { + let t = line.trim_start(); + let t = t.strip_prefix('\'').unwrap_or(t); + // v9 packages/snapshots key (leading `/` in v6 spelling). + // The vendor backend always writes the RELATIVE + // `file:.socket/vendor/…` spelling here, so anchoring on + // it keeps a user's own `file:` dep of the same name + // from being misreported as vendored. + let key = t.strip_prefix('/').unwrap_or(t); + if key + .strip_prefix(&v9_vendored_key) + .is_some_and(|rest| rest.starts_with(".socket/vendor/")) + { + return true; + } + // overrides / root-dep line: `@: file:…` + // (pnpm <=8 absolutizes the value, so only the + // `.socket/vendor/` tail is stable enough to match). + t.strip_prefix(&override_key) + .map(|rest| rest.strip_prefix('\'').unwrap_or(rest)) + .and_then(|rest| rest.strip_prefix(':')) + .is_some_and(|rest| { + rest.contains("file:") && rest.contains(".socket/vendor/") + }) + }) }); + if vendored { + result.warnings.push(RewriteWarning { + code: "redirect_pnpm_entry_vendored".into(), + detail: format!( + "{fname}@{} has no registry resolution because it is \ + VENDORED (the lock resolves it to a \ + file:.socket/vendor/… tarball); the hosted redirect \ + does not apply — run `socket-patch vendor --revert` to \ + restore the registry resolution, then re-run `scan \ + --mode hosted`", + dep.version + ), + }); + } else { + result.warnings.push(RewriteWarning { + code: "redirect_pnpm_entry_not_found".into(), + detail: format!("no inline resolution for {fname}@{}", dep.version), + }); + } } } for (key, content, changed) in contents { @@ -9047,13 +9268,14 @@ snapshots: /// pnpm lockfileVersion 6 embeds resolved peers in the `packages:` key /// itself, so one name@version can appear as BOTH `/pkg@1.0.0:` and - /// `/pkg@1.0.0(peer@2.0.0):`. Rewriting only the plain entry is silent - /// fail-open: the dep is confirmed and attested while every dependent - /// resolving through the peered entry still installs the unpatched - /// upstream tarball. The whole dep must be refused with a warning naming - /// the unmatched key — nothing rewritten, nothing confirmed. + /// `/pkg@1.0.0(peer@2.0.0):`. Rewriting only the plain entry would be + /// silent fail-open — every dependent resolving through the peered entry + /// would keep installing the unpatched upstream tarball — so EVERY + /// instance is spliced, each under its own per-instance ledger key + /// (lossless revert), and a re-run over the result is a byte-stable + /// no-op with zero new edits. #[test] - fn pnpm_v6_mixed_plain_and_peered_is_refused() { + fn pnpm_v6_mixed_plain_and_peered_rewrites_every_instance() { let lock = "lockfileVersion: '6.0' dependencies: @@ -9072,6 +9294,102 @@ packages: peerDependencies: react: '*' dev: false +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); + let url = "http://patch.test/left-pad-1.3.0.tgz"; + let overrides = vec![npm_override("left-pad", "1.3.0", url, "sha512-PATCHED==")]; + let r = rewrite_registry_redirect(&files, &overrides); + let out = r + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("v6 mixed lock must be rewritten: {:?}", r.warnings)); + let spliced = format!("resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}"); + assert!( + out.contains(&format!( + " /left-pad@1.3.0:\n {spliced}\n dev: false\n" + )) && out.contains(&format!( + " /left-pad@1.3.0(react@18.2.0):\n {spliced}\n peerDependencies:" + )), + "BOTH the plain and the peered instance must be spliced: {out}" + ); + assert!( + !out.contains("sha512-UPSTREAM=="), + "no instance may keep the upstream integrity: {out}" + ); + // Per-instance ledger edits, keyed by the canonical instance key. + let keys: Vec<&str> = r.edits.iter().filter_map(|e| e.key.as_deref()).collect(); + assert_eq!( + keys, + vec!["left-pad@1.3.0", "left-pad@1.3.0(react@18.2.0)"], + "one lossless ledger edit per instance: {:?}", + r.edits + ); + assert!( + r.edits.iter().all(|e| { + e.kind == "redirect_pnpm_resolution" + && e.original == Some(Value::String("{integrity: sha512-UPSTREAM==}".into())) + }), + "every edit must preserve its instance's original resolution for revert: {:?}", + r.edits + ); + assert!( + !r.warnings + .iter() + .any(|w| w.code.starts_with("redirect_pnpm_")), + "a fully-rewritten v6 lock emits no pnpm warnings: {:?}", + r.warnings + ); + + // Idempotency: a re-run over the rewritten lock changes nothing. + let mut files2 = BTreeMap::new(); + files2.insert("pnpm-lock.yaml".to_string(), out.clone()); + let r2 = rewrite_registry_redirect(&files2, &overrides); + assert!( + r2.files.is_empty() && r2.edits.is_empty(), + "re-run must be byte-stable with zero new edits: files={:?} edits={:?}", + r2.files.keys(), + r2.edits + ); + assert!( + !r2.warnings + .iter() + .any(|w| w.code == "redirect_pnpm_entry_not_found"), + "an already-redirected instance still counts as matched: {:?}", + r2.warnings + ); + } + + /// pnpm v6 peers-of-peers NEST the parens in the `packages:` key + /// (`/pkg@1.0.0(react@18.2.0(scheduler@0.23.2)):`) — a spelling the + /// splice regex's `\([^)\n]*\)` groups provably cannot match. Splicing + /// AROUND it would be the exact fail-open the old pre-splice refusal + /// guarded: the plain instance rewritten, the dep confirmed and + /// VEX-attested, while every dependent resolving through the nested-peer + /// instance keeps installing the unpatched upstream tarball. The + /// post-splice residual gate must refuse the dep — nothing rewritten, + /// no edits, a `redirect_pnpm_unsupported_lock_key` warning naming the + /// residual key. + #[test] + fn pnpm_v6_nested_paren_peer_key_refuses_the_dep_fail_closed() { + let lock = "lockfileVersion: '6.0' + +dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + + /left-pad@1.3.0: + resolution: {integrity: sha512-UPSTREAM==} + dev: false + + /left-pad@1.3.0(react@18.2.0(scheduler@0.23.2)): + resolution: {integrity: sha512-UPSTREAM==} + peerDependencies: + react: '*' + dev: false "; let mut files = BTreeMap::new(); files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); @@ -9080,7 +9398,7 @@ packages: let r = rewrite_registry_redirect(&files, &overrides); assert!( r.files.is_empty() && r.edits.is_empty(), - "a partially-matchable v6 lock must not be rewritten at all: files={:?} edits={:?}", + "a partial rewrite must not ship: files={:?} edits={:?}", r.files.keys(), r.edits ); @@ -9088,21 +9406,142 @@ packages: .warnings .iter() .find(|w| w.code == "redirect_pnpm_unsupported_lock_key") - .unwrap_or_else(|| panic!("refusal warning expected: {:?}", r.warnings)); + .unwrap_or_else(|| panic!("the residual must be warned about: {:?}", r.warnings)); assert!( - warning.detail.contains("/left-pad@1.3.0(react@18.2.0)"), - "warning must name the unmatched peered key: {}", + warning + .detail + .contains("/left-pad@1.3.0(react@18.2.0(scheduler@0.23.2))"), + "the warning must name the residual key: {}", warning.detail ); assert!( !r.warnings .iter() .any(|w| w.code == "redirect_pnpm_entry_not_found"), - "the refusal replaces entry-not-found, not stacks on it: {:?}", + "the residual refusal must not double-report as not-found: {:?}", r.warnings ); } + /// The residual gate is SET-WIDE: a Rush-style repo whose root v9 lock + /// splices fully while a nested lock resolves the same dep only through + /// a nested-peer key must refuse the dep in EVERY lock. Committing the + /// root rewrite alone would land the artifact URL in the project — the + /// CLI's substring confirmation probe would then confirm and attest the + /// dep while the nested lock's dependents stay on the upstream tarball. + #[test] + fn pnpm_residual_in_one_lock_refuses_the_dep_in_every_lock() { + let v9_root = "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: {} +"; + let v6_nested = "lockfileVersion: '6.0' + +packages: + + /left-pad@1.3.0(react@18.2.0(scheduler@0.23.2)): + resolution: {integrity: sha512-UPSTREAM==} + dev: false +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), v9_root.to_string()); + files.insert( + "common/config/rush/pnpm-lock.yaml".to_string(), + v6_nested.to_string(), + ); + let url = "http://patch.test/left-pad-1.3.0.tgz"; + let overrides = vec![npm_override("left-pad", "1.3.0", url, "sha512-PATCHED==")]; + let r = rewrite_registry_redirect(&files, &overrides); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "the dep must be refused in every lock, the fully-spliceable root \ + included: files={:?} edits={:?}", + r.files.keys(), + r.edits + ); + let warning = r + .warnings + .iter() + .find(|w| w.code == "redirect_pnpm_unsupported_lock_key") + .unwrap_or_else(|| panic!("the residual must be warned about: {:?}", r.warnings)); + assert!( + warning.detail.contains("common/config/rush/pnpm-lock.yaml"), + "the warning must name the lock holding the residual: {}", + warning.detail + ); + } + + /// Boundary contract of the loose residual probe: it flags exactly the + /// unrewritten registry-resolved instances of THIS name@version — never + /// v9 `snapshots:` keys (no resolution, nothing to repoint), never a + /// longer version sharing the prefix, never an instance already pointing + /// at the hosted artifact — while catching every suffix grammar (v6 + /// nested parens, v5 `_`) and quoted scoped spellings. + #[test] + fn pnpm_residual_probe_respects_version_and_section_boundaries() { + let url = "http://patch.test/left-pad-1.3.0.tgz"; + let content = format!( + "lockfileVersion: '9.0' + +packages: + left-pad@1.3.0: + resolution: {{integrity: sha512-PATCHED==, tarball: {url}}} + left-pad@1.3.01: + resolution: {{integrity: sha512-OTHERVERSION==}} + '@scope/left-pad@1.3.0': + resolution: {{integrity: sha512-OTHERPACKAGE==}} + +snapshots: + left-pad@1.3.0(react@18.2.0): + dependencies: + react: 18.2.0 +" + ); + assert!( + pnpm_unrewritten_instances(&content, "left-pad", "1.3.0", url).is_empty(), + "rewritten instances, other versions/packages, and resolution-less \ + snapshots keys must not count" + ); + let v6 = "lockfileVersion: '6.0' + +packages: + + /left-pad@1.3.0(react@18.2.0(scheduler@0.23.2)): + resolution: {integrity: sha512-UPSTREAM==} + dev: false +"; + assert_eq!( + pnpm_unrewritten_instances(v6, "left-pad", "1.3.0", url), + vec!["/left-pad@1.3.0(react@18.2.0(scheduler@0.23.2))"], + "a nested-paren v6 instance still on the registry is a residual" + ); + let v5 = "lockfileVersion: 5.4 + +packages: + + /left-pad/1.3.0_react@18.2.0: + resolution: {integrity: sha512-UPSTREAM==} + dev: false +"; + assert_eq!( + pnpm_unrewritten_instances(v5, "left-pad", "1.3.0", url), + vec!["/left-pad/1.3.0_react@18.2.0"], + "a v5 `_`-suffixed instance still on the registry is a residual" + ); + } + /// A dist block with no `url` has nothing to redirect: pinning a shasum /// onto it would claim a redirect that cannot happen. #[test] @@ -9123,12 +9562,13 @@ packages: assert_eq!(warning_codes(&r), vec!["redirect_composer_no_dist_url"]); } - /// A v6 dep resolved ONLY through peer-suffixed keys previously degraded - /// to a bare `entry_not_found`; the refusal must instead name the exact - /// key the grammar cannot repoint so the operator knows the lock (not the - /// dep) is the problem. + /// A v6 dep resolved ONLY through a peer-suffixed key (pnpm 8 dedupes a + /// workspace onto the peered instantiation — captured live from corepack + /// pnpm@8.15.9, 2026-08-18) is spliced in place like any other instance, + /// scoped names included, with the peered key preserved verbatim in the + /// ledger edit. #[test] - fn pnpm_v6_pure_peered_key_is_refused_by_name() { + fn pnpm_v6_pure_peered_key_is_rewritten_in_place() { let lock = "lockfileVersion: '6.0' packages: @@ -9139,41 +9579,54 @@ packages: "; let mut files = BTreeMap::new(); files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); + let url = "http://patch.test/socktest-pkg-1.0.0.tgz"; let overrides = vec![npm_override( "@socktest/pkg", "1.0.0", - "http://patch.test/socktest-pkg-1.0.0.tgz", + url, "sha512-PATCHED==", )]; let r = rewrite_registry_redirect(&files, &overrides); - assert!(r.files.is_empty() && r.edits.is_empty()); - let warning = r - .warnings - .iter() - .find(|w| w.code == "redirect_pnpm_unsupported_lock_key") - .unwrap_or_else(|| panic!("refusal warning expected: {:?}", r.warnings)); + let out = r + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("pure-peered v6 key must be rewritten: {:?}", r.warnings)); assert!( - warning - .detail - .contains("/@socktest/pkg@1.0.0(react@18.2.0)"), - "warning must name the unmatched key: {}", - warning.detail + out.contains(&format!( + " /@socktest/pkg@1.0.0(react@18.2.0):\n resolution: \ + {{integrity: sha512-PATCHED==, tarball: {url}}}\n dev: false\n" + )), + "the peered entry must be spliced with its key untouched: {out}" + ); + assert_eq!( + r.edits.len(), + 1, + "exactly one instance, one edit: {:?}", + r.edits + ); + assert_eq!( + r.edits[0].key.as_deref(), + Some("@socktest/pkg@1.0.0(react@18.2.0)"), + "the ledger edit is keyed by the canonical peered instance key: {:?}", + r.edits[0] ); assert!( !r.warnings .iter() - .any(|w| w.code == "redirect_pnpm_entry_not_found"), + .any(|w| w.code.starts_with("redirect_pnpm_")), "{:?}", r.warnings ); } /// pnpm lockfileVersion 5.x keys are path-style (`/name/version:`, peers - /// suffixed `_peer@ver`) — the rewrite grammar never matches them, so the - /// dep must be refused with the keys named rather than silently reported - /// as a missing entry. + /// suffixed `_peer@ver`): BOTH instances are spliced (rewriting only one + /// would leave the other installing upstream) and each edit is keyed by + /// the canonical `name@version` respelling — plain instances thus + /// share the `name@version` key shape with every other lock grammar. + /// Idempotency: a re-run over the result is a no-op. #[test] - fn pnpm_v5_path_style_keys_are_refused_by_name() { + fn pnpm_v5_path_style_keys_rewrite_every_instance() { let lock = "lockfileVersion: 5.4 specifiers: @@ -9194,40 +9647,57 @@ packages: "; let mut files = BTreeMap::new(); files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); - let overrides = vec![npm_override( - "left-pad", - "1.3.0", - "http://patch.test/left-pad-1.3.0.tgz", - "sha512-PATCHED==", - )]; + let url = "http://patch.test/left-pad-1.3.0.tgz"; + let overrides = vec![npm_override("left-pad", "1.3.0", url, "sha512-PATCHED==")]; let r = rewrite_registry_redirect(&files, &overrides); - assert!(r.files.is_empty() && r.edits.is_empty()); - let warning = r - .warnings - .iter() - .find(|w| w.code == "redirect_pnpm_unsupported_lock_key") - .unwrap_or_else(|| panic!("refusal warning expected: {:?}", r.warnings)); + let out = r + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("v5 lock must be rewritten: {:?}", r.warnings)); + let spliced = format!("resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}"); assert!( - warning.detail.contains("/left-pad/1.3.0") - && warning.detail.contains("/left-pad/1.3.0_react@18.2.0"), - "warning must name both v5 keys: {}", - warning.detail + out.contains(&format!(" /left-pad/1.3.0:\n {spliced}\n")) + && out.contains(&format!(" /left-pad/1.3.0_react@18.2.0:\n {spliced}\n")), + "BOTH v5 instances must be spliced with their path-style keys untouched: {out}" + ); + assert!( + !out.contains("sha512-UPSTREAM=="), + "no instance may keep the upstream integrity: {out}" + ); + let keys: Vec<&str> = r.edits.iter().filter_map(|e| e.key.as_deref()).collect(); + assert_eq!( + keys, + vec!["left-pad@1.3.0", "left-pad@1.3.0_react@18.2.0"], + "per-instance ledger keys use the canonical respelling: {:?}", + r.edits ); assert!( !r.warnings .iter() - .any(|w| w.code == "redirect_pnpm_entry_not_found"), + .any(|w| w.code.starts_with("redirect_pnpm_")), "{:?}", r.warnings ); + + // Idempotency over the rewritten bytes. + let mut files2 = BTreeMap::new(); + files2.insert("pnpm-lock.yaml".to_string(), out.clone()); + let r2 = rewrite_registry_redirect(&files2, &overrides); + assert!( + r2.files.is_empty() && r2.edits.is_empty(), + "re-run must be a no-op: files={:?} edits={:?}", + r2.files.keys(), + r2.edits + ); } /// When a dep lives in a rewritable v9 lock AND a legacy lock in the same - /// set (e.g. a Rush nested lock still on pnpm 7), rewriting just the v9 - /// lock would confirm the dep while the legacy lock keeps installing - /// upstream. The refusal must cover the WHOLE set: no lock rewritten. + /// set (e.g. a Rush nested lock still on pnpm 7), BOTH locks are + /// rewritten — rewriting just the v9 lock would confirm the dep while the + /// legacy lock kept installing upstream. Each lock's edit rides its own + /// `path` so the ledger stays lossless per file. #[test] - fn pnpm_legacy_lock_in_set_refuses_the_dep_everywhere() { + fn pnpm_legacy_lock_in_set_is_rewritten_alongside_v9() { let v9_lock = "lockfileVersion: '9.0' packages: @@ -9248,29 +9718,220 @@ packages: "common/config/rush/pnpm-lock.yaml".to_string(), v5_lock.to_string(), ); + let url = "http://patch.test/left-pad-1.3.0.tgz"; + let overrides = vec![npm_override("left-pad", "1.3.0", url, "sha512-PATCHED==")]; + let r = rewrite_registry_redirect(&files, &overrides); + let spliced = format!("resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}"); + let v9_out = r + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("the v9 lock must be rewritten: {:?}", r.warnings)); + assert!( + v9_out.contains(&format!(" left-pad@1.3.0:\n {spliced}\n")), + "{v9_out}" + ); + let v5_out = r + .files + .get("common/config/rush/pnpm-lock.yaml") + .unwrap_or_else(|| panic!("the nested v5 lock must be rewritten: {:?}", r.warnings)); + assert!( + v5_out.contains(&format!(" /left-pad/1.3.0:\n {spliced}\n")), + "{v5_out}" + ); + // One edit per lock, both under the canonical plain-instance key, each + // carrying its own path for a lossless per-file revert. + let mut paths: Vec<&str> = r + .edits + .iter() + .filter(|e| e.key.as_deref() == Some("left-pad@1.3.0")) + .map(|e| e.path.as_str()) + .collect(); + paths.sort_unstable(); + assert_eq!( + paths, + vec!["common/config/rush/pnpm-lock.yaml", "pnpm-lock.yaml"], + "both locks' edits must be recorded: {:?}", + r.edits + ); + assert!( + !r.warnings + .iter() + .any(|w| w.code.starts_with("redirect_pnpm_")), + "{:?}", + r.warnings + ); + } + + /// Byte-accurate pnpm 7 (lockfileVersion 5.4) grammar, captured live from + /// `corepack pnpm@7.33.5 install` of a workspace where pkg-a consumes + /// use-sync-external-store@1.2.0 bare and pkg-b consumes it beside + /// react@18.2.0 (2026-08-18): the plain and `_react@18.2.0`-suffixed + /// instances each carry their own resolution and BOTH get spliced, with + /// every sibling line (`peerDependencies:`, `dependencies:`, `dev:`) + /// byte-preserved. The spliced shape is exactly what pnpm@7.33.5 then + /// frozen-installed from an empty store in the capture session. + #[test] + fn pnpm_v5_real_captured_peered_grammar_rewrites_both_instances() { + let lock = "lockfileVersion: 5.4 + +importers: + + .: + specifiers: {} + + pkg-a: + specifiers: + use-sync-external-store: 1.2.0 + dependencies: + use-sync-external-store: 1.2.0 + + pkg-b: + specifiers: + react: 18.2.0 + use-sync-external-store: 1.2.0 + dependencies: + react: 18.2.0 + use-sync-external-store: 1.2.0_react@18.2.0 + +packages: + + /react/18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: false + + /use-sync-external-store/1.2.0: + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dev: false + + /use-sync-external-store/1.2.0_react@18.2.0: + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); + let url = "http://patch.test/use-sync-external-store-1.2.0.tgz"; let overrides = vec![npm_override( - "left-pad", - "1.3.0", - "http://patch.test/left-pad-1.3.0.tgz", + "use-sync-external-store", + "1.2.0", + url, "sha512-PATCHED==", )]; let r = rewrite_registry_redirect(&files, &overrides); + let out = r + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("captured v5 lock must be rewritten: {:?}", r.warnings)); + let spliced = format!("resolution: {{integrity: sha512-PATCHED==, tarball: {url}}}"); assert!( - r.files.is_empty() && r.edits.is_empty(), - "no lock in the set may be rewritten while a legacy key survives: files={:?}", - r.files.keys() + out.contains(&format!( + " /use-sync-external-store/1.2.0:\n {spliced}\n peerDependencies:\n \ + react: ^16.8.0 || ^17.0.0 || ^18.0.0\n dev: false\n" + )), + "plain instance spliced, siblings byte-preserved: {out}" ); - let warning = r - .warnings - .iter() - .find(|w| w.code == "redirect_pnpm_unsupported_lock_key") - .unwrap_or_else(|| panic!("refusal warning expected: {:?}", r.warnings)); assert!( - warning - .detail - .contains("/left-pad/1.3.0 in common/config/rush/pnpm-lock.yaml"), - "warning must name the key AND the lock it lives in: {}", - warning.detail + out.contains(&format!( + " /use-sync-external-store/1.2.0_react@18.2.0:\n {spliced}\n \ + peerDependencies:\n react: ^16.8.0 || ^17.0.0 || ^18.0.0\n \ + dependencies:\n react: 18.2.0\n dev: false\n" + )), + "peered instance spliced, siblings byte-preserved: {out}" + ); + // react's entry (whose resolution the lazy scan must not leak into) + // stays byte-untouched. + assert!( + out.contains(" /react/18.2.0:\n resolution: {integrity: sha512-/3IjMdb2L9"), + "unrelated entries stay untouched: {out}" + ); + let keys: Vec<&str> = r.edits.iter().filter_map(|e| e.key.as_deref()).collect(); + assert_eq!( + keys, + vec![ + "use-sync-external-store@1.2.0", + "use-sync-external-store@1.2.0_react@18.2.0" + ], + "{:?}", + r.edits + ); + } + + /// Byte-accurate pnpm 8 (lockfileVersion '6.0') grammar from the same + /// live capture (`corepack pnpm@8.15.9`, 2026-08-18): pnpm 8 deduped both + /// importers onto the single peer-suffixed instance + /// `/use-sync-external-store@1.2.0(react@18.2.0):` — the real-world v6 + /// peered shape — and the spliced lock frozen-installed from an empty + /// store in the capture session. + #[test] + fn pnpm_v6_real_captured_peered_grammar_rewrites_in_place() { + let lock = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +importers: + + .: {} + + pkg-a: + dependencies: + use-sync-external-store: + specifier: 1.2.0 + version: 1.2.0(react@18.2.0) + +packages: + + /use-sync-external-store@1.2.0(react@18.2.0): + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); + let url = "http://patch.test/use-sync-external-store-1.2.0.tgz"; + let overrides = vec![npm_override( + "use-sync-external-store", + "1.2.0", + url, + "sha512-PATCHED==", + )]; + let r = rewrite_registry_redirect(&files, &overrides); + let out = r + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("captured v6 lock must be rewritten: {:?}", r.warnings)); + assert!( + out.contains(&format!( + " /use-sync-external-store@1.2.0(react@18.2.0):\n resolution: \ + {{integrity: sha512-PATCHED==, tarball: {url}}}\n peerDependencies:" + )), + "the peered v6 instance must be spliced in place: {out}" + ); + assert_eq!(r.edits.len(), 1, "{:?}", r.edits); + assert_eq!( + r.edits[0].key.as_deref(), + Some("use-sync-external-store@1.2.0(react@18.2.0)"), + "{:?}", + r.edits[0] + ); + assert!( + !r.warnings + .iter() + .any(|w| w.code.starts_with("redirect_pnpm_")), + "{:?}", + r.warnings ); } @@ -9461,6 +10122,196 @@ snapshots: ); } + /// pnpm 1/2 projects lock with `shrinkwrap.yaml` (the pre-rename v5 + /// grammar — real layout captured in the 2026-08-18 legacy matrix: + /// shrinkwrap.yaml + node_modules/.modules.yaml, no pnpm-lock.yaml and + /// no package-lock.json). The no-lockfile diagnostic must be + /// pnpm-flavored there — the npm "no package-lock.json" wording + /// dead-ends (running `npm i --package-lock-only` would fork the + /// project onto npm). Marker-aware family selection, fail-closed: + /// nothing is rewritten either way. + #[test] + fn no_lockfile_warning_is_pnpm_flavored_when_pnpm_markers_present() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://patch.test/left-pad-1.3.0.tgz", + "sha512-PATCHED==", + ); + + // shrinkwrap.yaml present (with or without node_modules — a fresh + // clone has only the committed lock): name the legacy lock. + for with_marker in [true, false] { + let mut files = BTreeMap::new(); + files.insert( + "shrinkwrap.yaml".to_string(), + "dependencies:\n left-pad: 1.3.0\npackages:\n /left-pad/1.3.0:\n \ + dev: false\n resolution:\n integrity: sha512-UPSTREAM==\n\ + shrinkwrapVersion: 3\n" + .to_string(), + ); + if with_marker { + files.insert( + "node_modules/.modules.yaml".to_string(), + "packageManager: pnpm@2.17.0\n".to_string(), + ); + } + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!( + r.files.is_empty(), + "nothing may be rewritten (markers are read-only): {:?}", + r.files.keys() + ); + assert_eq!( + warning_codes(&r), + vec!["redirect_pnpm_legacy_lockfile"], + "shrinkwrap.yaml (with_marker={with_marker}) must select the \ + pnpm-legacy wording, never redirect_npm_no_lockfile: {:?}", + r.warnings + ); + assert!( + r.warnings[0].detail.contains("shrinkwrap.yaml") + && r.warnings[0].detail.contains("pnpm-lock.yaml"), + "detail must name the legacy lock and the upgrade target: {}", + r.warnings[0].detail + ); + } + + // pnpm marker only (lock deleted / never committed): pnpm-flavored + // "no lockfile", pointing at pnpm install — not npm. + let mut files = BTreeMap::new(); + files.insert( + "node_modules/.modules.yaml".to_string(), + "packageManager: pnpm@10.0.0\n".to_string(), + ); + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert_eq!( + warning_codes(&r), + vec!["redirect_pnpm_no_lockfile"], + "a pnpm layout without any lock must warn pnpm-flavored: {:?}", + r.warnings + ); + assert!( + r.warnings[0].detail.contains("pnpm install"), + "detail must point at pnpm, not npm: {}", + r.warnings[0].detail + ); + } + + /// A VENDORED pnpm dep has no registry resolution by design — the lock + /// key is `@file:.socket/vendor/…` (v9) and the generic + /// entry-not-found wording invites a wild-goose `pnpm install`. The + /// warning must name the vendored state and the `vendor --revert` path + /// instead, while a genuinely unlocked dep keeps the old code, and a + /// same-name USER `file:` dep (not under .socket/vendor/) is never + /// misreported as vendored. Fail-closed in all three: zero rewrites. + #[test] + fn pnpm_vendored_entry_is_named_vendored_not_entry_not_found() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://patch.test/left-pad-1.3.0.tgz", + "sha512-PATCHED==", + ); + + // Byte-real v9 vendored lock shape (2026-08-18 mode-conversion + // matrix, projB snap): overrides + file:-keyed packages/snapshots. + let vendored_lock = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +importers: + + .: + dependencies: + left-pad: + specifier: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + version: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +packages: + + left-pad@file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-VENDORED==, tarball: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz} + version: 1.3.0 + +snapshots: + + left-pad@file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz: {} +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), vendored_lock.to_string()); + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "a vendored lock must not be rewritten (fail-closed unchanged): {:?}", + r.files.keys() + ); + assert_eq!( + warning_codes(&r), + vec!["redirect_pnpm_entry_vendored"], + "the vendored dep must be named vendored: {:?}", + r.warnings + ); + assert!( + r.warnings[0].detail.contains("vendor --revert") + && r.warnings[0].detail.contains("left-pad@1.3.0"), + "detail must name the dep and the mode-switch path: {}", + r.warnings[0].detail + ); + + // Legacy vendored spelling (pnpm 7/8): packages rekeyed to a BARE + // `file:` key; the `@: file:…` overrides line (pnpm + // <=8 absolutizes the value) is what still carries name+version. + let legacy_vendored = "lockfileVersion: '6.0' + +overrides: + left-pad@1.3.0: file:/abs/project/.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +packages: + + file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-VENDORED==, tarball: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz} + name: left-pad + version: 1.3.0 + dev: false +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), legacy_vendored.to_string()); + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert_eq!( + warning_codes(&r), + vec!["redirect_pnpm_entry_vendored"], + "the legacy vendored spelling must also be recognized: {:?}", + r.warnings + ); + + // A user's own file: dep of the same name (NOT under .socket/vendor/) + // stays the generic entry-not-found — telling them to run `vendor + // --revert` would be wrong. + let user_file_lock = "lockfileVersion: '9.0' + +packages: + + left-pad@file:vendor/local/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-LOCAL==, tarball: file:vendor/local/left-pad-1.3.0.tgz} + version: 1.3.0 +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), user_file_lock.to_string()); + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert_eq!( + warning_codes(&r), + vec!["redirect_pnpm_entry_not_found"], + "a non-socket file: dep keeps the generic warning: {:?}", + r.warnings + ); + } + /// A CRLF berry lock must be diagnosed as a line-ending problem, not as /// `cacheKey is \`(missing)\`` — the lock's cacheKey IS 10c0; only the /// `\n\n` block grammar fails on `\r\n\r\n`. Fail-closed either way. diff --git a/crates/socket-patch-core/src/patch/redirect/state.rs b/crates/socket-patch-core/src/patch/redirect/state.rs index e019d1ca..054903c4 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -30,6 +30,13 @@ pub struct RedirectState { /// (the final mode name); the loader is tolerant of any string, so /// ledgers written before the rename (`"redirect"`) still load. pub mode: String, + /// Recorded [`FileEdit`]s, appended in write order (a revert walks them + /// in reverse). `kind` is an open vocabulary — additive kinds (e.g. the + /// hosted pnpm flow's `redirect_pnpm_workspace_trust`, recording the + /// auto-configured pnpm-workspace.yaml `trustLockfile: true` with + /// `action` `"created"` for a new file or `"added"` for a spliced-in + /// line) must round-trip through ledgers written before they existed, + /// so no field here may ever tighten into an enum. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub edits: Vec, /// PURL -> manifest patch record. Present so VEX can attest redirected @@ -171,6 +178,127 @@ pub async fn save_redirect_state( atomic_write_bytes(&path, format!("{json}\n").as_bytes()).await } +/// `pkg:/@` → `(, )`; the name keeps any +/// namespace slashes (`@scope/pkg`). `None` when either part is missing. +/// Input must already be canonicalized (qualifiers stripped, percent-decoded). +fn purl_name_version(purl: &str) -> Option<(&str, &str)> { + let rest = purl.strip_prefix("pkg:")?; + let (_, coord) = rest.split_once('/')?; + let at = coord.rfind('@').filter(|&i| i > 0)?; + Some((&coord[..at], &coord[at + 1..])) +} + +/// Drop one PURL's superseded takeover leftovers from the ledger: its +/// `records` entry (canonical-purl match, qualifiers stripped and +/// percent-decoded) and every recorded edit keyed to that package. This is +/// the npm-family half of the hosted→vendored takeover reconciliation: the +/// vendored flows call it ONLY after the LIVE lockfile provably wires the +/// package to the committed `.socket/vendor/` artifact and no longer +/// resolves the hosted URL — at that point the vendor ledger's wiring +/// `original` embeds the hosted-spliced lock fragment, so `vendor --revert` +/// stays lossless without these ledger edits, and keeping them would feed +/// VEX/updates stale records and re-fire the takeover warning on every +/// later run. CARGO purls are refused (returns `false`, drops nothing): a +/// cargo takeover must revert the hosted edits ON DISK first — that path is +/// [`revert_cargo_redirect_purl`](super::revert_cargo_redirect_purl), which +/// does its own ledger drop. +/// +/// The edit matcher is ARTIFACT-ANCHORED, never name-anchored. An edit is +/// claimed when either: +/// +/// * its `new` content references THIS purl's hosted artifact — every hosted +/// artifact URL embeds the patch uuid (on ANY patch-server host; the same +/// invariant the takeover classifier's `hosted_wiring_live` proof rests +/// on), and a uuid is hex-and-dashes so it spells identically raw, +/// `\/`-escaped (old composer) and percent-encoded (yarn-berry +/// `::__archiveUrl=`). The uuid(s) come from this purl's own `records` +/// entry, captured before it is removed. This is what claims the +/// version-blind key shapes: npm `node_modules/…` path keys, legacy +/// `dependencies` bare-name keys, bun `/` keys. +/// * (secondary guard, for when the record — and with it the artifact URL — +/// is unavailable) its key is a VERSION-EXACT instance key: +/// `"name@version"`, pnpm v6 peer-suffixed `"name@version(peer…)"`, or the +/// pnpm-v5 respelling `"name@version_peer…"`. +/// +/// The old matcher claimed by NAME alone (`key == name`, key ends with +/// `"/name"`): with two versions of one package hosted, vendoring one +/// deleted BOTH versions' path-keyed edits, destroying the other version's +/// revert originals. Name-only matching is gone; version-blind keys with no +/// artifact anchor are KEPT (fail-closed — they may be the other version's +/// only revert data). Consequence for the CLI's takeover-overlap fallback +/// matcher (which still matches edit keys by bare name, but ONLY when +/// `records` is empty — the degraded record-fetch-failed ledger): a normal +/// record-carrying ledger reconciles fully here (the record removal alone +/// ends the overlap), while a degraded ledger's unattributable path-keyed +/// edits stay and its takeover warning keeps advising the manual per-package +/// cleanup — the correct outcome when the ledger lacks the records needed to +/// attribute edits to a version safely. +/// +/// Edits that are not package-keyed (e.g. the pnpm workspace-trust edit, +/// keyed `"trustLockfile"`) stay: they belong to the hosted flow's own +/// config surface and other still-redirected package(s) may ride on them. +/// +/// Returns whether anything was removed. The caller persists the mutated +/// ledger via [`persist_redirect_state`] (atomic; an emptied ledger is +/// deleted). +pub fn drop_superseded_purl(state: &mut RedirectState, purl: &str) -> bool { + use crate::utils::purl::{normalize_purl, strip_purl_qualifiers}; + let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let target = canon(purl); + if target.starts_with("pkg:cargo/") { + return false; + } + let Some((name, version)) = purl_name_version(&target) else { + return false; + }; + let (name, version) = (name.to_string(), version.to_string()); + + let record_keys: Vec = state + .records + .keys() + .filter(|k| canon(k) == target) + .cloned() + .collect(); + // THIS purl's patch uuid(s), captured before the records are removed — + // the artifact anchor (see the doc comment). Distinct purls (including + // two versions of one package) carry distinct patch uuids, so a uuid + // match is version-exact by construction. + let uuids: Vec = record_keys + .iter() + .filter_map(|k| state.records.get(k)) + .map(|r| r.uuid.clone()) + .collect(); + for key in &record_keys { + state.records.remove(key); + } + + let name_at_version = format!("{name}@{version}"); + let edits_before = state.edits.len(); + state.edits.retain(|e| { + let Some(key) = e.key.as_deref() else { + // No key ⇒ not attributable to any package; keep. + return true; + }; + // Version-exact instance keys: `name@version`, pnpm v6 peer-suffixed + // `name@version(peer…)`, pnpm v5 respelled `name@version_peer…`. + let version_exact = key == name_at_version + || key + .strip_prefix(name_at_version.as_str()) + .is_some_and(|rest| rest.starts_with('(') || rest.starts_with('_')); + // Artifact anchor: the edit's rewritten (`new`) content references + // this purl's hosted artifact (its patch uuid — spelling-invariant + // across raw / `\/`-escaped / percent-encoded URL forms). + let anchored = !uuids.is_empty() + && e.new.as_ref().is_some_and(|new| { + let text = new.to_string(); + uuids.iter().any(|uuid| text.contains(uuid.as_str())) + }); + !(version_exact || anchored) + }); + + !record_keys.is_empty() || state.edits.len() != edits_before +} + /// Persist the redirect ledger via [`save_redirect_state`]'s atomic writer. /// An EMPTY ledger (no edits, no records) is DELETED instead: a residual /// empty file would keep takeover-overlap detection and VEX reading a ledger @@ -197,7 +325,15 @@ mod tests { use crate::manifest::schema::{PatchFileInfo, PatchRecord, VulnerabilityInfo}; use std::collections::HashMap; + /// The sample record's patch uuid — hosted artifact URLs embed it (the + /// artifact anchor `drop_superseded_purl` claims edits by). + const SAMPLE_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + fn sample_record() -> PatchRecord { + record_with_uuid(SAMPLE_UUID) + } + + fn record_with_uuid(uuid: &str) -> PatchRecord { let mut files = HashMap::new(); files.insert( "package/index.js".to_string(), @@ -217,7 +353,7 @@ mod tests { }, ); PatchRecord { - uuid: "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f".to_string(), + uuid: uuid.to_string(), exported_at: "2024-01-01T00:00:00Z".to_string(), files, vulnerabilities: vulns, @@ -227,6 +363,12 @@ mod tests { } } + /// The hosted artifact URL shape the patch server serves: the patch uuid + /// is a path segment, exactly the anchor `drop_superseded_purl` matches. + fn hosted_url(name: &str, version: &str, uuid: &str) -> String { + format!("https://patch.test/patch/npm/{name}/{version}/{uuid}/{name}-{version}.tgz") + } + #[test] fn round_trips_records_through_json() { let mut state = RedirectState::new(); @@ -242,6 +384,408 @@ mod tests { assert!(rec.vulnerabilities.contains_key("GHSA-xxxx-yyyy-zzzz")); } + /// The hosted pnpm flow's `redirect_pnpm_workspace_trust` edit (the + /// auto-configured pnpm-workspace.yaml `trustLockfile: true`) is plain + /// `FileEdit` vocabulary: it must round-trip byte-losslessly (camelCase + /// contract keys, revert-relevant fields intact) alongside the classic + /// lock edits — and, being additive, its ABSENCE must change nothing + /// (the legacy-ledger tests below stay green without it). + #[test] + fn workspace_trust_edit_round_trips_as_plain_file_edit_vocabulary() { + let mut state = RedirectState::new(); + state.edits.push(FileEdit { + path: "pnpm-lock.yaml".to_string(), + kind: "redirect_pnpm_resolution".to_string(), + action: "rewritten".to_string(), + key: Some("left-pad@1.3.0".to_string()), + original: Some(serde_json::json!("{integrity: sha512-UPSTREAM==}")), + new: Some(serde_json::json!( + "{integrity: sha512-PATCHED==, tarball: http://patch.test/x.tgz}" + )), + }); + state.edits.push(FileEdit { + path: "pnpm-workspace.yaml".to_string(), + kind: "redirect_pnpm_workspace_trust".to_string(), + action: "created".to_string(), + key: Some("trustLockfile".to_string()), + original: None, + new: Some(serde_json::json!("true")), + }); + let json = serde_json::to_string_pretty(&state).unwrap(); + let back: RedirectState = serde_json::from_str(&json).unwrap(); + assert_eq!(back.edits, state.edits, "edits must round-trip losslessly"); + // Edit order is the revert contract (walked in reverse): the trust + // edit stays AFTER the lock edit it accompanies. + assert_eq!(back.edits[1].kind, "redirect_pnpm_workspace_trust"); + assert_eq!(back.edits[1].action, "created"); + assert_eq!(back.edits[1].key.as_deref(), Some("trustLockfile")); + assert!( + back.edits[1].original.is_none(), + "a created file records no original" + ); + } + + /// A ledger written by a FUTURE (or concurrent) writer carrying an edit + /// kind/action this build has never heard of must still load — kind and + /// action are opaque strings, exactly like `mode`. Guards against + /// tightening the edit vocabulary into an enum, which would brick every + /// existing ledger the moment a new kind ships. + #[tokio::test] + async fn load_tolerates_unknown_edit_kinds_and_actions() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".socket/vendor"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write( + dir.join("redirect-state.json"), + br#"{ + "version": 1, + "mode": "hosted", + "edits": [ + { + "path": "pnpm-workspace.yaml", + "kind": "redirect_pnpm_workspace_trust", + "action": "added", + "key": "trustLockfile", + "new": "true" + }, + { + "path": "some-future-file", + "kind": "redirect_kind_from_the_future", + "action": "transmogrified" + } + ] +}"#, + ) + .await + .unwrap(); + let loaded = load_redirect_state(tmp.path()).await.unwrap().unwrap(); + assert_eq!(loaded.edits.len(), 2); + assert_eq!(loaded.edits[0].kind, "redirect_pnpm_workspace_trust"); + assert_eq!(loaded.edits[0].action, "added"); + assert_eq!(loaded.edits[0].original, None); + assert_eq!(loaded.edits[1].kind, "redirect_kind_from_the_future"); + } + + fn edit(path: &str, kind: &str, key: Option<&str>) -> FileEdit { + FileEdit { + path: path.to_string(), + kind: kind.to_string(), + action: "rewritten".to_string(), + key: key.map(str::to_string), + original: Some(serde_json::json!("orig")), + new: Some(serde_json::json!("new")), + } + } + + /// An edit whose rewritten content points at a hosted artifact URL — the + /// shape the npm rewriter records (`new` = the spliced resolved/integrity + /// pair), carrying the artifact anchor. + fn edit_resolved(path: &str, kind: &str, key: &str, url: &str) -> FileEdit { + FileEdit { + path: path.to_string(), + kind: kind.to_string(), + action: "rewritten".to_string(), + key: Some(key.to_string()), + original: Some(serde_json::json!({ + "resolved": "https://registry.npmjs.org/upstream.tgz", + "integrity": "sha512-UPSTREAM==" + })), + new: Some(serde_json::json!({ "resolved": url, "integrity": "sha512-P==" })), + } + } + + /// The takeover reconciliation drops exactly the superseded package's + /// halves — its `records` entry and every edit keyed to it (pnpm + /// `name@version`, pnpm v6 peer-suffixed and v5 `_`-suffixed instances, + /// npm `node_modules/…` paths whose rewritten content carries this purl's + /// hosted artifact) — while other packages' data and non-package-keyed + /// edits (the pnpm workspace-trust edit) survive verbatim. + #[test] + fn drop_superseded_purl_removes_both_halves_and_only_them() { + let mut state = RedirectState::new(); + state + .records + .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); + state + .records + .insert("pkg:npm/minimist@1.2.2".to_string(), sample_record()); + state.edits = vec![ + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0"), + ), + // pnpm v6 peer-suffixed instance key for the SAME package. + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0(react@18.2.0)"), + ), + // pnpm v5 `_`-suffixed instance key (the rewriter's own respelled + // `/left-pad/1.3.0_react@18.2.0` key) for the same package. + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0_react@18.2.0"), + ), + // npm nested node_modules path for the same package: the key is + // version-blind, so the claim rides the artifact anchor in `new`. + edit_resolved( + "package-lock.json", + "redirect_npm_lock_entry", + "node_modules/a/node_modules/left-pad", + &hosted_url("left-pad", "1.3.0", SAMPLE_UUID), + ), + // Another package's edit — must survive. + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("minimist@1.2.2"), + ), + // Non-package-keyed workspace-trust edit — must survive. + edit( + "pnpm-workspace.yaml", + "redirect_pnpm_workspace_trust", + Some("trustLockfile"), + ), + ]; + + assert!(drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); + + assert!( + !state.records.contains_key("pkg:npm/left-pad@1.3.0"), + "the superseded record must be dropped" + ); + assert!( + state.records.contains_key("pkg:npm/minimist@1.2.2"), + "other packages' records must survive" + ); + let keys: Vec<&str> = state + .edits + .iter() + .filter_map(|e| e.key.as_deref()) + .collect(); + assert_eq!( + keys, + vec!["minimist@1.2.2", "trustLockfile"], + "only the superseded package's edits may be dropped: {keys:?}" + ); + + // Idempotent: a second drop finds nothing and reports it. + assert!(!drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); + } + + /// TWO versions of one package hosted at once: dropping the vendored one + /// must not touch the other version's halves. The npm path keys + /// (`node_modules/…/left-pad`) and legacy `dependencies` bare-name keys + /// carry NO version, so the old name-anchored matcher claimed BOTH + /// versions' edits here — destroying left-pad@2.0.0's pre-redirect + /// originals (its only revert data) when left-pad@1.3.0 was vendored. + /// The matcher is artifact-anchored now: only edits whose rewritten + /// content references the dropped purl's own hosted artifact go. + #[test] + fn drop_superseded_purl_never_claims_the_other_hosted_versions_edits() { + const UUID_V2: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0f9e8d7c6b5a"; + let url_v1 = hosted_url("left-pad", "1.3.0", SAMPLE_UUID); + let url_v2 = hosted_url("left-pad", "2.0.0", UUID_V2); + let mut state = RedirectState::new(); + state + .records + .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); + state.records.insert( + "pkg:npm/left-pad@2.0.0".to_string(), + record_with_uuid(UUID_V2), + ); + state.edits = vec![ + // v1's edits: a version-blind path key (anchored via `new`) and + // a version-exact pnpm key. + edit_resolved( + "package-lock.json", + "redirect_npm_lock_entry", + "node_modules/left-pad", + &url_v1, + ), + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0"), + ), + // v2's edits: a nested path key, a legacy bare-name key, and a + // version-exact pnpm key — ALL must survive dropping v1. + edit_resolved( + "package-lock.json", + "redirect_npm_lock_entry", + "node_modules/a/node_modules/left-pad", + &url_v2, + ), + edit_resolved( + "package-lock.json", + "redirect_npm_lock_dep", + "left-pad", + &url_v2, + ), + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@2.0.0"), + ), + ]; + + assert!(drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); + + assert!( + !state.records.contains_key("pkg:npm/left-pad@1.3.0"), + "the vendored version's record must be dropped" + ); + assert!( + state.records.contains_key("pkg:npm/left-pad@2.0.0"), + "the still-hosted version's record must survive" + ); + let keys: Vec<&str> = state + .edits + .iter() + .filter_map(|e| e.key.as_deref()) + .collect(); + assert_eq!( + keys, + vec![ + "node_modules/a/node_modules/left-pad", + "left-pad", + "left-pad@2.0.0" + ], + "the other hosted version's edits are its only revert data and \ + must survive verbatim: {keys:?}" + ); + } + + /// A DEGRADED ledger (record fetch failed: `records` empty, edits only) + /// offers no artifact anchor. The secondary guard must stay version-exact + /// — `name@version` plus the `(`/`_` instance suffixes — and version-blind + /// path/bare-name keys must be KEPT (they cannot be attributed to a + /// version, and dropping them could destroy another version's revert + /// originals). Fail closed: leftover keys mean the takeover warning's + /// manual advisory keeps firing, which is the correct degraded outcome. + #[test] + fn drop_superseded_purl_without_a_record_claims_only_version_exact_keys() { + let mut state = RedirectState::new(); + state.edits = vec![ + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0"), + ), + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0_react@18.2.0"), + ), + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.0(react@18.2.0)"), + ), + // A LONGER version sharing the prefix: `1.3.0` must not claim + // `1.3.01`'s instances (the `_`/`(` boundary is load-bearing). + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.01_react@18.2.0"), + ), + // Version-blind keys: unattributable without the anchor — keep. + edit_resolved( + "package-lock.json", + "redirect_npm_lock_entry", + "node_modules/left-pad", + "https://patch.test/no-uuid-here/left-pad-1.3.0.tgz", + ), + edit_resolved( + "package-lock.json", + "redirect_npm_lock_dep", + "left-pad", + "https://patch.test/no-uuid-here/left-pad-1.3.0.tgz", + ), + ]; + + assert!(drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); + + let keys: Vec<&str> = state + .edits + .iter() + .filter_map(|e| e.key.as_deref()) + .collect(); + assert_eq!( + keys, + vec![ + "left-pad@1.3.01_react@18.2.0", + "node_modules/left-pad", + "left-pad" + ], + "without an artifact anchor only version-exact instance keys may \ + be claimed: {keys:?}" + ); + } + + /// A version-boundary key (`left-pad@1.3.10`) and a different package + /// whose name merely ends with the target's (`not-left-pad`) must never + /// be claimed — the `/`-boundary and `(`-boundary checks are load-bearing. + #[test] + fn drop_superseded_purl_respects_name_and_version_boundaries() { + let mut state = RedirectState::new(); + state.edits = vec![ + edit( + "pnpm-lock.yaml", + "redirect_pnpm_resolution", + Some("left-pad@1.3.10"), + ), + edit( + "package-lock.json", + "redirect_npm_lock_entry", + Some("node_modules/not-left-pad"), + ), + ]; + assert!(!drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.1")); + assert_eq!(state.edits.len(), 2, "no foreign edit may be claimed"); + } + + /// Scoped names: the record key may carry the percent-encoded API form + /// while the caller passes the canonical decoded purl; both halves must + /// still be claimed (the path-keyed edit via the artifact anchor its + /// rewritten content carries). + #[test] + fn drop_superseded_purl_matches_percent_encoded_scoped_records() { + let mut state = RedirectState::new(); + state + .records + .insert("pkg:npm/%40scope%2Fpkg@1.0.0".to_string(), sample_record()); + state.edits = vec![edit_resolved( + "package-lock.json", + "redirect_npm_lock_entry", + "node_modules/@scope/pkg", + &hosted_url("%40scope%2Fpkg", "1.0.0", SAMPLE_UUID), + )]; + assert!(drop_superseded_purl(&mut state, "pkg:npm/@scope/pkg@1.0.0")); + assert!(state.records.is_empty() && state.edits.is_empty()); + } + + /// Cargo purls are refused: their takeover must revert the hosted edits + /// ON DISK first (`revert_cargo_redirect_purl`), so a bare ledger drop + /// would destroy the only revert data. Fail closed by dropping nothing. + #[test] + fn drop_superseded_purl_refuses_cargo() { + let mut state = RedirectState::new(); + state + .records + .insert("pkg:cargo/cfg-if@1.0.4".to_string(), sample_record()); + state.edits = vec![edit( + "Cargo.lock", + "redirect_cargo_lock_entry", + Some("cfg-if@1.0.4"), + )]; + assert!(!drop_superseded_purl(&mut state, "pkg:cargo/cfg-if@1.0.4")); + assert_eq!(state.records.len(), 1); + assert_eq!(state.edits.len(), 1); + } + #[tokio::test] async fn load_missing_ledger_is_none() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 14c2eee9..1596d04b 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -290,6 +290,23 @@ pub(crate) async fn revert_bun( Ok(d) => d, Err(outcome) => return outcome, }; + // Nothing to replay (a `repair`-reconstructed entry): the artifact may + // only be removed when bun.lock provably no longer resolves through it + // — otherwise refuse, fail-closed, instead of silently bricking + // installs. Runs before the dry-run return so a preview never + // advertises a revert the wet run refuses. + if entry.wiring.is_empty() { + if let Some(blocked) = super::npm_lock::guard_unwired_textual_revert( + project_root, + &entry.uuid, + &uuid_dir_rel, + &[BUN_LOCK], + ) + .await + { + return blocked; + } + } if dry_run { return RevertOutcome::ok(); } @@ -1408,6 +1425,77 @@ mod tests { ); } + // ── empty-wiring (reconstructed) revert guard ────────────────────────── + + /// Reshape a vendored entry into what `repair`'s no-ledger + /// reconstruction persists: same uuid/artifact, EMPTY wiring. With + /// nothing to replay, revert must refuse (fail-closed) while bun.lock + /// still resolves through the artifact — dry-run preview included — + /// still remove a genuinely orphaned artifact, fail closed on an + /// unreadable lock, and proceed when no lock exists at all. + #[tokio::test] + async fn empty_wiring_revert_guards_against_bricking_installs() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + let tgz_path = fx.root().join(fx.rel_tgz()); + let lock_vendored = fx.read_lock().await; + + // Still referenced: refuse, artifact and lock untouched. + for dry_run in [true, false] { + let outcome = revert_bun(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "dry_run={dry_run}: must refuse"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{:?}", + outcome.warnings + ); + assert!(tgz_path.exists(), "artifact survives the refusal"); + assert_eq!(fx.read_lock().await, lock_vendored, "lock untouched"); + } + + // Unreadable lock (not UTF-8): undeterminable, fail closed. + tokio::fs::write(fx.root().join(BUN_LOCK), [0xff, 0xfe, b'x']) + .await + .unwrap(); + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(!outcome.success, "unreadable-lock revert must refuse"); + assert!(tgz_path.exists()); + + // Re-locked away from the artifact (provably orphaned): removal + // proceeds, replaying nothing. + tokio::fs::write(fx.root().join(BUN_LOCK), BN3_BEFORE_LOCK) + .await + .unwrap(); + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!tgz_path.exists(), "orphaned artifact removed"); + assert_eq!( + fx.read_lock().await, + BN3_BEFORE_LOCK, + "empty wiring replays nothing" + ); + + // No lock at all: nothing can reference the artifact — proceed. + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + tokio::fs::remove_file(fx.root().join(BUN_LOCK)) + .await + .unwrap(); + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + !fx.root().join(fx.rel_tgz()).exists(), + "no lock, no reference" + ); + } + #[tokio::test] async fn revert_refuses_tampered_uuid_fail_closed() { let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index c03e20d9..379efa8e 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -115,37 +115,110 @@ pub struct UnsupportedNpmLayout { /// Inventory the project's npm-family lockfile. Routes by /// [`detect_npm_lock_flavor`]. `Ok(None)` means there is nothing to -/// inventory (missing lockfile, bun.lockb, unsupported lock versions); -/// `Err` propagates the probe's Plug'n'Play diagnosis — a layout whose -/// packages the inventory can NEVER serve, which callers must not conflate -/// with the calm no-lockfile case. +/// inventory (missing lockfile, bun.lockb, dep-less locks); `Err` +/// propagates the probe's Plug'n'Play diagnosis — a layout whose packages +/// the inventory can NEVER serve, which callers must not conflate with the +/// calm no-lockfile case. Two pnpm-specific refusals fall back instead of +/// yielding `None`: an unsupported `lockfileVersion` reads the root +/// `pnpm-lock.yaml` directly — unless a live sibling lock the router would +/// otherwise have chosen sits beside it (a pnpm→yarn/npm migration +/// leftover), in which case the SIBLING is inventoried instead +/// ([`inventory_live_sibling_lock`]) — and `vendor_lockfile_missing` reads +/// the pnpm <=2-era `shrinkwrap.yaml` (same v5 grammar, older filename). +/// Any remaining probe failure falls back to Rush's common lock when +/// `rush.json` is present. pub(crate) async fn inventory_npm_lock( project_root: &Path, ) -> Result)>, UnsupportedNpmLayout> { - // Rush monorepos have no root package.json/lock pair; their single - // pnpm source-of-truth lives under common/config/rush/. The flavor - // probe (root-relative) can't see it, so fall back explicitly when the - // root lock is absent but rush.json is present. let (flavor, _warnings) = match detect_npm_lock_flavor(project_root).await { Ok(found) => found, Err((code, detail)) => { // The PnP loaders are a refusal, not an absence: propagate the - // diagnosis instead of discarding it. Every other probe error - // (missing lock, bun.lockb, unsupported versions) keeps the - // Rush fallback and the calm `Ok(None)`. + // diagnosis instead of discarding it. Under PnP the + // installed-tree crawl is ALSO structurally empty, so + // swallowing this here made `scan` a silent success-0 no-op in + // every mode. Every other probe error keeps the fallbacks below + // and the calm `Ok(None)`. if matches!( code, "vendor_yarn_berry_unsupported" | "vendor_pnpm_pnp_unsupported" ) { return Err(UnsupportedNpmLayout { code, detail }); } + // The flavor probe passes only pnpm locks the WIRING backends + // support (lockfileVersion 5.4/6.0/9.0), but inventory is + // read-only discovery — an out-of-family (pnpm <= 6-era or + // future) lock still names the resolved set, so on the probe's + // pnpm version refusal a present root lock is read directly + // rather than leaving fresh clones of such projects blind. Only + // that code: on any other refusal a root pnpm-lock.yaml is + // stale debris from a migration, and inventorying it would + // present dead resolutions as the live dependency set. + // (`vendor_lockfile_version_unsupported` also covers the + // unrecognizable-yarn.lock refusal, but the probe only sniffs + // yarn.lock when no root pnpm-lock.yaml exists, so the direct + // read is a no-op there.) + if code == "vendor_lockfile_version_unsupported" { + // The version refusal fires from the probe's pnpm step, + // which runs BEFORE its yarn/npm steps — so it says nothing + // about whether a LIVE sibling lock sits beside the refused + // pnpm lock (a pnpm→yarn/npm migration leaves exactly that + // shape behind). Prefer whichever sibling the router would + // have chosen had the pnpm lock not shadowed it; only a + // sibling-less project is a genuine old-pnpm project whose + // lock the fallback may surface. + match inventory_live_sibling_lock(project_root).await { + Some((flavor, entries)) if !entries.is_empty() => { + return Ok(Some((flavor, finalize_npm(entries)))); + } + // A sibling lock FILE exists but yields no entries + // (dep-less project, or a grammar we cannot read): the + // migration still happened, so the pnpm lock stays out — + // blind beats presenting dead resolutions as live. + Some(_) => {} + None => { + let pnpm = inventory_pnpm_lock(project_root).await.unwrap_or_default(); + if !pnpm.is_empty() { + return Ok(Some((NpmLockFlavor::Pnpm, finalize_npm(pnpm)))); + } + } + } + } + // pnpm 1/2 wrote the v5-era lock grammar under the name + // `shrinkwrap.yaml` (shrinkwrapVersion 3) — pnpm 3 renamed the + // file to pnpm-lock.yaml. The flavor probe doesn't know that + // filename, so such a project refuses as + // `vendor_lockfile_missing`; the lock still names the full + // resolved set, so read it directly rather than leaving pnpm<=2 + // projects (and their fresh clones) lockfile-blind. Gated on + // that ONE code: any other refusal means a DIFFERENT lock + // family is present (bun markers, an unsupported recognized + // lock), where a shrinkwrap.yaml is stale debris from a + // long-ago migration whose dead resolutions must not pose as + // the live dependency set. + if code == "vendor_lockfile_missing" { + let legacy = inventory_pnpm_lock_at(&project_root.join("shrinkwrap.yaml")) + .await + .unwrap_or_default(); + if !legacy.is_empty() { + return Ok(Some((NpmLockFlavor::PnpmLegacy, finalize_npm(legacy)))); + } + } + // Rush monorepos have no root package.json/lock pair; their + // single pnpm source-of-truth lives under common/config/rush/. + // The flavor probe (root-relative) can't see it, so fall back + // explicitly when the root lock is absent but rush.json is + // present. let rush = inventory_rush_pnpm_locks(project_root).await; return Ok((!rush.is_empty()).then(|| (NpmLockFlavor::Pnpm, finalize_npm(rush)))); } }; let raw = match flavor { NpmLockFlavor::PackageLock => inventory_package_lock(project_root).await, - NpmLockFlavor::Pnpm => inventory_pnpm_lock(project_root).await, + // The pnpm reader is grammar-agnostic (it already served legacy + // 5.4/6.0 locks through the refusal fallback below before those + // grammars had a wiring backend), so both pnpm flavors share it. + NpmLockFlavor::Pnpm | NpmLockFlavor::PnpmLegacy => inventory_pnpm_lock(project_root).await, NpmLockFlavor::YarnClassic => inventory_yarn_classic(project_root).await, NpmLockFlavor::YarnBerry => inventory_yarn_berry(project_root).await, NpmLockFlavor::Bun => inventory_bun(project_root).await, @@ -153,6 +226,58 @@ pub(crate) async fn inventory_npm_lock( Ok(raw.map(|raw| (flavor, finalize_npm(raw)))) } +/// The live sibling lock a version-refused root `pnpm-lock.yaml` may be +/// shadowing, or `None` when no sibling lock file exists at all. +/// +/// [`detect_npm_lock_flavor`] cannot be re-asked (it already refused on its +/// pnpm step), so this mirrors the rest of its precedence by hand — bun, +/// then yarn, then npm — on file EXISTENCE, and returns the first present +/// sibling's inventory (possibly empty: presence alone proves the pnpm lock +/// is migration debris, so the caller must not fall back to it). Raw +/// entries — the caller applies [`finalize_npm`]. +async fn inventory_live_sibling_lock(root: &Path) -> Option<(NpmLockFlavor, Vec)> { + let exists = |name: &str| { + let p = root.join(name); + async move { tokio::fs::metadata(&p).await.is_ok() } + }; + // bun.lock — router step 2. That step runs BEFORE the pnpm sniff, so + // when the version refusal fired no bun.lock can actually be present; + // probed anyway to keep this a literal transcription of the router's + // order. bun.lockb (the legacy binary lock) refuses in the router + // rather than routing — mirrored here by omission. + if exists("bun.lock").await { + return Some(( + NpmLockFlavor::Bun, + inventory_bun(root).await.unwrap_or_default(), + )); + } + // yarn.lock — router step 4, where classic vs berry is a content + // decision. Rather than re-deriving that head sniff, try both readers: + // each yields entries only for its own grammar (classic's `version "…"` + // fields vs berry's `resolution:` lines), so a non-empty result is the + // sniff's answer. Berry PnP needs no carve-out: a PnP marker would have + // refused at the router's step 1 with a code this fallback ignores. + if exists("yarn.lock").await { + let classic = inventory_yarn_classic(root).await.unwrap_or_default(); + if !classic.is_empty() { + return Some((NpmLockFlavor::YarnClassic, classic)); + } + return Some(( + NpmLockFlavor::YarnBerry, + inventory_yarn_berry(root).await.unwrap_or_default(), + )); + } + // npm — router step 5 (`inventory_package_lock` itself prefers the + // shrinkwrap when both exist, mirroring npm). + if exists("npm-shrinkwrap.json").await || exists("package-lock.json").await { + return Some(( + NpmLockFlavor::PackageLock, + inventory_package_lock(root).await.unwrap_or_default(), + )); + } + None +} + /// Match a manifest/API purl (possibly percent-encoded, possibly carrying /// qualifiers) against the inventory: components decode via /// [`crate::utils::purl::normalize_purl`], so `pkg:npm/%40scope/x@1` @@ -438,7 +563,7 @@ async fn inventory_package_lock(root: &Path) -> Option> { Some(out) } -// ─────────────────────────── pnpm-lock.yaml v9 ─────────────────────────── +// ────────────────────────────── pnpm-lock.yaml ────────────────────────────── async fn inventory_pnpm_lock(root: &Path) -> Option> { inventory_pnpm_lock_at(&root.join("pnpm-lock.yaml")).await @@ -455,16 +580,22 @@ async fn inventory_pnpm_lock_at(lock_path: &Path) -> Option> let mut i = start + 1; while let Some(block) = pnpm_lock::next_block(&lines, i, end) { i = block.end; - // Key grammar: `name@version` (name may be `@scope/name`), with - // optional peer-dep suffixes `(peer@1.2.3)…` after the version. - let base = match block.key.find('(') { + // Key grammar by lock generation: v9 `name@version`, v6 (pnpm 8) + // the same behind a leading `/`, v5.4 (pnpm 7) `/name/version` — + // names may be scoped (`@scope/name`) in all three. Peer suffixes: + // v6/v9 append `(peer@1.2.3)…` after the version; v5 appends + // `_peer@x`/`_` to the version itself. + let trimmed = match block.key.find('(') { Some(p) => block.key[..p].trim_end(), None => block.key.as_str(), }; - let Some(at) = base.rfind('@').filter(|&p| p > 0) else { + let (base, legacy) = match trimmed.strip_prefix('/') { + Some(stripped) => (stripped, true), + None => (trimmed, false), + }; + let Some((name, version)) = split_pnpm_key(base, legacy) else { continue; }; - let (name, version) = (&base[..at], &base[at + 1..]); // Only plain registry versions: `file:`/`link:`/`https:`/git specs // are not registry-resolvable. if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) { @@ -472,15 +603,40 @@ async fn inventory_pnpm_lock_at(lock_path: &Path) -> Option> } let mut integrity = LockIntegrity::None; let mut tarball: Option = None; - for line in &lines[block.header + 1..block.end] { + let entry_lines = &lines[block.header + 1..block.end]; + for (j, line) in entry_lines.iter().enumerate() { let t = line.trim(); - if let Some(rest) = t.strip_prefix("resolution:") { + let Some(rest) = t.strip_prefix("resolution:") else { + continue; + }; + if rest.trim().is_empty() { + // shrinkwrap.yaml (pnpm <=2, shrinkwrapVersion 3) nests the + // resolution as a BLOCK mapping — + // resolution: + // integrity: sha512-… + // — where every pnpm-lock.yaml generation writes the inline + // `resolution: {…}` flow map. Its fields are exactly the + // following deeper-indented lines (a shallower or blank + // line ends the mapping). + let indent = pnpm_lock::indent_of(line); + for child in &entry_lines[j + 1..] { + if child.trim().is_empty() || pnpm_lock::indent_of(child) <= indent { + break; + } + if let Some(v) = inline_yaml_field(child, "integrity:") { + integrity = LockIntegrity::Sri(v); + } + if let Some(v) = inline_yaml_field(child, "tarball:") { + tarball = Some(v); + } + } + } else { if let Some(v) = inline_yaml_field(rest, "integrity:") { integrity = LockIntegrity::Sri(v); } tarball = inline_yaml_field(rest, "tarball:"); - break; } + break; } // Our own vendored spec: not a registry dependency. if tarball @@ -499,6 +655,32 @@ async fn inventory_pnpm_lock_at(lock_path: &Path) -> Option> Some(out) } +/// Split a peer-paren-stripped, slash-stripped pnpm packages key into +/// `(name, version)`; `None` is skipped by the caller, never guessed. +/// `legacy` marks a key that carried the v5/v6 leading `/` — only those may +/// use the v5 `name/version` grammar. What tells v5 `/@scope/name/1.2.3` +/// apart from v6 `/@scope/name@1.2.3` is the segment after the last `/`: +/// a v5 version (its `_peer`/`_hash` suffix dropped) starts with a digit +/// and never contains `@`, while a v6 scoped key's trailing segment is +/// `name@version`. v5 non-default-registry keys (`example.com/name/1.2.3`) +/// carry no leading `/` and fall through to the `@` split, where they are +/// dropped fail-closed downstream. +fn split_pnpm_key(base: &str, legacy: bool) -> Option<(&str, &str)> { + if legacy { + if let Some((name, rest)) = base.rsplit_once('/') { + let version = rest.split('_').next().unwrap_or(rest); + if !name.is_empty() + && version.chars().next().is_some_and(|c| c.is_ascii_digit()) + && !version.contains('@') + { + return Some((name, version)); + } + } + } + let at = base.rfind('@').filter(|&p| p > 0)?; + Some((&base[..at], &base[at + 1..])) +} + // ─────────────────────────────── Rush monorepo ─────────────────────────────── /// Inventory a Rush monorepo's pnpm locks. Rush keeps a single @@ -1726,9 +1908,437 @@ snapshots: assert_eq!(entry(&entries, "peer-user").version, "4.0.0"); // registry entries carry no URL in v9 — constructed at fetch time. assert_eq!(entry(&entries, "left-pad").resolved, None); - for absent in ["local-thing", "vendored"] { - assert!(!entries.iter().any(|e| e.name == absent), "{entries:?}"); - } + // Exact set: the legacy v5/v6 grammars must not add or reshape v9 + // entries (local-thing and vendored stay skipped). + assert_eq!( + sorted_pairs(&entries), + vec![ + ("@scope/pkg".into(), "2.0.0".into()), + ("left-pad".into(), "1.3.0".into()), + ("peer-user".into(), "4.0.0".into()), + ] + ); + } + + fn sorted_pairs(entries: &[LockfileEntry]) -> Vec<(String, String)> { + let mut pairs: Vec<(String, String)> = entries + .iter() + .map(|e| (e.name.clone(), e.version.clone())) + .collect(); + pairs.sort(); + pairs + } + + // Real pnpm 7 shapes (lockfileVersion 5.4, captured from a pnpm 7.33.5 + // install: slash-separated `/name/version` keys, no `@` at all), plus + // synthetic keys in the same grammar: scoped, `_peer@x`-suffixed, + // `_`-suffixed, and a non-default-registry key (no leading `/`) + // that must stay out fail-closed. + const PNPM_LOCK_V5: &str = "lockfileVersion: 5.4 + +specifiers: + mkdirp: 0.5.5 + +dependencies: + mkdirp: 0.5.5 + +packages: + + /minimist/1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: false + + /mkdirp/0.5.5: + resolution: {integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: false + + /@scope/pkg/2.0.0: + resolution: {integrity: sha512-scoped==} + dev: false + + /styled-thing/5.3.3_react@17.0.2: + resolution: {integrity: sha512-peered==} + dev: false + + /hashed-thing/1.0.0_abc123deadbeef: + resolution: {integrity: sha512-hashed==} + dev: false + + example.com/private-pkg/1.0.0: + resolution: {integrity: sha512-registry==} + dev: false +"; + + #[tokio::test] + async fn pnpm_v5_slash_keys_inventory_with_peer_and_hash_suffixes() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V5).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + // The legacy grammars route to the PnpmLegacy wiring flavor now + // (they used to reach here through the version-refusal fallback); + // the inventory content is identical either way. + assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); + assert_eq!( + sorted_pairs(&entries), + vec![ + ("@scope/pkg".into(), "2.0.0".into()), + ("hashed-thing".into(), "1.0.0".into()), + ("minimist".into(), "1.2.8".into()), + ("mkdirp".into(), "0.5.5".into()), + ("styled-thing".into(), "5.3.3".into()), + ] + ); + assert_eq!( + entry(&entries, "minimist").integrity, + LockIntegrity::Sri( + "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + .into() + ) + ); + assert_eq!(entry(&entries, "minimist").purl, "pkg:npm/minimist@1.2.8"); + } + + // Real pnpm 8 shapes (lockfileVersion 6.0, captured from a pnpm 8.15.9 + // install: v9's `name@version` behind a leading `/`), plus synthetic + // scoped and peer-parenthesized keys in the same grammar. + const PNPM_LOCK_V6: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + mkdirp: + specifier: 0.5.5 + version: 0.5.5 + +packages: + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: false + + /mkdirp@0.5.5: + resolution: {integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: false + + /@scope/pkg@2.0.0: + resolution: {integrity: sha512-scoped==} + dev: false + + /peer-user@4.0.0(left-pad@1.3.0): + resolution: {integrity: sha512-peer==} + dev: false +"; + + #[tokio::test] + async fn pnpm_v6_leading_slash_keys_inventory_with_peer_parens() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V6).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + // The legacy grammars route to the PnpmLegacy wiring flavor now + // (they used to reach here through the version-refusal fallback); + // the inventory content is identical either way. + assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); + assert_eq!( + sorted_pairs(&entries), + vec![ + ("@scope/pkg".into(), "2.0.0".into()), + ("minimist".into(), "1.2.8".into()), + ("mkdirp".into(), "0.5.5".into()), + ("peer-user".into(), "4.0.0".into()), + ] + ); + assert_eq!( + entry(&entries, "mkdirp").integrity, + LockIntegrity::Sri( + "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==" + .into() + ) + ); + assert_eq!( + entry(&entries, "@scope/pkg").purl, + "pkg:npm/@scope/pkg@2.0.0" + ); + } + + /// A pnpm→yarn-berry migration leaves a stale root pnpm-lock.yaml behind + /// a `.pnp.cjs` loader. The probe's refusal there is a yarn refusal, not + /// a pnpm one — the legacy-lock fallback must NOT inventory the stale + /// lock as the live dependency set; the yarn-PnP diagnosis propagates + /// instead. + #[tokio::test] + async fn stale_pnpm_lock_behind_yarn_berry_pnp_marker_is_not_inventoried() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + write(tmp.path(), ".pnp.cjs", "/* yarn berry PnP loader */").await; + let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); + assert_eq!( + diag.code, "vendor_yarn_berry_unsupported", + "a stale pnpm-lock.yaml behind a yarn-berry PnP marker must not be inventoried" + ); + } + + /// Same stale-lock hazard for a pnpm→bun migration: bun.lockb refuses + /// with a bun-specific code, so the pnpm fallback must stay out. + #[tokio::test] + async fn stale_pnpm_lock_behind_bun_lockb_is_not_inventoried() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + write(tmp.path(), "bun.lockb", "\0binary").await; + assert!( + inventory_npm_lock(tmp.path()).await.unwrap().is_none(), + "a stale pnpm-lock.yaml behind bun.lockb must not be inventoried" + ); + } + + /// A pnpm-lock.yaml whose lockfileVersion the probe refuses — pnpm 6 + /// wrote 5.3; only 5.4/6.0/9.0 route to a backend. This is the shape + /// that reaches the version-refusal discovery fallback, where a live + /// sibling lock may be sitting beside it after a migration. + const PNPM_LOCK_V53_STALE: &str = "lockfileVersion: 5.3 + +packages: + + /dead-pnpm-dep/1.0.0: + resolution: {integrity: sha512-dead==} +"; + + /// A pnpm→yarn migration leaves a version-refused pnpm-lock.yaml beside + /// the live yarn.lock. The probe checks pnpm-lock.yaml BEFORE yarn.lock, + /// so its refusal says nothing about the sibling — the fallback must + /// surface the LIVE yarn resolutions, not the dead pnpm ones. + #[tokio::test] + async fn stale_pnpm_lock_beside_live_yarn_classic_yields_yarn_entries() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write(tmp.path(), "yarn.lock", YARN_CLASSIC).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnClassic); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!( + !entries.iter().any(|e| e.name == "dead-pnpm-dep"), + "dead pnpm resolutions must not pose as the live set: {entries:?}" + ); + } + + /// Same migration hazard toward yarn berry (node-modules linker: no PnP + /// marker, so the pnpm version refusal is what fires). + #[tokio::test] + async fn stale_pnpm_lock_beside_live_yarn_berry_yields_berry_entries() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write(tmp.path(), "yarn.lock", YARN_BERRY).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnBerry); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!( + !entries.iter().any(|e| e.name == "dead-pnpm-dep"), + "dead pnpm resolutions must not pose as the live set: {entries:?}" + ); + } + + /// Same migration hazard toward npm: the live package-lock.json wins + /// over the version-refused pnpm lock. + #[tokio::test] + async fn stale_pnpm_lock_beside_live_package_lock_yields_npm_entries() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write(tmp.path(), "package-lock.json", PACKAGE_LOCK).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::PackageLock); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!( + !entries.iter().any(|e| e.name == "dead-pnpm-dep"), + "dead pnpm resolutions must not pose as the live set: {entries:?}" + ); + } + + /// A version-refused pnpm lock ALONE is a genuine old-pnpm project (no + /// migration happened) — the discovery fallback must still read it. + #[tokio::test] + async fn unsupported_pnpm_lock_alone_is_still_inventoried() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm); + assert_eq!(entry(&entries, "dead-pnpm-dep").version, "1.0.0"); + } + + /// pnpm→bun migration with the TEXT bun.lock: the router routes Bun at + /// its bun step, which runs BEFORE the pnpm sniff, so no refusal (and no + /// fallback) ever fires — bun's entries are the inventory. Pinned here + /// because it is the router-precedence twin of the sibling checks above. + #[tokio::test] + async fn stale_pnpm_lock_beside_bun_lock_routes_to_bun() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write(tmp.path(), "bun.lock", BUN_LOCK).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Bun); + assert_eq!(entry(&entries, "left-pad").version, "1.3.0"); + assert!( + !entries.iter().any(|e| e.name == "dead-pnpm-dep"), + "dead pnpm resolutions must not pose as the live set: {entries:?}" + ); + } + + /// A live sibling lock FILE that yields no entries (here: an empty + /// package-lock, as a fresh dep-less `npm install` writes) still proves + /// the migration happened — the dead pnpm resolutions must stay out even + /// though there is nothing live to return. + #[tokio::test] + async fn stale_pnpm_lock_beside_empty_live_lock_yields_none() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK_V53_STALE).await; + write( + tmp.path(), + "package-lock.json", + r#"{ "lockfileVersion": 3, "packages": { "": {} } }"#, + ) + .await; + assert!( + inventory_npm_lock(tmp.path()).await.unwrap().is_none(), + "an empty live sibling must not resurrect the dead pnpm resolutions" + ); + } + + // ── shrinkwrap.yaml (pnpm 1/2) ────────────────────────────────────────── + + /// The exact grammar the 2026-08-18 legacy matrix captured from a real + /// pnpm 2 install (shrinkwrapVersion 3): v5-style `/name/version` keys, + /// BLOCK-mapped `resolution:` (integrity nested on its own line — every + /// pnpm-lock.yaml generation writes the inline `{…}` flow map instead), + /// quoted top-level `registry:`, and a transitive dep (`minimist`) + /// listed only under `packages:`. + const SHRINKWRAP_YAML: &str = "dependencies: + left-pad: 1.3.0 + mkdirp: 0.5.5 +packages: + /left-pad/1.3.0: + deprecated: use String.prototype.padStart() + dev: false + resolution: + integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== + /minimist/1.2.8: + dev: false + resolution: + integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + /mkdirp/0.5.5: + dependencies: + minimist: 1.2.8 + dev: false + hasBin: true + resolution: + integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== +registry: 'https://registry.npmjs.org/' +shrinkwrapMinorVersion: 9 +shrinkwrapVersion: 3 +specifiers: + left-pad: 1.3.0 + mkdirp: 0.5.5 +"; + + /// A pnpm <=2 project (shrinkwrap.yaml, no pnpm-lock.yaml, no other + /// lock) must be inventoried through the shrinkwrap fallback: same v5 + /// key grammar, integrity read from the BLOCK-mapped resolution — + /// without it such projects report lockfileOnlyPackages=0 despite the + /// lock listing everything. + #[tokio::test] + async fn shrinkwrap_yaml_inventories_pnpm_legacy_project() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "shrinkwrap.yaml", SHRINKWRAP_YAML).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()) + .await + .unwrap() + .expect("shrinkwrap.yaml must be inventoried"); + assert_eq!(flavor, NpmLockFlavor::PnpmLegacy); + assert_eq!(entries.len(), 3, "all three packages entries: {entries:?}"); + + let lp = entry(&entries, "left-pad"); + assert_eq!(lp.version, "1.3.0"); + assert_eq!(lp.purl, "pkg:npm/left-pad@1.3.0"); + assert_eq!( + lp.integrity, + LockIntegrity::Sri( + "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/\ + aVx2HrNcqQGsdot8ghrjyrvMCoEA==" + .into() + ), + "block-mapped resolution integrity must be captured" + ); + assert_eq!(lp.resolved, None, "no tarball recorded → registry URL"); + + // The transitive dep (a dependencies: child inside mkdirp's entry + // must not shadow it) and the binary-carrying dep both inventory. + assert_eq!(entry(&entries, "minimist").version, "1.2.8"); + assert_eq!(entry(&entries, "mkdirp").version, "0.5.5"); + } + + /// A root pnpm-lock.yaml wins over shrinkwrap.yaml: the flavor probe + /// recognizes the modern lock, so the legacy fallback never runs — a + /// leftover shrinkwrap.yaml from a long-ago pnpm upgrade must not + /// inject dead resolutions. + #[tokio::test] + async fn pnpm_lock_wins_over_stale_shrinkwrap_yaml() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + write(tmp.path(), "shrinkwrap.yaml", SHRINKWRAP_YAML).await; + + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::Pnpm); + assert!( + !entries.iter().any(|e| e.name == "mkdirp"), + "shrinkwrap-only entries must not leak in: {entries:?}" + ); + } + + /// Same stale-lock hazard as the pnpm-lock fallbacks: a shrinkwrap.yaml + /// behind another family's marker (yarn-berry PnP here — the probe + /// refuses with a NON-missing code) is migration debris, not the live + /// dependency set; the yarn-PnP diagnosis propagates instead. + #[tokio::test] + async fn stale_shrinkwrap_behind_yarn_berry_pnp_marker_is_not_inventoried() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "shrinkwrap.yaml", SHRINKWRAP_YAML).await; + write(tmp.path(), ".pnp.cjs", "/* yarn berry PnP loader */").await; + let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); + assert_eq!( + diag.code, "vendor_yarn_berry_unsupported", + "a shrinkwrap.yaml behind a yarn-berry PnP marker must not be inventoried" + ); + } + + /// pnpm's own `node-linker=pnp` layout (`.pnp.cjs` + pnpm store + lock, + /// no yarn.lock) refuses with the pnpm-specific PnP code, which + /// PROPAGATES as the layout diagnosis rather than falling back to the + /// lock read — under PnP the installed-tree crawl is also structurally + /// empty, so the honest answer is the refusal, not a lock-only + /// inventory posing as a served project (see + /// `pnp_layouts_propagate_the_diagnosis_instead_of_yielding_none`). + #[tokio::test] + async fn pnpm_pnp_layout_propagates_the_diagnosis() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", PNPM_LOCK).await; + write(tmp.path(), ".pnp.cjs", "/* pnpm node-linker=pnp loader */").await; + write_nested(tmp.path(), "node_modules/.modules.yaml", "").await; + + let diag = inventory_npm_lock(tmp.path()).await.unwrap_err(); + assert_eq!(diag.code, "vendor_pnpm_pnp_unsupported"); } // ── Rush monorepo ─────────────────────────────────────────────────────── @@ -2374,7 +2984,9 @@ source = { editable = "." } #[tokio::test] async fn unsupported_flavors_yield_none() { - // pnpm v6: no backend, but not a refusal — the calm None. + // pnpm v6.0: the probe passes it (the 6.0 grammar has a wiring + // backend), and a dep-less lock inventories to nothing — the calm + // None, not a refusal. let tmp = tempfile::tempdir().unwrap(); write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '6.0'\n").await; assert!(inventory_npm_lock(tmp.path()).await.unwrap().is_none()); diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index b4e6509c..96b5df96 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -65,6 +65,7 @@ pub mod npm_lock; mod npm_pack; pub mod nuget_feed; pub mod pnpm_lock; +pub mod pnpm_lock_legacy; pub mod pypi; pub mod pypi_pdm; pub mod pypi_pipenv; diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index b91d97bb..120d15fb 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -24,10 +24,11 @@ use std::path::Path; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; +use super::pnpm_lock_legacy::PnpmLockGrammar; use super::state::VendorEntry; use super::{ - bun_lock, npm_lock, pnpm_lock, yarn_berry_lock, yarn_classic_lock, RevertOutcome, - VendorOutcome, VendorWarning, + bun_lock, npm_lock, pnpm_lock, pnpm_lock_legacy, yarn_berry_lock, yarn_classic_lock, + RevertOutcome, VendorOutcome, VendorWarning, }; /// Which lockfile flavor drives this project's npm installs. @@ -41,6 +42,9 @@ pub(crate) enum NpmLockFlavor { YarnBerry, /// `pnpm-lock.yaml`, lockfileVersion 9.0 (pnpm >= 9). Pnpm, + /// `pnpm-lock.yaml`, the legacy grammars — lockfileVersion 5.4 (pnpm 7) + /// or 6.0 (pnpm 8). + PnpmLegacy, /// `bun.lock` (bun's text lockfile). Bun, } @@ -53,6 +57,7 @@ impl NpmLockFlavor { NpmLockFlavor::YarnClassic => "yarn-classic", NpmLockFlavor::YarnBerry => "yarn-berry", NpmLockFlavor::Pnpm => "pnpm", + NpmLockFlavor::PnpmLegacy => pnpm_lock_legacy::FLAVOR, NpmLockFlavor::Bun => "bun", } } @@ -92,8 +97,9 @@ const LOCKFILE_FAMILIES: [(NpmLockFlavor, &[&str]); 4] = [ /// [`crate::crawlers::pkg_managers::pnpm_pnp_layout`]) → Err /// `vendor_pnpm_pnp_unsupported` with a pnpm remedy; /// 2. `bun.lock` → Bun; else `bun.lockb` → Err `vendor_bun_lockb_unsupported`; -/// 3. `pnpm-lock.yaml` → head-sniff `lockfileVersion` (only `'9.0'`) → Pnpm, -/// else Err `vendor_lockfile_version_unsupported`; +/// 3. `pnpm-lock.yaml` → head-sniff `lockfileVersion`: `'9.0'` → Pnpm; +/// `5.4`/`'6.0'` (pnpm 7/8) → PnpmLegacy; anything else → Err +/// `vendor_lockfile_version_unsupported` (version-aware remedy); /// 4. `yarn.lock` → head-sniff: column-0 `__metadata:` → Err /// `vendor_yarn_berry_unsupported`; `# yarn lockfile v1` → YarnClassic; /// neither → Err `vendor_lockfile_version_unsupported`; @@ -164,13 +170,20 @@ pub(crate) async fn detect_npm_lock_flavor( )); } - // 3. pnpm: only lockfileVersion 9.0 has a wiring backend (the sniff - // is the pnpm backend's own pre-flight check). + // 3. pnpm: lockfileVersion 9.0 routes to the v9 backend, the legacy + // grammars 5.4 (pnpm 7) / 6.0 (pnpm 8) to the legacy backend; + // anything else refuses with the sniff's version-aware remedy. if exists("pnpm-lock.yaml").await { let text = read_lock(project_root, "pnpm-lock.yaml").await?; - pnpm_lock::check_lock_version(&text) - .map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?; - break 'flavor NpmLockFlavor::Pnpm; + match pnpm_lock_legacy::sniff_lock_grammar(&text) { + Ok(PnpmLockGrammar::V9) => break 'flavor NpmLockFlavor::Pnpm, + Ok(PnpmLockGrammar::V54 | PnpmLockGrammar::V60) => { + break 'flavor NpmLockFlavor::PnpmLegacy + } + Err(detail) => { + return Err(("vendor_lockfile_version_unsupported", detail)); + } + } } // 4. yarn: classic v1 vs berry (node-modules linker), decided by content. @@ -223,6 +236,9 @@ pub(crate) async fn detect_npm_lock_flavor( // berry detection claims it too (never self-warn about the wired file). let family_owner = match detected { NpmLockFlavor::YarnBerry => NpmLockFlavor::YarnClassic, + // Both pnpm backends wire the same pnpm-lock.yaml (the family table + // keys the family under Pnpm) — never self-warn about the wired file. + NpmLockFlavor::PnpmLegacy => NpmLockFlavor::Pnpm, other => other, }; let mut warnings = Vec::new(); @@ -330,6 +346,7 @@ pub async fn vendor_npm_any( NpmLockFlavor::YarnClassic => vend!(yarn_classic_lock::vendor_yarn_classic), NpmLockFlavor::YarnBerry => vend!(yarn_berry_lock::vendor_yarn_berry), NpmLockFlavor::Pnpm => vend!(pnpm_lock::vendor_pnpm), + NpmLockFlavor::PnpmLegacy => vend!(pnpm_lock_legacy::vendor_pnpm_legacy), NpmLockFlavor::Bun => vend!(bun_lock::vendor_bun), }; // Probe warnings (e.g. a sibling lockfile that will install UNPATCHED @@ -365,6 +382,9 @@ pub async fn vendor_npm_any( pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Option { match entry.flavor.as_deref() { Some("pnpm") => pnpm_lock::pnpm_entry_in_use(entry, project_root).await, + Some("pnpm-legacy") => { + pnpm_lock_legacy::pnpm_legacy_entry_in_use(entry, project_root).await + } // The remaining flavors wire resolutions into the lock itself // (resolved URLs / file: ranges / package tuples), so a textual // probe for the uuid dir is exact: the path appears iff some @@ -387,7 +407,13 @@ pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> } /// First readable lockfile from `names`, probed for the uuid artifact dir. -async fn lock_text_mentions_uuid(project_root: &Path, names: &[&str], uuid: &str) -> Option { +/// Shared with the textual backends' unwired-revert guard +/// ([`super::npm_lock::guard_unwired_textual_revert`]). +pub(super) async fn lock_text_mentions_uuid( + project_root: &Path, + names: &[&str], + uuid: &str, +) -> Option { let needle = format!(".socket/vendor/npm/{uuid}/"); for name in names { if let Ok(text) = tokio::fs::read_to_string(project_root.join(name)).await { @@ -415,6 +441,9 @@ pub async fn revert_npm_any( yarn_berry_lock::revert_yarn_berry(entry, project_root, dry_run).await } Some("pnpm") => pnpm_lock::revert_pnpm(entry, project_root, dry_run).await, + Some("pnpm-legacy") => { + pnpm_lock_legacy::revert_pnpm_legacy(entry, project_root, dry_run).await + } Some("bun") => bun_lock::revert_bun(entry, project_root, dry_run).await, Some(other) => RevertOutcome::failed(format!( "this socket-patch build cannot revert npm vendor flavor `{other}` — upgrade \ @@ -467,6 +496,7 @@ mod tests { assert_eq!(NpmLockFlavor::PackageLock.as_str(), "package-lock"); assert_eq!(NpmLockFlavor::YarnClassic.as_str(), "yarn-classic"); assert_eq!(NpmLockFlavor::Pnpm.as_str(), "pnpm"); + assert_eq!(NpmLockFlavor::PnpmLegacy.as_str(), "pnpm-legacy"); assert_eq!(NpmLockFlavor::Bun.as_str(), "bun"); } @@ -578,12 +608,21 @@ mod tests { assert_eq!(flavor, NpmLockFlavor::Pnpm, "{head}"); } - // Older version: named in the error. + // Legacy grammars route to the legacy backend: pnpm 7's bare-float + // 5.4 and pnpm 8's quoted '6.0' (their own spellings, captured). + for head in ["lockfileVersion: 5.4", "lockfileVersion: '6.0'"] { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "pnpm-lock.yaml", &format!("{head}\n")).await; + let (flavor, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::PnpmLegacy, "{head}"); + } + + // Pre-allowlist version (pnpm 6's 5.3): named in the error. let tmp = tempfile::tempdir().unwrap(); - touch(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '6.0'\n").await; + touch(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: 5.3\n").await; let (code, detail) = detect_npm_lock_flavor(tmp.path()).await.unwrap_err(); assert_eq!(code, "vendor_lockfile_version_unsupported"); - assert!(detail.contains("6.0"), "{detail}"); + assert!(detail.contains("5.3"), "{detail}"); assert!(detail.contains("pnpm >= 9"), "{detail}"); // No version line in the head at all. @@ -893,6 +932,7 @@ mod tests { Some("yarn-classic".to_string()), Some("yarn-berry".to_string()), Some("pnpm".to_string()), + Some("pnpm-legacy".to_string()), Some("bun".to_string()), ] { entry.flavor = flavor.clone(); diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 0926179f..66e6deae 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -368,6 +368,78 @@ pub async fn vendor_npm( done(result, Some(entry), warnings) } +/// FAIL-CLOSED revert guard for a ledger entry with NO wiring records, +/// shared by the four TEXTUAL npm-family backends (npm / yarn classic / +/// yarn berry / bun) — the flavor-parameterized sibling of +/// [`super::pnpm_lock::guard_unwired_revert`]. +/// +/// Such entries come out of `repair`'s no-ledger reconstruction (the +/// npm-family pre-vendor lock fragments are not offline-recoverable, so the +/// restored entry carries empty wiring). Revert has nothing to replay for +/// them — it cannot un-wire the lock — so removing the artifact while the +/// lockfile still resolves through it bricks every subsequent install +/// (ENOENT on the missing `file:` tarball), and used to do so silently. +/// The in-use probe is textual and EXACT for these flavors (the uuid dir +/// path appears iff some resolution still points at the artifact — see +/// [`super::npm_flavor::vendored_entry_in_use`]), over `lock_names` in the +/// caller's own precedence order (npm: shrinkwrap wins). Mentioned ⇒ +/// refuse; readable and provably absent ⇒ `None`, the caller's removal +/// proceeds unchanged; no readable lock ⇒ refuse, fail-closed (it may still +/// resolve through the artifact) UNLESS no lock file exists at all — a +/// missing lock cannot reference the artifact, matching the wired revert's +/// own missing-lock tolerance. +pub(super) async fn guard_unwired_textual_revert( + project_root: &Path, + entry_uuid: &str, + uuid_dir_rel: &str, + lock_names: &[&str], +) -> Option { + let locks = lock_names.join("/"); + let mentioned = + super::npm_flavor::lock_text_mentions_uuid(project_root, lock_names, entry_uuid).await; + let clause = match mentioned { + Some(false) => return None, + Some(true) => format!("{locks} still resolves through it"), + None => { + let mut unreadable = None; + for name in lock_names { + match tokio::fs::try_exists(project_root.join(name)).await { + Ok(false) => {} + // Fail-closed: a lock we cannot read may still resolve + // through the artifact. + _ => { + unreadable = Some(*name); + break; + } + } + } + // No lock file at all: nothing can reference the artifact. + let name = unreadable?; + format!("{name} exists but could not be read to prove it no longer references it") + } + }; + let detail = format!( + "refusing to remove {uuid_dir_rel}: the ledger entry records no pre-vendor wiring to \ + replay (it was likely reconstructed by `socket-patch repair`; the npm-family \ + pre-vendor lock fragments are not offline-recoverable) and {clause} — deleting the \ + artifact would make every subsequent install fail; run `socket-patch repair` to keep \ + the vendored artifact healthy, and revert by restoring the pre-vendor {locks} (or by \ + removing the dependency and re-locking) before re-running `vendor --revert`" + ); + Some(RevertOutcome { + success: false, + warnings: vec![VendorWarning::new( + "vendor_wiring_unknown_revert_blocked", + detail.clone(), + )], + error: Some(detail), + // Refusal, not a drift-skip: `kept_artifact` is never set on + // failure (the entry and artifact survive because the whole + // revert is blocked, not counted as a Skipped keep). + kept_artifact: false, + }) +} + /// Undo one vendored npm package: restore the recorded lock fragments and /// remove the artifact dir. pub async fn revert_npm(entry: &VendorEntry, project_root: &Path, dry_run: bool) -> RevertOutcome { @@ -379,6 +451,23 @@ pub async fn revert_npm(entry: &VendorEntry, project_root: &Path, dry_run: bool) Ok(d) => d, Err(outcome) => return outcome, }; + // Nothing to replay (a `repair`-reconstructed entry): the artifact may + // only be removed when the lock provably no longer resolves through it — + // otherwise refuse, fail-closed, instead of silently bricking installs. + // Runs before the dry-run return so a preview never advertises a revert + // the wet run refuses (same precedent as the uuid guard above). + if entry.wiring.is_empty() { + if let Some(blocked) = guard_unwired_textual_revert( + project_root, + &entry.uuid, + &uuid_dir_rel, + &[SHRINKWRAP, PACKAGE_LOCK], + ) + .await + { + return blocked; + } + } if dry_run { return RevertOutcome::ok(); } @@ -2072,6 +2161,105 @@ mod tests { ); } + // ── empty-wiring (reconstructed) revert guard ────────────────────────── + // Same brick as the pnpm backends' (empirically confirmed 2026-08-18): + // a `repair`-reconstructed entry carries no wiring records; revert used + // to remove the artifact dir unconditionally, leaving the lock resolving + // through a deleted tarball — every later `npm ci` failed ENOENT, and + // nothing said so. + + /// Reshape a vendored entry into what `repair`'s no-ledger + /// reconstruction persists: same uuid/artifact, EMPTY wiring. + async fn reconstructed_fixture() -> (Fixture, VendorEntry) { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + (fx, entry) + } + + /// With nothing to replay, revert must refuse (fail-closed) while the + /// lock still resolves through the artifact — in the dry-run preview + /// too (never advertise a revert the wet run refuses). + #[tokio::test] + async fn empty_wiring_revert_refuses_while_lock_resolves_through_artifact() { + let (fx, entry) = reconstructed_fixture().await; + let tgz_path = fx.root().join(fx.expected_rel_tgz()); + let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); + + for dry_run in [true, false] { + let outcome = revert_npm(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "dry_run={dry_run}: must refuse"); + let err = outcome.error.as_deref().unwrap_or_default(); + assert!(err.contains("socket-patch repair"), "{err}"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{:?}", + outcome.warnings + ); + assert!(tgz_path.exists(), "the artifact must survive the refusal"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_vendored, + "lock untouched" + ); + } + } + + /// The flip side: when the lock provably no longer resolves through the + /// artifact (re-locked away from it), the empty-wiring revert keeps its + /// pre-guard behavior and removes the genuinely orphaned artifact — + /// replaying nothing. A shrinkwrap wins the probe like it wins installs: + /// an uuid mention left behind in package-lock.json does not block. + #[tokio::test] + async fn empty_wiring_revert_removes_a_genuinely_orphaned_artifact() { + let (fx, entry) = reconstructed_fixture().await; + // npm installs from the shrinkwrap when both exist; the pre-vendor + // one carries no uuid reference while package-lock.json still does. + tokio::fs::write(fx.root().join(SHRINKWRAP), &fx.lock_bytes) + .await + .unwrap(); + let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); + + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "orphaned artifact dir removed" + ); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_vendored, + "empty wiring replays nothing" + ); + } + + /// Undeterminable lock (present but unreadable — not UTF-8): fail + /// closed — it may still resolve through the artifact. A lock that is + /// absent altogether cannot reference anything, so removal proceeds. + #[tokio::test] + async fn empty_wiring_revert_fails_closed_on_unreadable_lock() { + let (fx, entry) = reconstructed_fixture().await; + let tgz_path = fx.root().join(fx.expected_rel_tgz()); + + tokio::fs::write(fx.lock_path(), [0xff, 0xfe, b'x']) + .await + .unwrap(); + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(!outcome.success, "unreadable-lock revert must refuse"); + assert!(tgz_path.exists()); + + tokio::fs::remove_file(fx.lock_path()).await.unwrap(); + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!tgz_path.exists(), "no lock, no reference: removal is safe"); + } + #[tokio::test] async fn traversal_uuid_is_refused_before_any_write() { let mut fx = fixture().await; diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 6be64b42..3ff77087 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -79,9 +79,9 @@ const WS_SCAFFOLD_PACKAGES: [&str; 2] = ["packages:", " - '.'"]; const SUPPORTED_LOCK_VERSION: &str = "9.0"; /// Wiring kinds (the `WiringRecord.kind` discriminators this backend owns). -const KIND_PKG_OVERRIDE: &str = "pnpm_pkg_override"; +pub(super) const KIND_PKG_OVERRIDE: &str = "pnpm_pkg_override"; const KIND_WS_OVERRIDE: &str = "pnpm_ws_override"; -const KIND_LOCK_OVERRIDES: &str = "pnpm_lock_overrides"; +pub(super) const KIND_LOCK_OVERRIDES: &str = "pnpm_lock_overrides"; const KIND_LOCK_IMPORTER_DEP: &str = "pnpm_lock_importer_dep"; const KIND_LOCK_PACKAGE: &str = "pnpm_lock_package"; const KIND_LOCK_SNAPSHOT: &str = "pnpm_lock_snapshot"; @@ -194,8 +194,7 @@ pub async fn vendor_pnpm( if let Err(detail) = check_lock_override(&lines, name, version, &effective_key) { return refused("vendor_override_conflict", detail); } - if let Err(detail) = - check_workspace_override(ws_text.as_deref(), name, version, &effective_key) + if let Err(detail) = check_workspace_override(ws_text.as_deref(), name, version, &effective_key) { return refused("vendor_override_conflict", detail); } @@ -282,11 +281,11 @@ pub async fn vendor_pnpm( // The pnpm >= 11 override surface. Mirrors the package.json override // key-for-key so whichever surface the installed pnpm reads matches the // lock's `overrides:` section. - let ws_edit = match apply_workspace_override(ws_text.as_deref(), &effective_key, &spec, &mut wiring) - { - Ok(edit) => edit, - Err(e) => return done_failure(purl, format!("{PNPM_WORKSPACE} surgery failed: {e}")), - }; + let ws_edit = + match apply_workspace_override(ws_text.as_deref(), &effective_key, &spec, &mut wiring) { + Ok(edit) => edit, + Err(e) => return done_failure(purl, format!("{PNPM_WORKSPACE} surgery failed: {e}")), + }; if !pkg_changed && !lock_changed && ws_edit.new_text.is_none() { // Everything already carries this uuid + the packed integrity: the @@ -402,6 +401,60 @@ pub async fn pnpm_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Opti Some(false) } +/// FAIL-CLOSED revert guard for a ledger entry with NO wiring records, +/// shared by both pnpm backends. +/// +/// Such entries come out of `repair`'s no-ledger reconstruction (the +/// npm-family pre-vendor lock fragments are not offline-recoverable, so the +/// restored entry carries empty wiring). Revert has nothing to replay for +/// them — it cannot un-wire the lock — so removing the artifact while +/// `pnpm-lock.yaml` still resolves through it bricks every subsequent +/// install (ENOENT on the missing `file:` tarball), and used to do so +/// silently. `in_use` is the calling backend's own lock probe result +/// ([`pnpm_entry_in_use`] / its legacy twin): `Some(true)` refuses; +/// `Some(false)` (provably orphaned) returns `None` and the caller's +/// removal proceeds unchanged; undeterminable (`None`) refuses too UNLESS +/// the lock is absent altogether — a missing lock cannot reference the +/// artifact, matching the wired revert's own missing-lock tolerance. +pub(super) async fn guard_unwired_revert( + project_root: &Path, + in_use: Option, + uuid_dir_rel: &str, +) -> Option { + let clause = match in_use { + Some(false) => return None, + Some(true) => format!("{PNPM_LOCK} still resolves through it"), + None => match tokio::fs::try_exists(project_root.join(PNPM_LOCK)).await { + Ok(false) => return None, + // Fail-closed: a lock we cannot read or parse may still + // resolve through the artifact. + _ => format!( + "{PNPM_LOCK} exists but could not be parsed to prove it no longer references it" + ), + }, + }; + let detail = format!( + "refusing to remove {uuid_dir_rel}: the ledger entry records no pre-vendor wiring to \ + replay (it was likely reconstructed by `socket-patch repair`; pnpm's pre-vendor lock \ + fragments are not offline-recoverable) and {clause} — deleting the artifact would make \ + every subsequent install fail; run `socket-patch repair` to keep the vendored artifact \ + healthy, and revert by restoring the pre-vendor {PNPM_LOCK}/{PACKAGE_JSON} (or by \ + removing the override and re-locking) before re-running `vendor --revert`" + ); + Some(RevertOutcome { + success: false, + warnings: vec![VendorWarning::new( + "vendor_wiring_unknown_revert_blocked", + detail.clone(), + )], + error: Some(detail), + // Refusal, not a drift-skip: `kept_artifact` is never set on + // failure (the entry and artifact survive because the whole + // revert is blocked, not counted as a Skipped keep). + kept_artifact: false, + }) +} + /// Undo one pnpm-vendored package: restore the recorded pair fragments and /// remove the artifact dir. Reverse application order; per-record ownership /// is re-checked against the live fragment (drift ⇒ warning, left alone). @@ -413,6 +466,17 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool Ok(d) => d, Err(outcome) => return outcome, }; + // Nothing to replay (a `repair`-reconstructed entry): the artifact may + // only be removed when the lock provably no longer resolves through it — + // otherwise refuse, fail-closed, instead of silently bricking installs. + // Runs before the dry-run return so a preview never advertises a revert + // the wet run refuses (same precedent as the uuid guard above). + if entry.wiring.is_empty() { + let in_use = pnpm_entry_in_use(entry, project_root).await; + if let Some(blocked) = guard_unwired_revert(project_root, in_use, &uuid_dir_rel).await { + return blocked; + } + } if dry_run { return RevertOutcome::ok(); } @@ -573,7 +637,10 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool .find(|r| r.file == PNPM_WORKSPACE && r.kind == KIND_WS_OVERRIDE) { let (created_file, created_overrides) = match &entry.pnpm { - Some(meta) => (meta.created_workspace_file, meta.created_workspace_overrides), + Some(meta) => ( + meta.created_workspace_file, + meta.created_workspace_overrides, + ), None => (false, false), }; if let Err(e) = revert_workspace( @@ -716,7 +783,11 @@ fn revert_ws_record( } match rec.original.as_ref().and_then(Value::as_str) { Some(orig) => { - lines[i] = format!("{}{}: {orig}", " ".repeat(indent), yaml_key_like(key, &repr)); + lines[i] = format!( + "{}{}: {orig}", + " ".repeat(indent), + yaml_key_like(key, &repr) + ); } None => { lines.remove(i); @@ -813,7 +884,11 @@ impl EditCtx<'_> { // ─────────────────────────── pre-flight checks ─────────────────────────── /// `lockfileVersion: '9.0'` head check (accept pnpm's single quotes plus -/// double-quoted/bare spellings). Also serves as the flavor router's sniff. +/// double-quoted/bare spellings) — the v9 BACKEND's own guard. The flavor +/// router sniffs with [`super::pnpm_lock_legacy::sniff_lock_grammar`] +/// instead, whose allowlist also routes the legacy 5.4/6.0 grammars to +/// their backend; this check only fires if a non-9.0 lock reaches +/// `vendor_pnpm` directly. pub(super) fn check_lock_version(text: &str) -> Result<(), String> { let version = text .lines() @@ -822,10 +897,25 @@ pub(super) fn check_lock_version(text: &str) -> Result<(), String> { .map(|rest| rest.trim().trim_matches(['\'', '"']).to_string()); match version { Some(v) if v == SUPPORTED_LOCK_VERSION => Ok(()), - Some(v) => Err(format!( - "{PNPM_LOCK} has lockfileVersion {v}; only {SUPPORTED_LOCK_VERSION} is \ - supported — re-lock with pnpm >= 9" - )), + Some(v) => { + // The remedy must point the right way: 5.x (pnpm 7) / 6.x + // (pnpm 8) locks predate the v9 grammar and upgrading pnpm + // re-locks them, but a HIGHER version means the user's pnpm + // already outgrew this build — telling them "re-lock with + // pnpm >= 9" would loop them back to the lock they have. + let major = v.split('.').next().and_then(|m| m.parse::().ok()); + Err(match major { + Some(m) if m < 9 => format!( + "{PNPM_LOCK} has lockfileVersion {v}; only {SUPPORTED_LOCK_VERSION} is \ + supported — re-lock with pnpm >= 9" + ), + _ => format!( + "{PNPM_LOCK} has lockfileVersion {v}; this socket-patch build supports \ + lockfileVersion {SUPPORTED_LOCK_VERSION} — re-lock with a pnpm release \ + that emits it, or update socket-patch" + ), + }) + } None => Err(format!( "{PNPM_LOCK} has no lockfileVersion in its head; only \ {SUPPORTED_LOCK_VERSION} is supported — re-lock with pnpm >= 9" @@ -836,7 +926,7 @@ pub(super) fn check_lock_version(text: &str) -> Result<(), String> { /// The package-name component of a pnpm override key /// (`[@scope/]name[@range]`, possibly behind a `parent>child` selector /// chain — the override targets the LAST segment). -fn override_key_name(key: &str) -> &str { +pub(super) fn override_key_name(key: &str) -> &str { let last = key.rsplit('>').next().unwrap_or(key).trim(); if let Some(rest) = last.strip_prefix('@') { match rest.find('@') { @@ -852,7 +942,7 @@ fn override_key_name(key: &str) -> &str { } /// Does `value` point into `.socket/vendor/npm/` (ours — any uuid)? -fn is_vendor_value(value: &str) -> bool { +pub(super) fn is_vendor_value(value: &str) -> bool { parse_vendor_path(value).is_some_and(|p| p.eco == "npm") } @@ -860,7 +950,7 @@ fn is_vendor_value(value: &str) -> bool { /// The leaf binding matters: a project can vendor the same package at /// several versions, and edits must never treat a SIBLING version's /// override/entry as their own. -fn vendor_value_is_for(value: &str, name: &str, version: &str) -> bool { +pub(super) fn vendor_value_is_for(value: &str, name: &str, version: &str) -> bool { parse_vendor_path(value) .is_some_and(|p| p.eco == "npm" && p.leaf == tgz_rel_leaf(name, version)) } @@ -870,7 +960,7 @@ fn vendor_value_is_for(value: &str, name: &str, version: &str) -> bool { /// 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. -enum OverrideDisposition { +pub(super) enum OverrideDisposition { /// No same-name key: insert our canonical `name@version` key. Insert, /// A same-name key already points into `.socket/vendor/npm/` — ours @@ -891,7 +981,7 @@ enum OverrideDisposition { impl OverrideDisposition { /// The override key both surfaces edit: the matched existing key, or /// our canonical `name@version` on a fresh insert. - fn effective_key<'a>(&'a self, our_key: &'a str) -> &'a str { + pub(super) fn effective_key<'a>(&'a self, our_key: &'a str) -> &'a str { match self { OverrideDisposition::Insert => our_key, OverrideDisposition::Ours { key } | OverrideDisposition::Takeover { key } => key, @@ -904,7 +994,7 @@ impl OverrideDisposition { /// a range/different-version value, a `parent>child` selector chain /// (scoped to one dependent — our whole-graph rewrite has different /// semantics), a non-string value, or several same-name keys. -fn classify_pkg_override( +pub(super) fn classify_pkg_override( pkg: &Value, name: &str, version: &str, @@ -963,7 +1053,7 @@ 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. -fn check_lock_override( +pub(super) fn check_lock_override( lines: &[String], name: &str, version: &str, @@ -1164,7 +1254,7 @@ fn check_rewritable_refs(lines: &[String], name: &str, version: &str) -> Result< /// Add/refresh `pnpm.overrides[@] = file:` on the /// parsed (preserve_order) document. Returns /// `(changed, created_pnpm_table, created_overrides_table)`. -fn apply_pkg_override( +pub(super) fn apply_pkg_override( pkg: &mut Value, our_key: &str, spec: &str, @@ -1359,7 +1449,10 @@ fn apply_workspace_override( lines[i] = format!("{pad}{}: {spec}", yaml_key_like(our_key, &repr)); wiring.push(ws_record(our_key, spec, WiringAction::Rewritten, original)); } else { - lines.insert(last_entry + 1, format!("{pad}{}: {spec}", yaml_key(our_key))); + lines.insert( + last_entry + 1, + format!("{pad}{}: {spec}", yaml_key(our_key)), + ); wiring.push(ws_record(our_key, spec, WiringAction::Added, None)); } return Ok(WorkspaceEdit { @@ -1379,7 +1472,10 @@ fn apply_workspace_override( .unwrap_or(lines.len()); lines.splice( anchor..anchor, - ["overrides:".to_string(), format!(" {}: {spec}", yaml_key(our_key))], + [ + "overrides:".to_string(), + format!(" {}: {spec}", yaml_key(our_key)), + ], ); wiring.push(ws_record(our_key, spec, WiringAction::Added, None)); Ok(WorkspaceEdit { @@ -1474,7 +1570,7 @@ fn edit_overrides( Ok(true) } -fn overrides_record( +pub(super) fn overrides_record( key: &str, spec: &str, action: WiringAction, @@ -1796,7 +1892,7 @@ fn edit_snapshot_refs( // ───────────────────────────── revert helpers ───────────────────────────── -fn revert_pkg_record( +pub(super) fn revert_pkg_record( doc: &mut Value, rec: &WiringRecord, entry_uuid: &str, @@ -1898,7 +1994,7 @@ fn revert_lock_record( } } -fn revert_overrides_line( +pub(super) fn revert_overrides_line( lines: &mut Vec, rec: &WiringRecord, key: &str, @@ -2209,7 +2305,7 @@ fn revert_snapshot_ref( ))); } -fn drifted(detail: impl Into) -> VendorWarning { +pub(super) fn drifted(detail: impl Into) -> VendorWarning { VendorWarning::new("vendor_lock_entry_drifted", detail.into()) } @@ -2219,7 +2315,7 @@ fn drifted(detail: impl Into) -> VendorWarning { /// the lock LAST; a lock failure unwinds both override surfaces so the P3 /// desync (an override with no matching lock entry, which pnpm silently /// unpatches or rejects as a config mismatch) is never left on disk. -async fn commit_surfaces( +pub(super) async fn commit_surfaces( project_root: &Path, new_pkg: Option<&[u8]>, original_pkg: &[u8], @@ -2362,7 +2458,7 @@ pub(super) fn next_block(lines: &[String], mut i: usize, end: usize) -> Option usize { +pub(super) fn indent_of(line: &str) -> usize { line.len() - line.trim_start_matches(' ').len() } @@ -2371,7 +2467,7 @@ fn indent_of(line: &str) -> usize { /// and both quote styles (single quotes are what pnpm emits for `@`-leading /// keys); the value separator is the first `:` followed by a space or EOL /// (keys themselves contain `:` in `file:` specs). -fn parse_key_line(line: &str, indent: usize) -> Option<(String, String, String)> { +pub(super) fn parse_key_line(line: &str, indent: usize) -> Option<(String, String, String)> { if line.len() <= indent || !line.as_bytes()[..indent].iter().all(|&b| b == b' ') { return None; } @@ -2422,7 +2518,7 @@ fn unquote_value(value: &str) -> &str { /// pnpm quotes `@`-leading keys with single quotes; everything we write is /// otherwise bare. -fn yaml_key(key: &str) -> String { +pub(super) fn yaml_key(key: &str) -> String { if key.starts_with('@') { format!("'{key}'") } else { @@ -2431,7 +2527,7 @@ fn yaml_key(key: &str) -> String { } /// Re-spell `key` in the same quoting style as the original `repr`. -fn yaml_key_like(key: &str, original_repr: &str) -> String { +pub(super) fn yaml_key_like(key: &str, original_repr: &str) -> String { match original_repr.as_bytes().first() { Some(b'\'') => format!("'{key}'"), Some(b'"') => format!("\"{key}\""), @@ -2439,11 +2535,11 @@ fn yaml_key_like(key: &str, original_repr: &str) -> String { } } -fn lines_value(lines: &[String]) -> Value { +pub(super) fn lines_value(lines: &[String]) -> Value { Value::Array(lines.iter().map(|l| Value::String(l.clone())).collect()) } -fn value_lines(v: &Value) -> Option> { +pub(super) fn value_lines(v: &Value) -> Option> { v.as_array().map(|a| { a.iter() .filter_map(Value::as_str) @@ -3011,6 +3107,109 @@ snapshots: assert_eq!(pnpm_entry_in_use(&entry, fx.root()).await, None); } + // ── empty-wiring (reconstructed) revert guard ────────────────────────── + + /// Vendor, then reshape the entry into what `repair`'s no-ledger + /// reconstruction persists: same uuid/artifact, EMPTY wiring, no meta. + async fn reconstructed_fixture() -> (Fixture, VendorEntry) { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + entry.pnpm = None; + (fx, entry) + } + + /// P1 regression (empirically confirmed vs a real pnpm@10.34.5 project, + /// 2026-08-18): a `repair`-reconstructed entry carries no wiring + /// records; revert used to remove the artifact dir unconditionally, + /// leaving the lock resolving through a deleted tarball — every later + /// install failed ENOENT, and nothing said so. With nothing to replay, + /// revert must refuse (fail-closed) while the lock still resolves + /// through the artifact. + #[tokio::test] + async fn empty_wiring_revert_refuses_while_lock_resolves_through_artifact() { + let (fx, entry) = reconstructed_fixture().await; + let tgz_path = fx.root().join(fx.rel_tgz()); + let lock_before = fx.read(PNPM_LOCK).await; + + // The dry-run preview must refuse too (same precedent as the uuid + // grammar guard: never advertise a revert the wet run refuses). + for dry_run in [true, false] { + let outcome = revert_pnpm(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "dry_run={dry_run}: must refuse"); + let err = outcome.error.as_deref().unwrap_or_default(); + assert!(err.contains("socket-patch repair"), "{err}"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{:?}", + outcome.warnings + ); + assert!(tgz_path.exists(), "the artifact must survive the refusal"); + assert_eq!(fx.read(PNPM_LOCK).await, lock_before, "lock untouched"); + } + } + + /// The flip side: when the lock provably no longer resolves through the + /// artifact (dependency removed and re-locked; only the mirrored + /// overrides declaration lingers), the empty-wiring revert keeps its + /// pre-guard behavior and removes the genuinely orphaned artifact. + #[tokio::test] + async fn empty_wiring_revert_removes_a_genuinely_orphaned_artifact() { + let (fx, entry) = reconstructed_fixture().await; + let removed_lock = format!( + "lockfileVersion: '9.0'\n\noverrides:\n left-pad@1.3.0: file:{}\n\nimporters:\n\n \ + .:\n dependencies:\n consumer:\n specifier: file:./consumer\n \ + version: file:consumer\n\npackages:\n\n consumer@file:consumer:\n \ + resolution: {{directory: consumer, type: directory}}\n\nsnapshots:\n\n \ + consumer@file:consumer: {{}}\n", + fx.rel_tgz() + ); + tokio::fs::write(fx.root().join(PNPM_LOCK), &removed_lock) + .await + .unwrap(); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "orphaned artifact dir removed" + ); + assert_eq!( + fx.read(PNPM_LOCK).await, + removed_lock, + "empty wiring replays nothing" + ); + } + + /// Undeterminable lock (present but unsupported grammar): fail closed — + /// it may still resolve through the artifact. A lock that is absent + /// altogether cannot reference anything, so removal proceeds. + #[tokio::test] + async fn empty_wiring_revert_fails_closed_on_undeterminable_lock() { + let (fx, entry) = reconstructed_fixture().await; + let tgz_path = fx.root().join(fx.rel_tgz()); + + tokio::fs::write(fx.root().join(PNPM_LOCK), "lockfileVersion: '6.0'\n") + .await + .unwrap(); + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(!outcome.success, "unparseable-lock revert must refuse"); + assert!(tgz_path.exists()); + + tokio::fs::remove_file(fx.root().join(PNPM_LOCK)) + .await + .unwrap(); + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!tgz_path.exists(), "no lock, no reference: removal is safe"); + } + // ── exact-version pin takeover ───────────────────────────────────────── /// package.json with a user-authored override pin (`key: value`) plus the @@ -4171,6 +4370,61 @@ snapshots: ); } + /// The unsupported-version remedy must point the right way. The router + /// sniff is now the ALLOWLIST `sniff_lock_grammar` (5.4 / 6.0 / 9.0 — + /// the legacy grammars route to their own backend instead of refusing), + /// so the remedy assertions moved onto it: pre-allowlist locks (pnpm + /// <= 6's 5.x line) are fixed by upgrading pnpm, but a FUTURE lock + /// version means pnpm already outgrew this build — "re-lock with + /// pnpm >= 9" would loop those users back to the lock they have. + #[test] + fn lock_version_remedy_is_version_aware() { + use super::super::pnpm_lock_legacy::{sniff_lock_grammar, PnpmLockGrammar}; + + assert!(check_lock_version("lockfileVersion: '9.0'\n").is_ok()); + assert_eq!( + sniff_lock_grammar("lockfileVersion: '9.0'\n"), + Ok(PnpmLockGrammar::V9) + ); + + // The legacy grammars are ALLOWLISTED now (pnpm 7's bare-float 5.4, + // pnpm 8's quoted 6.0): the router routes them to the legacy + // backend rather than refusing. + assert_eq!( + sniff_lock_grammar("lockfileVersion: 5.4\n"), + Ok(PnpmLockGrammar::V54) + ); + assert_eq!( + sniff_lock_grammar("lockfileVersion: '6.0'\n"), + Ok(PnpmLockGrammar::V60) + ); + // …while the v9 BACKEND's own guard still holds them out (they can + // only reach `vendor_pnpm` through a router bug). + for head in ["lockfileVersion: 5.4\n", "lockfileVersion: '6.0'\n"] { + assert!(check_lock_version(head).is_err(), "{head}"); + } + + // Too old for the allowlist (pnpm 6's 5.3): upgrade pnpm. + let err = sniff_lock_grammar("lockfileVersion: 5.3\n").unwrap_err(); + assert!(err.contains("re-lock with pnpm >= 9"), "{err}"); + + // Future versions: the fix is an allowlisted-emitting pnpm or a + // newer build, never "pnpm >= 9" (the user's pnpm already is). + for head in ["lockfileVersion: '9.1'\n", "lockfileVersion: '10.0'\n"] { + let err = sniff_lock_grammar(head).unwrap_err(); + assert!(err.contains("update socket-patch"), "{err}"); + assert!(!err.contains("re-lock with pnpm >= 9"), "{err}"); + // The v9 backend's guard agrees on the direction. + let err = check_lock_version(head).unwrap_err(); + assert!(err.contains("update socket-patch"), "{err}"); + } + + // No version in the head: unrecognizably old — upgrade advice stands. + let err = sniff_lock_grammar("importers:\n").unwrap_err(); + assert!(err.contains("re-lock with pnpm >= 9"), "{err}"); + assert!(check_lock_version("importers:\n").is_err()); + } + #[test] fn override_key_name_grammar() { assert_eq!(override_key_name("left-pad"), "left-pad"); @@ -4256,7 +4510,10 @@ snapshots: #[tokio::test] async fn workspace_file_is_created_with_root_scaffold_and_revert_deletes_it() { let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; - assert!(!ws_exists(&fx).await, "fixture starts with no workspace file"); + assert!( + !ws_exists(&fx).await, + "fixture starts with no workspace file" + ); let (_, entry, _) = expect_done(fx.vendor(false).await); let entry = entry.unwrap(); @@ -4267,9 +4524,10 @@ snapshots: "created workspace carries `packages: ['.']` + the override" ); // The three surfaces agree on the same key → value (no config mismatch). - assert!(fx.read(PNPM_WORKSPACE).await.contains(&format!( - "overrides:\n left-pad@1.3.0: {spec}" - ))); + assert!(fx + .read(PNPM_WORKSPACE) + .await + .contains(&format!("overrides:\n left-pad@1.3.0: {spec}"))); assert!(fx .read(PNPM_LOCK) .await @@ -4393,7 +4651,10 @@ snapshots: assert!(pnpm_meta.created_pnpm_table && pnpm_meta.created_overrides_table); assert!(prev.wiring.iter().any(|r| r.file == PACKAGE_JSON)); assert!(prev.wiring.iter().any(|r| r.file == PNPM_LOCK)); - assert!(!ws_exists(&fx).await, "downgraded state has no workspace file"); + assert!( + !ws_exists(&fx).await, + "downgraded state has no workspace file" + ); // 2. Re-vendor under the current code: package.json + lock are already // in sync, so ONLY the workspace mirror is written and the fresh @@ -4420,7 +4681,11 @@ snapshots: P1_BEFORE_PKG, "package.json byte-restored" ); - assert_eq!(fx.read(PNPM_LOCK).await, P1_BEFORE_LOCK, "lock byte-restored"); + assert_eq!( + fx.read(PNPM_LOCK).await, + P1_BEFORE_LOCK, + "lock byte-restored" + ); assert!( !ws_exists(&fx).await, "the workspace file the re-vendor created is deleted" diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs new file mode 100644 index 00000000..17b03197 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -0,0 +1,2702 @@ +//! pnpm LEGACY vendor backend: the pre-9 lock grammars — `lockfileVersion: +//! 5.4` (pnpm 7) and `'6.0'` (pnpm 8) — wired through the same +//! `package.json pnpm.overrides` + `pnpm-lock.yaml` pair surgery as the v9 +//! backend ([`super::pnpm_lock`]), with the legacy serialization shapes. +//! +//! Every splice below is a faithful port of REAL captured pnpm output +//! (spike `matrix/vendor-legacy-spike/{p7,p8,t7,t8}`, pnpm 7.33.5 / +//! 8.15.9, 2026-08-18): a `file:` tarball override was added to +//! `package.json` and THAT pnpm's own `install` re-serialized the lock; the +//! unit-test fixtures quote those locks verbatim. Both majors were also +//! proven byte-stable across an install re-run of the captured shape. +//! +//! ## The legacy shapes (vs the v9 grammar) +//! +//! 1. `overrides:` — same `@: file:` entry, but the +//! section sits after `lockfileVersion:`/`settings:` (pnpm's +//! ROOT_KEYS_ORDER puts `overrides` at priority 4, before +//! `specifiers:`/`dependencies:`/`packages:`). +//! 2. root dependency — v5.4 keeps flat top-level maps (`specifiers:` + +//! `dependencies:`/`devDependencies:`/`optionalDependencies:` with bare +//! values); v6.0 nests `specifier:`/`version:` under each dep. The +//! resolved value moves to `file:` in both. +//! 3. the SPECIFIER is ABSOLUTE: pnpm <= 8 absolutizes `file:` override +//! prefs against the project root before recording them (verified in the +//! bundled `createVersionsOverrider`: `path.join(rootDir, pkgPath)`), so +//! the captured locks carry `file:/abs/project/.socket/...`. This makes +//! the frozen check path-bound: `pnpm install --frozen-lockfile` only +//! passes in a checkout at that exact absolute path (spike probes A/B), +//! while a `pnpm install --offline --no-frozen-lockfile` at any path installs the +//! patched tarball and re-resolves only that specifier line (probe C). +//! Vendoring writes the absolute spelling pnpm itself emits and surfaces +//! the portability limit as `vendor_pnpm_legacy_absolute_specifier`. +//! 4. `packages:` — the registry entry (`/name/version` in v5.4, +//! `/name@version` in v6.0) is REKEYED to the bare `file:` key +//! (no `name@` prefix, unlike v9), its `resolution:` replaced with +//! `{integrity: , tarball: file:}`, `name:`/`version:` +//! lines inserted after it (legacy registry entries derive both from the +//! key; file: entries spell them out), and `deprecated:` dropped — +//! everything else (`dev: false`, engines, …) verbatim. The rekey MOVES +//! the block to its byte-sorted position (pnpm sorts package keys with +//! the default code-unit compare; `/`-keys sort before `file:`-keys). +//! 5. other packages' `dependencies:`/`optionalDependencies:` refs to the +//! exact version become `name: file:`. +//! +//! No `pnpm-workspace.yaml` is written for legacy locks: pnpm <= 8 reads +//! overrides ONLY from package.json `pnpm.overrides` (proven by the spike — +//! the override applied with no workspace file present), and creating one +//! would flip the project into workspace mode. Legacy WORKSPACE locks +//! (an `importers:` section) are refused fail-closed: the flat-map surgery +//! has no captured fixtures for them. +//! +//! Same commit discipline as v9: package.json first, lock second, unwind on +//! a lock write failure; wiring fragments recorded for byte-identical +//! revert; peer-suffixed / aliased reference spellings refuse before any +//! write. + +use std::path::Path; + +use serde_json::Value; + +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::PatchSources; +use crate::patch::copy_tree::remove_tree; +use crate::utils::fs::atomic_write_bytes_preserving_mode; + +use super::common::{already_patched_result, detect_indent, done, refused, serialize_json}; +use super::npm_common::{ + done_failure, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, +}; +use super::path::parse_vendor_path; +use super::pnpm_lock::{ + apply_pkg_override, check_lock_override, classify_pkg_override, commit_surfaces, drifted, + guard_unwired_revert, lines_value, next_block, overrides_record, parse_key_line, + revert_overrides_line, revert_pkg_record, section_bounds, split_lines, value_lines, + vendor_value_is_for, yaml_key, yaml_key_like, KIND_LOCK_OVERRIDES, +}; +use super::state::{ + write_marker, PnpmMeta, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, +}; +use super::{RevertOutcome, VendorOutcome, VendorWarning}; + +const PACKAGE_JSON: &str = "package.json"; +const PNPM_LOCK: &str = "pnpm-lock.yaml"; + +/// The [`VendorEntry::flavor`] string legacy wirings are stamped with. +/// Distinct from the v9 backend's `"pnpm"` so an older binary (which has no +/// legacy backend) fails CLOSED on revert instead of misreading the records. +pub(super) const FLAVOR: &str = "pnpm-legacy"; + +/// Wiring kinds. `pnpm_pkg_override`/`pnpm_lock_overrides` are shared with +/// the v9 backend (identical fragment shapes); the rest are legacy-only. +const KIND_LOCK_SPECIFIER: &str = "pnpm_lock_specifier"; +const KIND_LOCK_ROOT_DEP: &str = "pnpm_lock_root_dep"; +const KIND_LOCK_ROOT_DEP_PAIR: &str = "pnpm_lock_root_dep_pair"; +const KIND_LOCK_PACKAGE: &str = "pnpm_lock_package"; +const KIND_LOCK_PKG_DEP_REF: &str = "pnpm_lock_pkg_dep_ref"; + +/// SECURITY: same rule as the v9 backend — revert writes are restricted to +/// exactly the pair vendor edits (legacy never touches pnpm-workspace.yaml). +const REVERT_ALLOWLIST: [&str; 2] = [PNPM_LOCK, PACKAGE_JSON]; + +/// The flat root-dependency sections a v5.4/v6.0 single-package lock keys +/// its direct deps under. +const ROOT_DEP_SECTIONS: [&str; 3] = ["dependencies", "devDependencies", "optionalDependencies"]; + +/// Top-level keys that sort BEFORE `overrides:` (pnpm's ROOT_KEYS_ORDER, +/// identical in the 7.33.5 and 8.15.9 bundles); the insert anchor is the +/// first section that is none of these. +const OVERRIDES_PRECEDING: [&str; 4] = [ + "lockfileVersion", + "settings", + "neverBuiltDependencies", + "onlyBuiltDependencies", +]; + +/// Normalize a `std::fs::canonicalize`d project-root path for embedding in +/// the pnpm <= 8 absolute `file:` override specifier. +/// +/// On Windows `canonicalize` returns a VERBATIM path — `\\?\C:\dir` (or +/// `\\?\UNC\server\share\dir` for network paths) with backslash separators +/// — a spelling pnpm itself never emits (its `path.join(rootDir, pkgPath)` +/// records `C:/dir`-shaped, forward-slashed specifiers). Embedding the +/// verbatim spelling makes the very next `pnpm install` re-serialize the +/// specifier line (lock churn) and `pnpm install --frozen-lockfile` fail +/// even in a checkout at the recorded path — contradicting the +/// `vendor_pnpm_legacy_absolute_specifier` warning. So: strip the verbatim +/// prefix (`\\?\` → drive path, `\\?\UNC\` → `\\`-rooted UNC path) and +/// forward-slash the separators of Windows-shaped inputs. +/// +/// Unix paths pass through BYTE-UNCHANGED — including any literal `\` in a +/// unix file name, which is only a separator on Windows-shaped inputs. +/// Pure string-level so the transformation is unit-testable on any host; +/// a real Windows CI leg should confirm pnpm 7/8's own emission spelling. +/// +/// `pub` because the e2e capstone's byte-exact lock oracle must build its +/// expected absolute specifier with THIS transformation — a hand-copied +/// oracle drifted on Windows the moment the real spelling differed. +pub fn normalize_canonical_root(path: &str) -> String { + /// Drive-letter (`C:\...` / `C:/...`) or UNC (`\\server\...`) shape. + fn is_windows_shaped(path: &str) -> bool { + let b = path.as_bytes(); + let drive_letter = b.len() >= 3 + && b[0].is_ascii_alphabetic() + && b[1] == b':' + && (b[2] == b'\\' || b[2] == b'/'); + drive_letter || path.starts_with(r"\\") + } + + if let Some(rest) = path.strip_prefix(r"\\?\UNC\") { + // Verbatim UNC: `\\?\UNC\server\share\dir` → `//server/share/dir`. + format!("//{}", rest.replace('\\', "/")) + } else if let Some(rest) = path.strip_prefix(r"\\?\") { + // Verbatim drive: `\\?\C:\dir` → `C:/dir`. + rest.replace('\\', "/") + } else if is_windows_shaped(path) { + path.replace('\\', "/") + } else { + path.to_string() + } +} + +// ───────────────────────────── grammar sniff ────────────────────────────── + +/// Which pnpm lock grammar a `pnpm-lock.yaml` head declares. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PnpmLockGrammar { + /// `lockfileVersion: '9.0'` — the [`super::pnpm_lock`] backend. + V9, + /// `lockfileVersion: 5.4` (pnpm 7, bare float spelling). + V54, + /// `lockfileVersion: '6.0'` (pnpm 8). + V60, +} + +/// The full vendor allowlist sniff (5.4 / 6.0 / 9.0) the flavor router +/// uses; anything else refuses with a version-aware remedy: pre-allowlist +/// versions (pnpm <= 6's 5.x line) are fixed by upgrading pnpm, but a +/// FUTURE version means the user's pnpm already outgrew this build — +/// looping them back to "re-lock with pnpm >= 9" would hand them the lock +/// they have. +pub(crate) fn sniff_lock_grammar(text: &str) -> Result { + let version = text + .lines() + .take(5) + .find_map(|line| line.strip_prefix("lockfileVersion:")) + .map(|rest| rest.trim().trim_matches(['\'', '"']).to_string()); + match version.as_deref() { + Some("9.0") => Ok(PnpmLockGrammar::V9), + Some("5.4") => Ok(PnpmLockGrammar::V54), + Some("6.0") => Ok(PnpmLockGrammar::V60), + Some(v) => { + let major = v.split('.').next().and_then(|m| m.parse::().ok()); + Err(match major { + Some(m) if m < 9 => format!( + "{PNPM_LOCK} has lockfileVersion {v}; supported versions are 5.4 \ + (pnpm 7), 6.0 (pnpm 8), and 9.0 (pnpm >= 9) — re-lock with pnpm >= 9" + ), + _ => format!( + "{PNPM_LOCK} has lockfileVersion {v}; this socket-patch build supports \ + lockfileVersions 5.4, 6.0, and 9.0 — re-lock with a pnpm release that \ + emits one of them, or update socket-patch" + ), + }) + } + None => Err(format!( + "{PNPM_LOCK} has no lockfileVersion in its head; supported versions are 5.4, \ + 6.0, and 9.0 — re-lock with pnpm >= 9" + )), + } +} + +impl PnpmLockGrammar { + /// Human name for diagnostics (`pnpm 7 (lockfileVersion 5.4)`). + fn describe(self) -> &'static str { + match self { + PnpmLockGrammar::V9 => "pnpm >= 9 (lockfileVersion 9.0)", + PnpmLockGrammar::V54 => "pnpm 7 (lockfileVersion 5.4)", + PnpmLockGrammar::V60 => "pnpm 8 (lockfileVersion 6.0)", + } + } +} + +// ───────────────────────────── edit context ────────────────────────────── + +struct Ctx<'a> { + grammar: PnpmLockGrammar, + name: &'a str, + version: &'a str, + /// `.socket/vendor/npm//` (forward slashes, root-relative). + rel_tgz: &'a str, + /// `file:` — override values, root-dep values, packages key. + spec: &'a str, + /// `file:/` — the SPECIFIER spelling pnpm + /// <= 8 itself emits (module doc §3). + abs_spec: &'a str, + /// `sha512-` of the packed tarball. + integrity: &'a str, + /// The override key both surfaces edit (canonical `name@version`, or a + /// taken-over user key). + override_key: &'a str, +} + +impl Ctx<'_> { + /// The registry packages key this grammar spells for `name@version`. + fn reg_key(&self) -> String { + match self.grammar { + PnpmLockGrammar::V54 => format!("/{}/{}", self.name, self.version), + _ => format!("/{}@{}", self.name, self.version), + } + } + + /// Our rekeyed packages key (`file:` — bare, no `name@`). + fn new_key(&self) -> String { + format!("file:{}", self.rel_tgz) + } + + /// Does `value` point at OUR vendored tarball for THIS name@version + /// (any uuid, relative or the machine-absolute specifier spelling — + /// `parse_vendor_path` anchors on `.socket/vendor/` anywhere in the + /// string)? + fn is_ours(&self, value: &str) -> bool { + vendor_value_is_for(value, self.name, self.version) + } + + /// The peer-suffix marker this grammar appends to a version/key + /// (`1.3.0_peer@1.0.0` in v5.4, `1.3.0(peer@1.0.0)` in v6.0). + fn peer_sep(&self) -> char { + match self.grammar { + PnpmLockGrammar::V54 => '_', + _ => '(', + } + } +} + +// ─────────────────────────────── vendor ─────────────────────────────────── + +/// Vendor one installed npm package into a pnpm 7/8 project. Same contract +/// as [`super::pnpm_lock::vendor_pnpm`]: refuse-early / wire-last, `entry` +/// present iff success and not a dry run, in-sync re-runs synthesize +/// AlreadyPatched. +#[allow(clippy::too_many_arguments)] +pub async fn vendor_pnpm_legacy( + purl: &str, + installed_dir: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + vendored_at: &str, + dry_run: bool, + force: bool, + service: Option<&super::VendorServiceConfig>, +) -> VendorOutcome { + let mut warnings: Vec = Vec::new(); + + // ── 1. Coordinates ──────────────────────────────────────────────────── + let coords = match guard_coordinates(purl, record) { + Ok(coords) => coords, + Err(outcome) => return *outcome, + }; + let (name, version) = (coords.name.as_str(), coords.version.as_str()); + let rel_tgz = format!("{}/{}", coords.uuid_dir_rel, tgz_rel_leaf(name, version)); + let spec = format!("file:{rel_tgz}"); + let override_key = format!("{name}@{version}"); + + // ── 2. Read the pair (refuse before any write) ─────────────────────── + let pkg_bytes = match tokio::fs::read(project_root.join(PACKAGE_JSON)).await { + Ok(bytes) => bytes, + Err(e) => { + return refused( + "vendor_lockfile_missing", + format!( + "cannot read {PACKAGE_JSON}: {e} — the pnpm wiring edits the \ + package.json + pnpm-lock.yaml PAIR (a lock-only edit silently \ + unpatches on the next plain `pnpm install`)" + ), + ); + } + }; + let mut pkg: Value = match serde_json::from_slice(&pkg_bytes) { + Ok(Value::Object(map)) => Value::Object(map), + Ok(_) | Err(_) => { + return refused( + "vendor_pkg_json_unsupported", + format!("{PACKAGE_JSON} is not a JSON object; cannot add pnpm.overrides"), + ); + } + }; + let lock_text = match tokio::fs::read_to_string(project_root.join(PNPM_LOCK)).await { + Ok(text) => text, + Err(e) => { + return refused( + "vendor_lockfile_missing", + format!("cannot read {PNPM_LOCK}: {e} — run `pnpm install` first"), + ); + } + }; + let grammar = match sniff_lock_grammar(&lock_text) { + Ok(PnpmLockGrammar::V9) => { + // Router bug guard: v9 locks belong to the v9 backend. + return refused( + "vendor_lockfile_version_unsupported", + format!("{PNPM_LOCK} is a lockfileVersion 9.0 lock; not a legacy grammar"), + ); + } + Ok(g) => g, + Err(detail) => return refused("vendor_lockfile_version_unsupported", detail), + }; + // CRLF fails closed exactly like the v9 backend: every structural probe + // below is byte-exact on LF lines. + if lock_text.contains('\r') { + return refused( + "vendor_lockfile_crlf_unsupported", + format!( + "{PNPM_LOCK} has CRLF line endings, which this rewriter cannot edit \ + byte-faithfully — normalize the file to LF (re-run `pnpm install`, \ + or add `pnpm-lock.yaml text eol=lf` to .gitattributes and re-checkout) \ + and retry" + ), + ); + } + let mut lines = split_lines(&lock_text); + + // Legacy WORKSPACE locks nest everything under `importers:` — a shape + // with no captured fixtures. Fail closed with the upgrade path. + if section_bounds(&lines, "importers").is_some() { + return refused( + "vendor_lock_entry_unsupported", + format!( + "{PNPM_LOCK} is a {} WORKSPACE lock (importers: section); the legacy \ + pair surgery only supports single-package locks — upgrade to pnpm >= 9 \ + (its lockfileVersion 9.0 workspace grammar is supported) and re-lock", + grammar.describe() + ), + ); + } + + // pnpm <= 8 absolutizes file: override prefs against the project root + // (module doc §3), so the specifier splice needs the canonical absolute + // path — the same one pnpm's own process.cwd() yields. On Windows, + // `canonicalize` returns a VERBATIM path (`\\?\C:\...`) that pnpm never + // emits; `normalize_canonical_root` rewrites it to the pnpm spelling so + // the embedded specifier doesn't churn the lock on the next install + // (which would fail `--frozen-lockfile`, contradicting the + // `vendor_pnpm_legacy_absolute_specifier` warning below). + let abs_root = match std::fs::canonicalize(project_root) { + Ok(p) => p, + Err(e) => { + return refused( + "vendor_lock_entry_unsupported", + format!( + "cannot canonicalize the project root ({e}) — the pnpm <= 8 lock \ + records the override specifier as an absolute path" + ), + ); + } + }; + let abs_root = normalize_canonical_root(&abs_root.display().to_string()); + let abs_spec = format!("file:{abs_root}/{rel_tgz}"); + + // ── 3. Pre-flight refusals ──────────────────────────────────────────── + let disposition = match classify_pkg_override(&pkg, name, version, &override_key) { + Ok(d) => d, + Err(detail) => return refused("vendor_override_conflict", detail), + }; + let effective_key = disposition.effective_key(&override_key).to_string(); + if let Err(detail) = check_lock_override(&lines, name, version, &effective_key) { + return refused("vendor_override_conflict", detail); + } + let ctx = Ctx { + grammar, + name, + version, + rel_tgz: &rel_tgz, + spec: &spec, + abs_spec: &abs_spec, + integrity: "", // filled after packing; pre-flight never reads it + override_key: &effective_key, + }; + // Refs guard FIRST: a peer-suffixed packages key is also not the plain + // registry key, and "entry not found" would misdiagnose that shape. + if let Err(detail) = check_rewritable_refs(&lines, &ctx) { + return refused("vendor_lock_entry_unsupported", detail); + } + if !lock_has_target_package(&lines, &ctx) { + return refused( + "vendor_lock_entry_not_found", + format!( + "{PNPM_LOCK} has no packages entry for {name}@{version} — make sure the \ + package is installed and locked (`pnpm install`) before vendoring" + ), + ); + } + + // ── 4. Stage → patch → pack ─────────────────────────────────────────── + let (staged, result) = match stage_patch_pack( + purl, + installed_dir, + project_root, + record, + sources, + dry_run, + force, + &mut warnings, + service, + ) + .await + { + Ok(pair) => pair, + Err(outcome) => return *outcome, + }; + let Some(staged) = staged else { + return done(result, None, warnings); + }; + debug_assert_eq!(staged.rel_tgz, rel_tgz); + let packed = staged.packed; + if staged.staged_pkg_json.is_some() { + // Legacy locks mirror the package's dependency maps inside its + // packages entry, preserved verbatim here — same caveat as v9. + warnings.push(VendorWarning::new( + "vendor_dep_manifest_stale", + format!( + "the patch rewrites {name}@{version}'s package.json; pnpm-lock.yaml's \ + dependency mirrors were preserved verbatim — if the patch changed \ + dependency ranges, run `pnpm install` to re-resolve them" + ), + )); + } + + // ── 5. Compute both edits in memory ────────────────────────────────── + let ctx = Ctx { + integrity: &packed.integrity, + ..ctx + }; + let mut wiring: Vec = Vec::new(); + + let (pkg_changed, created_pnpm_table, created_overrides_table) = + match apply_pkg_override(&mut pkg, &effective_key, &spec, &mut wiring) { + Ok(out) => out, + Err(e) => return done_failure(purl, e), + }; + + let mut lock_changed = false; + match edit_overrides(&mut lines, &ctx, &mut wiring) { + Ok(changed) => lock_changed |= changed, + Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + } + let root_edit = match grammar { + PnpmLockGrammar::V54 => edit_root_deps_v54(&mut lines, &ctx, &mut wiring), + _ => edit_root_deps_v60(&mut lines, &ctx, &mut wiring), + }; + let root_dep_hit = match root_edit { + Ok((changed, hit)) => { + lock_changed |= changed; + hit + } + Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + }; + if grammar == PnpmLockGrammar::V54 && root_dep_hit { + match edit_specifier_v54(&mut lines, &ctx, &mut wiring) { + Ok(changed) => lock_changed |= changed, + Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + } + } + for edit in [edit_packages, edit_pkg_dep_refs] { + match edit(&mut lines, &ctx, &mut wiring) { + Ok(changed) => lock_changed |= changed, + Err(e) => return done_failure(purl, format!("{PNPM_LOCK} surgery failed: {e}")), + } + } + + if !pkg_changed && !lock_changed { + return done( + already_patched_result(purl, &project_root.join(&rel_tgz), &record.files), + None, + warnings, + ); + } + + if root_dep_hit { + // The committable-artifact caveat this grammar cannot avoid + // (module doc §3) — surfaced every wiring run, not just the first. + warnings.push(VendorWarning::new( + "vendor_pnpm_legacy_absolute_specifier", + format!( + "{} records the override specifier as an absolute path \ + (pnpm <= 8 absolutizes file: overrides itself), so `pnpm install \ + --frozen-lockfile` only passes in a checkout at exactly \ + {} — checkouts at other paths must run `pnpm install --offline \ + --no-frozen-lockfile` once (the flag matters on CI, where pnpm \ + defaults --frozen-lockfile on), which installs the vendored \ + tarball and re-resolves only that specifier line", + grammar.describe(), + abs_root + ), + )); + } + + // ── 6. Commit: package.json first, lock second, unwind on failure ──── + let pkg_indent = detect_indent(&String::from_utf8_lossy(&pkg_bytes)); + let new_pkg_bytes = match serialize_json(&pkg, &pkg_indent) { + Ok(bytes) => bytes, + Err(e) => return done_failure(purl, format!("cannot serialize {PACKAGE_JSON}: {e}")), + }; + let lock_out = lines.join("\n"); + if let Err(e) = commit_surfaces( + project_root, + pkg_changed.then_some(new_pkg_bytes.as_slice()), + &pkg_bytes, + None, + None, + false, + lock_changed.then_some(lock_out.as_bytes()), + ) + .await + { + return done_failure(purl, e); + } + + // ── 7. Marker + ledger entry ────────────────────────────────────────── + let marker = VendorMarker::new("npm", &coords.base_purl, record, vendored_at); + if let Err(e) = write_marker(&project_root.join(&coords.uuid_dir_rel), &marker).await { + warnings.push(VendorWarning::new( + "vendor_marker_write_failed", + format!("could not write the informational vendor marker: {e}"), + )); + } + + let entry = VendorEntry { + ecosystem: "npm".to_string(), + base_purl: coords.base_purl, + uuid: record.uuid.clone(), + artifact: VendorArtifact { + path: rel_tgz, + sha256: packed.sha256_hex, + size: Some(packed.size), + platform_locked: None, + file_inventory: None, + }, + wiring, + lock: None, + took_over_go_patches: false, + detached: false, + record: None, + flavor: Some(FLAVOR.to_string()), + uv: None, + pnpm: Some(PnpmMeta { + created_overrides_table, + created_pnpm_table, + // Legacy never touches pnpm-workspace.yaml (module doc). + created_workspace_file: false, + created_workspace_overrides: false, + }), + poetry: None, + pdm: None, + pipenv: None, + }; + done(result, Some(entry), warnings) +} + +/// Is this legacy-vendored entry still consumed by the lock? `Some(true)` +/// when a `packages:` block is keyed by the entry's artifact path; +/// `Some(false)` when the lock parses as a legacy grammar and carries none +/// (the `overrides:` declaration alone never counts); `None` when +/// undeterminable — callers keep the entry, fail-safe. +pub async fn pnpm_legacy_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Option { + let text = tokio::fs::read_to_string(project_root.join(PNPM_LOCK)) + .await + .ok()?; + match sniff_lock_grammar(&text) { + Ok(PnpmLockGrammar::V54 | PnpmLockGrammar::V60) => {} + _ => return None, + } + let lines = split_lines(&text); + let Some((start, end)) = section_bounds(&lines, "packages") else { + return Some(false); + }; + let mut i = start + 1; + while let Some(block) = next_block(&lines, i, end) { + let ours = + parse_vendor_path(&block.key).is_some_and(|p| p.eco == "npm" && p.uuid == entry.uuid); + if ours { + return Some(true); + } + i = block.end; + } + Some(false) +} + +// ─────────────────────────── pre-flight checks ─────────────────────────── + +/// Does the lock have a packages entry vendoring can target — the grammar's +/// registry key, or our rekeyed `file:` key (the in-sync / stale-uuid +/// re-run)? +fn lock_has_target_package(lines: &[String], ctx: &Ctx<'_>) -> bool { + let Some((start, end)) = section_bounds(lines, "packages") else { + return false; + }; + let reg_key = ctx.reg_key(); + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + if block.key == reg_key || ctx.is_ours(&block.key) { + return true; + } + i = block.end; + } + false +} + +/// Fail-closed guard against legacy reference forms the surgery does not +/// rewrite: PEER-SUFFIXED dep paths (`/name/version_peer…` in v5.4, +/// `/name@version(peer…)` in v6.0) and ALIASED references (a dep whose +/// recorded value IS the registry dep path). Both would survive the rekey +/// verbatim and dangle — pnpm then hard-rejects the lock — and neither has +/// a pnpm-blessed fixture. Scans the root dep maps and every packages +/// block's dep maps. +fn check_rewritable_refs(lines: &[String], ctx: &Ctx<'_>) -> Result<(), String> { + let reg_key = ctx.reg_key(); + let key_peer_prefix = format!("{reg_key}{}", ctx.peer_sep()); + let val_peer_prefix = format!("{}{}", ctx.version, ctx.peer_sep()); + let refuse = |what: &str, spelling: &str| { + Err(format!( + "{PNPM_LOCK} references {}@{} through {what} (`{spelling}`) that the \ + pair surgery cannot rewrite — vendoring would leave a dangling reference \ + pnpm rejects; this lock shape is not supported yet", + ctx.name, ctx.version + )) + }; + // Root dep maps (v5.4 bare values; v6.0 version: fields). + for section in ROOT_DEP_SECTIONS { + let Some((start, end)) = section_bounds(lines, section) else { + continue; + }; + let mut k = start + 1; + while k < end { + let Some((dep, _repr, rest)) = parse_key_line(&lines[k], 2) else { + k += 1; + continue; + }; + let value = if rest.is_empty() { + // v6.0 nested shape: read the version: field. + let (_, ver, f) = dep_field_lines(lines, k + 1, end, 4); + k = f; + match ver { + Some((_, v)) => v, + None => continue, + } + } else { + k += 1; + rest + }; + if value == reg_key || value.starts_with(&key_peer_prefix) { + return refuse("an aliased root dependency", &value); + } + if dep == ctx.name && value.starts_with(&val_peer_prefix) { + return refuse("a peer-suffixed root dependency", &value); + } + } + } + // packages blocks: peer-suffixed keys + aliased/peer-suffixed dep refs. + if let Some((start, end)) = section_bounds(lines, "packages") { + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + if block.key.starts_with(&key_peer_prefix) { + return refuse("a peer-suffixed packages key", &block.key); + } + for line in &lines[block.header + 1..block.end] { + let Some((dep, _repr, rest)) = parse_key_line(line, 6) else { + continue; + }; + if rest == reg_key || rest.starts_with(&key_peer_prefix) { + return refuse("an aliased dependency reference", &rest); + } + if dep == ctx.name && rest.starts_with(&val_peer_prefix) { + return refuse("a peer-suffixed dependency reference", &rest); + } + } + i = block.end; + } + } + Ok(()) +} + +// ───────────────────────────── lock edits ───────────────────────────────── + +/// Locate a dep entry's `specifier:`/`version:` field lines at `indent` +/// starting at `f` (v6.0 root deps use 4; the v9 backend's importers use 8). +#[allow(clippy::type_complexity)] +fn dep_field_lines( + lines: &[String], + mut f: usize, + end: usize, + indent: usize, +) -> (Option<(usize, String)>, Option<(usize, String)>, usize) { + let mut spec = None; + let mut ver = None; + while f < end { + let Some((field, _repr, fval)) = parse_key_line(&lines[f], indent) else { + break; + }; + match field.as_str() { + "specifier" => spec = Some((f, fval)), + "version" => ver = Some((f, fval)), + _ => {} + } + f += 1; + } + (spec, ver, f) +} + +/// Edit 1: the `overrides:` section — splice our entry into an existing one, +/// or insert the section at pnpm's ROOT_KEYS_ORDER slot (after +/// `lockfileVersion:`/`settings:`, before everything else — byte-identical +/// to the p7/p8 captures). +fn edit_overrides( + lines: &mut Vec, + ctx: &Ctx<'_>, + wiring: &mut Vec, +) -> Result { + let our_key = ctx.override_key.to_string(); + let entry_line = format!(" {}: {}", yaml_key(&our_key), ctx.spec); + if let Some((start, end)) = section_bounds(lines, "overrides") { + let mut ours = None; + let mut last_entry = start; + for (i, line) in lines.iter().enumerate().take(end).skip(start + 1) { + if let Some((key, repr, rest)) = parse_key_line(line, 2) { + last_entry = i; + if key == our_key { + ours = Some((i, repr, rest)); + break; + } + } + } + if let Some((i, repr, rest)) = ours { + if rest == ctx.spec { + return Ok(false); // in sync + } + // Ours with a stale uuid (no original), or the user's pinned + // value being TAKEN OVER (recorded as original). + let original = (!super::pnpm_lock::is_vendor_value(&rest)).then(|| rest.clone()); + lines[i] = format!(" {}: {}", yaml_key_like(&our_key, &repr), ctx.spec); + wiring.push(overrides_record( + &our_key, + ctx.spec, + WiringAction::Rewritten, + original, + )); + return Ok(true); + } + lines.insert(last_entry + 1, entry_line); + wiring.push(overrides_record( + &our_key, + ctx.spec, + WiringAction::Added, + None, + )); + return Ok(true); + } + // No overrides section: insert at the first top-level key that sorts + // after it (the captures show it between `lockfileVersion:`/`settings:` + // and `specifiers:`/`dependencies:`). + let anchor = lines + .iter() + .position(|l| { + !l.is_empty() + && !l.starts_with(' ') + && !OVERRIDES_PRECEDING.contains(&l.split(':').next().unwrap_or("")) + }) + .unwrap_or(lines.len()); + lines.splice( + anchor..anchor, + ["overrides:".to_string(), entry_line, String::new()], + ); + wiring.push(overrides_record( + &our_key, + ctx.spec, + WiringAction::Added, + None, + )); + Ok(true) +} + +/// Edit 2a (v5.4): the flat root dep maps — `name: ` moves to +/// `name: file:`. Returns `(changed, root_dep_hit)`; `root_dep_hit` +/// is true when the root depends on the package directly (in-sync re-runs +/// included), which is what gates the specifier edit. +fn edit_root_deps_v54( + lines: &mut [String], + ctx: &Ctx<'_>, + wiring: &mut Vec, +) -> Result<(bool, bool), String> { + let mut changed = false; + let mut hit = false; + for section in ROOT_DEP_SECTIONS { + let Some((start, end)) = section_bounds(lines, section) else { + continue; + }; + for line in lines.iter_mut().take(end).skip(start + 1) { + let Some((dep, repr, rest)) = parse_key_line(line, 2) else { + continue; + }; + if dep != ctx.name { + continue; + } + if rest == ctx.spec { + hit = true; // in sync + continue; + } + let target = rest == ctx.version || ctx.is_ours(&rest); + if !target { + continue; + } + hit = true; + let was_ours = ctx.is_ours(&rest); + let original = (!was_ours).then(|| Value::String(rest.clone())); + *line = format!(" {}: {}", yaml_key_like(&dep, &repr), ctx.spec); + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_ROOT_DEP.to_string(), + action: WiringAction::Rewritten, + key: Some(format!("{section}|{dep}")), + original, + new: Some(Value::String(ctx.spec.to_string())), + }); + changed = true; + } + } + Ok((changed, hit)) +} + +/// Edit 2b (v5.4): the `specifiers:` entry — whatever range the user wrote +/// moves to the machine-absolute `file:` spelling pnpm itself records +/// (module doc §3). Only runs when the root depends on the package. +fn edit_specifier_v54( + lines: &mut [String], + ctx: &Ctx<'_>, + wiring: &mut Vec, +) -> Result { + let Some((start, end)) = section_bounds(lines, "specifiers") else { + return Ok(false); + }; + for line in lines.iter_mut().take(end).skip(start + 1) { + let Some((key, repr, rest)) = parse_key_line(line, 2) else { + continue; + }; + if key != ctx.name { + continue; + } + if rest == ctx.abs_spec { + return Ok(false); // in sync + } + // Ours at a stale root/uuid (a moved checkout being re-vendored) has + // no original; anything else is the user's range, recorded. + let original = (!ctx.is_ours(&rest)).then(|| Value::String(rest.clone())); + *line = format!(" {}: {}", yaml_key_like(&key, &repr), ctx.abs_spec); + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_SPECIFIER.to_string(), + action: WiringAction::Rewritten, + key: Some(key), + original, + new: Some(Value::String(ctx.abs_spec.to_string())), + }); + return Ok(true); + } + Ok(false) +} + +/// Edit 2 (v6.0): the nested root dep entries — `specifier:` moves to the +/// machine-absolute spelling, `version:` to the relative `file:` spec (both +/// captured verbatim from pnpm 8.15.9). Returns `(changed, root_dep_hit)`. +fn edit_root_deps_v60( + lines: &mut [String], + ctx: &Ctx<'_>, + wiring: &mut Vec, +) -> Result<(bool, bool), String> { + let mut changed = false; + let mut hit = false; + for section in ROOT_DEP_SECTIONS { + let Some((start, end)) = section_bounds(lines, section) else { + continue; + }; + let mut k = start + 1; + while k < end { + let Some((dep, _repr, rest)) = parse_key_line(&lines[k], 2) else { + k += 1; + continue; + }; + if dep != ctx.name || !rest.is_empty() { + k += 1; + continue; + } + let (spec_f, ver_f, f) = dep_field_lines(lines, k + 1, end, 4); + if let (Some((si, old_spec)), Some((vi, old_ver))) = (spec_f, ver_f) { + let target = old_ver == ctx.version || ctx.is_ours(&old_ver); + if target { + hit = true; + if old_ver == ctx.spec && old_spec == ctx.abs_spec { + k = f; + continue; // in sync + } + let was_ours = ctx.is_ours(&old_ver); + lines[si] = format!(" specifier: {}", ctx.abs_spec); + lines[vi] = format!(" version: {}", ctx.spec); + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_ROOT_DEP_PAIR.to_string(), + action: WiringAction::Rewritten, + key: Some(format!("{section}|{dep}")), + original: if was_ours { + None + } else { + Some(serde_json::json!({ + "specifier": old_spec, + "version": old_ver, + })) + }, + new: Some(serde_json::json!({ + "specifier": ctx.abs_spec, + "version": ctx.spec, + })), + }); + changed = true; + } + } + k = f; + } + } + Ok((changed, hit)) +} + +/// Edit 3: rekey the `packages:` entry to the bare `file:` key at +/// its byte-sorted position — resolution replaced with our integrity + +/// tarball, `name:`/`version:` inserted after it, `deprecated:` dropped, +/// everything else verbatim (module doc §4). +fn edit_packages( + lines: &mut Vec, + ctx: &Ctx<'_>, + wiring: &mut Vec, +) -> Result { + let (start, end) = section_bounds(lines, "packages").ok_or("no packages: section")?; + let reg_key = ctx.reg_key(); + let new_key = ctx.new_key(); + + // Fail closed on a half-drifted lock carrying BOTH spellings. + let mut has_registry = false; + let mut has_ours = false; + let mut j = start + 1; + while let Some(block) = next_block(lines, j, end) { + if block.key == reg_key { + has_registry = true; + } else if ctx.is_ours(&block.key) { + has_ours = true; + } + j = block.end; + } + if has_registry && has_ours { + return Err(format!( + "packages section carries BOTH `{reg_key}` and a `file:…` entry (a \ + half-edited lock); run `pnpm install` to re-resolve it, then re-vendor" + )); + } + + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + let is_registry = block.key == reg_key; + let is_ours_key = ctx.is_ours(&block.key); + if !is_registry && !is_ours_key { + i = block.end; + continue; + } + let original_lines: Vec = lines[block.header..block.end].to_vec(); + let expected_resolution = format!( + " resolution: {{integrity: {}, tarball: {}}}", + ctx.integrity, ctx.spec + ); + if block.key == new_key && original_lines.iter().any(|l| l == &expected_resolution) { + return Ok(false); // in sync + } + let mut new_lines = Vec::with_capacity(original_lines.len() + 2); + // file: keys are emitted bare by pnpm (never quoted) — captured. + new_lines.push(format!(" {new_key}:")); + let mut replaced_resolution = false; + for line in &original_lines[1..] { + if let Some((field, _repr, _rest)) = parse_key_line(line, 4) { + match field.as_str() { + "resolution" => { + new_lines.push(expected_resolution.clone()); + new_lines.push(format!(" name: {}", ctx.name)); + new_lines.push(format!(" version: {}", ctx.version)); + replaced_resolution = true; + continue; + } + // Re-emitted canonically after resolution / dropped + // (pnpm drops `deprecated:` for file: entries — captured). + "name" | "version" | "deprecated" => continue, + _ => {} + } + } + new_lines.push(line.clone()); + } + if !replaced_resolution { + return Err(format!( + "packages entry `{}` has no resolution line", + block.key + )); + } + let old_key = block.key.clone(); + swap_block_sorted(lines, "packages", &old_key, &new_key, &new_lines)?; + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_PACKAGE.to_string(), + action: WiringAction::Rewritten, + key: Some(old_key), + original: if is_ours_key { + None + } else { + Some(lines_value(&original_lines)) + }, + new: Some(lines_value(&new_lines)), + }); + return Ok(true); + } + Err(format!("packages entry for {reg_key} vanished mid-rewrite")) +} + +/// Edit 4: every OTHER packages block's `dependencies:` / +/// `optionalDependencies:` reference to the exact version — `name: +/// ` → `name: file:` (captured: the `file:consumer` +/// directory dep's map). `peerDependencies` values are RANGES, never +/// resolutions, so only the two resolution maps are touched. +// &mut Vec keeps both edit functions' signatures unifiable into the one fn +// array `vendor_pnpm_legacy` iterates (edit_packages needs the Vec). +#[allow(clippy::ptr_arg)] +fn edit_pkg_dep_refs( + lines: &mut Vec, + ctx: &Ctx<'_>, + wiring: &mut Vec, +) -> Result { + let Some((start, end)) = section_bounds(lines, "packages") else { + return Ok(false); + }; + let mut changed = false; + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + 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.as_str(), "dependencies" | "optionalDependencies"); + continue; + } + if !in_dep_map { + continue; + } + let Some((dep, _repr, rest)) = parse_key_line(line, 6) else { + continue; + }; + if dep != ctx.name { + continue; + } + let target = rest == ctx.version || (rest != ctx.spec && ctx.is_ours(&rest)); + if !target { + continue; + } + let was_ours = ctx.is_ours(&rest); + *line = format!(" {}: {}", yaml_key(&dep), ctx.spec); + wiring.push(WiringRecord { + file: PNPM_LOCK.to_string(), + kind: KIND_LOCK_PKG_DEP_REF.to_string(), + action: WiringAction::Rewritten, + key: Some(format!("{}|{dep}", block.key)), + original: if was_ours { + None + } else { + Some(Value::String(rest.clone())) + }, + new: Some(Value::String(ctx.spec.to_string())), + }); + changed = true; + } + i = block.end; + } + Ok(changed) +} + +/// Replace `old_key`'s block with `new_block` at `new_block`'s byte-sorted +/// position inside a top-level section, preserving the blank-line-separated +/// shape pnpm emits (a rekey can MOVE across the sort boundary — `/`-keys +/// sort before `file:`-keys). pnpm sorts package keys with the `sort-keys` +/// default compare (JS code-unit order), which plain Rust `str` ordering +/// matches for these ASCII keys. +fn swap_block_sorted( + lines: &mut Vec, + section: &str, + old_key: &str, + new_key: &str, + new_block: &[String], +) -> Result<(), String> { + let (start, end) = section_bounds(lines, section).ok_or("section vanished mid-rewrite")?; + // Collect the section's blocks in order. + let mut blocks: Vec<(String, Vec)> = Vec::new(); + let mut last_block_end = start + 1; + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + blocks.push((block.key.clone(), lines[block.header..block.end].to_vec())); + last_block_end = block.end; + i = block.end; + } + // Whatever trails the final block (the file's trailing blank when the + // section is last) is preserved verbatim. + let trailer: Vec = lines[last_block_end..end].to_vec(); + + let pos = blocks + .iter() + .position(|(k, _)| k == old_key) + .ok_or_else(|| format!("{section} entry `{old_key}` vanished mid-rewrite"))?; + blocks.remove(pos); + let insert_at = blocks + .iter() + .position(|(k, _)| k.as_str() > new_key) + .unwrap_or(blocks.len()); + blocks.insert(insert_at, (new_key.to_string(), new_block.to_vec())); + + let mut rebuilt: Vec = Vec::with_capacity(end - start); + for (_, block_lines) in &blocks { + rebuilt.push(String::new()); + rebuilt.extend(block_lines.iter().cloned()); + } + rebuilt.extend(trailer); + lines.splice(start + 1..end, rebuilt); + Ok(()) +} + +// ─────────────────────────────── revert ─────────────────────────────────── + +/// Undo one legacy-vendored package: restore the recorded pair fragments +/// and remove the artifact dir. Reverse application order; per-record +/// ownership re-checked against the live fragment (drift ⇒ warning, left +/// alone) — same discipline as [`super::pnpm_lock::revert_pnpm`]. +pub async fn revert_pnpm_legacy( + entry: &VendorEntry, + project_root: &Path, + dry_run: bool, +) -> RevertOutcome { + let uuid_dir_rel = match guard_revert_uuid_dir(&entry.uuid) { + Ok(d) => d, + Err(outcome) => return outcome, + }; + // Nothing to replay (a `repair`-reconstructed entry): refuse the + // artifact removal while the legacy lock still resolves through it — + // fail-closed, before the dry-run return, exactly like the v9 backend + // (see [`super::pnpm_lock::guard_unwired_revert`]). + if entry.wiring.is_empty() { + let in_use = pnpm_legacy_entry_in_use(entry, project_root).await; + if let Some(blocked) = guard_unwired_revert(project_root, in_use, &uuid_dir_rel).await { + return blocked; + } + } + if dry_run { + return RevertOutcome::ok(); + } + let mut outcome = RevertOutcome::ok(); + + let mut touches_pkg = false; + let mut touches_lock = false; + for rec in &entry.wiring { + if !REVERT_ALLOWLIST.contains(&rec.file.as_str()) { + outcome.warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "ignoring wiring record for non-allowlisted file `{}`", + rec.file + ), + )); + continue; + } + if rec.file == PACKAGE_JSON { + touches_pkg = true; + } else { + touches_lock = true; + } + } + + let mut lock_lines: Option> = None; + if touches_lock { + match tokio::fs::read_to_string(project_root.join(PNPM_LOCK)).await { + Ok(text) => lock_lines = Some(split_lines(&text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + outcome.warnings.push(VendorWarning::new( + "vendor_lockfile_missing", + format!("{PNPM_LOCK} is missing; lock fragments cannot be restored"), + )); + } + Err(e) => return RevertOutcome::failed(format!("cannot read {PNPM_LOCK}: {e}")), + } + } + let mut pkg_state: Option<(Value, String)> = None; + if touches_pkg { + match tokio::fs::read(project_root.join(PACKAGE_JSON)).await { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(doc) if doc.is_object() => { + let indent = detect_indent(&String::from_utf8_lossy(&bytes)); + pkg_state = Some((doc, indent)); + } + _ => { + return RevertOutcome::failed(format!( + "{PACKAGE_JSON} is not a JSON object; fix it and re-run revert" + )) + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + outcome.warnings.push(VendorWarning::new( + "vendor_lockfile_missing", + format!("{PACKAGE_JSON} is missing; the pnpm override cannot be removed"), + )); + } + Err(e) => return RevertOutcome::failed(format!("cannot read {PACKAGE_JSON}: {e}")), + } + } + + let mut lock_dirty = false; + let mut pkg_dirty = false; + for rec in entry.wiring.iter().rev() { + match rec.file.as_str() { + PNPM_LOCK => { + if let Some(lines) = lock_lines.as_mut() { + revert_lock_record( + lines, + rec, + &entry.uuid, + &mut lock_dirty, + &mut outcome.warnings, + ); + } + } + PACKAGE_JSON => { + if let Some((doc, _)) = pkg_state.as_mut() { + revert_pkg_record(doc, rec, &entry.uuid, &mut pkg_dirty, &mut outcome.warnings); + } + } + _ => {} // warned above + } + } + + // Remove the now-empty tables iff vendor created them. + if let Some((doc, _)) = pkg_state.as_mut() { + let (created_overrides, created_pnpm) = match &entry.pnpm { + Some(meta) => (meta.created_overrides_table, meta.created_pnpm_table), + None => (false, false), + }; + if let Some(obj) = doc.as_object_mut() { + if let Some(pnpm_tbl) = obj.get_mut("pnpm").and_then(Value::as_object_mut) { + if created_overrides + && pnpm_tbl + .get("overrides") + .and_then(Value::as_object) + .is_some_and(serde_json::Map::is_empty) + { + pnpm_tbl.shift_remove("overrides"); + pkg_dirty = true; + } + } + if created_pnpm + && obj + .get("pnpm") + .and_then(Value::as_object) + .is_some_and(serde_json::Map::is_empty) + { + obj.shift_remove("pnpm"); + pkg_dirty = true; + } + } + } + + // Reverse write order: lock first, package.json second. + if lock_dirty { + 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(), + ) + .await + { + return RevertOutcome::failed(format!("cannot write {PNPM_LOCK}: {e}")); + } + } + } + if pkg_dirty { + if let Some((doc, indent)) = &pkg_state { + let bytes = match serialize_json(doc, indent) { + Ok(b) => b, + Err(e) => { + return RevertOutcome::failed(format!("cannot serialize {PACKAGE_JSON}: {e}")) + } + }; + if let Err(e) = + atomic_write_bytes_preserving_mode(&project_root.join(PACKAGE_JSON), &bytes).await + { + return RevertOutcome::failed(format!("cannot write {PACKAGE_JSON}: {e}")); + } + } + } + + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + } + outcome +} + +fn revert_lock_record( + lines: &mut Vec, + rec: &WiringRecord, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some(key) = rec.key.as_deref() else { + warnings.push(drifted(format!( + "wiring record in {PNPM_LOCK} has no key; left alone" + ))); + return; + }; + match rec.kind.as_str() { + KIND_LOCK_OVERRIDES => revert_overrides_line(lines, rec, key, entry_uuid, dirty, warnings), + KIND_LOCK_SPECIFIER => { + revert_value_line(lines, rec, "specifiers", key, entry_uuid, dirty, warnings) + } + KIND_LOCK_ROOT_DEP => match key.rsplit_once('|') { + Some((section, dep)) => { + revert_value_line(lines, rec, section, dep, entry_uuid, dirty, warnings) + } + None => warnings.push(drifted(format!( + "malformed root-dep key `{key}`; left alone" + ))), + }, + KIND_LOCK_ROOT_DEP_PAIR => { + revert_root_dep_pair(lines, rec, key, entry_uuid, dirty, warnings) + } + KIND_LOCK_PACKAGE => revert_package_block(lines, rec, key, entry_uuid, dirty, warnings), + KIND_LOCK_PKG_DEP_REF => revert_pkg_dep_ref(lines, rec, key, entry_uuid, dirty, warnings), + other => warnings.push(drifted(format!( + "unknown wiring kind `{other}` for `{key}`; left alone" + ))), + } +} + +/// Restore one flat `key: value` line (v5.4 specifiers / root dep maps) to +/// its recorded original. Fail-closed on drift. +fn revert_value_line( + lines: &mut [String], + rec: &WiringRecord, + section: &str, + dep: &str, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some((start, end)) = section_bounds(lines, section) else { + warnings.push(drifted(format!( + "{section} section is gone; `{dep}` not restored" + ))); + return; + }; + for line in lines.iter_mut().take(end).skip(start + 1) { + let Some((k, repr, rest)) = parse_key_line(line, 2) else { + continue; + }; + if k != dep { + continue; + } + let ours = Some(rest.as_str()) == 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!( + "{section} entry `{dep}` was changed since vendoring ({rest}); left alone" + ))); + return; + } + let Some(orig) = rec.original.as_ref().and_then(Value::as_str) else { + warnings.push(drifted(format!( + "{section} entry `{dep}` has no recorded pre-vendor original; left as-is \ + (re-run `pnpm install` to re-resolve it)" + ))); + return; + }; + *line = format!(" {}: {orig}", yaml_key_like(dep, &repr)); + *dirty = true; + return; + } + warnings.push(drifted(format!( + "{section} entry `{dep}` no longer exists; nothing to restore" + ))); +} + +/// Restore a v6.0 root dep's `specifier:`/`version:` pair. +fn revert_root_dep_pair( + lines: &mut [String], + rec: &WiringRecord, + key: &str, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some((section, dep)) = key.rsplit_once('|') else { + warnings.push(drifted(format!( + "malformed root-dep key `{key}`; left alone" + ))); + return; + }; + let Some((start, end)) = section_bounds(lines, section) else { + warnings.push(drifted(format!( + "{section} section is gone; `{dep}` not restored" + ))); + return; + }; + let mut k = start + 1; + while k < end { + let Some((d, _repr, rest)) = parse_key_line(&lines[k], 2) else { + k += 1; + continue; + }; + if d != dep || !rest.is_empty() { + k += 1; + continue; + } + let (spec_f, ver_f, _) = dep_field_lines(lines, k + 1, end, 4); + let (Some((si, _)), Some((vi, live_ver))) = (spec_f, ver_f) else { + break; + }; + let new_ver = rec + .new + .as_ref() + .and_then(|n| n.get("version")) + .and_then(Value::as_str); + let ours = Some(live_ver.as_str()) == new_ver + || parse_vendor_path(&live_ver).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if !ours { + warnings.push(drifted(format!( + "root dep `{key}` was re-resolved since vendoring ({live_ver}); left alone" + ))); + return; + } + let Some(original) = rec.original.as_ref() else { + warnings.push(drifted(format!( + "root dep `{key}` has no recorded pre-vendor original; left as-is \ + (re-run `pnpm install` to re-resolve it)" + ))); + return; + }; + let (Some(orig_spec), Some(orig_ver)) = ( + original.get("specifier").and_then(Value::as_str), + original.get("version").and_then(Value::as_str), + ) else { + warnings.push(drifted(format!("root dep `{key}` original is malformed"))); + return; + }; + lines[si] = format!(" specifier: {orig_spec}"); + lines[vi] = format!(" version: {orig_ver}"); + *dirty = true; + return; + } + warnings.push(drifted(format!( + "root dep `{key}` no longer exists; nothing to restore" + ))); +} + +/// Restore the rekeyed packages block: locate it by the NEW `file:` key, +/// 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). +fn revert_package_block( + lines: &mut Vec, + rec: &WiringRecord, + key: &str, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some(new_lines) = rec.new.as_ref().and_then(value_lines) else { + warnings.push(drifted(format!( + "record for `{key}` has no `new` fragment; left alone" + ))); + return; + }; + let Some((new_key, _repr, _rest)) = new_lines.first().and_then(|l| parse_key_line(l, 2)) else { + warnings.push(drifted(format!( + "record for `{key}` has a malformed fragment" + ))); + return; + }; + let Some((start, end)) = section_bounds(lines, "packages") else { + warnings.push(drifted(format!( + "packages section is gone; `{key}` not restored" + ))); + return; + }; + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + if block.key != new_key { + i = block.end; + continue; + } + let live: Vec = lines[block.header..block.end].to_vec(); + let key_is_ours = + parse_vendor_path(&new_key).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + if live != new_lines && !key_is_ours { + warnings.push(drifted(format!( + "packages entry `{new_key}` was changed since vendoring; left alone" + ))); + return; + } + let Some(original) = rec.original.as_ref().and_then(value_lines) else { + warnings.push(drifted(format!( + "packages entry `{key}` has no recorded pre-vendor original; left as-is \ + (re-run `pnpm install` to re-resolve it)" + ))); + return; + }; + let Some((orig_key, _r, _v)) = original.first().and_then(|l| parse_key_line(l, 2)) else { + warnings.push(drifted(format!( + "packages entry `{key}` original is malformed" + ))); + return; + }; + if swap_block_sorted(lines, "packages", &new_key, &orig_key, &original).is_err() { + warnings.push(drifted(format!( + "packages entry `{new_key}` vanished mid-restore; left alone" + ))); + return; + } + *dirty = true; + return; + } + warnings.push(drifted(format!( + "packages entry `{new_key}` no longer exists; nothing to restore" + ))); +} + +fn revert_pkg_dep_ref( + lines: &mut [String], + rec: &WiringRecord, + key: &str, + entry_uuid: &str, + dirty: &mut bool, + warnings: &mut Vec, +) { + let Some((pkg_key, dep)) = key.rsplit_once('|') else { + warnings.push(drifted(format!( + "malformed dep-ref key `{key}`; left alone" + ))); + return; + }; + let Some((start, end)) = section_bounds(lines, "packages") else { + warnings.push(drifted( + "packages section is gone; nothing to restore".to_string(), + )); + 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.as_str(), "dependencies" | "optionalDependencies"); + continue; + } + if !in_dep_map { + continue; + } + let Some((d, _repr, rest)) = parse_key_line(line, 6) else { + continue; + }; + if d != dep { + continue; + } + let ours = Some(rest.as_str()) == 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; + return; + } + break; + } + warnings.push(drifted(format!( + "dep ref `{key}` no longer exists; nothing to restore" + ))); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; + use crate::patch::apply::{ApplyResult, VerifyStatus}; + use base64::Engine as _; + use sha2::{Digest, Sha512}; + use std::collections::HashMap; + use std::path::PathBuf; + + // ── normalize_canonical_root (pure string level; no Windows host) ───── + // The synthetic inputs mirror what `std::fs::canonicalize` returns on + // Windows (verbatim paths); a real Windows CI leg should confirm pnpm + // 7/8's own emission spelling (tracked residual). + + #[test] + fn normalize_strips_windows_verbatim_drive_prefix_and_forward_slashes() { + assert_eq!( + normalize_canonical_root(r"\\?\C:\Users\dev\proj"), + "C:/Users/dev/proj" + ); + // The full specifier shape the lock embeds — never `\\?\`-prefixed, + // never backslashed. + let spec = format!( + "file:{}/{}", + normalize_canonical_root(r"\\?\C:\proj"), + ".socket/vendor/npm/uuid/left-pad-1.3.0.tgz" + ); + assert_eq!( + spec, + "file:C:/proj/.socket/vendor/npm/uuid/left-pad-1.3.0.tgz" + ); + } + + #[test] + fn normalize_strips_windows_verbatim_unc_prefix() { + assert_eq!( + normalize_canonical_root(r"\\?\UNC\srv\share\proj"), + "//srv/share/proj" + ); + } + + #[test] + fn normalize_forward_slashes_plain_windows_shapes() { + // Non-verbatim spellings (defensive: canonicalize is verbatim on + // Windows today, but the splice must never emit a backslash). + assert_eq!(normalize_canonical_root(r"C:\proj"), "C:/proj"); + assert_eq!( + normalize_canonical_root(r"\\srv\share\proj"), + "//srv/share/proj" + ); + } + + #[test] + fn normalize_leaves_unix_paths_byte_unchanged() { + assert_eq!(normalize_canonical_root("/home/dev/proj"), "/home/dev/proj"); + // A literal backslash inside a unix file name is NOT a separator + // and must survive untouched. + assert_eq!( + normalize_canonical_root(r"/home/we\ird/proj"), + r"/home/we\ird/proj" + ); + } + + /// The uuid the 2026-08-18 legacy spike vendored under (the captured + /// locks quote it verbatim). + const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; + const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; + const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; + + /// The spike tarball's integrity as the captured after-locks record it. + /// Our pack pipeline produces a DIFFERENT (deterministic) tarball, so + /// fixture comparisons substitute the actual integrity for this token — + /// everything else must be byte-identical. + const SPIKE_INTEGRITY: &str = + "sha512-pceaN98Av+E8ugNGKlqbfzvbJWVAdWx3RKI7kc7jPThP6QHZg7c2xbZhCqV8N42Jf9hKWdLW4ZNDnFHQinZ0Hw=="; + + /// The absolute project root the captured locks embedded (pnpm <= 8 + /// absolutizes the override specifier). Fixtures carry this token; the + /// tests substitute the tempdir's canonical path. + const ROOT_TOKEN: &str = "__PROJECT_ROOT__"; + + // ── tool-generated byte-exact oracles ───────────────────────────────── + // Provenance: matrix/vendor-legacy-spike/{t7,t8} — a `file:` tarball + // pnpm.overrides entry added to the fixture below, then serialized by + // REAL `corepack pnpm@7.33.5` / `pnpm@8.15.9` installs (2026-08-18) and + // proven byte-stable across an install re-run. Only the machine path + // and the tarball integrity are tokenized. + const T_BEFORE_PKG: &str = r#"{ + "name": "legacy-spike2", + "version": "0.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer", + "left-pad": "1.3.0", + "left-pad-old": "npm:left-pad@1.2.0" + } +} +"#; + const T_AFTER_PKG: &str = r#"{ + "name": "legacy-spike2", + "version": "0.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer", + "left-pad": "1.3.0", + "left-pad-old": "npm:left-pad@1.2.0" + }, + "pnpm": { + "overrides": { + "left-pad@1.3.0": "file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz" + } + } +} +"#; + const T7_BEFORE_LOCK: &str = "lockfileVersion: 5.4 + +specifiers: + consumer: file:./consumer + left-pad: 1.3.0 + left-pad-old: npm:left-pad@1.2.0 + +dependencies: + consumer: file:consumer + left-pad: 1.3.0 + left-pad-old: /left-pad/1.2.0 + +packages: + + /left-pad/1.2.0: + resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} + deprecated: use String.prototype.padStart() + dev: false + + /left-pad/1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + version: 1.0.0 + dependencies: + left-pad: 1.3.0 + dev: false +"; + const T7_AFTER_LOCK: &str = "lockfileVersion: 5.4 + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +specifiers: + consumer: file:./consumer + left-pad: file:__PROJECT_ROOT__/.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + left-pad-old: npm:left-pad@1.2.0 + +dependencies: + consumer: file:consumer + left-pad: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + left-pad-old: /left-pad/1.2.0 + +packages: + + /left-pad/1.2.0: + resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} + deprecated: use String.prototype.padStart() + dev: false + + file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-pceaN98Av+E8ugNGKlqbfzvbJWVAdWx3RKI7kc7jPThP6QHZg7c2xbZhCqV8N42Jf9hKWdLW4ZNDnFHQinZ0Hw==, tarball: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz} + name: left-pad + version: 1.3.0 + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + version: 1.0.0 + dependencies: + left-pad: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + dev: false +"; + const T8_BEFORE_LOCK: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + consumer: + specifier: file:./consumer + version: file:consumer + left-pad: + specifier: 1.3.0 + version: 1.3.0 + left-pad-old: + specifier: npm:left-pad@1.2.0 + version: /left-pad@1.2.0 + +packages: + + /left-pad@1.2.0: + resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} + deprecated: use String.prototype.padStart() + dev: false + + /left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + dependencies: + left-pad: 1.3.0 + dev: false +"; + const T8_AFTER_LOCK: &str = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +dependencies: + consumer: + specifier: file:./consumer + version: file:consumer + left-pad: + specifier: file:__PROJECT_ROOT__/.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + version: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + left-pad-old: + specifier: npm:left-pad@1.2.0 + version: /left-pad@1.2.0 + +packages: + + /left-pad@1.2.0: + resolution: {integrity: sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg==} + deprecated: use String.prototype.padStart() + dev: false + + file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-pceaN98Av+E8ugNGKlqbfzvbJWVAdWx3RKI7kc7jPThP6QHZg7c2xbZhCqV8N42Jf9hKWdLW4ZNDnFHQinZ0Hw==, tarball: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz} + name: left-pad + version: 1.3.0 + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + dependencies: + left-pad: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + dev: false +"; + + // Provenance: matrix/vendor-legacy-spike/x7 — the transitive-ONLY shape + // (root depends on `consumer` only): pnpm rekeys the packages entry and + // the consumer's dep ref but touches NO root section — no absolute path + // appears anywhere. + const X7_BEFORE_PKG: &str = r#"{ + "name": "legacy-spike3", + "version": "0.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer" + } +} +"#; + const X7_BEFORE_LOCK: &str = "lockfileVersion: 5.4 + +specifiers: + consumer: file:./consumer + +dependencies: + consumer: file:consumer + +packages: + + /left-pad/1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + version: 1.0.0 + dependencies: + left-pad: 1.3.0 + dev: false +"; + const X7_AFTER_LOCK: &str = "lockfileVersion: 5.4 + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +specifiers: + consumer: file:./consumer + +dependencies: + consumer: file:consumer + +packages: + + file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz: + resolution: {integrity: sha512-pceaN98Av+E8ugNGKlqbfzvbJWVAdWx3RKI7kc7jPThP6QHZg7c2xbZhCqV8N42Jf9hKWdLW4ZNDnFHQinZ0Hw==, tarball: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz} + name: left-pad + version: 1.3.0 + dev: false + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + version: 1.0.0 + dependencies: + left-pad: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + dev: false +"; + + struct Fixture { + tmp: tempfile::TempDir, + record: PatchRecord, + } + + impl Fixture { + fn root(&self) -> &Path { + self.tmp.path() + } + + /// The canonical root — what the backend embeds in the absolute + /// specifier (macOS tempdirs are symlinks; pnpm's process.cwd() and + /// our canonicalize agree on the physical path). + fn canon_root(&self) -> PathBuf { + std::fs::canonicalize(self.root()).unwrap() + } + + /// The root as the BACKEND spells it into locks — the raw + /// `canonicalize().display()` form differs on Windows (`\\?\C:\...`), + /// so every oracle/fixture must go through the same normalizer the + /// splice uses or the byte-exact asserts diverge there. + fn canon_root_str(&self) -> String { + normalize_canonical_root(&self.canon_root().display().to_string()) + } + + fn installed(&self) -> PathBuf { + self.root().join("node_modules/left-pad") + } + + fn rel_tgz(&self) -> String { + format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") + } + + async fn read(&self, name: &str) -> String { + tokio::fs::read_to_string(self.root().join(name)) + .await + .unwrap() + } + + /// The actual SRI of the tarball our pack produced. + async fn actual_integrity(&self) -> String { + let tgz = tokio::fs::read(self.root().join(self.rel_tgz())) + .await + .unwrap(); + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&tgz)) + ) + } + + /// Instantiate a captured after-lock for THIS tempdir: the spike's + /// integrity and absolute-root tokens swapped for the live values. + async fn expected_lock(&self, fixture: &str) -> String { + fixture + .replace(SPIKE_INTEGRITY, &self.actual_integrity().await) + .replace(ROOT_TOKEN, &self.canon_root_str()) + } + + async fn vendor(&self, dry_run: bool) -> VendorOutcome { + let blobs = self.root().join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + vendor_pnpm_legacy( + "pkg:npm/left-pad@1.3.0", + &self.installed(), + self.root(), + &self.record, + &sources, + "2026-08-18T00:00:00Z", + dry_run, + false, + None, + ) + .await + } + } + + async fn fixture_with(pkg_json: &str, lock: &str) -> Fixture { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + let installed = root.join("node_modules/left-pad"); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write( + installed.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .await + .unwrap(); + tokio::fs::write(installed.join("index.js"), ORIG_INDEX) + .await + .unwrap(); + + let blobs = root.join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + tokio::fs::write(blobs.join(&after_hash), PATCHED_INDEX) + .await + .unwrap(); + + tokio::fs::write(root.join(PACKAGE_JSON), pkg_json) + .await + .unwrap(); + tokio::fs::write(root.join(PNPM_LOCK), lock).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(ORIG_INDEX), + after_hash, + }, + ); + let record = PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-08-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: "test patch".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }; + Fixture { tmp, record } + } + + fn expect_done( + outcome: VendorOutcome, + ) -> (ApplyResult, Option, Vec) { + match outcome { + VendorOutcome::Done { + result, + entry, + warnings, + } => (result, entry, warnings), + VendorOutcome::Refused { code, detail } => { + panic!("expected Done, got Refused {code}: {detail}") + } + } + } + + fn expect_refused(outcome: VendorOutcome, want_code: &str) -> String { + match outcome { + VendorOutcome::Refused { code, detail } => { + assert_eq!(code, want_code, "wrong refusal code ({detail})"); + detail + } + VendorOutcome::Done { result, .. } => { + panic!( + "expected Refused {want_code}, got Done (success={})", + result.success + ) + } + } + } + + // ── oracle transforms ───────────────────────────────────────────────── + + /// pnpm 7 (5.4): the whole captured transform — overrides inserted at + /// the ROOT_KEYS_ORDER slot, specifier absolutized, root dep + the + /// consumer's dep ref moved to the relative file: spec, the packages + /// entry rekeyed ACROSS the `/` vs `file:` sort boundary — byte-identical + /// to what pnpm 7.33.5 itself serialized. + #[tokio::test] + async fn v54_oracle_transform_is_byte_identical_for_both_files() { + let fx = fixture_with(T_BEFORE_PKG, T7_BEFORE_LOCK).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("success carries a ledger entry"); + + assert_eq!(fx.read(PACKAGE_JSON).await, T_AFTER_PKG); + assert_eq!( + fx.read(PNPM_LOCK).await, + fx.expected_lock(T7_AFTER_LOCK).await + ); + + // Ledger facts: flavor + meta (NO workspace surface for legacy). + assert_eq!(entry.flavor.as_deref(), Some("pnpm-legacy")); + assert_eq!( + entry.pnpm, + Some(PnpmMeta { + created_overrides_table: true, + created_pnpm_table: true, + created_workspace_file: false, + created_workspace_overrides: false, + }) + ); + assert_eq!(entry.artifact.path, fx.rel_tgz()); + let kinds: Vec<&str> = entry.wiring.iter().map(|r| r.kind.as_str()).collect(); + assert_eq!( + kinds, + vec![ + "pnpm_pkg_override", + KIND_LOCK_OVERRIDES, + KIND_LOCK_ROOT_DEP, + KIND_LOCK_SPECIFIER, + KIND_LOCK_PACKAGE, + KIND_LOCK_PKG_DEP_REF, + ], + "{:?}", + entry.wiring + ); + // The consumer's dep ref is keyed pkg|dep with the bare-version + // original recorded for revert. + let dep_ref = entry + .wiring + .iter() + .find(|r| r.kind == KIND_LOCK_PKG_DEP_REF) + .unwrap(); + assert_eq!(dep_ref.key.as_deref(), Some("file:consumer|left-pad")); + assert_eq!(dep_ref.original, Some(Value::String("1.3.0".into()))); + + // The absolute-specifier portability caveat is surfaced. + assert!( + warnings + .iter() + .any(|w| w.code == "vendor_pnpm_legacy_absolute_specifier"), + "{warnings:?}" + ); + // …and no workspace file appeared. + assert!(!fx.root().join("pnpm-workspace.yaml").exists()); + } + + /// pnpm 8 (6.0): same transform through the nested specifier/version + /// grammar — byte-identical to what pnpm 8.15.9 itself serialized. + #[tokio::test] + async fn v60_oracle_transform_is_byte_identical_for_both_files() { + let fx = fixture_with(T_BEFORE_PKG, T8_BEFORE_LOCK).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("success carries a ledger entry"); + + assert_eq!(fx.read(PACKAGE_JSON).await, T_AFTER_PKG); + assert_eq!( + fx.read(PNPM_LOCK).await, + fx.expected_lock(T8_AFTER_LOCK).await + ); + + assert_eq!(entry.flavor.as_deref(), Some("pnpm-legacy")); + let kinds: Vec<&str> = entry.wiring.iter().map(|r| r.kind.as_str()).collect(); + assert_eq!( + kinds, + vec![ + "pnpm_pkg_override", + KIND_LOCK_OVERRIDES, + KIND_LOCK_ROOT_DEP_PAIR, + KIND_LOCK_PACKAGE, + KIND_LOCK_PKG_DEP_REF, + ], + "{:?}", + entry.wiring + ); + let pair = entry + .wiring + .iter() + .find(|r| r.kind == KIND_LOCK_ROOT_DEP_PAIR) + .unwrap(); + assert_eq!(pair.key.as_deref(), Some("dependencies|left-pad")); + assert_eq!( + pair.original, + Some(serde_json::json!({"specifier": "1.3.0", "version": "1.3.0"})) + ); + assert!( + warnings + .iter() + .any(|w| w.code == "vendor_pnpm_legacy_absolute_specifier"), + "{warnings:?}" + ); + assert!(!fx.root().join("pnpm-workspace.yaml").exists()); + } + + /// The transitive-ONLY capture (x7): no root section mentions the + /// package, so nothing absolute is written and no portability warning + /// fires — overrides + rekeyed packages entry + the consumer's dep ref + /// only, byte-identical to pnpm 7.33.5's own serialization. + #[tokio::test] + async fn v54_transitive_only_writes_no_absolute_specifier() { + let fx = fixture_with(X7_BEFORE_PKG, X7_BEFORE_LOCK).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("entry"); + + let lock_after = fx.read(PNPM_LOCK).await; + assert_eq!(lock_after, fx.expected_lock(X7_AFTER_LOCK).await); + assert!( + !lock_after.contains(&fx.canon_root_str()), + "no machine path may leak into a transitive-only wiring:\n{lock_after}" + ); + assert!( + !warnings + .iter() + .any(|w| w.code == "vendor_pnpm_legacy_absolute_specifier"), + "{warnings:?}" + ); + let kinds: Vec<&str> = entry.wiring.iter().map(|r| r.kind.as_str()).collect(); + assert_eq!( + kinds, + vec![ + "pnpm_pkg_override", + KIND_LOCK_OVERRIDES, + KIND_LOCK_PACKAGE, + KIND_LOCK_PKG_DEP_REF, + ], + "{:?}", + entry.wiring + ); + } + + // ── idempotency + revert ────────────────────────────────────────────── + + /// A second vendor over an in-sync legacy wiring is AlreadyPatched: no + /// new ledger entry, every byte stable — for BOTH grammars. + #[tokio::test] + async fn rerun_is_already_patched_and_byte_stable() { + for (before_lock, tag) in [(T7_BEFORE_LOCK, "5.4"), (T8_BEFORE_LOCK, "6.0")] { + let fx = fixture_with(T_BEFORE_PKG, before_lock).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{tag}: {:?}", result.error); + assert!(entry.is_some(), "{tag}"); + let pkg_after = fx.read(PACKAGE_JSON).await; + let lock_after = fx.read(PNPM_LOCK).await; + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{tag}: {:?}", result.error); + assert!(entry.is_none(), "{tag}: in-sync rerun records nothing"); + assert!( + result + .files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), + "{tag}" + ); + assert_eq!( + fx.read(PACKAGE_JSON).await, + pkg_after, + "{tag}: bytes stable" + ); + assert_eq!(fx.read(PNPM_LOCK).await, lock_after, "{tag}: bytes stable"); + } + } + + /// Revert restores BOTH files byte-identical (the packages block moves + /// back across the sort boundary to its original slot) and removes the + /// artifact dir — for BOTH grammars. + #[tokio::test] + async fn revert_round_trips_both_files_and_removes_the_artifact() { + for (before_lock, tag) in [(T7_BEFORE_LOCK, "5.4"), (T8_BEFORE_LOCK, "6.0")] { + let fx = fixture_with(T_BEFORE_PKG, before_lock).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let tgz_path = fx.root().join(fx.rel_tgz()); + assert!(tgz_path.exists(), "{tag}"); + + // Dry-run revert touches nothing. + let outcome = revert_pnpm_legacy(&entry, fx.root(), true).await; + assert!(outcome.success, "{tag}"); + assert!(tgz_path.exists(), "{tag}"); + assert_ne!(fx.read(PNPM_LOCK).await, before_lock, "{tag}"); + + let outcome = revert_pnpm_legacy(&entry, 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, + T_BEFORE_PKG, + "{tag}: package.json byte-restored" + ); + assert_eq!( + fx.read(PNPM_LOCK).await, + before_lock, + "{tag}: lock byte-restored" + ); + assert!(!tgz_path.exists(), "{tag}"); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "{tag}" + ); + } + } + + // ── empty-wiring (reconstructed) revert guard ───────────────────────── + + /// Same P1 regression guard as the v9 backend: a `repair`-reconstructed + /// entry (empty wiring — the legacy fragments are just as + /// offline-unrecoverable) must not have its artifact deleted while the + /// legacy lock still resolves through it; a provably orphaned artifact + /// still gets removed. Both grammars. + #[tokio::test] + async fn empty_wiring_revert_refuses_then_removes_orphan_both_grammars() { + for (before_lock, tag) in [(T7_BEFORE_LOCK, "5.4"), (T8_BEFORE_LOCK, "6.0")] { + let fx = fixture_with(T_BEFORE_PKG, before_lock).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + entry.pnpm = None; + let tgz_path = fx.root().join(fx.rel_tgz()); + let lock_wired = fx.read(PNPM_LOCK).await; + + // Wired lock (dry and wet): refuse, artifact + lock untouched. + for dry_run in [true, false] { + let outcome = revert_pnpm_legacy(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "{tag} dry_run={dry_run}: must refuse"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{tag}: {:?}", + outcome.warnings + ); + assert!(tgz_path.exists(), "{tag}: artifact survives the refusal"); + assert_eq!( + fx.read(PNPM_LOCK).await, + lock_wired, + "{tag}: lock untouched" + ); + } + + // Undeterminable grammar (a v9 re-lock): still fail-closed. + tokio::fs::write(fx.root().join(PNPM_LOCK), "lockfileVersion: '9.0'\n") + .await + .unwrap(); + let outcome = revert_pnpm_legacy(&entry, fx.root(), false).await; + assert!(!outcome.success, "{tag}: unparseable grammar must refuse"); + assert!(tgz_path.exists(), "{tag}"); + + // Pre-vendor lock restored: provably orphaned → removal proceeds. + tokio::fs::write(fx.root().join(PNPM_LOCK), before_lock) + .await + .unwrap(); + let outcome = revert_pnpm_legacy(&entry, fx.root(), false).await; + assert!(outcome.success, "{tag}: {:?}", outcome.error); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "{tag}: orphaned artifact dir removed" + ); + assert_eq!( + fx.read(PNPM_LOCK).await, + before_lock, + "{tag}: empty wiring replays nothing" + ); + } + } + + // ── takeover / conflict ─────────────────────────────────────────────── + + /// A user-authored exact-version pin is TAKEN OVER on both surfaces and + /// restored verbatim on revert (v6.0 grammar; the package.json handling + /// is grammar-independent and shared with the v9 backend). + #[tokio::test] + async fn exact_pin_takeover_round_trips() { + let pkg_before = r#"{ + "name": "legacy-spike2", + "version": "0.0.0", + "private": true, + "dependencies": { + "consumer": "file:./consumer", + "left-pad": "1.3.0", + "left-pad-old": "npm:left-pad@1.2.0" + }, + "pnpm": { + "overrides": { + "left-pad": "1.3.0" + } + } +} +"#; + let fx = fixture_with(pkg_before, T8_BEFORE_LOCK).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + + // The USER's key carries our spec now (never a second key). + let pkg: Value = serde_json::from_str(&fx.read(PACKAGE_JSON).await).unwrap(); + assert_eq!( + pkg["pnpm"]["overrides"]["left-pad"].as_str(), + Some(format!("file:{}", fx.rel_tgz()).as_str()) + ); + assert!(pkg["pnpm"]["overrides"] + .as_object() + .unwrap() + .get("left-pad@1.3.0") + .is_none()); + // The lock's overrides section mirrors the taken-over key. + let lock = fx.read(PNPM_LOCK).await; + assert!( + lock.contains(&format!("\n left-pad: file:{}\n", fx.rel_tgz())), + "{lock}" + ); + + let outcome = revert_pnpm_legacy(&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_before, "pin restored"); + assert_eq!(fx.read(PNPM_LOCK).await, T8_BEFORE_LOCK); + } + + /// A same-name override that is NOT an ownable pin refuses fail-closed. + #[tokio::test] + async fn conflicting_override_refuses() { + let pkg = r#"{ + "name": "x", + "dependencies": { "left-pad": "1.3.0" }, + "pnpm": { "overrides": { "left-pad": "^1.0.0" } } +} +"#; + let fx = fixture_with(pkg, T8_BEFORE_LOCK).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_override_conflict"); + assert!(detail.contains("left-pad"), "{detail}"); + assert_eq!( + fx.read(PNPM_LOCK).await, + T8_BEFORE_LOCK, + "refusal writes nothing" + ); + } + + // ── fail-closed refusals ────────────────────────────────────────────── + + /// Both grammars' PEER-SUFFIXED packages keys and ALIASED references + /// refuse before any write (the rekey cannot follow those spellings — + /// they would dangle). + #[tokio::test] + async fn peer_suffixed_and_aliased_spellings_refuse() { + // v5.4 peer-suffixed key (`_peer` spelling). + let lock = T7_BEFORE_LOCK.replace("/left-pad/1.3.0:", "/left-pad/1.3.0_react@18.2.0:"); + let fx = fixture_with(T_BEFORE_PKG, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("_react@18.2.0"), "{detail}"); + assert_eq!(fx.read(PNPM_LOCK).await, lock, "refusal writes nothing"); + + // v6.0 peer-suffixed key (`(peer)` spelling). + let lock = T8_BEFORE_LOCK.replace("/left-pad@1.3.0:", "/left-pad@1.3.0(react@18.2.0):"); + let fx = fixture_with(T_BEFORE_PKG, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("(react@18.2.0)"), "{detail}"); + + // v5.4 aliased root dep resolving to the SAME version (`npm:` spec + // records the registry dep path as its value). + let lock = T7_BEFORE_LOCK.replace( + " left-pad-old: /left-pad/1.2.0", + " left-pad-old: /left-pad/1.3.0", + ); + let fx = fixture_with(T_BEFORE_PKG, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("aliased"), "{detail}"); + + // v6.0 aliased root dep, nested grammar. + let lock = T8_BEFORE_LOCK.replace( + " version: /left-pad@1.2.0", + " version: /left-pad@1.3.0", + ); + let fx = fixture_with(T_BEFORE_PKG, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("aliased"), "{detail}"); + } + + /// Legacy WORKSPACE locks (importers:) have no captured fixtures — + /// refuse with the pnpm >= 9 upgrade path. + #[tokio::test] + async fn legacy_workspace_lock_refuses() { + let lock = "lockfileVersion: 5.4 + +importers: + + .: + specifiers: + left-pad: 1.3.0 + dependencies: + left-pad: 1.3.0 + +packages: + + /left-pad/1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + dev: false +"; + let fx = fixture_with(T_BEFORE_PKG, lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("WORKSPACE"), "{detail}"); + assert!(detail.contains("pnpm >= 9"), "{detail}"); + assert_eq!(fx.read(PNPM_LOCK).await, lock, "refusal writes nothing"); + } + + /// CRLF and non-allowlisted lock versions refuse with the same codes + /// and byte-untouched files as the v9 backend. + #[tokio::test] + async fn crlf_and_foreign_versions_refuse() { + let crlf = T7_BEFORE_LOCK.replace('\n', "\r\n"); + let fx = fixture_with(T_BEFORE_PKG, &crlf).await; + expect_refused(fx.vendor(false).await, "vendor_lockfile_crlf_unsupported"); + assert_eq!(fx.read(PNPM_LOCK).await, crlf, "refusal writes nothing"); + + // pnpm 6's 5.3 is NOT allowlisted. + let old = T7_BEFORE_LOCK.replace("lockfileVersion: 5.4", "lockfileVersion: 5.3"); + let fx = fixture_with(T_BEFORE_PKG, &old).await; + let detail = expect_refused( + fx.vendor(false).await, + "vendor_lockfile_version_unsupported", + ); + assert!(detail.contains("5.3"), "{detail}"); + + // A 9.0 lock reaching the legacy backend directly is a router bug — + // still refused, never mis-spliced. + let fx = fixture_with( + T_BEFORE_PKG, + "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n", + ) + .await; + expect_refused( + fx.vendor(false).await, + "vendor_lockfile_version_unsupported", + ); + } + + /// No packages entry for the target → the not-found refusal, nothing + /// written. + #[tokio::test] + async fn missing_lock_entry_refuses() { + let lock = "lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + ms: + specifier: 2.1.3 + version: 2.1.3 + +packages: + + /ms@2.1.3: + resolution: {integrity: sha512-abc==} + dev: false +"; + let fx = fixture_with(T_BEFORE_PKG, lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); + assert!(detail.contains("left-pad@1.3.0"), "{detail}"); + assert!( + !fx.root().join(".socket/vendor").exists(), + "refusals write nothing" + ); + } + + // ── moved checkout / stale absolute specifier ───────────────────────── + + /// Re-vendoring a project whose lock still carries ANOTHER machine's + /// absolute specifier (a moved checkout) heals just that line to the + /// current root — the stale path parses as ours through the + /// `.socket/vendor/` anchor. + #[tokio::test] + async fn moved_checkout_revendor_heals_the_absolute_specifier() { + let fx = fixture_with(T_BEFORE_PKG, T7_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + assert!(entry.is_some()); + + // Simulate the moved checkout: swap the live absolute root for a + // foreign machine's path. + let here = fx.canon_root_str(); + let lock = fx.read(PNPM_LOCK).await; + let foreign = lock.replace(&here, "/home/other/checkout"); + assert_ne!(lock, foreign, "fixture must embed the absolute root"); + tokio::fs::write(fx.root().join(PNPM_LOCK), &foreign) + .await + .unwrap(); + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_some(), "the heal is a real rewrite, recorded"); + assert_eq!( + fx.read(PNPM_LOCK).await, + lock, + "only the specifier line moves back to this machine's root" + ); + } + + // ── in-use probe ────────────────────────────────────────────────────── + + #[tokio::test] + async fn entry_in_use_probe_reads_the_packages_keys() { + let fx = fixture_with(T_BEFORE_PKG, T7_BEFORE_LOCK).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + assert_eq!( + pnpm_legacy_entry_in_use(&entry, fx.root()).await, + Some(true) + ); + + // Dep removed + re-locked: unused (the overrides declaration alone + // never counts). + let relocked = "lockfileVersion: 5.4 + +overrides: + left-pad@1.3.0: file:.socket/vendor/npm/1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab/left-pad-1.3.0.tgz + +specifiers: + consumer: file:./consumer + +dependencies: + consumer: file:consumer + +packages: + + file:consumer: + resolution: {directory: consumer, type: directory} + name: consumer + version: 1.0.0 + dev: false +"; + tokio::fs::write(fx.root().join(PNPM_LOCK), relocked) + .await + .unwrap(); + assert_eq!( + pnpm_legacy_entry_in_use(&entry, fx.root()).await, + Some(false) + ); + + // Unsupported grammar / missing lock: undeterminable, fail-safe. + tokio::fs::write(fx.root().join(PNPM_LOCK), "lockfileVersion: '9.0'\n") + .await + .unwrap(); + assert_eq!(pnpm_legacy_entry_in_use(&entry, fx.root()).await, None); + tokio::fs::remove_file(fx.root().join(PNPM_LOCK)) + .await + .unwrap(); + assert_eq!(pnpm_legacy_entry_in_use(&entry, fx.root()).await, None); + } +} diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index e46e25d4..ced497d8 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -513,6 +513,23 @@ pub async fn revert_yarn_berry( Ok(d) => d, Err(outcome) => return outcome, }; + // Nothing to replay (a `repair`-reconstructed entry): the artifact may + // only be removed when yarn.lock provably no longer resolves through it + // — otherwise refuse, fail-closed, instead of silently bricking + // installs. Runs before the dry-run return so a preview never + // advertises a revert the wet run refuses. + if entry.wiring.is_empty() { + if let Some(blocked) = super::npm_lock::guard_unwired_textual_revert( + project_root, + &entry.uuid, + &uuid_dir_rel, + &[YARN_LOCK], + ) + .await + { + return blocked; + } + } if dry_run { return RevertOutcome::ok(); } @@ -1876,6 +1893,75 @@ __metadata: assert!(!fx.root().parent().unwrap().join("x").exists()); } + // ── empty-wiring (reconstructed) revert guard ────────────────────────── + + /// Reshape a vendored entry into what `repair`'s no-ledger + /// reconstruction persists: same uuid/artifact, EMPTY wiring. With + /// nothing to replay, revert must refuse (fail-closed) while yarn.lock + /// still resolves through the artifact — dry-run preview included — + /// still remove a genuinely orphaned artifact, fail closed on an + /// unreadable lock, and proceed when no lock exists at all. + #[tokio::test] + async fn empty_wiring_revert_guards_against_bricking_installs() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); + + // Still referenced: refuse, artifact and lock untouched. + for dry_run in [true, false] { + let outcome = revert_yarn_berry(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "dry_run={dry_run}: must refuse"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{:?}", + outcome.warnings + ); + assert!(fx.tgz_path().exists(), "artifact survives the refusal"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_vendored, + "lock untouched" + ); + } + + // Unreadable lock (not UTF-8): undeterminable, fail closed. + tokio::fs::write(fx.lock_path(), [0xff, 0xfe, b'x']) + .await + .unwrap(); + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(!outcome.success, "unreadable-lock revert must refuse"); + assert!(fx.tgz_path().exists()); + + // Re-locked away from the artifact (provably orphaned): removal + // proceeds, replaying nothing. + tokio::fs::write(fx.lock_path(), &fx.lock_bytes) + .await + .unwrap(); + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!fx.tgz_path().exists(), "orphaned artifact removed"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "empty wiring replays nothing" + ); + + // No lock at all: nothing can reference the artifact — proceed. + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + tokio::fs::remove_file(fx.lock_path()).await.unwrap(); + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!fx.tgz_path().exists(), "no lock, no reference"); + } + #[tokio::test] async fn revert_refuses_tampered_uuid_fail_closed() { let fx = fixture().await; diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index e4f29482..d321818a 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -290,6 +290,23 @@ pub async fn revert_yarn_classic( Ok(d) => d, Err(outcome) => return outcome, }; + // Nothing to replay (a `repair`-reconstructed entry): the artifact may + // only be removed when yarn.lock provably no longer resolves through it + // — otherwise refuse, fail-closed, instead of silently bricking + // installs. Runs before the dry-run return so a preview never + // advertises a revert the wet run refuses. + if entry.wiring.is_empty() { + if let Some(blocked) = super::npm_lock::guard_unwired_textual_revert( + project_root, + &entry.uuid, + &uuid_dir_rel, + &[YARN_LOCK], + ) + .await + { + return blocked; + } + } if dry_run { return RevertOutcome::ok(); } @@ -1530,6 +1547,75 @@ left-pad@^1.3.0: assert!(!fx.root().parent().unwrap().join("x").exists()); } + // ── empty-wiring (reconstructed) revert guard ────────────────────────── + + /// Reshape a vendored entry into what `repair`'s no-ledger + /// reconstruction persists: same uuid/artifact, EMPTY wiring. With + /// nothing to replay, revert must refuse (fail-closed) while yarn.lock + /// still resolves through the artifact — dry-run preview included — + /// still remove a genuinely orphaned artifact, fail closed on an + /// unreadable lock, and proceed when no lock exists at all. + #[tokio::test] + async fn empty_wiring_revert_guards_against_bricking_installs() { + let fx = fixture_with_lock(Y2_BEFORE).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); + + // Still referenced: refuse, artifact and lock untouched. + for dry_run in [true, false] { + let outcome = revert_yarn_classic(&entry, fx.root(), dry_run).await; + assert!(!outcome.success, "dry_run={dry_run}: must refuse"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_wiring_unknown_revert_blocked"), + "{:?}", + outcome.warnings + ); + assert!(fx.tgz_path().exists(), "artifact survives the refusal"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_vendored, + "lock untouched" + ); + } + + // Unreadable lock (not UTF-8): undeterminable, fail closed. + tokio::fs::write(fx.lock_path(), [0xff, 0xfe, b'x']) + .await + .unwrap(); + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(!outcome.success, "unreadable-lock revert must refuse"); + assert!(fx.tgz_path().exists()); + + // Re-locked away from the artifact (provably orphaned): removal + // proceeds, replaying nothing. + tokio::fs::write(fx.lock_path(), &fx.lock_bytes) + .await + .unwrap(); + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!fx.tgz_path().exists(), "orphaned artifact removed"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + fx.lock_bytes, + "empty wiring replays nothing" + ); + + // No lock at all: nothing can reference the artifact — proceed. + let fx = fixture_with_lock(Y2_BEFORE).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + tokio::fs::remove_file(fx.lock_path()).await.unwrap(); + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!fx.tgz_path().exists(), "no lock, no reference"); + } + #[tokio::test] async fn revert_refuses_tampered_uuid_fail_closed() { let fx = fixture_with_lock(Y2_BEFORE).await; diff --git a/docs/ecosystems.md b/docs/ecosystems.md index aef663c9..308e91f5 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -14,7 +14,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | Ecosystem | agent (`--mode agent`) | vendored (`--mode vendored`) | hosted (`--mode hosted`) | |-----------|------------------------|------------------------------|--------------------------| -| npm (`npm`) — pnpm / yarn / berry / bun | ✅ any install layout; `setup` postinstall hook | ✅ five lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, bun `bun.lock` (binary `bun.lockb` refused with a `--save-text-lockfile` pointer). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml (pnpm v9), yarn classic, yarn berry, bun — pnpm, berry, and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | +| npm (`npm`) — pnpm / yarn / berry / bun | ✅ any install layout; `setup` postinstall hook | ✅ six lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun `bun.lock` (binary `bun.lockb` refused with a `--save-text-lockfile` pointer). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml (pnpm v5.4/v6.0/v9 — every major since pnpm 7), yarn classic, yarn berry, bun — pnpm, berry, and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ five lockfile flavors: uv, poetry, pdm, pipenv (lock rewired, but pipenv doesn't hash-check file entries — `vendor_integrity_unverified` warning; the committed wheel bytes are the protection), and requirements.txt (consumed by pip or `uv pip`) | ✅ requirements.txt + uv.lock. **poetry / pdm / pipenv locks are not rewritten** — use vendored | | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum) | | RubyGems (`gem`) | ✅ Bundler plugin via `setup` — needs bundler ≥ 2.2 (1.x cannot load `plugin ... path:` directives; `setup` refuses below the floor and `setup --check` red-flags a wired 1.x project) | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning) | @@ -35,12 +35,17 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. ## npm hosted-mode notes -- **pnpm** — lockfileVersion 9 (`pnpm >=9`). Older lock grammars carry `packages:` keys - the rewrite cannot repoint — v6 embeds resolved peers in the key itself - (`/name@1.0.0(peer@2.0.0)`) and v5.x is path-style (`/name/1.0.0`). A dep that - resolves through any such key is refused outright - (`redirect_pnpm_unsupported_lock_key` names the key and the lock), never partially - rewritten: regenerate the lock with pnpm ≥ 9 and re-run. +- **pnpm** — lockfileVersion 5.4 (`pnpm 7`), 6.0 (`pnpm 8`), and 9 (`pnpm >=9`) are all + rewritten. Legacy grammars carry per-instance `packages:` keys — v6 embeds resolved + peers in the key itself (`/name@1.0.0(peer@2.0.0)`), v5.x is path-style + (`/name/1.0.0`, peer-suffixed `/name/1.0.0_peer@2.0.0`) — and the rewrite splices + EVERY instance of the dep (each key owns its own `resolution:`), recording one + revert-ledger edit per instance; a partial rewrite is never possible. On 9.0 locks + the run also configures `trustLockfile: true` in `pnpm-workspace.yaml` (created or + merged, ledger-recorded, opt out with `--no-trust-lockfile-config`) so pnpm ≥ 11's + lockfile verification accepts the repointed tarballs with no flags and no CI + changes; pnpm ≤ 10 ignores the key. Legacy 5.4/6.0 locks skip the trust config — + pnpm 7/8 have no such verification. - **yarn berry** — the redirect edits the `yarn.lock` entry only (cacheKey `10c0` / yarn 4), and `.yarnrc.yml`'s `compressionLevel` must stay 0. The node-modules linker is e2e-covered; PnP is untested for hosted — the lock rewrite fires, but PnP's