diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b7f8c64f6..b987b6fe8 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -373,13 +373,14 @@ Discovery is read-only, never touches the network, and never fails the run: a ma | maven | `pom.xml` (+ `.mvn/maven.config`, `.mvn/checksums/checksums.sha256`) | a dependency version `-socket.` matching exactly ONE `socket-patch-` repository on the patch host | `socket-patch-vendor-` repository + exactly one jar under `.socket/vendor/maven//` with a matching `.sha1` | Trusted Checksums line when enabled; not required (the suffixed version is the pin) | | gradle (v5.0) | `.socket/gradle/hosted-index.tsv`, the owned hosted script, and every settings script and lock file the script graph reaches | an index row whose repository URL is on the patch host and names the row's uuid, live only while the owned script is intact, every build's settings file applies it with the current index digest, every lock entry of the GA is the suffixed version, no `settings-gradle.lockfile` names the GA and no build script sets a custom `lockFile` (otherwise `patched_ref_invalid`) | none here: a vendored Gradle entry is gated by its ledger entry's wiring check | the suffixed copies installed in the Gradle cache; required (never the lock basis), because the script lets a higher upstream version resolve | | nuget | the first of `nuget.config` / `NuGet.config` / `NuGet.Config`, + `packages.lock.json` | source `socket-patch-` + its exclusive exact-id ``; version from `packages.lock.json` | the same mapping onto `.socket/vendor/nuget/`; version from the lock, else the feed's single nupkg | `contentHash`, required | -| deno | none | — (no hosted mode) | — (no vendored backend) | — | +| deno | `deno.lock`'s npm section, only as evidence against an npm-family pin of a `name@version` it also locks, which `vex` then omits (see **Unattested references**, #406) | — (no hosted mode) | — (no vendored backend) | — | Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. -* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. Another entry of the **same** npm or Bun lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install`) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. +* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. +* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Two cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. * **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). **Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the hosted records above (this run's, then a pre-v5 ledger's), then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API — for a v5 hosted checkout this is the normal path. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so. @@ -389,7 +390,7 @@ Recognition rules that hold for every ecosystem: | Wiring | Evidence (verify mode) | Marker | |---|---|---| | Vendored: a lockfile/config wires a `.socket/vendor` artifact, or a live vendor ledger entry | The **committed artifact** is hashed against the record's `afterHash`. The ledger entry is used when it names the wired artifact (it carries the dir-artifact inventory); otherwise an entry is synthesized from the reference. A present installed tree with different bytes only warns `vendored_tree_out_of_sync`. | `(vendored)` | -| Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. The same goes for npm purls when an installed pnpm tree records its virtual store outside the project (`enableGlobalVirtualStore`, or a `virtualStoreDir` that climbs out): transitive deps there are invisible to the crawler. A pnpm `modulesDir` inside the project is crawled. | `(redirected)` | +| Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. The same goes for npm purls when an installed pnpm tree records its virtual store outside the project (`enableGlobalVirtualStore`, or a `virtualStoreDir` that climbs out): transitive deps there are invisible to the crawler. A pnpm `modulesDir` inside the project is crawled. A yarn Plug'n'Play project (`.pnp.cjs` / `.pnp.js`) has no tree to crawl: an npm purl there attests from the lock's pin only when the PnP loader itself resolves it through the patch (berry names the hosted url, yarn 1 the resolved url's `#` in its cache folder). A loader written before the lock was rewired runs the registry copy, so the purl stays omitted until `yarn install` rewrites it (#519). | `(redirected)` | | Agent: a manifest record with no live hosted/vendored wiring | The installed tree, unchanged. **Every** installed copy the crawler finds for the purl (npm nests duplicates of one `name@version`; pnpm, vlt, Bun and Deno stores add peer-variant copies and copies bundled inside other packages) must hash to the patched bytes, as `apply` patches every copy (Maven: every copy a build consumes, Gradle hash dirs included — see [Gradle builds](#gradle-builds-v50)). One unpatched copy omits the purl with that copy's tag (`not_applied` / `hash_mismatch`). | none | **Liveness gates.** These gates run before hashing, and `--no-verify` / `--vex-no-verify` skips only the hashing, never the gates: @@ -2150,6 +2151,8 @@ match it, withholds the statement: | `vex_gradle_unpatched_copy` | run warning | A copy a build may load does not carry the patch; no statement until every copy does. | | `gradle_unpatched_copy` | `failed[].reason` | The purl the warning above withheld. | | `vex_gradle_lock_above_base` | run warning and `failed[].reason` | A hosted pin is wired, but a lock file records a release above its base, which that build resolves instead of the patch; no statement until it is re-locked or the patch is rolled back. | +| `vex_pnpm_bundled_copy` | run warning and `failed[].reason` | An npm pin is wired, but its pnpm lock names a package whose `bundledDependencies` may ship an unpatched copy of it (the lock does not record that copy's version); no statement while that package bundles it. `vendor --check` and `scan` still treat the wiring as live. | +| `vex_deno_lock_copy` | run warning and `failed[].reason` | An npm pin is wired, but `deno.lock` locks the same `name@version`, which `deno install` installs without the wiring; no statement while it does. `vendor --check` and `scan` still treat the wiring as live. | | `vex_gradle_derived_cache_unchecked` | run warning | The derived-cache walk was cut short (a very large transforms cache); the statement is still emitted, the copies the walk did reach were checked. | A vendored Gradle entry attests only while its wiring is live (apply line, index rows, diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 909766f82..b2ce4cd02 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -687,7 +687,26 @@ async fn generate_vex( // unpatched transitive dep (#696). let npm_store_hidden = socket_patch_core::crawlers::npm_crawler::pnpm_store_outside_project(&common.cwd); - let hidden = |purl: &str| npm_store_hidden && purl.starts_with("pkg:npm/"); + // + // Nor can it look inside a yarn Plug'n'Play install: the packages + // are zips the loader resolves, so an npm purl not found may still + // run from the cache. The lock's pin is evidence only when the + // loader itself resolves the package through that patch (a fresh + // install of the hosted lock); a loader written before the lock was + // rewired runs the registry copy (#519). + let pnp_loader = socket_patch_core::crawlers::YarnPnpLoader::detect(&common.cwd); + let pnp_unconsumed = |purl: &str| { + pnp_loader.as_ref().is_some_and(|loader| { + !plan.hosted.get(purl).is_some_and(|wiring| { + loader.resolves_patch( + &wiring.uuid, + wiring.refs.iter().filter_map(|r| r.url.as_deref()), + ) + }) + }) + }; + let hidden = + |purl: &str| purl.starts_with("pkg:npm/") && (npm_store_hidden || pnp_unconsumed(purl)); let mut lockfile_attested = Vec::new(); outcome.failed.retain(|f| { let excused = f.reason == "package_not_found" diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 370dc1cee..e1c17b2d7 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -84,7 +84,7 @@ use socket_patch_core::vendor::state::{VendorArtifact, VendorEntry, VendorState} use socket_patch_core::vex::discover::{ canonical_base_purl, vendor_ref, Discovery, LedgerLiveness, PatchedRef, WiringMode, }; -use socket_patch_core::vex::FailedPatch; +use socket_patch_core::vex::{FailedPatch, UnattestedKind}; use crate::args::GlobalArgs; use crate::ui::plural; @@ -195,6 +195,38 @@ pub(crate) const NOTE_API_AUTH_FALLBACK: &str = "api_auth_fallback"; /// records a release above its base, which that build resolves instead /// (`vex::Unattested`). pub(crate) const NOTE_LOCK_ABOVE_BASE: &str = "vex_gradle_lock_above_base"; +/// Omission tag and note: an npm pin is wired, but its pnpm lock names a +/// package that bundles a copy of it, which no wiring reaches +/// (`vex::Unattested`, `UnattestedKind::BundledCopy`). +pub(crate) const NOTE_PNPM_BUNDLED_COPY: &str = "vex_pnpm_bundled_copy"; +/// Omission tag and note: an npm pin is wired, but `deno.lock` locks the +/// same version, which `deno install` installs without the wiring +/// (`vex::Unattested`, `UnattestedKind::DenoLock`). +pub(crate) const NOTE_DENO_LOCK_COPY: &str = "vex_deno_lock_copy"; + +/// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested) +/// ref, and the remedy its note ends with. +fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { + match kind { + UnattestedKind::LockAboveBase => ( + NOTE_LOCK_ABOVE_BASE, + "not attested until that build resolves the patch (re-lock it, or roll the patch \ + back once upstream ships the fix)", + ), + UnattestedKind::BundledCopy => ( + NOTE_PNPM_BUNDLED_COPY, + "not attested while that package bundles it; the wiring itself is intact, so \ + re-running `scan` / `vendor` does not change this (drop or upgrade the bundling \ + package)", + ), + UnattestedKind::DenoLock => ( + NOTE_DENO_LOCK_COPY, + "not attested while deno.lock locks the same version; the wiring itself is \ + intact, so re-running `scan` / `vendor` does not change this (drop the entry from \ + deno.lock if Deno does not install this project's npm dependencies)", + ), + } +} fn note(code: &'static str, detail: String) -> PlanNote { PlanNote { code, detail } @@ -330,8 +362,9 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S } let superseded = attach_discovered(&mut cands, &discovery, &vendor, &conflicts); // Wired, but a build bypasses the pin (`Unattested`: a Gradle lock - // above the hosted base resolves the newer upstream release): the ref - // keeps rollback, remove and list working, and the patch is omitted. + // above the hosted base resolves the newer upstream release, a pnpm + // bundled copy, a deno.lock copy): the ref keeps rollback, remove, + // list and the ledgers' liveness working, and the patch is omitted. cands.retain(|c| { let pkg = canonical_base_purl(&c.key); let Some(u) = discovery @@ -341,12 +374,12 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S else { return true; }; - gated.push(failed(&c.key, NOTE_LOCK_ABOVE_BASE)); + let (code, remedy) = unattested_note(u.kind); + gated.push(failed(&c.key, code)); notes.push(note( - NOTE_LOCK_ABOVE_BASE, + code, format!( - "{}: patch {} is wired, but {}; not attested until that build resolves the \ - patch (re-lock it, or roll the patch back once upstream ships the fix)", + "{}: patch {} is wired, but {}; {remedy}", c.key, c.uuid, u.detail ), )); @@ -1559,6 +1592,7 @@ mod tests { uuid: U1.into(), file: "b/gradle.lockfile".into(), detail: "b/gradle.lockfile:1 locks it above the patched 1.10.0".into(), + kind: UnattestedKind::LockAboveBase, }); let sources = |discovery: Discovery| Sources { manifest: PatchManifest::new(), diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs index d547e4d72..f40652d88 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs @@ -1137,40 +1137,86 @@ fn pin_spellings_and_shadowed_blocks() { /// surface does with a PnP checkout that nonetheless carries Socket lock /// wiring (hand-made, or left behind by a linker switch): /// -/// * standalone `vex`, hosted: there is no crawlable installed tree, so the -/// ref is "not installed" and attests on the lock's integrity pin exactly -/// like a lockfile-only checkout (design D5 — yarn enforces the berry -/// `checksum:` on every fetch into the zip cache). Berry 2/3's bare-hex -/// checksum is no pin (module doc), so those are omitted -/// (`package_not_found`) — never a false attestation. +/// * standalone `vex`, hosted: there is no crawlable installed tree, so +/// "not found" does not mean "not installed". The lock's integrity pin +/// attests (design D5 — yarn enforces the berry `checksum:` on every +/// fetch into the zip cache) only when the PnP loader itself resolves the +/// package through the patch (a fresh install of the hosted lock: berry +/// names the hosted url, classic the resolved url's hash). A loader +/// written before the lock was rewired runs the registry copy, so the +/// purl is omitted (`package_not_found`, #519). Berry 2/3's bare-hex +/// checksum is no pin (module doc), so those are omitted either way — +/// never a false attestation. /// * standalone `vex`, vendored: the committed tarball is the evidence /// whatever the linker; PnP consumes the same `file:` artifact. /// * `apply --vex`: apply refuses a PnP layout outright, manifest or not /// (`yarn_pnp_unsupported`, exit 1, no document). #[test] fn pnp_layout_contract() { - for flavor in [Flavor::Berry2, Flavor::Berry3, Flavor::Berry4] { - for mode in ["hosted", "vendored"] { + for flavor in FLAVORS { + for mode in ["hosted", "hosted-stale", "vendored"] { + if mode == "vendored" && !flavor.is_berry() { + continue; + } let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - if mode == "hosted" { + if mode == "vendored" { + vendored_checkout(cwd, flavor, PATCHED); + } else { hosted_checkout(cwd, flavor); + } + let fresh = mode != "hosted-stale"; + if flavor.is_berry() { + put( + cwd, + ".yarnrc.yml", + b"nodeLinker: pnp\nenableGlobalCache: false\n", + ); + // The package registry of `.pnp.cjs`: a fresh install of the + // hosted lock names the hosted locator, an install from + // before the rewire the registry one. + let reference = if fresh { + format!( + "npm:1.3.0::__archiveUrl={}", + encode_uri_component(&berry_hosted_url("https://patch.socket.dev", UUID)) + ) + } else { + "npm:1.3.0".to_string() + }; + put( + cwd, + ".pnp.cjs", + format!( + "/* yarn PnP loader */\n[\"left-pad\", [[\"{reference}\", \ + {{\"packageLocation\": \"./.yarn/cache/left-pad.zip/node_modules/left-pad/\"}}]]]\n" + ) + .as_bytes(), + ); } else { - vendored_checkout(cwd, flavor, PATCHED); + // yarn 1 (`installConfig.pnp`): `.pnp.js` names the cache + // folder, whose hash is the resolved url's fragment. + let hash = if fresh { + "abcdef0123456789abcdef0123456789abcdef01" + } else { + "5b8a3a7765dfe001261dde915589e782f8c94d1e" + }; + put( + cwd, + ".pnp.js", + format!( + "/* yarn PnP loader */\npackageLocation: \ + \"/home/u/.cache/yarn/v6/npm-left-pad-1.3.0-{hash}-integrity/node_modules/left-pad/\"\n" + ) + .as_bytes(), + ); } - put( - cwd, - ".yarnrc.yml", - b"nodeLinker: pnp\nenableGlobalCache: false\n", - ); - put(cwd, ".pnp.cjs", b"/* yarn PnP loader */\n"); assert!(!cwd.join("node_modules").exists()); let (_rt, server) = serve_left_pad(); let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); let cell = format!("{flavor:?} {mode} PnP vex"); if mode == "vendored" { assert_attested(cwd, code, &env, UUID, "vendored", &cell); - } else if flavor.hosted_pin_is_read() { + } else if fresh && flavor.hosted_pin_is_read() { assert_attested(cwd, code, &env, UUID, "redirected", &cell); } else { assert_omitted(cwd, code, &env, "package_not_found", &cell); @@ -1204,6 +1250,77 @@ fn pnp_layout_contract() { } } +/// #1033 review: the PnP loader check matches the patch anywhere in the +/// loader text, so a loader that resolves BOTH the hosted locator and a +/// registry locator of the same `name@version` (scoped `resolutions`, a +/// workspace member added after the rewire) still "names the patch". What +/// keeps that from attesting is the yarn same-lock rule: the lock's +/// registry entry of the same version contests the hosted ref. Pinned here +/// for berry 4 and yarn 1, the two flavors whose hosted pin is read. +#[test] +fn pnp_loader_naming_hosted_and_registry_copies_is_not_attested() { + for flavor in [Flavor::Classic, Flavor::Berry4] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_project(cwd, flavor, None); + let hosted = hosted_lock(flavor, "https://patch.socket.dev", UUID); + let (lock, loader_name, loader) = if flavor.is_berry() { + put( + cwd, + ".yarnrc.yml", + b"nodeLinker: pnp\nenableGlobalCache: false\n", + ); + let archive = format!( + "npm:1.3.0::__archiveUrl={}", + encode_uri_component(&berry_hosted_url("https://patch.socket.dev", UUID)) + ); + ( + format!( + "{hosted}\n\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \ + \"left-pad@npm:1.3.0\"\n checksum: {}\n languageName: node\n \ + linkType: hard\n", + flavor.berry_checksum('3') + ), + ".pnp.cjs", + format!( + "/* yarn PnP loader */\n[\"left-pad\", [[\"{archive}\", \ + {{\"packageLocation\": \"./.yarn/cache/a.zip/node_modules/left-pad/\"}}], \ + [\"npm:1.3.0\", {{\"packageLocation\": \ + \"./.yarn/cache/b.zip/node_modules/left-pad/\"}}]]]\n" + ), + ) + } else { + ( + format!( + "{hosted}\nleft-pad@^1.3.0:\n version \"1.3.0\"\n resolved \ + \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e\"\n \ + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==\n" + ), + ".pnp.js", + "/* yarn PnP loader */\n\ + packageLocation: \"/c/v6/npm-left-pad-1.3.0-abcdef0123456789abcdef0123456789abcdef01-integrity/\"\n\ + packageLocation: \"/c/v6/npm-left-pad-1.3.0-5b8a3a7765dfe001261dde915589e782f8c94d1e-integrity/\"\n" + .to_string(), + ) + }; + put(cwd, "yarn.lock", lock.as_bytes()); + put(cwd, loader_name, loader.as_bytes()); + let (_rt, server) = serve_left_pad(); + let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); + let cell = format!("{flavor:?} PnP, hosted + registry copies"); + assert_ne!(code, Some(0), "{cell}: {env}"); + let doc = read_doc(&cwd.join("out.vex.json")); + assert!( + doc.as_ref().is_none_or(|d| !d.to_string().contains(PURL)), + "{cell}: nothing may attest left-pad: {doc:?}" + ); + assert!( + env.to_string().contains("patched_ref_unattributable"), + "{cell}: the run says why: {env}" + ); + } +} + // ────────────────────────────────────────────────────────────────────── // EMBEDDED — scan --vex / scan --mode hosted --vex / scan --vendor --vex / // apply --vex, manifest-less diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index 0e5f46792..b44d90c04 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -2323,6 +2323,141 @@ fn reconstructed_ledger_entry_without_wiring_attests_from_the_root_lock() { ); } +/// REVIEW (#1033, audit B04): a pnpm lock that also holds a package with +/// `bundledDependencies` naming the vendored package (or `true`, bundling +/// every dependency) ships a copy pnpm unpacks from that package's own +/// tarball, which no wiring reaches and whose version the lock does not +/// record. `vex` must not attest the purl (`vex_pnpm_bundled_copy`), but +/// the wiring itself is intact and no re-vendor could clear the bundled +/// copy, so `vendor --check` must still verify the entry. Without the +/// bundling package both attest and verify (the control). +#[test] +fn pnpm_bundled_copy_blocks_vex_but_not_vendor_check() { + let uuid = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; + let purl = "pkg:npm/left-pad@1.3.0"; + let patched = b"patched left-pad\n"; + for (label, bundling) in [ + ("control", ""), + ( + "bundledDependencies: true", + " host-pkg@1.0.0:\n resolution: {integrity: sha512-HOST==}\n \ + bundledDependencies: true\n", + ), + ( + "bundledDependencies list", + " host-pkg@1.0.0:\n resolution: {integrity: sha512-HOST==}\n \ + bundledDependencies:\n - left-pad\n", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let rel = format!(".socket/vendor/npm/{uuid}/left-pad-1.3.0.tgz"); + let sha256 = sha256_hex(&write_member_tgz( + &cwd.join(&rel), + "package/index.js", + patched, + )); + std::fs::write( + cwd.join("pnpm-lock.yaml"), + format!( + "lockfileVersion: '9.0'\n\nimporters:\n .:\n dependencies:\n \ + left-pad:\n specifier: file:{rel}\n version: file:{rel}\n\n\ + packages:\n left-pad@file:{rel}:\n resolution: {{integrity: sha512-xyz==, \ + tarball: file:{rel}}}\n version: 1.3.0\n{bundling}" + ), + ) + .unwrap(); + std::fs::write( + cwd.join("package.json"), + format!( + r#"{{"name":"app","version":"1.0.0","dependencies":{{"left-pad":"file:{rel}"}}}}"# + ), + ) + .unwrap(); + let mut state = VendorState::new(); + state.entries.insert( + purl.to_string(), + VendorEntry { + ecosystem: "npm".to_string(), + base_purl: purl.to_string(), + uuid: uuid.to_string(), + artifact: VendorArtifact { + yarn_berry10c0: None, + path: rel.clone(), + sha256, + size: None, + platform_locked: None, + file_inventory: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + detached: true, + record: Some(make_record( + uuid, + "package/index.js", + &compute_git_sha256_from_bytes(patched), + "GHSA-bndl-aaaa", + &["CVE-2026-1033"], + )), + flavor: Some("pnpm".to_string()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + }, + ); + std::fs::create_dir_all(cwd.join(".socket/vendor")).unwrap(); + std::fs::write( + cwd.join(".socket/vendor/state.json"), + serde_json::to_string_pretty(&state).unwrap(), + ) + .unwrap(); + + let check = cli() + .args([ + "vendor", + "--check", + "--cwd", + cwd.to_str().unwrap(), + "--json", + ]) + .output() + .expect("invoke vendor --check"); + let check_env: Value = serde_json::from_slice(&check.stdout).unwrap_or_else(|e| { + panic!( + "{label}: vendor --check envelope JSON on stdout ({e}): {}", + String::from_utf8_lossy(&check.stdout) + ) + }); + assert!( + check.status.success(), + "{label}: the wiring is intact: {check_env}" + ); + + let (code, env) = vex_json(cwd, &["--offline"]); + if bundling.is_empty() { + assert_eq!(code, Some(0), "{label}: {env}"); + continue; + } + assert_eq!(code, Some(1), "{label}: {env}"); + assert!( + !cwd.join("out.vex.json").exists(), + "{label}: no document may attest the purl: {env}" + ); + let event = skipped_event(&env, purl); + assert_eq!( + event["errorCode"], "vex_pnpm_bundled_copy", + "{label}: {env}" + ); + assert!( + env.to_string().contains("host-pkg@1.0.0"), + "{label}: the run names the bundling entry: {env}" + ); + } +} + // ────────────────────────────────────────────────────────────────────── // 9. Core discover rule 11: a vendor ledger entry whose artifact the // lockfiles still MENTION, but only in a shape the package manager does not diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs index 7d7b1b74c..d10399d62 100644 --- a/crates/socket-patch-core/src/crawlers/mod.rs +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -31,7 +31,7 @@ pub use go_crawler::GoCrawler; pub use maven_crawler::MavenCrawler; pub use npm_crawler::NpmCrawler; pub use nuget_crawler::NuGetCrawler; -pub use pkg_managers::{detect_npm_pkg_manager, NpmPkgManager}; +pub use pkg_managers::{detect_npm_pkg_manager, NpmPkgManager, YarnPnpLoader}; pub use python_crawler::PythonCrawler; pub use ruby_crawler::RubyCrawler; pub use types::*; diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index dddbad4a7..4351f540c 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -313,10 +313,140 @@ pub(crate) fn pnpm_pnp_layout(project_root: &Path) -> bool { && !project_root.join("yarn.lock").is_file() } +/// The yarn Plug'n'Play loader of a project: the text of each loader file +/// the install wrote (the [`PNP_MARKERS`] plus berry's `.pnp.data.json`, +/// written when `pnpEnableInlining: false`). +/// +/// [`PNP_MARKERS`]: crate::constants::npm_family::PNP_MARKERS +/// +/// The npm crawler cannot look inside a PnP install (the packages are +/// zips in a cache), so "the crawler found no copy" says nothing there. +/// What the loader CAN say is which locator each package resolved to when +/// it was written — the evidence `vex` needs before it trusts a lock's +/// hosted pin over a PnP install (#519). +#[derive(Debug, Clone, Default)] +pub struct YarnPnpLoader { + texts: Vec, +} + +impl YarnPnpLoader { + /// The loader at `project_root`, or `None` unless the project is a + /// yarn PnP layout ([`NpmPkgManager::YarnBerryPnP`] — pnpm's own + /// `node-linker=pnp` tree is not). A loader file that cannot be read + /// contributes nothing, so it never vouches for a patch. + pub fn detect(project_root: &Path) -> Option { + if detect_npm_pkg_manager(project_root) != NpmPkgManager::YarnBerryPnP { + return None; + } + let texts = crate::constants::npm_family::PNP_MARKERS + .iter() + .chain(std::iter::once(&".pnp.data.json")) + .filter_map(|name| { + crate::utils::fs::read_regular_to_string_sync(&project_root.join(name)).ok() + }) + .collect(); + Some(YarnPnpLoader { texts }) + } + + /// Whether the loader resolves a package through Socket patch `uuid`, + /// fetched from the hosted `urls`. + /// + /// * yarn berry keeps each locator's reference verbatim in the package + /// registry, so a package installed from the hosted tarball names its + /// url (`/…//-.tgz`, or the legacy + /// percent-encoded `npm:::__archiveUrl=` binding): the uuid, which + /// encoding never changes (hex and `-`), is in the text. + /// * yarn classic names only the cache folder, + /// `npm---`, whose hash is the `#` + /// fragment of the lock's `resolved` url — the patched tarball's, not + /// the registry's. + /// + /// A loader written before the lock was rewired (a pulled hosted lock + /// over an old install) names the registry copy instead, so this is + /// `false` and the copy the loader runs is not the patched one. + pub fn resolves_patch<'a>(&self, uuid: &str, urls: impl IntoIterator) -> bool { + let mut marks = vec![uuid.to_string()]; + marks.extend(urls.into_iter().filter_map(|url| { + let (_, hash) = url.rsplit_once('#')?; + (hash.len() >= 40 && hash.bytes().all(|b| b.is_ascii_hexdigit())) + .then(|| format!("-{hash}")) + })); + !uuid.is_empty() + && self + .texts + .iter() + .any(|text| marks.iter().any(|mark| text.contains(mark.as_str()))) + } +} + #[cfg(test)] mod tests { use super::*; + /// A yarn PnP loader vouches for a patch only when it names it: berry + /// by the hosted url's uuid, classic by the resolved url's hash. A + /// stale loader (the registry locator), an unreadable or empty one, or + /// a non-PnP project never does (#519). + #[test] + fn yarn_pnp_loader_resolves_patch_only_when_it_names_it() { + const UUID: &str = "4d5e6f70-8192-4a3b-9c4d-5e6f70819243"; + const HASH: &str = "88e54a85256e9d1b6ff92cf972a12f91ba21d4da"; + let url = format!("https://patch.socket.dev/patch/npm/t/{UUID}/left-pad-1.3.0.tgz"); + let classic_url = format!("{url}#{HASH}"); + + let d = tempfile::tempdir().unwrap(); + assert!(YarnPnpLoader::detect(d.path()).is_none(), "not PnP"); + + // berry, fresh install: the registry names the hosted locator. + std::fs::write( + d.path().join(".pnp.cjs"), + format!("[\"left-pad\", [[\"{url}\", {{packageLocation: \"./.yarn/cache/x.zip\"}}]]]"), + ) + .unwrap(); + let loader = YarnPnpLoader::detect(d.path()).expect("berry PnP"); + assert!(loader.resolves_patch(UUID, [url.as_str()])); + assert!(!loader.resolves_patch("5e6f7081-92a3-4b4c-8d5e-6f7081920354", [])); + assert!(!loader.resolves_patch("", [])); + + // berry, stale install: the registry locator only. + std::fs::write( + d.path().join(".pnp.cjs"), + "[\"left-pad\", [[\"npm:1.3.0\", {packageLocation: \"./.yarn/cache/x.zip\"}]]]", + ) + .unwrap(); + let loader = YarnPnpLoader::detect(d.path()).unwrap(); + assert!(!loader.resolves_patch(UUID, [url.as_str()])); + + // berry with pnpEnableInlining: false — the data file names it. + std::fs::write( + d.path().join(".pnp.data.json"), + format!("{{\"r\":\"{url}\"}}"), + ) + .unwrap(); + assert!(YarnPnpLoader::detect(d.path()) + .unwrap() + .resolves_patch(UUID, [])); + + // classic: the cache folder carries the resolved url's hash. + let c = tempfile::tempdir().unwrap(); + std::fs::write( + c.path().join(".pnp.js"), + format!("packageLocation: \"/c/v6/npm-left-pad-1.3.0-{HASH}-integrity/\""), + ) + .unwrap(); + let loader = YarnPnpLoader::detect(c.path()).expect("classic PnP"); + assert!(loader.resolves_patch(UUID, [classic_url.as_str()])); + assert!( + !loader.resolves_patch(UUID, [url.as_str()]), + "no hash to match" + ); + let registry = "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e"; + assert!( + !loader.resolves_patch(UUID, [registry]), + "stale: another hash" + ); + } + /// #975: yarn 4 keeps a Yarn 2 `.pnp.js` after a switch to the /// node-modules or pnpm linker; yarn ignores it, so must detection. #[test] diff --git a/crates/socket-patch-core/src/formats/pnpm/grammar.rs b/crates/socket-patch-core/src/formats/pnpm/grammar.rs index c6b435bfe..40fd21fba 100644 --- a/crates/socket-patch-core/src/formats/pnpm/grammar.rs +++ b/crates/socket-patch-core/src/formats/pnpm/grammar.rs @@ -107,6 +107,56 @@ pub(crate) fn entry_field<'a>(entry: &Entry<'a>, field: &str) -> Option<&'a str> values.next().is_none().then_some(first) } +/// What a packages entry's `bundledDependencies:` field says the package +/// ships inside its own tarball (see [`entry_bundled`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Bundled<'a> { + /// The named dependencies (a block or flow list). + Names(Vec<&'a str>), + /// Every dependency (`bundledDependencies: true`), or a value the + /// grammar cannot read: pnpm does not lock bundled dependencies, so + /// which names these are is not in the lock. + All, +} + +/// The entry-level `bundledDependencies:` field of a packages entry, as +/// real pnpm writes it (checked with pnpm 11.27, lockfile 9.0): a block +/// list (` - left-pad`), a flow list, or `true`. pnpm never resolves a +/// bundled dependency, so the lock has no entry for it, and its version is +/// whatever the parent's tarball carries. `None` when the field is absent, +/// `false` or an empty list; a value this grammar cannot read is +/// [`Bundled::All`] (fail closed). +pub(crate) fn entry_bundled<'a>(entry: &Entry<'a>) -> Option> { + let mut lines = entry.body.lines().map(|line| line.trim_end_matches('\r')); + let value = lines.by_ref().find_map(|line| { + let rest = line.strip_prefix(" ").filter(|r| !r.starts_with(' '))?; + let (key, value) = rest.split_once(':')?; + matches!(key, "bundledDependencies" | "bundleDependencies").then(|| value.trim()) + })?; + let names: Vec<&str> = match value { + "false" => return None, + "true" => return Some(Bundled::All), + "" => lines + .take_while(|line| line.starts_with(" ")) + .map(|line| line.trim_start().strip_prefix("- ").map(str::trim)) + .collect::>() + .unwrap_or_else(|| vec![""]), + flow => match flow.strip_prefix('[').and_then(|f| f.strip_suffix(']')) { + Some(inner) => inner + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect(), + None => vec![""], + }, + }; + let names: Vec<&str> = names.into_iter().map(unquote).collect(); + if names.iter().any(|name| name.is_empty()) { + return Some(Bundled::All); + } + (!names.is_empty()).then_some(Bundled::Names(names)) +} + /// Loose identity match, also used to refuse unsupported suffixes atomically. pub(crate) fn suffix<'a>(key: &'a str, name: &str, version: &str) -> Option<&'a str> { let key = unquote(key); @@ -360,3 +410,47 @@ pub(crate) fn resolution<'a>(entry: &Entry<'a>) -> Option> { } None } + +#[cfg(test)] +mod tests { + use super::*; + + /// `entry_bundled` of a lock whose first packages entry ends in `field`. + fn assert_bundled(field: &str, expected: Option>) { + let lock = format!( + "lockfileVersion: '9.0'\n\npackages:\n\n host@1.0.0:\n resolution: \ + {{integrity: sha512-AA==}}\n version: 1.0.0\n{field}\n other@1.0.0:\n \ + resolution: {{integrity: sha512-BB==}}\n" + ); + let entries = entries(&lock); + assert_eq!(entry_bundled(&entries[0]), expected, "{field:?}"); + } + + /// Every spelling of `bundledDependencies:` real pnpm writes (block + /// list, `true`) plus the flow list and quoted names; an unreadable + /// value fails closed to [`Bundled::All`]. + #[test] + fn entry_bundled_reads_every_spelling() { + let names = |n: Vec<&'static str>| Some(Bundled::Names(n)); + assert_bundled("", None); + assert_bundled( + " bundledDependencies:\n - left-pad\n - '@s/x'", + names(vec!["left-pad", "@s/x"]), + ); + assert_bundled( + " bundledDependencies:\r\n - left-pad\r", + names(vec!["left-pad"]), + ); + assert_bundled( + " bundledDependencies: [left-pad, '@s/x']", + names(vec!["left-pad", "@s/x"]), + ); + assert_bundled(" bundledDependencies: true", Some(Bundled::All)); + assert_bundled(" bundledDependencies: false", None); + assert_bundled(" bundledDependencies: []", None); + // Not a list: fail closed. + assert_bundled(" bundledDependencies: left-pad", Some(Bundled::All)); + // A nested field of the same name is not the entry's. + assert_bundled(" engines:\n bundledDependencies: true", None); + } +} diff --git a/crates/socket-patch-core/src/formats/pnpm/mod.rs b/crates/socket-patch-core/src/formats/pnpm/mod.rs index 75dcb1d43..03ad7d97d 100644 --- a/crates/socket-patch-core/src/formats/pnpm/mod.rs +++ b/crates/socket-patch-core/src/formats/pnpm/mod.rs @@ -29,7 +29,9 @@ pub(crate) mod hosted; pub(crate) mod lines; pub(crate) mod workspace; -pub(crate) use grammar::{entry_field, is_pnpm_lock_text, Entry, Resolution}; +pub(crate) use grammar::{ + entry_bundled, entry_field, is_pnpm_lock_text, Bundled, Entry, Resolution, +}; pub(crate) use hosted::plan_hosted; use std::collections::HashSet; diff --git a/crates/socket-patch-core/src/vex/discover/deno.rs b/crates/socket-patch-core/src/vex/discover/deno.rs index 9b680aaf2..92fdb939e 100644 --- a/crates/socket-patch-core/src/vex/discover/deno.rs +++ b/crates/socket-patch-core/src/vex/discover/deno.rs @@ -9,14 +9,83 @@ //! a user's own import. The extractor exists so the per-ecosystem coverage is //! explicit and a future backend has an obvious home; Deno patches attest //! only through the manifest + installed tree (agent mode, `setup.manual`). +//! +//! It still reads `deno.lock`'s npm section as evidence AGAINST an +//! npm-family wiring: `deno install` installs a `package.json` project's +//! npm dependencies from `deno.lock` and never reads `package-lock.json` / +//! `pnpm-lock.yaml` / `yarn.lock`, so a Socket pin there does not reach +//! the copy Deno installs (#406). Every `name@version` of that section is +//! an [`UnwiredCopy`]: a ref of the same version in any lock is marked +//! [`Unattested`](super::Unattested) (`vex` omits it) but stays a ref. +//! Whether Deno or another package manager populates `node_modules` +//! (`nodeModulesDir: "manual"` allows either) is not in the files, so +//! this is a missed attestation at worst; and since re-running `scan` / +//! `vendor` cannot clear it, the ledgers' liveness gates (`vendor +//! --check`, `scan`) are left alone. The npm section is the top-level +//! `npm` map in lockfile versions 4 and 5, `npm.packages` in version 2 +//! and `packages.npm` in version 3; a key may carry Deno's peer suffix +//! (`name@1.0.0_peer@2.0.0`). The read is advisory: an unreadable or +//! unparseable `deno.lock` marks nothing. + +use serde_json::Value; + +use std::path::PathBuf; -use super::{DiscoverCtx, Discovery}; +use super::{ + canonical_base_purl, npm_purl, parse_json, CopyTarget, DiscoverCtx, Discovery, UnattestedKind, + UnwiredCopy, +}; -pub(crate) async fn extract(_ctx: &DiscoverCtx<'_>, _out: &mut Discovery) {} +const DENO_LOCK: &str = "deno.lock"; + +pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { + let Some(text) = ctx.read_advisory_text(DENO_LOCK).await else { + return; + }; + let Ok(lock) = parse_json(DENO_LOCK, text.as_bytes()) else { + return; + }; + for purl in deno_npm_keys(&lock).into_iter().filter_map(deno_npm_purl) { + out.unwired_copy(UnwiredCopy { + scope: None, + target: CopyTarget::Purl(canonical_base_purl(&purl)), + file: PathBuf::from(DENO_LOCK), + detail: format!( + "{DENO_LOCK} also locks it, and `deno install` installs this project's npm \ + dependencies from {DENO_LOCK}, where no Socket wiring reaches" + ), + kind: UnattestedKind::DenoLock, + }); + } +} + +/// The keys of `deno.lock`'s npm package map, in any lockfile version. +fn deno_npm_keys(lock: &Value) -> Vec<&str> { + let npm = lock.get("npm"); + let map = npm + .and_then(|n| n.get("packages")) + .or(npm) + .or_else(|| lock.get("packages").and_then(|p| p.get("npm"))) + .and_then(Value::as_object); + map.map(|m| m.keys().map(String::as_str).collect()) + .unwrap_or_default() +} + +/// The purl of one npm package key (`name@version`, `@scope/name@version`, +/// either with Deno's `_peer@x` suffix). +fn deno_npm_purl(key: &str) -> Option { + let at = key.get(1..)?.find('@')? + 1; + let (name, version) = (&key[..at], &key[at + 1..]); + let version = version.split('_').next()?; + npm_purl(name, version) +} #[cfg(test)] mod tests { use super::super::testing::*; + use super::super::WiringMode; + + const SRI: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; /// Even a lock that names a patch-server url yields nothing. #[tokio::test] @@ -33,4 +102,85 @@ mod tests { let all = p.discover().await; assert!(all.refs.is_empty(), "{:?}", all.refs); } + + #[test] + fn deno_npm_keys_cover_every_lock_version() { + let keys = |text: &str| { + let lock: serde_json::Value = serde_json::from_str(text).unwrap(); + super::deno_npm_keys(&lock) + .into_iter() + .filter_map(super::deno_npm_purl) + .collect::>() + }; + let want = vec!["pkg:npm/left-pad@1.3.0".to_string()]; + // v4 / v5 (deno 2.9 writes this), v3, v2. + assert_eq!(keys(r#"{"version":"5","npm":{"left-pad@1.3.0":{}}}"#), want); + assert_eq!( + keys(r#"{"version":"3","packages":{"npm":{"left-pad@1.3.0":{}}}}"#), + want + ); + assert_eq!( + keys(r#"{"version":"2","npm":{"specifiers":{},"packages":{"left-pad@1.3.0":{}}}}"#), + want + ); + assert_eq!(keys(r#"{"version":"5"}"#), Vec::::new()); + assert_eq!( + super::deno_npm_purl("@types/node@20.0.0"), + super::super::npm_purl("@types/node", "20.0.0") + ); + assert_eq!( + super::deno_npm_purl("left-pad@1.3.0_react@18.2.0"), + super::super::npm_purl("left-pad", "1.3.0") + ); + assert_eq!(super::deno_npm_purl("nonsense"), None); + } + + /// REGRESSION (#406): `deno install` installs a `package.json` + /// project's npm deps from `deno.lock` and never reads + /// `package-lock.json`, so a hosted pin in the npm lock beside a + /// deno.lock entry of the same version is marked unattested — but stays + /// a ref with a live claim (no rewire could clear it). Another version + /// in deno.lock marks nothing. + #[tokio::test] + async fn deno_lock_marks_an_npm_lock_pin_of_the_same_version_unattested() { + let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let npm_lock = format!( + r#"{{"name":"m","lockfileVersion":3,"packages":{{"":{{"name":"m"}}, + "node_modules/left-pad":{{"version":"1.3.0","resolved":"{url}","integrity":"{SRI}"}}}}}}"# + ); + for (deno_version, marked) in [("1.3.0", true), ("1.2.0", false)] { + let p = Project::new(); + p.write("package-lock.json", npm_lock.clone()); + p.write( + "deno.lock", + format!( + r#"{{"version":"5","npm":{{"left-pad@{deno_version}":{{"integrity":"sha512-X=="}}}}}}"# + ), + ); + let out = p.discover().await; + assert_refs( + &out, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)], + ); + assert_eq!( + out.hosted_claim("pkg:npm/left-pad@1.3.0", UUID_A), + Some(true), + "{deno_version}" + ); + assert!(out.contested.is_empty(), "{:#?}", out.contested); + assert_eq!( + out.unattested.len(), + usize::from(marked), + "{deno_version}: {:#?}", + out.unattested + ); + if marked { + let u = &out.unattested[0]; + assert_eq!(u.kind, super::super::UnattestedKind::DenoLock); + assert_eq!(u.uuid, UUID_A); + assert_eq!(u.file, std::path::Path::new("deno.lock")); + assert!(u.detail.contains("deno.lock"), "{}", u.detail); + } + } + } } diff --git a/crates/socket-patch-core/src/vex/discover/gradle.rs b/crates/socket-patch-core/src/vex/discover/gradle.rs index 95fe73d82..248e01d0a 100644 --- a/crates/socket-patch-core/src/vex/discover/gradle.rs +++ b/crates/socket-patch-core/src/vex/discover/gradle.rs @@ -50,7 +50,8 @@ use std::collections::BTreeMap; use super::{ - maven_purl, DiscoverCtx, Discovery, PatchedRef, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, + maven_purl, DiscoverCtx, Discovery, PatchedRef, UnattestedKind, DIAG_LOCKFILE_UNPARSEABLE, + DIAG_REF_INVALID, }; use crate::gradle::eol::eol_eq; use crate::gradle::locks; @@ -192,7 +193,13 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { continue; }; if let Some((rel, detail)) = bypass { - out.unattested(&purl, &row.uuid, &rel, detail); + out.unattested( + &purl, + &row.uuid, + &rel, + detail, + UnattestedKind::LockAboveBase, + ); } out.push(PatchedRef::hosted( purl, diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index ba855e20b..bae6d3b4f 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -441,12 +441,31 @@ pub struct ContestedRef { pub other: PathBuf, } -/// A ref discovery emits (so rollback, remove and list find the wiring) -/// that must not be attested: the files show a build that resolves the -/// package from somewhere the pin does not reach. Today: a Gradle lock -/// entry above the hosted pin's base (the owned script lets that newer -/// upstream release resolve), so that build consumes no patch. The CLI's -/// VEX plan omits every candidate of `(purl, uuid)` with `detail`. +/// Why an [`Unattested`] ref's wiring does not reach the copy a build runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum UnattestedKind { + /// A Gradle lock entry above the hosted pin's base: the owned script + /// lets that newer upstream release resolve. + LockAboveBase, + /// The ref's own pnpm lock names a package that bundles a copy of it + /// (`bundledDependencies`): pnpm unpacks that copy from the parent's + /// tarball, where no wiring reaches, and does not lock its version. + BundledCopy, + /// `deno.lock` locks the same `name@version`: `deno install` installs + /// a `package.json` project's npm deps from it, never from the + /// npm-family lock that carries the wiring. + DenoLock, +} + +/// A ref discovery emits (so rollback, remove and list find the wiring, +/// and the ledgers' liveness gates still see it wired) that must not be +/// attested: the files show a build that may run a copy the pin does not +/// reach ([`UnattestedKind`]). The CLI's VEX plan omits every candidate of +/// `(purl, uuid)` with `detail`. Unlike a contest +/// ([`Discovery::unpatched_copy`], [`Discovery::contest_across_locks`]), +/// this never drops the ref, so `vendor --check` and `scan` keep treating +/// the wiring as live — the right answer when re-running `scan` / `vendor` +/// could never clear the evidence. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Unattested { /// Canonical base purl ([`canonical_base_purl`]). @@ -455,6 +474,49 @@ pub struct Unattested { /// Root-relative file that shows the bypass. pub file: PathBuf, pub detail: String, + pub kind: UnattestedKind, +} + +/// Which refs an [`UnwiredCopy`] may stand beside. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum CopyTarget { + /// Exactly this canonical base purl. + Purl(String), + /// An npm package of this name, whatever its version. + NpmName(String), + /// Every ref. + Any, +} + +/// A copy some build installs where no Socket wiring reaches, recorded by +/// an extractor ([`Discovery::unwired_copy`]) and turned into +/// [`Unattested`] marks for the refs it may stand beside once every +/// extractor has run ([`Discovery::unattest_unwired_copies`]). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct UnwiredCopy { + /// Only refs of this root-relative file; `None` = refs of any file. + pub(crate) scope: Option, + pub(crate) target: CopyTarget, + /// Root-relative file that shows the copy. + pub(crate) file: PathBuf, + /// Completes "patch is wired, but …"; names the file. + pub(crate) detail: String, + pub(crate) kind: UnattestedKind, +} + +impl UnwiredCopy { + fn covers(&self, r: &PatchedRef) -> bool { + if self.scope.as_ref().is_some_and(|f| *f != r.source_file) { + return false; + } + match &self.target { + CopyTarget::Purl(purl) => *purl == r.purl, + CopyTarget::NpmName(name) => crate::utils::purl::purl_name_version(&r.purl) + .and_then(|(_, version)| npm_purl(name, version)) + .is_some_and(|p| canonical_base_purl(&p) == r.purl), + CopyTarget::Any => true, + } + } } /// Everything [`discover_patched_refs`] found. @@ -479,6 +541,9 @@ pub struct Discovery { pub unpatched_copies: Vec, /// Refs in `refs` whose wiring a build bypasses ([`Unattested`]). pub unattested: Vec, + /// Copies no wiring reaches, pending [`Discovery::unattest_unwired_copies`]; + /// always empty once discovery returns (folded into `unattested`). + pub unwired_copies: Vec, /// Refs dropped because another lock contests them ([`ContestedRef`]). pub contested: Vec, } @@ -821,15 +886,55 @@ impl Discovery { /// Record that the ref `(purl, uuid)` is wired but bypassed by the /// build `file` shows ([`Unattested`]). Pushed beside the ref itself. - pub(crate) fn unattested(&mut self, purl: &str, uuid: &str, file: &str, detail: String) { + pub(crate) fn unattested( + &mut self, + purl: &str, + uuid: &str, + file: &str, + detail: String, + kind: UnattestedKind, + ) { self.unattested.push(Unattested { purl: canonical_base_purl(purl), uuid: uuid.to_string(), file: PathBuf::from(file), detail, + kind, }); } + /// Record a copy some build installs where no Socket wiring reaches, + /// when the files cannot tie it to a ref's exact `name@version` or + /// cannot say the build runs it ([`UnwiredCopy`]). Every ref it may + /// stand beside is marked [`Unattested`] — a missed attestation at + /// worst, never a false one — and stays a ref, so no ledger claim dies + /// over evidence a rewire could never clear. + pub(crate) fn unwired_copy(&mut self, copy: UnwiredCopy) { + self.unwired_copies.push(copy); + } + + /// Mark every surviving ref an [`UnwiredCopy`] covers [`Unattested`] + /// (the first covering copy names the cause), once every extractor has + /// run. + fn unattest_unwired_copies(&mut self) { + let copies = std::mem::take(&mut self.unwired_copies); + let marks: Vec = self + .refs + .iter() + .filter_map(|r| { + let c = copies.iter().find(|c| c.covers(r))?; + Some(Unattested { + purl: r.purl.clone(), + uuid: r.uuid.clone(), + file: c.file.clone(), + detail: c.detail.clone(), + kind: c.kind, + }) + }) + .collect(); + self.unattested.extend(marks); + } + fn finalize(&mut self) { self.elsewhere.sort(); self.elsewhere.dedup(); @@ -911,6 +1016,7 @@ async fn discover_with_ctx(ctx: DiscoverCtx<'_>) -> Discovery { deno::extract(&ctx, &mut out).await; out.contest_within_locks(); out.contest_across_locks(); + out.unattest_unwired_copies(); out.recognized.extend(ctx.take_recognized()); out.finalize(); out @@ -2326,8 +2432,10 @@ pub(crate) mod testing { let ctx = self.ctx(); let mut out = Discovery::default(); extract(&ctx, &mut out).await; - // Same-lock copies contest within one extractor's own locks. + // Same-lock copies contest within one extractor's own locks, + // and its unwired copies mark its own refs. out.contest_within_locks(); + out.unattest_unwired_copies(); let swept = ctx.take_recognized(); assert_recognition_covers_refs(&out, &swept, "the ctx sweep", Some(self.root())); out.recognized.extend(swept); diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index c130efa95..1412b1b3b 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -41,17 +41,19 @@ //! the entry is not Socket-written and is diagnosed, not trusted. use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; use serde_json::Value; use super::{ - npm_purl, npm_vendored_tarball_names, parse_json, vendor_ref, DiscoverCtx, Discovery, - LocateOpts, Located, PatchedRef, VendorRef, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, - DIAG_REF_UNATTRIBUTABLE, + npm_purl, npm_vendored_tarball_names, parse_json, vendor_ref, CopyTarget, DiscoverCtx, + Discovery, LocateOpts, Located, PatchedRef, UnattestedKind, UnwiredCopy, VendorRef, + DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; use crate::constants::npm_family::{NPM_LOCKS, PNPM_LOCK, PNPM_SHRINKWRAP_LEGACY}; use crate::formats::pnpm::{ - classify_pnpm_key, entry_field, pnpm_registry_key, PnpmKey, PnpmLock, PnpmPackage, + classify_pnpm_key, entry_bundled, entry_field, pnpm_registry_key, Bundled, PnpmKey, PnpmLock, + PnpmPackage, }; use crate::utils::digest::is_sri_pin; use crate::vendor::lock_inventory::pnpm::rush_lock_rels; @@ -573,10 +575,59 @@ async fn extract_pnpm_lock(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discover return; } let mut copies: Vec = Vec::new(); + let mut bundles: Vec<(&str, Bundled<'_>)> = Vec::new(); for package in lock.packages() { pnpm_entry_ref(ctx, file, package, &mut copies, out); + bundles.extend(entry_bundled(&package.entry).map(|b| (package.key, b))); } record_pnpm_file_copies(ctx, file, copies, out).await; + record_pnpm_bundled_copies(file, bundles, out); +} + +/// Record the bundled copies a pnpm lock installs ([`UnwiredCopy`]). +/// pnpm unpacks a package's `bundledDependencies` from its own tarball +/// into its store directory +/// (`node_modules/.pnpm//node_modules//node_modules/`) +/// and never resolves them, so no Socket wiring of the lock reaches that +/// copy — the npm, bun and vlt extractors contest the same case from their +/// locks' bundled entries. Unlike theirs, the pnpm lock names the bundled +/// package but not its version (that lives in the parent's tarball), so +/// the copy cannot be tied to a ref's `name@version`: every ref of the same +/// NAME in this lock is marked unattested whatever its version (a missed +/// attestation when the bundled copy is another version, never a false +/// one), and `bundledDependencies: true` — every dependency of the parent, +/// which the lock does not list — marks every ref of the lock. The refs +/// stay refs: the wiring is intact and no rewire could clear the bundled +/// copy, so the ledgers' liveness gates (`vendor --check`, `scan`) keep +/// seeing them live, and the copy is no cross-lock evidence either. +fn record_pnpm_bundled_copies(file: &str, bundles: Vec<(&str, Bundled<'_>)>, out: &mut Discovery) { + for (parent, bundled) in bundles { + let (targets, what) = match bundled { + Bundled::All => ( + vec![CopyTarget::Any], + "bundles every dependency (`bundledDependencies: true`)", + ), + Bundled::Names(names) => ( + names + .into_iter() + .map(|name| CopyTarget::NpmName(name.to_string())) + .collect(), + "bundles a copy of it (`bundledDependencies`)", + ), + }; + for target in targets { + out.unwired_copy(UnwiredCopy { + scope: Some(PathBuf::from(file)), + target, + file: PathBuf::from(file), + detail: format!( + "{file} entry `{parent}` {what}, which pnpm unpacks from that package's \ + own tarball without locking its version, so no Socket wiring reaches it" + ), + kind: UnattestedKind::BundledCopy, + }); + } + } } /// A pnpm `packages:` entry installed from a user's `file:` directory or @@ -2159,6 +2210,153 @@ mod tests { ); } + /// pnpm unpacks a package's `bundledDependencies` from its own tarball + /// and never locks them, so a bundled copy of the wired package stays + /// unpatched beside the Socket wiring (audit B04; npm, bun and vlt + /// already contest it). The lock does not record the bundled version, + /// so a ref of the same name is marked unattested; a bundle of another + /// name is not. The ref itself STAYS a ref (the wiring is intact and no + /// rewire could clear the bundled copy), so the ledgers' liveness gates + /// keep it live. Hosted and vendored refs alike, v9 and legacy keys. + #[tokio::test] + async fn pnpm_bundled_copy_marks_the_ref_unattested() { + let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let wired = + format!(" left-pad@1.3.0:\n resolution: {{integrity: {SRI}, tarball: {url}}}\n\n"); + let host = |key: &str, field: &str| { + format!(" {key}:\n resolution: {{integrity: sha512-HOST==}}\n{field}\n\n") + }; + let lock = |extra: &str| format!("lockfileVersion: '9.0'\n\npackages:\n\n{wired}{extra}"); + let hosted = [("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)]; + + // Controls: no bundle, a bundle of another name, `false`. + for extra in [ + String::new(), + host( + "host-pkg@1.0.0", + " bundledDependencies:\n - right-pad", + ), + host("host-pkg@1.0.0", " bundledDependencies: false"), + ] { + let p = Project::new(); + p.write("pnpm-lock.yaml", lock(&extra)); + let out = run(&p).await; + assert_refs(&out, &hosted); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + } + + for (case, text) in [ + ( + "v9 block list", + lock(&host( + "host-pkg@1.0.0", + " bundledDependencies:\n - left-pad", + )), + ), + ( + "v9 flow list", + lock(&host( + "host-pkg@1.0.0", + " bundledDependencies: [left-pad]", + )), + ), + ( + "v9 true", + lock(&host("host-pkg@1.0.0", " bundledDependencies: true")), + ), + ( + "v6 key", + format!( + "lockfileVersion: '6.0'\n\npackages:\n\n /left-pad@1.3.0:\n \ + resolution: {{integrity: {SRI}, tarball: {url}}}\n dev: false\n\n{}", + host( + "/host-pkg@1.0.0", + " bundledDependencies:\n - left-pad" + ) + ), + ), + ] { + let p = Project::new(); + p.write("pnpm-lock.yaml", text); + let out = run(&p).await; + assert_refs(&out, &hosted); + assert_eq!( + out.hosted_claim("pkg:npm/left-pad@1.3.0", UUID_A), + Some(true), + "{case}" + ); + assert_eq!(out.unattested.len(), 1, "{case}: {:#?}", out.unattested); + let u = &out.unattested[0]; + assert_eq!( + (u.purl.as_str(), u.uuid.as_str(), u.kind), + ( + "pkg:npm/left-pad@1.3.0", + UUID_A, + UnattestedKind::BundledCopy + ), + "{case}" + ); + assert_eq!(u.file, std::path::Path::new("pnpm-lock.yaml"), "{case}"); + assert!( + u.detail.contains("host-pkg@1.0.0") && u.detail.contains("bundl"), + "{case}: {}", + u.detail + ); + } + + // A vendored ref is marked the same way, and its ledger claim stays + // live: `vendor --check` must not fail over a copy no rewire clears. + let p = Project::new(); + let rel = format!(".socket/vendor/npm/{UUID_A}/left-pad-1.3.0.tgz"); + p.write(&rel, npm_tgz("left-pad", "1.3.0")); + p.write( + "pnpm-lock.yaml", + format!( + "lockfileVersion: '9.0'\n\npackages:\n\n left-pad@file:{rel}:\n \ + resolution: {{integrity: {SRI}, tarball: file:{rel}}}\n version: 1.3.0\n\n{}", + host("host-pkg@1.0.0", " bundledDependencies: true") + ), + ); + let out = p.discover().await; + assert_refs( + &out, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Vendored)], + ); + assert_eq!( + out.vendored_claim("pkg:npm/left-pad@1.3.0", UUID_A, &rel), + Some(true) + ); + assert_eq!(out.unattested.len(), 1, "vendored: {:#?}", out.unattested); + } + + /// The pnpm bundled mark stays in its own lock: a same-version ref in + /// another lock (a `package-lock.json` twin) is neither marked nor + /// contested by it — the pnpm lock does not record the bundled version, + /// so it is no cross-lock evidence. + #[tokio::test] + async fn pnpm_bundled_copy_does_not_reach_another_lock() { + let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let p = Project::new(); + p.write( + "package-lock.json", + format!( + r#"{{"name":"m","lockfileVersion":3,"packages":{{"":{{"name":"m"}}, + "node_modules/left-pad":{{"version":"1.3.0","resolved":"{url}","integrity":"{SRI}"}}}}}}"# + ), + ); + p.write( + "pnpm-lock.yaml", + "lockfileVersion: '9.0'\n\npackages:\n\n host-pkg@1.0.0:\n \ + resolution: {integrity: sha512-HOST==}\n bundledDependencies: true\n\n", + ); + let out = p.discover().await; + assert_refs( + &out, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)], + ); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + } + /// The committed golden (TS backend output — what a depscan PR leaves). #[tokio::test] async fn pnpm_golden_hosted_fixture() { diff --git a/crates/socket-patch-core/src/vex/discover/testing/golden.rs b/crates/socket-patch-core/src/vex/discover/testing/golden.rs index 36b15632e..823f8e4a0 100644 --- a/crates/socket-patch-core/src/vex/discover/testing/golden.rs +++ b/crates/socket-patch-core/src/vex/discover/testing/golden.rs @@ -123,6 +123,8 @@ fn render(out: &Discovery, root: &Path) -> Value { unpatched_copies, unattested, contested, + // Already folded into `unattested` by the time a run returns. + unwired_copies: _, } = out; let refs: Vec = refs .iter() @@ -217,12 +219,34 @@ fn render(out: &Discovery, root: &Path) -> Value { uuid, file, detail, + kind, } = u; json!({ "purl": purl, "uuid": uuid, "file": path_str(file), "detail": normalize(detail, &roots), + "kind": format!("{kind:?}"), + }) + }) + .collect::>() + .into(); + } + if !unpatched_copies.is_empty() { + rendered["unpatched_copies"] = unpatched_copies + .iter() + .map(|c| { + let UnpatchedCopy { + purl, + file, + key, + how, + } = c; + json!({ + "purl": purl, + "file": path_str(file), + "key": key, + "how": normalize(how, &roots), }) }) .collect::>() diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index 8ea1cc8c8..a9000bcfc 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -402,9 +402,20 @@ fn berry_block( // locator itself encodes (npm: locators only). let (spec, locator_version) = if let Some((version, _)) = locator.npm() { let Some(archive) = locator.archive_url() else { - // A plain registry entry. + // A plain registry entry: an unpatched copy, which contests a + // wiring of the same version in this lock (berry installs every + // locator the lock resolves, so a registry locator beside a + // hosted or vendored one of the same `name@version` — scoped + // `resolutions`, a workspace member added after the rewire — + // ships the registry bytes too) and in any other. let version = berry_field(&block.lines, "version").unwrap_or(version); - out.resolved_elsewhere(YARN_LOCK, npm_purl(name, version)); + out.unpatched_copy( + YARN_LOCK, + npm_purl(name, version), + &block.key, + "installs it from the registry, not a Socket patch (yarn berry installs \ + every locator the lock resolves)", + ); return; }; (archive, Some(version)) @@ -417,7 +428,12 @@ fn berry_block( // A custom-registry `__archiveUrl`: the registry package. if let (Some(v), false) = (locator_version, root_anchored_spelling(spec)) { let version = berry_field(&block.lines, "version").unwrap_or(v); - out.resolved_elsewhere(YARN_LOCK, npm_purl(name, version)); + out.unpatched_copy( + YARN_LOCK, + npm_purl(name, version), + &block.key, + &format!("installs it from {spec:?}, not a Socket patch"), + ); } else if locator_version.is_none() && !root_anchored_spelling(spec) { // A user's `file:` / url copy: yarn keys it by the DEPENDENCY // name (`lp2@file:…`), so which package it installs is read @@ -1464,6 +1480,47 @@ mod tests { } } + /// A berry registry locator beside a hosted one of the same + /// `name@version` (scoped `resolutions`, or a workspace member added + /// after the rewire, then `yarn install`) installs the registry bytes + /// too, so the hosted ref is contested in the same lock — the rule + /// `vex`'s yarn PnP loader check relies on, since a loader that names + /// both locators still names the patch (#1033 review). A registry + /// locator of another version contests nothing. + #[tokio::test] + async fn berry_registry_locator_beside_a_hosted_one_contests_it() { + let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let hosted = berry_block( + "left-pad@npm:1.3.0", + "1.3.0", + &format!("left-pad@npm:1.3.0::__archiveUrl={}", archive(&url)), + Some("10c0/aaaa"), + ); + for (version, contested) in [("1.3.0", true), ("1.2.0", false)] { + let registry = berry_block( + &format!("left-pad@npm:^{version}"), + version, + &format!("left-pad@npm:{version}"), + Some("10c0/bbbb"), + ); + let p = Project::new(); + p.write("yarn.lock", berry(&[hosted.clone(), registry])); + let out = run(&p).await; + assert_eq!(out.refs.is_empty(), contested, "{version}: {:#?}", out.refs); + if contested { + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains("left-pad@npm:^1.3.0") + && d.detail.contains("UNPATCHED")), + "{:#?}", + out.diagnostics + ); + } + } + } + /// The vendored berry pair exactly as `vendor::yarn_berry_lock` writes it /// — spike B3's lock entry plus the root `package.json` `resolutions` /// value — for a plain and a scoped package. diff --git a/crates/socket-patch-core/src/vex/mod.rs b/crates/socket-patch-core/src/vex/mod.rs index 4abef38b8..0ce4b58ea 100644 --- a/crates/socket-patch-core/src/vex/mod.rs +++ b/crates/socket-patch-core/src/vex/mod.rs @@ -28,7 +28,7 @@ pub use build::{build_document, BuildOptions}; pub use discover::{ canonical_base_purl, discover_patched_refs, discover_patched_refs_in, discover_patched_refs_with, Diag, DiscoverOptions, Discovery, PatchedRef, Recognized, - Unattested, UnlockedPin, WiringMode, + Unattested, UnattestedKind, UnlockedPin, WiringMode, }; pub use product::{detect_product, DetectResult}; pub use schema::{ diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json index 32a8dedda..6b78afbec 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json @@ -6516,7 +6516,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.3.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-berry/cachekey-mismatch-refusal/input": { "refs": [], @@ -6529,7 +6537,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.3.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-berry/existing-archive-url/expected": { "refs": [ @@ -6589,7 +6605,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.3.0\"", + "how": "installs it from \"https%3A%2F%2Fregistry.corp.example%2Fleft-pad-1.3.0.tgz\", not a Socket patch" + } + ] }, "redirect/npm/yarn-berry/missing-berry-checksum/input": { "refs": [], @@ -6602,7 +6626,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.3.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-berry/multi-descriptor-key/expected": { "refs": [ @@ -6662,7 +6694,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.0.0, left-pad@npm:^1.3.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-berry/multiple-versions/expected": { "refs": [ @@ -6714,6 +6754,14 @@ "uuid": "77777777-7777-7777-7777-777777777777", "purl": "pkg:npm/left-pad@1.3.0" } + ], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.0.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.0.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } ] }, "redirect/npm/yarn-berry/multiple-versions/input": { @@ -6731,7 +6779,21 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.0.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.0.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + }, + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.3.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-berry/rerun-noop/input": { "refs": [ @@ -6838,7 +6900,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/@babel/core@7.0.0", + "file": "yarn.lock", + "key": "\"@babel/core@npm:^7.0.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-berry/yarnrc-compression-refusal/input": { "refs": [], @@ -6851,7 +6921,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.3.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-classic/alias-guard/input": { "refs": [], diff --git a/docs/testing/yarn-berry-compatibility.md b/docs/testing/yarn-berry-compatibility.md index 9bbfe2df8..6b821e06f 100644 --- a/docs/testing/yarn-berry-compatibility.md +++ b/docs/testing/yarn-berry-compatibility.md @@ -11,10 +11,13 @@ vendored (`vendor` wires the root linkers are covered end to end; Plug'n'Play keeps packages inside `.yarn/cache` zips, so `vendor` refuses it (`vendor_yarn_berry_unsupported`) and so does `apply` (`yarn_pnp_unsupported`), while standalone `vex` still -attests a hosted lock's `checksum:` pin. Plug'n'Play is decided by the -configured linker (`YARN_NODE_LINKER`, else the nearest rc file at or above -the project that sets `nodeLinker`, else the home folder's rc file; the rc -file is `.yarnrc.yml` unless `YARN_RC_FILENAME` renames it; unset means +attests a hosted lock's `checksum:` pin once the PnP loader (`.pnp.cjs`) +resolves the package through the hosted url. A loader written before the lock +was rewired still runs the registry copy, so `vex` omits the package +(`package_not_found`) until `yarn install` rewrites it (#519). Plug'n'Play is +decided by the configured linker (`YARN_NODE_LINKER`, else the nearest rc file +at or above the project that sets `nodeLinker`, else the home folder's rc file; +the rc file is `.yarnrc.yml` unless `YARN_RC_FILENAME` renames it; unset means berry's default, `pnp`), not by whether a `.pnp.*` loader happens to exist: `vendor` refuses a lock-only PnP checkout up front, and a stale `.pnp.js` left by a Yarn 2 migration to `node-modules` or `pnpm` is ignored. Yarn 1 PnP