diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..52165d509 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -270,7 +270,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **pnpm with its lockfile off (#1074)**: pnpm's `lockfile` setting off makes pnpm neither read nor write `pnpm-lock.yaml` (nor a member's own lock): it resolves every dependency from the registry, so a hosted pin in a committed lock never installs. Each pnpm major reads the setting from one file: pnpm 11 and newer a top-level `lockfile: false` in the project's `pnpm-workspace.yaml`, pnpm 10 and older `lockfile=false` in its `.npmrc`; when `lockfile` is unset (or not a boolean) pnpm falls back to npm's `package-lock` (`packageLock: false` / `package-lock=false`), which counts the same way. The major is the one `node_modules/.modules.yaml` records, else package.json's `packageManager` pin, else what the root lock's grammar implies (a pre-9 lock or `shrinkwrap.yaml`: pnpm 8 or older; an env lockfile document: pnpm 11 or newer); when none says, either file turning it off counts. Hosted mode also reads the setting from its environment, in the one variable family that major reads (#1460): `pnpm_config_lockfile` / `pnpm_config_package_lock` on pnpm 11 and newer, `npm_config_lockfile` / `npm_config_package_lock` on pnpm 10 and older, either family when the major is unknown (so the `npm_config_package_lock=` that `npx` / `npm exec` export from an npm-era `.npmrc` refuses nothing on pnpm 11+). Only the project's own files are read (a workspace member with no settings file of its own does not inherit its root's). In a pnpm project (a root pnpm lock or a `pnpm-workspace.yaml`), hosted mode then leaves every pnpm lock and `pnpm-workspace.yaml` untouched (no `trustLockfile` key is added), confirms no npm pin from them and warns `redirect_pnpm_lockfile_disabled` (in place of `redirect_pnpm_no_lockfile`), exiting 0 like the per-branch lock refusal below; the detail names the setting, and the remedy is `--mode vendored` when a pnpm lock exists (pnpm applies the `overrides` it wires without a lock), else removing the setting and running `pnpm install`. The check comes before the per-branch lock check. Manifest-less `vex` keeps the hosted references of such a lock but withholds their lockfile basis (see the pnpm row of the discovery table). Rush locks are not affected: Rush runs pnpm with its own settings. Vendored mode is unchanged. -**pnpm per-branch locks (#556)**: with `gitBranchLockfile` on (a top-level `gitBranchLockfile: true` in the root `pnpm-workspace.yaml`; when the YAML leaves the key unset, `git-branch-lockfile=true` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `pnpm_config_git_branch_lockfile` (pnpm 11 and newer) or `npm_config_git_branch_lockfile` (pnpm 10 and older; either when the major is unknown, read as for the `lockfile` setting above) set to `true`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm installs a git branch from its own `pnpm-lock..yaml` whenever that lock exists, in every directory it installs a lock from: the root, and under `sharedWorkspaceLockfile: false` each member's (#492). When the setting is on AND at least one such `pnpm-lock..yaml` exists (at the root, or in a member that installs from its own lock), hosted mode leaves every root pnpm lock (`pnpm-lock.yaml`, `shrinkwrap.yaml`) and `pnpm-workspace.yaml` untouched (every member lock too), confirms no npm pin from them and warns `redirect_pnpm_git_branch_lockfile` (in place of `redirect_pnpm_no_lockfile` when the branch lock is the only one); the detail names the setting and the branch lock(s), and the remedy is to turn `gitBranchLockfile` off and fold the branch locks into `pnpm-lock.yaml` with `pnpm install --merge-git-branch-lockfiles`, then re-run. A vendored→hosted takeover of a pnpm-vendored purl is refused with the same code (the vendored wiring and ledger stay byte-for-byte). Vendored mode refuses the same projects with `vendor_pnpm_git_branch_lockfile`, before any write and in the pre-flight. The setting with no branch lock (main, or a branch whose dependencies never changed) installs from `pnpm-lock.yaml`, which both modes wire as usual; a stray branch lock with the setting off is ignored. The in-memory engine fetches root `pnpm-lock.*.yaml` names presence-only and refuses the same way. +**pnpm per-branch locks (#556)**: with `gitBranchLockfile` on (a top-level `gitBranchLockfile: true` in the root `pnpm-workspace.yaml`; when the YAML leaves the key unset, `git-branch-lockfile=true` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `pnpm_config_git_branch_lockfile` (pnpm 11 and newer) or `npm_config_git_branch_lockfile` (pnpm 10 and older; either when the major is unknown, read as for the `lockfile` setting above) set to `true`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm installs a git branch from its own `pnpm-lock..yaml` whenever that lock exists, in every directory it installs a lock from: the root, and under `sharedWorkspaceLockfile: false` each member's (#492). When the setting is on AND at least one such `pnpm-lock..yaml` exists (at the root, or in a member that installs from its own lock), hosted mode leaves every root pnpm lock (`pnpm-lock.yaml`, `shrinkwrap.yaml`) and `pnpm-workspace.yaml` untouched (every member lock too), confirms no npm pin from them and warns `redirect_pnpm_git_branch_lockfile` (in place of `redirect_pnpm_no_lockfile` when the branch lock is the only one); the detail names the setting and the branch lock(s), and the remedy is to turn `gitBranchLockfile` off and fold the branch locks into `pnpm-lock.yaml` with `pnpm install --merge-git-branch-lockfiles`, then re-run. A vendored→hosted takeover of a pnpm-vendored purl is refused with the same code (the vendored wiring and ledger stay byte-for-byte). Vendored mode refuses the same projects with `vendor_pnpm_git_branch_lockfile`, before any write and in the pre-flight. The setting with no branch lock (main, or a branch whose dependencies never changed) installs from `pnpm-lock.yaml`, which both modes wire as usual; a stray branch lock with the setting off is ignored. The in-memory engine fetches root `pnpm-lock.*.yaml` names presence-only and refuses the same way. `vex` omits every pin of those pnpm locks (`vex_pnpm_git_branch_lockfile`) whenever such a branch lock exists, whatever the setting says (fail-closed; read from the project's files only, not the environment); see **Unattested references**. `redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers. @@ -547,7 +547,7 @@ 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. The root `requirements.txt` and its in-root `-r` includes count as one lock here: pip reads them as one requirement set, where a direct reference wins over a compatible `==` pin of the same version in another file of the set (#1086). A PyPI version matches under PEP 440 equality, so a requirements line pinned in any spelling that can only install the wired release (`==1.2.3.0`, `==v1.2.3`, `===1.2.3`) contests it like `==1.2.3` does; a range or wildcard (`~=1.2.3`, `==1.2.*`) does not (#1602). A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`, beneath a dependency that bundles it; the root project's own bundles install from the lock, #1618) 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`. yarn classic's `yarn.lock` records nothing about bundled copies either (#758), so for a lock with a Socket wiring they are found in the installed `node_modules`: each package a parent's `bundleDependencies` / `bundledDependencies` names inside the parent's own `node_modules`, the dependencies of those beside them, and everything nested under them. Such a copy contests the reference in `yarn.lock` (the diagnostic names the parent's lock entry and the copy's directory); a lock-only `vex` with no `node_modules` can't see it. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`, and a yarn classic hosted scan names one with `redirect_copy_unpatched`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. A reference withheld only because such an unreachable copy installs beside it (a bundled npm / Bun / vlt copy, an npm copy beneath a `hasShrinkwrap` package (#753) or one npm installs from a git / url / `file:` spec, or a yarn classic git or `file:` directory block of the same `name@version`) is still the rewriter's own wiring of exactly one package version: it is never attested, but `rollback`, `remove`, `list` and the hosted → vendored takeover treat it as an ordinary hosted pin and restore its upstream registry entry (#828), rather than refusing it with `hosted_wiring_contested`. 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`. Four 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. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. +* **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`. Six 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. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. **pnpm branch locks** (`vex_pnpm_git_branch_lockfile`): with `gitBranchLockfile` on, pnpm installs a git branch from its own `pnpm-lock..yaml` (the **pnpm per-branch locks** state above), which no wiring reaches. `vex` does not decide whether the setting is on: the presence of any branch lock (at the root, or in a workspace member that installs from its own lock under `sharedWorkspaceLockfile: false`, read from the project's files and never from the environment) omits every reference of the root pnpm lock (`pnpm-lock.yaml`, or `shrinkwrap.yaml` without it) and of each member's own lock, hosted and vendored alike, whatever `gitBranchLockfile` or pnpm's `lockfile` setting says (#556; deterministic and fail-closed). This is checked only when a root or member pnpm lock wires a patch. Hosted mode refuses every pnpm pin when the setting is on and vendored mode refuses the project, so this also covers wiring written before the branch lock appeared. The run warning names the branch lock in the marked lock's own directory. Rush locks are not affected. **Self-patched npm and Bun packages** (`vex_npm_patched_dependency`): a reference in `package-lock.json` / `npm-shrinkwrap.json` / `bun.lock` / `bun.lockb` to a package the project patches itself is omitted, by the hosted rewriters' own per-dependency rule (`redirect_npm_patched_dependency_skipped`, #711; `redirect_bun_patched_dependency_skipped`, #367): a root `package.json` `patchedDependencies` key for its `name@version`, its bare name, or `name@` of a tarball other than the one the reference pins (npm and Bun alike, as both hosted gates read it); for npm also a non-null `patched` record on any present npm lock's entry for that `name@version`; for `bun.lock` also the keys it mirrors (the run warning then names `bun.lock` as the key's source). npm applies the user's diff on top of whatever tarball the lock names and Bun applies it only to the resolution it is keyed on, so both modes leave such a package unwired; this covers pins an earlier release wrote, and an npm one fails `npm ci` with `EPATCHFAILED` until `socket-patch rollback ` restores the registry entry (the remedy says so). A key naming the pinned tarball itself is no conflict; a reference with neither a hosted url nor a vendored tarball is matched by its `name@version` and counts every tarball key. Only a reference that survives the lock contests carries this reason. * **Shrinkwrap without a twin (#899).** npm >= 12 never reads `npm-shrinkwrap.json`: on a project whose only npm lock is the shrinkwrap it resolves the tree from the registry and writes a fresh `package-lock.json`, so a Socket reference there reaches npm <= 11 only. The reference is still discovered, so `list`, `rollback` and `remove` manage it, but `vex` omits the patch (`vex_npm_shrinkwrap_only`, as a run warning and as the `failed[].reason`). A `package-lock.json` beside it (wired the same way) makes it attestable again. Hosted and vendored runs still rewire the shrinkwrap and warn `redirect_npm_shrinkwrap_only` / `vendor_npm_shrinkwrap_only`. * **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). @@ -2678,6 +2678,8 @@ match it, withholds the statement: | `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_maven_reactor_root` | run warning and `failed[].reason` | A hosted Maven pin sits in a root `pom.xml` that declares `` / `` (a profile's included), where a module's own literal `` overrides it; only the root pom is read, so no statement (#261). `list`, `rollback` and `remove` still find the pin. | | `vex_maven_classifier_unpatched` | run warning and `failed[].reason` | A hosted Maven pin's GA also has a live executable `` copy (not `sources` / `javadoc`) at a version other than the pin, which the classifier-less pin does not retarget and the grant does not serve; no statement while it does. `list`, `rollback` and `remove` still find the pin. | +| `vex_pnpm_git_branch_lockfile` | run warning and `failed[].reason` | An npm pin is wired in a pnpm lock, but a `pnpm-lock..yaml` exists, which pnpm installs the branch from instead when `gitBranchLockfile` is on (#556); the branch lock's presence withholds the statement whatever the setting says, until the branch locks are folded in and deleted. `list`, `rollback` and `remove` still find the pin, and `vendor --check` and `scan` still treat the wiring as live. | +| `vex_npm_patched_dependency` | run warning and `failed[].reason` | An npm or Bun pin is wired, but the project patches that package itself (`patchedDependencies`, or npm's lock-entry `patched` record; #711, #367), which hosted and vendored mode never wire; no statement while it does. `list`, `rollback` and `remove` still find the pin. | | `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_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 25e49273e..130d6f0c0 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -232,6 +232,13 @@ const NOTE_MAVEN_REACTOR_ROOT: &str = "vex_maven_reactor_root"; /// classifier copy at another version, which the pin does not reach /// (`vex::Unattested`). const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched"; +/// Omission tag and note: a pnpm lock pins the patch, but a +/// `pnpm-lock..yaml` exists, which pnpm installs from instead when +/// `gitBranchLockfile` is on; its presence withholds (`vex::Unattested`). +const NOTE_PNPM_GIT_BRANCH_LOCKFILE: &str = "vex_pnpm_git_branch_lockfile"; +/// Omission tag and note: an npm or Bun pin's package is one the project +/// patches itself (`patchedDependencies`, `vex::Unattested`). +const NOTE_NPM_PATCHED_DEPENDENCY: &str = "vex_npm_patched_dependency"; /// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested) /// ref, and the remedy its note ends with. @@ -268,6 +275,20 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { "not attested while that classifier copy resolves an unpatched version; the \ wiring itself is intact, so re-running `scan` does not change this", ), + UnattestedKind::PnpmGitBranchLock => ( + NOTE_PNPM_GIT_BRANCH_LOCKFILE, + "not attested while any branch lock exists, whatever gitBranchLockfile says; the \ + wiring itself is intact, so re-running `scan` / `vendor` does not change this \ + (fold the branch locks in with `pnpm install --merge-git-branch-lockfiles`, turn \ + gitBranchLockfile off and delete the `pnpm-lock..yaml` files)", + ), + UnattestedKind::UserPatchedDependency => ( + NOTE_NPM_PATCHED_DEPENDENCY, + "not attested while the project patches the package itself (fold the Socket fix \ + into your own patch, or remove the `patchedDependencies` entry and re-run); an \ + npm pin an earlier hosted run wrote fails `npm ci` with `EPATCHFAILED` until \ + `socket-patch rollback ` restores the registry entry", + ), } } @@ -418,7 +439,9 @@ pub(crate) async fn plan( // Wired, but a build bypasses the pin (`Unattested`: a Gradle lock // above the hosted base resolves the newer upstream release, a pnpm // bundled copy, a deno.lock copy, an npm shrinkwrap with no - // package-lock.json twin, which npm >= 12 ignores): the ref keeps + // package-lock.json twin, which npm >= 12 ignores, a pnpm lock + // `gitBranchLockfile` bypasses, a package the project patches itself): + // the ref keeps // rollback, remove, list and the ledgers' liveness working, and the // patch is omitted. cands.retain(|c| { @@ -1762,6 +1785,37 @@ mod tests { assert!(live.hosted.contains_key(PURL)); } + /// Each npm-family bypass (#556 branch locks, #711 / #367 user patches) + /// is omitted under its own tag, distinct from every other kind's. + #[test] + fn npm_family_unattested_kinds_have_their_own_tags() { + let kinds = [ + UnattestedKind::LockAboveBase, + UnattestedKind::BundledCopy, + UnattestedKind::DenoLock, + UnattestedKind::NpmShrinkwrapOnly, + UnattestedKind::MavenReactorRoot, + UnattestedKind::MavenClassifierUnpatched, + UnattestedKind::PnpmGitBranchLock, + UnattestedKind::UserPatchedDependency, + ]; + let tags: std::collections::BTreeSet<&str> = + kinds.iter().map(|k| unattested_note(*k).0).collect(); + assert_eq!(tags.len(), kinds.len(), "{tags:?}"); + assert_eq!( + unattested_note(UnattestedKind::PnpmGitBranchLock).0, + "vex_pnpm_git_branch_lockfile" + ); + assert_eq!( + unattested_note(UnattestedKind::UserPatchedDependency).0, + "vex_npm_patched_dependency" + ); + // Review (#2010): a pre-#711 pin breaks `npm ci` (EPATCHFAILED) + // until it is rolled back, as hosted's warning says. + let remedy = unattested_note(UnattestedKind::UserPatchedDependency).1; + assert!(remedy.contains("socket-patch rollback"), "{remedy}"); + } + /// Ledger-only records with no wiring anywhere are gated, except the /// manifest-owned one, which falls back to agent-mode verification; an /// in-run confirmed purl is live by construction. diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0c778ac74..70bf33e3a 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1302,52 +1302,22 @@ fn rewrite_npm_lock( // would still stack the user's diff on the hosted bytes. Each present // lock is parsed ONCE, here, and the parse is handed to its rewrite; // only a lock that spells `"patched"` at all is walked for the gate. - let user_patched = crate::vendor::bun_lock_text::patched_dependency_keys( - files.get("package.json").map(String::as_str), - None, - ); + let mut user_patches = + NpmUserPatches::from_manifest(files.get("package.json").map(String::as_str)); let parsed: Vec<(&str, Option)> = present .iter() .map(|lockfile| (*lockfile, parse_json_text(&files[*lockfile]).ok())) .collect(); - let lock_patched: Vec<(String, String, &str, String)> = parsed - .iter() - .filter(|(lockfile, _)| files[*lockfile].contains("\"patched\"")) - .filter_map(|(lockfile, lock)| Some((*lockfile, lock.as_ref()?))) - .flat_map(|(lockfile, lock)| { - npm_lock_entries(lock) - .into_iter() - .filter(|e| e.section == NpmLockSection::Packages && e.is_dependency()) - .filter(|e| e.value.get("patched").is_some_and(|p| !p.is_null())) - .filter_map(|e| { - let version = e.node.version?.to_string(); - Some(( - e.node.name.to_string(), - version, - lockfile, - e.key.to_string(), - )) - }) - .collect::>() - }) - .collect(); + for (lockfile, lock) in &parsed { + if let (Some(lock), true) = (lock, files[*lockfile].contains("\"patched\"")) { + user_patches.add_lock(lockfile, lock); + } + } let npm: Vec<&DepOverride> = npm .into_iter() .filter(|dep| { let fname = full_name(dep); - let source = if let Some(key) = crate::vendor::bun_lock_text::patched_dependency_key( - &user_patched, - &fname, - &dep.version, - &dep.artifact_url, - ) { - format!("package.json `patchedDependencies` has `{key}`") - } else if let Some((_, _, lockfile, key)) = lock_patched - .iter() - .find(|(name, version, _, _)| *name == fname && *version == dep.version) - { - format!("{lockfile} entry `{key}` carries npm's `patched` record") - } else { + let Some(source) = user_patches.source(&fname, &dep.version, &dep.artifact_url) else { return true; }; // A lock an earlier (pre-#711) hosted run already pinned keeps @@ -1440,6 +1410,91 @@ pub fn npm_deno_lock_detail(packages: &[String], how: &str) -> String { ) } +/// The packages a project patches itself with npm 12's native `npm patch` +/// (#711), as the hosted npm rewriter's gate reads them: a root +/// `package.json` `patchedDependencies` key, or the non-null `patched` +/// record npm writes on a lock's `packages` dependency entry (lockfileVersion +/// 4) in any present npm lock. npm applies that diff to every install of the +/// package, whichever lock names it. `vex` discovery reads the same +/// evidence, so it never attests a pin hosted mode refuses, and reads Bun's +/// (`bun patch`, #367: the same manifest key, or the copy `bun.lock` +/// mirrors, [`NpmUserPatches::add_mirrored_keys`]) through it too. +#[derive(Debug, Default)] +pub(crate) struct NpmUserPatches { + /// The `patchedDependencies` keys: the root manifest's, then any only a + /// lock mirrors. + keys: Vec, + /// `(key, lockfile)` of each key only a lock mirrors. + mirrored: Vec<(String, String)>, + /// `(name, version, lockfile, entry key)` of every lock entry carrying + /// a `patched` record. + locked: Vec<(String, String, String, String)>, +} + +impl NpmUserPatches { + /// The root `package.json`'s `patchedDependencies` keys (none when + /// `manifest` is absent or unparseable). + pub(crate) fn from_manifest(manifest: Option<&str>) -> Self { + NpmUserPatches { + keys: crate::vendor::bun_lock_text::patched_dependency_keys(manifest, None), + ..Self::default() + } + } + + /// Add the `patchedDependencies` keys the text lock `lockfile` + /// (`bun.lock`) mirrors at its top that the manifest does not have. + pub(crate) fn add_mirrored_keys(&mut self, lockfile: &str, lock: &str) { + for key in crate::vendor::bun_lock_text::patched_dependency_keys(None, Some(lock)) { + if !self.keys.contains(&key) { + self.mirrored.push((key.clone(), lockfile.to_string())); + self.keys.push(key); + } + } + } + + /// Add the `patched` records of the parsed npm lock `lockfile`. + pub(crate) fn add_lock(&mut self, lockfile: &str, lock: &Value) { + let patched = npm_lock_entries(lock) + .into_iter() + .filter(|e| e.section == NpmLockSection::Packages && e.is_dependency()) + .filter(|e| e.value.get("patched").is_some_and(|p| !p.is_null())) + .filter_map(|e| { + let version = e.node.version?.to_string(); + Some(( + e.node.name.to_string(), + version, + lockfile.to_string(), + e.key.to_string(), + )) + }); + self.locked.extend(patched); + } + + /// Why `name@version` counts as patched by the project itself, for a + /// warning or diagnostic, when it does. `target` is what the wiring + /// resolves the package to (the hosted URL, or the vendored tarball + /// path): a `patchedDependencies` key naming exactly it is no conflict + /// ([`crate::vendor::bun_lock_text::patched_dependency_key`]). + pub(crate) fn source(&self, name: &str, version: &str, target: &str) -> Option { + if let Some(key) = + crate::vendor::bun_lock_text::patched_dependency_key(&self.keys, name, version, target) + { + let file = self + .mirrored + .iter() + .find(|(k, _)| k == key) + .map_or("package.json", |(_, lockfile)| lockfile.as_str()); + return Some(format!("{file} `patchedDependencies` has `{key}`")); + } + self.locked + .iter() + .find(|(n, v, _, _)| n == name && v == version) + .map(|(_, _, lockfile, key)| { + format!("{lockfile} entry `{key}` carries npm's `patched` record") + }) + } +} + /// Leave `dep` on its registry entry because the project patches it with /// npm 12's native `npm patch` (#711). npm extracts whatever tarball the /// lock names and then applies the user's diff, failing the whole install diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 8671daab2..2424f75df 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -221,17 +221,22 @@ pub async fn trust_lockfile_env_for( /// [`pnpm_setting`], falling back to the environment ([`env_setting`], /// for the project's pnpm major) on disk, where the CLI runs pnpm's -/// environment. +/// environment. Without `env` (`vex` discovery, which answers from the +/// project's files alone) the environment is never read. async fn view_setting( view: &ProjectView<'_>, workspace: Option<&str>, npmrc: Option<&str>, yaml_key: &str, npmrc_key: &str, + env: bool, ) -> Option { if let Some(found) = pnpm_setting(workspace, npmrc, yaml_key, npmrc_key) { return Some(found); } + if !env { + return None; + } let env = |var: &str| std::env::var(var).ok(); if matches!(view, ProjectView::Memory(_)) || !env_any(&[npmrc_key], env) { return None; @@ -520,7 +525,13 @@ pub(crate) fn member_dirs(tree: &dyn DirTree, globs: &[String]) -> Result) -> MemberLocks { - let (dirs, globs, root_lock) = match workspace_members(view).await { + member_locks_in(view, true).await +} + +/// [`member_locks`], consulting the environment only with `env` (see +/// [`git_branch_locks_in`]). +pub async fn member_locks_in(view: &ProjectView<'_>, env: bool) -> MemberLocks { + let (dirs, globs, root_lock) = match workspace_members(view, env).await { Members::Shared => return MemberLocks::Shared, Members::Unresolved(why) => return MemberLocks::Unresolved(why), Members::Dirs { @@ -603,7 +614,7 @@ enum Members { Unresolved(String), } -async fn workspace_members(view: &ProjectView<'_>) -> Members { +async fn workspace_members(view: &ProjectView<'_>, env: bool) -> Members { let Ok(workspace) = view.read_text(PNPM_WORKSPACE).await else { return Members::Shared; }; @@ -614,6 +625,7 @@ async fn workspace_members(view: &ProjectView<'_>) -> Members { npmrc.as_deref(), "sharedWorkspaceLockfile", "shared-workspace-lockfile", + env, ) .await; if shared.is_none_or(|setting| setting.as_bool() != Some(false)) { @@ -775,9 +787,43 @@ pub fn is_git_branch_lock_name(name: &str) -> bool { /// on pnpm 11+, `npm_config_git_branch_lockfile` on pnpm 10 and older, /// see [`env_setting`]). pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option { + git_branch_locks_in(view, true).await +} + +/// Every `pnpm-lock..yaml` pnpm could install the project from, +/// root-relative and sorted per directory: the root's, then those of the +/// workspace members that install from their own lock ([`member_locks`]), +/// whatever `gitBranchLockfile` says. `vex` discovery withholds on their +/// mere presence (fail-closed), so the setting is not read; neither is the +/// environment, and nothing outside the project. +/// +/// `Err(why)` when the members that install from their own lock cannot be +/// listed ([`Members::Unresolved`]): a member branch lock then cannot be +/// ruled out, as in [`git_branch_locks_in`], and discovery withholds. +pub async fn git_branch_lock_files(view: &ProjectView<'_>) -> Result, String> { + let mut locks = branch_lock_names(view, "").await; + match workspace_members(view, false).await { + Members::Dirs { dirs, .. } => { + for dir in dirs { + locks.extend(branch_lock_names(view, &dir).await); + } + } + Members::Unresolved(why) => return Err(why), + Members::Shared => {} + } + Ok(locks) +} + +/// [`git_branch_locks`], consulting the environment only with `env`: the +/// hosted and vendored writers run in the install's environment, while +/// `vex` discovery answers from the project's files alone (as it does for +/// [`lockfile_disabled`]). Without `env` a governing ancestor's settings +/// are read only from inside the project's git repository +/// ([`governing_file_in_git_repository`]), never from above its root. +pub async fn git_branch_locks_in(view: &ProjectView<'_>, env: bool) -> Option { let mut locks = branch_lock_names(view, "").await; let mut members_unlisted = None; - match workspace_members(view).await { + match workspace_members(view, env).await { Members::Dirs { dirs, .. } => { for dir in dirs { locks.extend(branch_lock_names(view, &dir).await); @@ -794,7 +840,15 @@ pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option const KEYS: (&str, &str) = ("gitBranchLockfile", "git-branch-lockfile"); let mut setting = pnpm_setting(workspace.as_deref(), npmrc.as_deref(), KEYS.0, KEYS.1); if setting.is_none() { - if let Some(file) = governing_file(view) { + let in_repository = || { + view.disk_root() + .is_some_and(governing_file_in_git_repository) + }; + let governing = match env || in_repository() { + true => governing_file(view), + false => None, + }; + if let Some(file) = governing { let workspace = read_regular_to_string(&file).await.ok(); let npmrc = read_regular_to_string(&file.with_file_name(".npmrc")) .await @@ -809,7 +863,7 @@ pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option } } if setting.is_none() { - setting = view_setting(view, None, None, KEYS.0, KEYS.1).await; + setting = view_setting(view, None, None, KEYS.0, KEYS.1, env).await; } let setting = git_branch_on(setting)?; Some(GitBranchLocks { @@ -949,6 +1003,27 @@ async fn branch_lock_names(view: &ProjectView<'_>, dir: &str) -> Vec { names } +/// Whether the nearest `pnpm-workspace.yaml` above `project_root` (the one +/// [`governing_workspace_file`] would read) lies inside the project's git +/// repository: at or below the nearest directory, `project_root` itself +/// included, with a `.git` entry. `false` outside any repository. Only +/// stats paths, so nothing above the repository root is read. +fn governing_file_in_git_repository(project_root: &Path) -> bool { + let canonical = + std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf()); + let Some(repository) = canonical + .ancestors() + .find(|dir| std::fs::symlink_metadata(dir.join(".git")).is_ok()) + else { + return false; + }; + canonical + .ancestors() + .skip(1) + .take_while(|dir| dir.starts_with(repository)) + .any(|dir| dir.join(PNPM_WORKSPACE).is_file()) +} + /// The ancestor `pnpm-workspace.yaml` governing a disk view's project /// ([`governing_workspace_file`]); an in-memory project has none. fn governing_file(view: &ProjectView<'_>) -> Option { @@ -1838,6 +1913,37 @@ mod tests { assert_eq!(git_branch_locks(&view).await, None); } + /// The files-only reading (`vex` discovery) takes the setting from a + /// governing ancestor `pnpm-workspace.yaml` only inside the project's + /// git repository, never from a file above the repository root; the + /// install-time reading is unchanged. + #[tokio::test] + async fn the_files_only_reading_stays_inside_the_git_repository() { + let tmp = tempfile::tempdir().unwrap(); + let outer = tmp.path(); + write( + outer, + PNPM_WORKSPACE, + "packages:\n - 'proj'\ngitBranchLockfile: true\n", + ); + let root = outer.join("proj"); + write(&root, "package.json", "{}"); + write(&root, PNPM_LOCK, "lockfileVersion: '9.0'\n"); + write(&root, "pnpm-lock.feature.yaml", "lockfileVersion: '9.0'\n"); + let view = ProjectView::Disk(&root); + assert!(git_branch_locks(&view).await.is_some()); + // No repository at all: nothing above the project is read. + assert_eq!(git_branch_locks_in(&view, false).await, None); + // A repository rooted at the project: the ancestor is outside it. + std::fs::create_dir(root.join(".git")).unwrap(); + assert_eq!(git_branch_locks_in(&view, false).await, None); + assert!(git_branch_locks(&view).await.is_some()); + // A repository rooted at the ancestor: its file is read. + std::fs::remove_dir(root.join(".git")).unwrap(); + std::fs::create_dir(outer.join(".git")).unwrap(); + assert!(git_branch_locks_in(&view, false).await.is_some()); + } + #[test] fn the_verbatim_prefix_is_dropped_and_nothing_else_changes() { let strip = |text: &str| without_verbatim_prefix(PathBuf::from(text)); diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index 85b24e95b..f18aaaa04 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -91,12 +91,13 @@ //! keeps none for it) withdraws every ref of the same package in that lock //! ([`Unwired`], #497). +use super::npm::mark_user_patched; use super::{ canonical_base_purl, npm_purl, DiscoverCtx, Discovery, LocateOpts, Located, PatchedRef, WiringMode, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB}; -use crate::patch::redirect::hosted_url_version; +use crate::patch::redirect::{hosted_url_version, NpmUserPatches}; use crate::utils::digest::is_sri_pin; use crate::vendor::bun_lock_text::{ decode_json_string, is_bundled_entry, split_name_spec, user_tarball_version, @@ -177,6 +178,41 @@ async fn extract_text(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { } bundled.contest(BUN_LOCK, out); unwired.contest(BUN_LOCK, out); + // The hosted text rewriter reads the manifest's keys and the copy Bun + // mirrors at the top of `bun.lock`. + let manifest = ctx.read_advisory_text("package.json").await; + let mut user_patches = NpmUserPatches::from_manifest(manifest.as_deref()); + user_patches.add_mirrored_keys(BUN_LOCK, &text); + unattest_user_patched(BUN_LOCK, &user_patches, out); +} + +// ── `bun patch` ────────────────────────────────────────────────────────── + +/// Mark every ref of `file` whose package the project patches itself with +/// `bun patch` (#367) [`UnattestedKind::UserPatchedDependency`], by the +/// hosted rewriters' own per-dependency rule (the npm extractor's +/// [`mark_user_patched`], read against the ref's hosted URL or vendored +/// tarball): Bun applies that patch only to the resolution it is keyed on, +/// so hosted and vendored mode leave the package where it is and never +/// confirm the Socket patch. A ref a pre-#367 run wired stays a ref, so +/// `rollback` / `remove` / `list` still find it. +fn unattest_user_patched(file: &str, user_patches: &NpmUserPatches, out: &mut Discovery) { + let refs: Vec = out + .refs + .iter() + .filter(|r| r.source_file == std::path::Path::new(file)) + .cloned() + .collect(); + for r in &refs { + mark_user_patched( + user_patches, + r, + "a patch the project applies with `bun patch` only while the package resolves \ + to what that key names, not to this pin, so hosted and vendored mode leave the \ + package alone", + out, + ); + } } // ── bundled copies ─────────────────────────────────────────────────────── @@ -347,6 +383,11 @@ async fn extract_binary(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { } bundled.contest(BUN_LOCKB, out); unwired.contest(BUN_LOCKB, out); + // The binary lock mirrors no keys: the hosted engine and the vendored + // binary backend read the manifest's alone. + let manifest = ctx.read_advisory_text("package.json").await; + let user_patches = NpmUserPatches::from_manifest(manifest.as_deref()); + unattest_user_patched(BUN_LOCKB, &user_patches, out); } // ── shared classification ──────────────────────────────────────────────── @@ -1877,6 +1918,120 @@ mod tests { } } + fn user_patched(out: &Discovery) -> Vec<(&str, &str)> { + out.unattested + .iter() + .filter(|u| u.kind == UnattestedKind::UserPatchedDependency) + .map(|u| (u.purl.as_str(), u.uuid.as_str())) + .collect() + } + + /// REGRESSION (#367, N2): a package the project patches itself with + /// `bun patch` is one the hosted rewriters (text and binary) leave on + /// its resolution and never confirm, and one vendored mode refuses. A + /// pin a pre-#367 run left stays a ref, for rollback, but is never + /// attested — by the rewriters' own per-dependency rule, so a key for + /// the pin's own tarball (a `bun patch` made on top of it) is no + /// conflict, and the manifest's keys and `bun.lock`'s mirror both count. + #[tokio::test] + async fn bun_user_patched_dependency_pin_is_not_attested() { + let p = fixture("basic", "expected"); + let out = run(&p).await; + assert_refs(&out, &[(LEFT_PAD, FIXTURE_UUID, WiringMode::Hosted)]); + assert!(out.unattested.is_empty(), "control: {:#?}", out.unattested); + let url = out.refs[0].url.clone().unwrap(); + + for key in ["left-pad@1.3.0", "left-pad"] { + p.write( + "package.json", + format!(r#"{{"patchedDependencies":{{"{key}":"patches/left-pad.patch"}}}}"#), + ); + let out = run(&p).await; + assert_refs(&out, &[(LEFT_PAD, FIXTURE_UUID, WiringMode::Hosted)]); + assert_eq!(user_patched(&out), [(LEFT_PAD, FIXTURE_UUID)], "{key}"); + let detail = &out.unattested[0].detail; + assert!(detail.contains(&format!("`{key}`")), "{detail}"); + } + // A patch keyed to the pin's own tarball applies on top of it: the + // rewriters leave such a package wired, and so does discovery. + p.write( + "package.json", + format!(r#"{{"patchedDependencies":{{"left-pad@{url}":"patches/left-pad.patch"}}}}"#), + ); + assert_eq!(user_patched(&run(&p).await), []); + + // Vendored, keyed to the registry release; the key mirrored at the + // top of `bun.lock` counts like the manifest's. + let p = Project::new(); + let mut lock = text_lock( + 1, + &[tuple( + "left-pad", + &format!("left-pad@.socket/vendor/npm/{UUID_A}/left-pad-1.3.0.tgz"), + Some(SRI), + )], + ); + p.write("bun.lock", &lock); + assert_eq!(user_patched(&run(&p).await), [], "control"); + lock = lock.replacen( + " \"packages\": {\n", + " \"patchedDependencies\": {\n \"left-pad@1.3.0\": \"patches/left-pad@1.3.0.patch\",\n },\n \"packages\": {\n", + 1, + ); + p.write("bun.lock", &lock); + let out = run(&p).await; + assert_refs(&out, &[(LEFT_PAD, UUID_A, WiringMode::Vendored)]); + assert_eq!(user_patched(&out), [(LEFT_PAD, UUID_A)]); + // Review (#2010): the detail names where the key is, not + // package.json when only the lock mirrors it. + let detail = &out.unattested[0].detail; + assert!( + detail.contains("bun.lock `patchedDependencies` has `left-pad@1.3.0`") + && !detail.contains("package.json"), + "{detail}" + ); + + // `bun.lockb`: the manifest's keys. + let (_, bytes) = lockb_fixtures().into_iter().next().unwrap(); + let p = Project::new(); + p.write( + "bun.lockb", + rewire(&bytes, &[hosted_minimist("1.2.2", UUID_A)]), + ); + p.write( + "package.json", + r#"{"patchedDependencies":{"minimist@1.2.2":"patches/minimist.patch"}}"#, + ); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:npm/minimist@1.2.2", UUID_A, WiringMode::Hosted)], + ); + assert_eq!(user_patched(&out), [("pkg:npm/minimist@1.2.2", UUID_A)]); + } + + /// Review (#2010): a user-patched `bun.lock` ref another lock contests + /// is dropped by the cross-lock contest and leaves no mark behind. + #[tokio::test] + async fn a_contested_user_patched_bun_ref_leaves_no_mark() { + let p = fixture("basic", "expected"); + p.write( + "package.json", + r#"{"patchedDependencies":{"left-pad@1.3.0":"patches/left-pad.patch"}}"#, + ); + p.write( + "pnpm-lock.yaml", + "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n left-pad:\n specifier: 1.3.0\n version: 1.3.0\n\npackages:\n\n left-pad@1.3.0:\n resolution: {integrity: sha512-UP==}\n", + ); + let out = p.discover().await; + assert!( + out.refs.iter().all(|r| r.purl != LEFT_PAD), + "the contest drops the ref: {:#?}", + out.refs + ); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + } + /// REGRESSION (#469), `bun.lockb`: a record a bundled edge reaches is /// (also) installed from the parent's tarball, so a Socket resolution /// on it is never attested — whether only the bundled edge reaches it diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 815d4644d..0f3360d31 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -606,6 +606,18 @@ pub enum UnattestedKind { /// version: the classifier-less pin does not retarget it and the grant /// serves only the main jar, so the build runs the public classifier jar. MavenClassifierUnpatched, + /// A pnpm lock's pin while pnpm's `gitBranchLockfile` setting is on and + /// a `pnpm-lock..yaml` exists (#556): pnpm installs a branch + /// from its own lock, which no wiring reaches, so hosted mode withholds + /// these locks and vendored mode refuses the project. + PnpmGitBranchLock, + /// An npm or Bun pin of a package the project patches itself + /// (`patchedDependencies`, npm's lock-entry `patched` record; npm + /// #711, `bun patch` #367): the package manager applies the user's + /// diff on top of the locked tarball (npm) or keys it to another + /// resolution (Bun), so hosted and vendored mode leave the package + /// unwired and never confirm it. + UserPatchedDependency, } /// A ref discovery emits (so rollback, remove and list find the wiring, @@ -637,6 +649,10 @@ pub enum CopyTarget { NpmName(String), /// Every ref. Any, + /// Exactly the ref of this canonical base purl wiring this patch uuid: + /// a mark for one ref that applies only if that ref survives every + /// contest. + Ref { purl: String, uuid: String }, } /// A copy some build installs where no Socket wiring reaches, recorded by @@ -666,6 +682,7 @@ impl UnwiredCopy { .and_then(|(_, version)| npm_purl(name, version)) .is_some_and(|p| PurlKey::same(&p, &r.purl)), CopyTarget::Any => true, + CopyTarget::Ref { purl, uuid } => *uuid == r.uuid && PurlKey::same(purl, &r.purl), } } } diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index 3193d7f80..7ef77fc49 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -46,9 +46,15 @@ //! (`vendor::npm_lock`) names the tarball `[@scope/]-.tgz` //! after the entry it rewires, so a leaf naming a DIFFERENT package than //! the entry is not Socket-written and is diagnosed, not trusted. +//! +//! A ref of a package the project patches itself with npm's native `npm +//! patch` (#711: a root `patchedDependencies` key, or a `patched` record in +//! either npm lock) stays a ref but is marked +//! [`UnattestedKind::UserPatchedDependency`] ([`push_uncontested`]), by the +//! hosted rewriter's own gate ([`NpmUserPatches`]). use std::collections::{BTreeMap, BTreeSet}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use serde_json::Value; @@ -63,6 +69,7 @@ use crate::formats::pnpm::{ PnpmPackage, }; use crate::formats::text::strip_bom; +use crate::patch::redirect::NpmUserPatches; use crate::utils::digest::is_sri_pin; use crate::vendor::lock_inventory::pnpm::rush_lock_rels; use crate::vendor::lock_inventory::{ @@ -76,8 +83,14 @@ use crate::vendor::npm_origin::{ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let mut locks: Vec = Vec::new(); let mut twin_present = false; + // The root manifest: its `overrides` (#490) and the packages the + // project patches itself (`patchedDependencies`, #711). + let manifest = ctx.read_advisory_text("package.json").await; + let mut user_patches = NpmUserPatches::from_manifest(manifest.as_deref()); for lock in NPM_LOCKS { - if let Some(read) = extract_package_lock(ctx, lock, out).await { + if let Some(read) = + extract_package_lock(ctx, lock, manifest.as_deref(), &mut user_patches, out).await + { locks.push(read); } else if lock == NPM_LOCKS[1] { // Present but unreadable / unparseable (already diagnosed): @@ -86,7 +99,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { twin_present = ctx.exists(lock).await; } } - push_uncontested(locks, twin_present, out); + push_uncontested(locks, twin_present, &user_patches, out); extract_pnpm(ctx, out).await; } @@ -151,7 +164,20 @@ impl NpmLockRefs { /// npm 12 never reads the shrinkwrap and installs from the registry /// instead. `twin_present`: a package-lock.json exists but could not be /// read or parsed (the detail says so instead of calling it missing). -fn push_uncontested(locks: Vec, twin_present: bool, out: &mut Discovery) { +/// +/// A pushed ref of a package the project patches itself with npm's native +/// `npm patch` (`user_patches`, #711) is marked +/// [`UnattestedKind::UserPatchedDependency`]: npm applies the user's diff on +/// top of whatever tarball the lock names, so the bytes it installs are +/// neither the original nor the Socket patch, and the hosted rewriter +/// refuses the package. The ref stays (a pre-#711 hosted pin is still +/// wiring that `rollback` must unwind). +fn push_uncontested( + locks: Vec, + twin_present: bool, + user_patches: &NpmUserPatches, + out: &mut Discovery, +) { let wired: Vec> = locks .iter() .map(|l| l.refs.iter().map(|r| r.purl.clone()).collect()) @@ -269,18 +295,75 @@ fn push_uncontested(locks: Vec, twin_present: bool, out: &mut Disco UnattestedKind::NpmShrinkwrapOnly, ); } + mark_user_patched( + user_patches, + r, + "a patch the project applies with npm's native `npm patch` on top of \ + whatever tarball the lock names, so npm installs bytes that are neither \ + the original nor the Socket patch (or fails `EPATCHFAILED`), and hosted \ + mode leaves the package alone", + out, + ); out.push(r.clone()); } } } } +/// Why the project counts as patching `r`'s package itself +/// ([`NpmUserPatches::source`], the hosted rewriters' #711 / #367 gate), +/// read against what `r` resolves the package to: its hosted URL, or its +/// vendored tarball. A ref with neither is still matched by its +/// `name@version` (and every tarball key counts against it). +fn user_patch_source(user_patches: &NpmUserPatches, r: &PatchedRef) -> Option { + let (name, version) = crate::utils::purl::purl_name_version(&r.purl)?; + let target = r + .url + .as_deref() + .or(r.artifact_rel.as_deref()) + .unwrap_or_default(); + user_patches.source(name, version, target) +} + +/// Mark the npm or Bun ref `r` [`UnattestedKind::UserPatchedDependency`] +/// when the project patches its package itself ([`user_patch_source`]). +/// `how` completes the detail after the key or record it names. The mark +/// is an [`UnwiredCopy`] for exactly `r`, so it lands only if `r` survives +/// every contest: a ref another lock drops carries no stale reason. +pub(super) fn mark_user_patched( + user_patches: &NpmUserPatches, + r: &PatchedRef, + how: &str, + out: &mut Discovery, +) { + let Some(source) = user_patch_source(user_patches, r) else { + return; + }; + out.unwired_copy(UnwiredCopy { + scope: Some(r.source_file.clone()), + target: CopyTarget::Ref { + purl: r.purl.clone(), + uuid: r.uuid.clone(), + }, + file: r.source_file.clone(), + detail: format!("{source}, {how}"), + kind: UnattestedKind::UserPatchedDependency, + }); +} + +/// npm's lock-entry record of a native `npm patch` (#711), as JSON spells +/// its key. +const PATCHED_KEY: &[u8] = b"\"patched\""; + /// One npm lock (`file` is root-relative): both entry trees, see the module /// docs. `None` when the lock is absent, unreadable or unparseable (already -/// diagnosed). +/// diagnosed). `manifest` is the root `package.json`'s text; the lock's +/// npm `patched` records are added to `user_patches`. async fn extract_package_lock( ctx: &DiscoverCtx<'_>, file: &'static str, + manifest: Option<&str>, + user_patches: &mut NpmUserPatches, out: &mut Discovery, ) -> Option { let bytes = ctx.read_bytes(file, out).await?; @@ -320,12 +403,15 @@ async fn extract_package_lock( read.bundled.entry(purl).or_insert(location); } } + // Only a lock that spells `"patched"` at all is walked for npm's + // records, as in the hosted rewriter. + if bytes.windows(PATCHED_KEY.len()).any(|w| w == PATCHED_KEY) { + user_patches.add_lock(file, &doc); + } // The root manifest's `overrides` (#490): which git / url / `file:` // dependent specs npm really installs from. - let overrides = ctx - .read_advisory_text("package.json") - .await - .map(|text| NpmOverrides::from_manifest_text(&text)) + let overrides = manifest + .map(NpmOverrides::from_manifest_text) .unwrap_or_default(); drop_non_registry_installs(file, &doc, &overrides, &mut read, out); drop_mirror_unwired(ctx, file, &doc, &mut read, out); @@ -642,6 +728,10 @@ fn entry_ref( /// exists, like the rewriter (no precedence between files — rule 10). /// Vendoring refuses Rush (`npm_flavor`), so these only carry hosted refs. /// +/// With pnpm's `gitBranchLockfile` on and a `pnpm-lock..yaml` +/// present (#556), every ref of the root and member locks is marked +/// [`UnattestedKind::PnpmGitBranchLock`] ([`unattest_git_branch_lock`]). +/// /// ## Entries /// /// Only `packages:` entries are read — the section pnpm resolves and fetches @@ -695,6 +785,9 @@ async fn extract_pnpm(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { crate::utils::pnpm_workspace::lockfile_disabled(&ctx.view, false).await }; let disabled = disabled.as_deref(); + // The root and member locks read (Rush's are not: its pnpm runs with a + // workspace of its own). + let mut read: Vec = Vec::new(); let root_lock = ctx.exists(PNPM_LOCK).await; if root_lock { extract_pnpm_lock(ctx, PNPM_LOCK, disabled, out).await; @@ -702,8 +795,10 @@ async fn extract_pnpm(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { // are recognized as NOT wired (module docs, rule 11), so a ledger // record its stale Socket url alone keeps "mentioned" is dead. ctx.recognize_ignored(PNPM_SHRINKWRAP_LEGACY).await; + read.push(PNPM_LOCK.to_string()); } else { extract_pnpm_lock(ctx, PNPM_SHRINKWRAP_LEGACY, disabled, out).await; + read.push(PNPM_SHRINKWRAP_LEGACY.to_string()); } if rush { // The common lock, then each subspace's, sorted for deterministic @@ -711,8 +806,10 @@ async fn extract_pnpm(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { for rel in rush_lock_rels(ctx.view).await { extract_pnpm_lock(ctx, &rel, None, out).await; } - } else if let crate::utils::pnpm_workspace::MemberLocks::PerMember(rels) = - crate::utils::pnpm_workspace::member_locks(&ctx.view).await + return; + } + if let crate::utils::pnpm_workspace::MemberLocks::PerMember(rels) = + crate::utils::pnpm_workspace::member_locks_in(&ctx.view, false).await { // `sharedWorkspaceLockfile: false`: each workspace member installs // from its own lock, which hosted mode pins beside the root's @@ -721,10 +818,93 @@ async fn extract_pnpm(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { // not among `rels`. for rel in rels { extract_pnpm_lock(ctx, &rel, disabled, out).await; + read.push(rel); + } + } + // A branch lock (#556): with `gitBranchLockfile` on, pnpm installs a + // branch from `pnpm-lock..yaml`, so no root or member lock is + // what installs. Whether the setting is on (and in which environment) + // is not decided here: a branch lock's presence alone withholds + // (fail-closed), read from the project's files only, and only when one + // of those locks wired something. + let wired = out + .refs + .iter() + .any(|r| read.iter().any(|rel| r.source_file == Path::new(rel))); + if !wired { + return; + } + let branch_locks = match crate::utils::pnpm_workspace::git_branch_lock_files(&ctx.view).await { + Ok(locks) => locks, + // The members that install from their own lock cannot be listed, + // so a member branch lock cannot be ruled out (fail-closed, as in + // hosted mode). + Err(why) => { + for rel in &read { + unattest_unlisted_branch_locks(rel, &why, out); + } + return; } + }; + if branch_locks.is_empty() { + return; + } + for rel in &read { + unattest_git_branch_lock(rel, &branch_locks, out); } } +/// Mark every ref of the pnpm lock `file` [`UnattestedKind::PnpmGitBranchLock`] +/// because the workspace members that install from their own lock cannot +/// be listed (`why`), so a member's `pnpm-lock..yaml` cannot be +/// ruled out. See [`unattest_git_branch_lock`]. +fn unattest_unlisted_branch_locks(file: &str, why: &str, out: &mut Discovery) { + out.unwired_copy(UnwiredCopy { + scope: Some(PathBuf::from(file)), + target: CopyTarget::Any, + file: PathBuf::from(file), + detail: format!( + "its pin is in {file}, but a pnpm branch lock cannot be ruled out: {why}; with \ + `gitBranchLockfile` on, pnpm installs a git branch from its own \ + `pnpm-lock..yaml` rather than {file}" + ), + kind: UnattestedKind::PnpmGitBranchLock, + }); +} + +/// Mark every ref of the pnpm lock `file` [`UnattestedKind::PnpmGitBranchLock`] +/// because `branch_locks` (root-relative `pnpm-lock..yaml` files) +/// exist: with `gitBranchLockfile` on pnpm installs a branch from its own +/// lock instead, the hosted engine then leaves the root lock out and +/// refuses every pnpm pin, and vendored mode refuses the project. +/// Discovery reads neither the setting nor the branch locks, so a branch +/// lock's presence withholds whatever the setting says. The refs stay refs: +/// the wiring in `file` is intact (rollback, remove and list still manage +/// it, and the ledgers keep it live), and no `scan` / `vendor` re-run can +/// rewire the branch lock. The mark names the branch lock in `file`'s own +/// directory when there is one. +fn unattest_git_branch_lock(file: &str, branch_locks: &[String], out: &mut Discovery) { + let dir = Path::new(file).parent().unwrap_or(Path::new("")); + let shown = branch_locks + .iter() + .find(|lock| Path::new(lock.as_str()).parent() == Some(dir)) + .or(branch_locks.first()) + .map_or(file, String::as_str); + out.unwired_copy(UnwiredCopy { + scope: Some(PathBuf::from(file)), + target: CopyTarget::Any, + file: PathBuf::from(shown), + detail: format!( + "its pin is in {file} while the pnpm branch lock(s) {} exist: with \ + `gitBranchLockfile` on, pnpm installs a git branch from its own \ + `pnpm-lock..yaml` rather than {file}, and a branch lock's presence \ + withholds the statement whatever the setting says", + branch_locks.join(", ") + ), + kind: UnattestedKind::PnpmGitBranchLock, + }); +} + /// One pnpm lock at root-relative `file` (see [`extract_pnpm`]). /// `disabled` names where pnpm's `lockfile` setting is turned off: its /// hosted refs then keep no lock pin, and one [`DIAG_REF_UNATTRIBUTABLE`] @@ -3346,6 +3526,343 @@ mod tests { withheld(&run(&p).await, "`lockfile: false` in pnpm-workspace.yaml"); } + /// REGRESSION (#556, N1): with pnpm's `gitBranchLockfile` on and a + /// `pnpm-lock..yaml` present, pnpm installs the branch from that + /// lock, so hosted mode withholds the root (and member) locks and refuses + /// every pnpm pin, and vendored mode refuses the project. Their pins stay + /// refs (the wiring is intact; rollback must still find it) but are never + /// attested. Owner decision: discovery withholds on a branch lock's mere + /// presence, whatever `gitBranchLockfile` (or pnpm's `lockfile`) says — + /// deterministic and fail-closed. No branch lock attests as before. + #[tokio::test] + async fn pnpm_git_branch_lockfile_marks_the_lock_pins_unattested() { + let lock = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n ms:\n specifier: 1.3.0\n version: 1.3.0\n\npackages:\n\n ms@1.3.0:\n resolution: {integrity: sha512-UP==}\n"; + let wired = hosted_rewrite("pnpm-lock.yaml", lock, &[pnpm_override("ms", UUID_B, None)]); + let branch_lock = |out: &Discovery, lock: &str| { + assert_refs(out, &[("pkg:npm/ms@1.3.0", UUID_B, WiringMode::Hosted)]); + let u = out + .unattested + .iter() + .find(|u| u.kind == UnattestedKind::PnpmGitBranchLock) + .unwrap_or_else(|| panic!("{:#?}", out.unattested)); + assert_eq!( + (u.purl.as_str(), u.uuid.as_str()), + ("pkg:npm/ms@1.3.0", UUID_B) + ); + assert_eq!(u.file, std::path::PathBuf::from(lock)); + assert!( + u.detail.contains(lock) && u.detail.contains("presence withholds"), + "{}", + u.detail + ); + }; + + let p = Project::new(); + p.write("pnpm-lock.yaml", &wired); + p.write( + "pnpm-workspace.yaml", + "packages:\n - '.'\ngitBranchLockfile: true\n", + ); + assert!( + run(&p).await.unattested.is_empty(), + "control: no branch lock" + ); + p.write("pnpm-lock.feature.yaml", lock); + branch_lock(&run(&p).await, "pnpm-lock.feature.yaml"); + // The setting off: a branch lock's presence still withholds. + p.write( + "pnpm-workspace.yaml", + "packages:\n - '.'\ngitBranchLockfile: false\n", + ); + branch_lock(&run(&p).await, "pnpm-lock.feature.yaml"); + // No setting anywhere, and `.npmrc` turning it off: the same. + std::fs::remove_file(p.root().join("pnpm-workspace.yaml")).unwrap(); + branch_lock(&run(&p).await, "pnpm-lock.feature.yaml"); + p.write(".npmrc", "git-branch-lockfile=false\n"); + branch_lock(&run(&p).await, "pnpm-lock.feature.yaml"); + // The branch lock gone: attested again. + std::fs::remove_file(p.root().join("pnpm-lock.feature.yaml")).unwrap(); + assert!( + run(&p).await.unattested.is_empty(), + "control: no branch lock" + ); + + // pnpm's `lockfile` setting off as well: still withheld (fail-closed). + let p = Project::new(); + p.write("pnpm-lock.yaml", &wired); + p.write("pnpm-lock.feature.yaml", lock); + p.write("pnpm-workspace.yaml", "lockfile: false\n"); + branch_lock(&run(&p).await, "pnpm-lock.feature.yaml"); + + // A member that installs from its own lock, with its branch lock and + // no `gitBranchLockfile` setting. + let p = Project::new(); + p.write( + "pnpm-workspace.yaml", + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n", + ); + p.write("packages/a/package.json", "{}"); + p.write("packages/a/pnpm-lock.yaml", &wired); + assert!( + run(&p).await.unattested.is_empty(), + "control: no branch lock" + ); + p.write("packages/a/pnpm-lock.feature.yaml", lock); + branch_lock(&run(&p).await, "packages/a/pnpm-lock.feature.yaml"); + } + + /// The hosted npm rewriter's own pin of `left-pad@1.3.0` under `UUID_A`, + /// in a v3 lock whose entry carries `extra` fields too. + fn npm_hosted_lock(version: u64, extra: serde_json::Value) -> String { + let hosted = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let mut entry = + serde_json::json!({ "version": "1.3.0", "resolved": hosted, "integrity": SRI }); + entry + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + let mut lock: serde_json::Value = + serde_json::from_str(&lock_with_packages(serde_json::json!({ + "": { "name": "app", "version": "1.0.0" }, + "node_modules/left-pad": entry, + }))) + .unwrap(); + lock["lockfileVersion"] = version.into(); + lock.to_string() + } + + fn user_patched(out: &Discovery) -> Option<&Unattested> { + out.unattested + .iter() + .find(|u| u.kind == UnattestedKind::UserPatchedDependency) + } + + /// REGRESSION (#711, N2): a package the project patches itself with + /// npm's native `npm patch` — a root `patchedDependencies` key, or the + /// `patched` record npm writes on the lock entry — is one the hosted + /// rewriter leaves alone and never confirms (npm applies the user's + /// diff on top of whatever tarball the lock names). A pin a pre-#711 + /// run left stays a ref, for rollback, but is never attested. + #[tokio::test] + async fn npm_user_patched_dependency_pin_is_not_attested() { + const MANIFEST: &str = r#"{"name":"app","dependencies":{"left-pad":"1.3.0"},"patchedDependencies":{"left-pad@1.3.0":"patches/left-pad@1.3.0.patch"}}"#; + let refs = [("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)]; + + let p = Project::new(); + p.write( + "package-lock.json", + npm_hosted_lock(3, serde_json::json!({})), + ); + p.write( + "package.json", + r#"{"name":"app","dependencies":{"left-pad":"1.3.0"}}"#, + ); + let out = run(&p).await; + assert_refs(&out, &refs); + assert!(out.unattested.is_empty(), "control: {:#?}", out.unattested); + + p.write("package.json", MANIFEST); + let out = run(&p).await; + assert_refs(&out, &refs); + let u = user_patched(&out).unwrap_or_else(|| panic!("{:#?}", out.unattested)); + assert_eq!( + (u.purl.as_str(), u.uuid.as_str()), + ("pkg:npm/left-pad@1.3.0", UUID_A) + ); + assert_eq!(u.file, std::path::PathBuf::from("package-lock.json")); + assert!( + u.detail.contains("`left-pad@1.3.0`") && u.detail.contains("npm patch"), + "{}", + u.detail + ); + + // npm's lock-entry `patched` record (lockfileVersion 4), with no + // manifest key. + let p = Project::new(); + let record = serde_json::json!({ + "patched": { "integrity": "sha512-DIFF==", "path": "patches/left-pad@1.3.0.patch" } + }); + p.write("package-lock.json", npm_hosted_lock(4, record.clone())); + let out = run(&p).await; + assert_refs(&out, &refs); + let u = user_patched(&out).unwrap_or_else(|| panic!("{:#?}", out.unattested)); + assert!( + u.detail + .contains("package-lock.json entry `node_modules/left-pad`"), + "{}", + u.detail + ); + + // The record in the sibling shrinkwrap counts too: npm applies the + // diff whichever lock it reads (and hosted reads every present lock). + let p = Project::new(); + p.write( + "package-lock.json", + npm_hosted_lock(4, serde_json::json!({})), + ); + p.write("npm-shrinkwrap.json", npm_hosted_lock(4, record)); + let out = run(&p).await; + let marked: Vec<_> = out + .unattested + .iter() + .filter(|u| u.kind == UnattestedKind::UserPatchedDependency) + .map(|u| u.file.clone()) + .collect(); + assert!( + marked.contains(&std::path::PathBuf::from("package-lock.json")), + "{marked:?}" + ); + + // A key for another package (or version) marks nothing. + let p = Project::new(); + p.write( + "package-lock.json", + npm_hosted_lock(3, serde_json::json!({})), + ); + p.write( + "package.json", + r#"{"patchedDependencies":{"left-pad@1.2.0":"p.patch","is-number@7.0.0":"q.patch"}}"#, + ); + let out = run(&p).await; + assert_refs(&out, &refs); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + } + + /// Review (#2010): a ref with no target to compare a tarball key with + /// (neither a hosted url nor a vendored artifact) is still matched by + /// its `name@version` key, not skipped. + #[test] + fn a_ref_without_a_target_is_still_matched_by_name_and_version() { + let patches = super::NpmUserPatches::from_manifest(Some( + r#"{"patchedDependencies":{"left-pad@1.3.0":"p.patch"}}"#, + )); + let r = PatchedRef::hosted( + "pkg:npm/left-pad@1.3.0".into(), + UUID_A.into(), + "package-lock.json", + None, + None, + true, + ); + let source = super::user_patch_source(&patches, &r); + assert!( + source + .as_deref() + .is_some_and(|s| s.contains("`left-pad@1.3.0`")), + "{source:?}" + ); + } + + /// Review (#2010): a user-patched ref another lock contests is dropped + /// by the cross-lock contest, so it must leave no `UserPatchedDependency` + /// mark behind (the vex plan would report that reason for it). + #[tokio::test] + async fn a_contested_user_patched_ref_leaves_no_mark() { + let p = Project::new(); + p.write( + "package-lock.json", + npm_hosted_lock(3, serde_json::json!({})), + ); + p.write( + "package.json", + r#"{"patchedDependencies":{"left-pad@1.3.0":"patches/left-pad.patch"}}"#, + ); + // A pnpm lock resolving the same version from the registry. + p.write( + "pnpm-lock.yaml", + "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n left-pad:\n specifier: 1.3.0\n version: 1.3.0\n\npackages:\n\n left-pad@1.3.0:\n resolution: {integrity: sha512-UP==}\n", + ); + let out = p.discover().await; + assert!( + out.refs.iter().all(|r| r.purl != "pkg:npm/left-pad@1.3.0"), + "the contest drops the ref: {:#?}", + out.refs + ); + assert!(!out.contested.is_empty(), "{:#?}", out.diagnostics); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + } + + /// Review (#2010): discovery reads pnpm's settings from the project's + /// files only. `sharedWorkspaceLockfile` set only in the environment + /// must not turn the workspace's member branch lock into one that + /// withholds the root lock's pin. + #[tokio::test] + #[serial_test::serial] + async fn pnpm_git_branch_lockfile_discovery_reads_no_environment() { + let lock = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n ms:\n specifier: 1.3.0\n version: 1.3.0\n\npackages:\n\n ms@1.3.0:\n resolution: {integrity: sha512-UP==}\n"; + let wired = hosted_rewrite("pnpm-lock.yaml", lock, &[pnpm_override("ms", UUID_B, None)]); + let p = Project::new(); + p.write("pnpm-lock.yaml", &wired); + p.write( + "pnpm-workspace.yaml", + "packages:\n - 'packages/*'\ngitBranchLockfile: true\n", + ); + p.write("packages/a/package.json", "{}"); + p.write("packages/a/pnpm-lock.feature.yaml", lock); + let _env = crate::test_env::EnvGuard::many([ + ("npm_config_shared_workspace_lockfile", Some("false")), + ("pnpm_config_shared_workspace_lockfile", Some("false")), + ]); + let out = run(&p).await; + assert_refs(&out, &[("pkg:npm/ms@1.3.0", UUID_B, WiringMode::Hosted)]); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); + } + + /// Review (#2010): a member lock's mark names the branch lock in the + /// member's own directory, not the first one found (the root's). + #[tokio::test] + async fn pnpm_git_branch_lock_mark_names_the_locks_own_branch_lock() { + let lock = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n ms:\n specifier: 1.3.0\n version: 1.3.0\n\npackages:\n\n ms@1.3.0:\n resolution: {integrity: sha512-UP==}\n"; + let root = hosted_rewrite("pnpm-lock.yaml", lock, &[pnpm_override("ms", UUID_A, None)]); + let member = hosted_rewrite("pnpm-lock.yaml", lock, &[pnpm_override("ms", UUID_B, None)]); + let p = Project::new(); + p.write( + "pnpm-workspace.yaml", + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\ngitBranchLockfile: true\n", + ); + p.write("pnpm-lock.yaml", &root); + p.write("pnpm-lock.feature.yaml", lock); + p.write("packages/a/package.json", "{}"); + p.write("packages/a/pnpm-lock.yaml", &member); + p.write("packages/a/pnpm-lock.feature.yaml", lock); + let out = run(&p).await; + let file_of = |uuid: &str| { + out.unattested + .iter() + .find(|u| u.uuid == uuid && u.kind == UnattestedKind::PnpmGitBranchLock) + .map(|u| u.file.clone()) + .unwrap_or_else(|| panic!("{uuid}: {:#?}", out.unattested)) + }; + assert_eq!( + file_of(UUID_A), + std::path::PathBuf::from("pnpm-lock.feature.yaml") + ); + assert_eq!( + file_of(UUID_B), + std::path::PathBuf::from("packages/a/pnpm-lock.feature.yaml") + ); + } + + /// With the shared lock off and no `packages:` list, the members that + /// install from their own lock cannot be listed, so a member branch + /// lock cannot be ruled out: the root pin is withheld (fail-closed). + #[tokio::test] + async fn pnpm_unlisted_members_withhold_for_a_possible_branch_lock() { + let lock = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n ms:\n specifier: 1.3.0\n version: 1.3.0\n\npackages:\n\n ms@1.3.0:\n resolution: {integrity: sha512-UP==}\n"; + let root = hosted_rewrite("pnpm-lock.yaml", lock, &[pnpm_override("ms", UUID_A, None)]); + let p = Project::new(); + p.write("pnpm-workspace.yaml", "sharedWorkspaceLockfile: false\n"); + p.write("pnpm-lock.yaml", &root); + let out = run(&p).await; + assert!( + out.unattested + .iter() + .any(|u| u.uuid == UUID_A && u.kind == UnattestedKind::PnpmGitBranchLock), + "{:#?}", + out.unattested + ); + } + /// `shrinkwrap.yaml` (pnpm <= 2) is read only when there is no /// `pnpm-lock.yaml`: beside one it is migration debris, and a stale /// Socket url left in it must not attest a reverted project.