diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..7f330bd40 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`) in the same state, 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 and a `pnpm-lock..yaml` present (the **pnpm per-branch locks** state above, read from `pnpm-workspace.yaml` / `.npmrc` and a governing ancestor's, never from the environment, and only while pnpm's `lockfile` setting is on), pnpm installs a branch from that lock, which no wiring reaches, so every reference of the root pnpm lock (`pnpm-lock.yaml`, or `shrinkwrap.yaml` without it) and of each member's own lock is omitted, hosted and vendored alike (#556). Hosted mode refuses every pnpm pin in that state and vendored mode refuses the project, so this covers wiring written before the setting or the branch lock appeared. 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 (Bun) another tarball than the one the reference pins; 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. 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 (the hosted scan's warning names `socket-patch rollback ` for them). A key naming the pinned tarball itself (a `bun patch` made on top of the pin) is no conflict. * **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 `gitBranchLockfile` is on and a `pnpm-lock..yaml` exists, so pnpm installs the branch from that lock instead (#556); no statement while branch locks are in use. `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..922350420 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -232,6 +232,12 @@ 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 `gitBranchLockfile` +/// makes pnpm install from a `pnpm-lock..yaml` (`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 +274,17 @@ 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 branch locks are in use; the wiring itself is intact, so \ + re-running `scan` / `vendor` does not change this (turn gitBranchLockfile off and \ + fold the branch locks in with `pnpm install --merge-git-branch-lockfiles`)", + ), + 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)", + ), } } @@ -418,7 +435,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 +1781,33 @@ 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" + ); + } + /// 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..efabbf35d 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,70 @@ 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. +#[derive(Debug, Default)] +pub(crate) struct NpmUserPatches { + /// The root manifest's `patchedDependencies` keys. + keys: Vec, + /// `(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), + locked: Vec::new(), + } + } + + /// 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) + { + return Some(format!("package.json `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..45f261742 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -775,6 +775,14 @@ 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 +} + +/// [`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`]). +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 { @@ -808,7 +816,7 @@ pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option }); } } - if setting.is_none() { + if setting.is_none() && env { setting = view_setting(view, None, None, KEYS.0, KEYS.1).await; } let setting = git_branch_on(setting)?; diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index 85b24e95b..9ccd21c55 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -93,13 +93,15 @@ use super::{ canonical_base_purl, npm_purl, DiscoverCtx, Discovery, LocateOpts, Located, PatchedRef, - WiringMode, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, + UnattestedKind, 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::utils::digest::is_sri_pin; use crate::vendor::bun_lock_text::{ - decode_json_string, is_bundled_entry, split_name_spec, user_tarball_version, + decode_json_string, is_bundled_entry, patched_dependency_key, patched_dependency_keys, + split_name_spec, user_tarball_version, }; use crate::vendor::bun_lockb::BunLockb; use crate::vendor::lock_inventory::bun::bun_text_entries; @@ -177,6 +179,52 @@ 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 keys = patched_dependency_keys(manifest.as_deref(), Some(&text)); + unattest_user_patched(BUN_LOCK, &keys, 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 ([`patched_dependency_key`], +/// 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, keys: &[String], out: &mut Discovery) { + if keys.is_empty() { + return; + } + let marks: Vec<(String, String, String)> = out + .refs + .iter() + .filter(|r| r.source_file == std::path::Path::new(file)) + .filter_map(|r| { + let (name, version) = crate::utils::purl::purl_name_version(&r.purl)?; + let target = r.url.as_deref().or(r.artifact_rel.as_deref())?; + let key = patched_dependency_key(keys, name, version, target)?; + let detail = format!( + "package.json `patchedDependencies` has `{key}`, a patch the project applies \ + with `bun patch` only while {name}@{version} resolves to what that key names, \ + not to this pin, so hosted and vendored mode leave the package alone" + ); + Some((r.purl.clone(), r.uuid.clone(), detail)) + }) + .collect(); + for (purl, uuid, detail) in marks { + out.unattested( + &purl, + &uuid, + file, + detail, + UnattestedKind::UserPatchedDependency, + ); + } } // ── bundled copies ─────────────────────────────────────────────────────── @@ -347,6 +395,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 keys = patched_dependency_keys(manifest.as_deref(), None); + unattest_user_patched(BUN_LOCKB, &keys, out); } // ── shared classification ──────────────────────────────────────────────── @@ -1877,6 +1930,90 @@ 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)]); + + // `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)]); + } + /// 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..5b3188b07 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, diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index 3193d7f80..50e30c69d 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -46,6 +46,12 @@ //! (`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; @@ -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,46 @@ fn push_uncontested(locks: Vec, twin_present: bool, out: &mut Disco UnattestedKind::NpmShrinkwrapOnly, ); } + if let Some(source) = user_patch_source(user_patches, r) { + out.unattested( + &r.purl, + &r.uuid, + lock.file, + format!( + "{source}, 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" + ), + UnattestedKind::UserPatchedDependency, + ); + } out.push(r.clone()); } } } } +/// Why the project counts as patching `r`'s package itself +/// ([`NpmUserPatches::source`], the hosted rewriter's #711 / #367 gate), +/// read against what `r` resolves the package to: its hosted URL, or its +/// vendored tarball. +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())?; + user_patches.source(name, version, target) +} + /// 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 +374,11 @@ async fn extract_package_lock( read.bundled.entry(purl).or_insert(location); } } + 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 +695,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 +752,15 @@ async fn extract_pnpm(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { crate::utils::pnpm_workspace::lockfile_disabled(&ctx.view, false).await }; let disabled = disabled.as_deref(); + // `gitBranchLockfile` with a branch lock (#556): pnpm installs a branch + // from `pnpm-lock..yaml`, so no root or member lock is what + // installs. Detected as the hosted engine detects it (and, like it, + // only while the lock is enabled), from the project's files alone. + let branch = match (rush, disabled) { + (false, None) => crate::utils::pnpm_workspace::git_branch_locks_in(&ctx.view, false).await, + _ => None, + }; + let branch = branch.as_ref(); let root_lock = ctx.exists(PNPM_LOCK).await; if root_lock { extract_pnpm_lock(ctx, PNPM_LOCK, disabled, out).await; @@ -702,8 +768,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; + unattest_git_branch_lock(PNPM_LOCK, branch, out); } else { extract_pnpm_lock(ctx, PNPM_SHRINKWRAP_LEGACY, disabled, out).await; + unattest_git_branch_lock(PNPM_SHRINKWRAP_LEGACY, branch, out); } if rush { // The common lock, then each subspace's, sorted for deterministic @@ -721,10 +789,38 @@ async fn extract_pnpm(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { // not among `rels`. for rel in rels { extract_pnpm_lock(ctx, &rel, disabled, out).await; + unattest_git_branch_lock(&rel, branch, out); } } } +/// Mark every ref of the pnpm lock `file` [`UnattestedKind::PnpmGitBranchLock`] +/// when `branch` (pnpm's `gitBranchLockfile` on, a branch lock present) says +/// pnpm installs a branch from its own `pnpm-lock..yaml` instead. +/// The hosted engine then leaves the root lock out and refuses every pnpm +/// pin, vendored mode refuses the project, and neither reads member locks; +/// discovery does not read branch locks either, so which bytes a branch +/// installs is unknown. 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. +fn unattest_git_branch_lock( + file: &str, + branch: Option<&crate::utils::pnpm_workspace::GitBranchLocks>, + out: &mut Discovery, +) { + let Some(branch) = branch else { + return; + }; + let shown = 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} and {}", branch.describe()), + 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 +3442,205 @@ 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. The setting off, or 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, setting: &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(setting) && u.detail.contains(lock), + "{}", + 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", + "`gitBranchLockfile: true` in pnpm-workspace.yaml", + ); + p.write( + "pnpm-workspace.yaml", + "packages:\n - '.'\ngitBranchLockfile: false\n", + ); + assert!(run(&p).await.unattested.is_empty(), "control: setting off"); + + // The `.npmrc` spelling. + let p = Project::new(); + p.write("pnpm-lock.yaml", &wired); + p.write("pnpm-lock.feature.yaml", lock); + p.write(".npmrc", "git-branch-lockfile=true\n"); + branch_lock( + &run(&p).await, + "pnpm-lock.feature.yaml", + "git-branch-lockfile", + ); + + // A member that installs from its own lock, with its branch lock. + let p = Project::new(); + p.write( + "pnpm-workspace.yaml", + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\ngitBranchLockfile: true\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", + "gitBranchLockfile", + ); + } + + /// 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); + } + /// `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.