diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index d5f8cecf0..bfe6eb4c4 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -165,6 +165,8 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **pnpm per-member locks (#492)**: a workspace whose root `pnpm-workspace.yaml` sets `sharedWorkspaceLockfile: false` (or, when the YAML leaves the key unset, whose root `.npmrc` sets `shared-workspace-lockfile=false`; on disk, when neither file sets it, `pnpm_config_shared_workspace_lockfile` / `npm_config_shared_workspace_lockfile`; booleans in any letter case, as pnpm's YAML reader takes them) installs each member from the member's own `pnpm-lock.yaml`. Run from the workspace root, hosted mode reads every member lock the `packages:` globs name (quoted `!` negations applied; `node_modules`, `bower_components`, `test` and `tests` trees and symlinked directories skipped) beside the root lock, which may be absent (pnpm 7): each is rewritten like the root lock, a 9.0 member lock engages the `trustLockfile: true` auto-config in the ROOT `pnpm-workspace.yaml` (heal-on-rerun included), and discovery (`list`, `vex`, `rollback`, `remove`) reads the same member locks. A root lock whose `importers:` lists a member is a shared lock, so member locks beside it are stale and never read or attested. When the members' locks cannot be listed or read (no `packages:` key, whose pnpm <= 8 default finds projects in every directory; a `packages:` value that is not a plain list of globs; no member lock and no root lock, or a member lock that exists but is not readable text), every pnpm lock (the root's included) is left untouched and the run warns `redirect_pnpm_member_locks_unresolved` instead of `redirect_pnpm_no_lockfile`; no pnpm pin is confirmed. The lockfile inventory reads the same member locks, so their dependencies are discovered without an installed tree. The in-memory engine gives the same result as a disk run from the workspace root: a directory whose `pnpm-lock.yaml` has no `pnpm-workspace.yaml` beside it is a workspace member, never a root of its own, when (with `npm` among the `ecosystems`) the nearest ancestor `pnpm-workspace.yaml` is readable and its `packages:` globs (read as above, no braces, classes or extglobs) list it, it holds a `package.json` / `package.yaml` / `package.json5`, the workspace root installs it from its own lock (`sharedWorkspaceLockfile: false` with the member list resolved) or from a root lock beside which its lock is a stale leftover, and socket.yml admits the workspace root. Its lock is then demoted into that workspace root, which becomes a root even with no lock of its own (pnpm 7), the way a Cargo member's lock is demoted; the member stays a root only when it holds another lock marker, without its pnpm lock. The workspace root then pins (or refuses) its member locks as above, trusts them in its own file, refuses a member's branch lock (#556) and leaves a member lock beside a shared root lock unread. A directory that fails any of those tests keeps its lock as a root of its own (pinned like any root, or refused with `redirect_pnpm_settings_elsewhere` while the trust auto-config is on and the ancestor file may list it). Path selection adds the workspace root above every candidate to `roots`, fetches it, and fetches the member locks, branch locks and manifests below it in every directory holding a package manifest; given those `roots` as `projectRoots`, the session drops a confirmed member named beside its workspace root (unless it holds another lock) and a lockless workspace root that pins no member, so it runs the roots it would detect. A confirmed member named in `projectRoots` without its workspace root is not pinned: its lock is dropped and the run-level `pnpm_member_lock_ignored` warning names the workspace root to scan. +**pnpm 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 `npm_config_lockfile` / `pnpm_config_lockfile` from its environment. 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 `npm_config_git_branch_lockfile` / `pnpm_config_git_branch_lockfile` set to `true`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm installs a git branch from its own `pnpm-lock..yaml` whenever that lock exists, in every directory it installs a lock from: the root, and under `sharedWorkspaceLockfile: false` each member's (#492). When the setting is on AND at least one such `pnpm-lock..yaml` exists (at the root, or in a member that installs from its own lock), hosted mode leaves every root pnpm lock (`pnpm-lock.yaml`, `shrinkwrap.yaml`) and `pnpm-workspace.yaml` untouched (every member lock too), confirms no npm pin from them and warns `redirect_pnpm_git_branch_lockfile` (in place of `redirect_pnpm_no_lockfile` when the branch lock is the only one); the detail names the setting and the branch lock(s), and the remedy is to turn `gitBranchLockfile` off and fold the branch locks into `pnpm-lock.yaml` with `pnpm install --merge-git-branch-lockfiles`, then re-run. A vendored→hosted takeover of a pnpm-vendored purl is refused with the same code (the vendored wiring and ledger stay byte-for-byte). Vendored mode refuses the same projects with `vendor_pnpm_git_branch_lockfile`, before any write and in the pre-flight. The setting with no branch lock (main, or a branch whose dependencies never changed) installs from `pnpm-lock.yaml`, which both modes wire as usual; a stray branch lock with the setting off is ignored. The in-memory engine fetches root `pnpm-lock.*.yaml` names presence-only and refuses the same way. `redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers. @@ -417,7 +419,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma | Ecosystem | Files read | Hosted reference | Vendored reference | Hosted pin (`integrity_required`) | |---|---|---|---|---| | npm | `package-lock.json` and `npm-shrinkwrap.json` (both when both exist) | `resolved` on the patch host (`packages` in v2/v3; `dependencies` only in v1; `link` / `inBundle` / `bundled` entries skipped, and so is any entry npm installs from a git, URL or `file:` spec or from a dependency's own `npm-shrinkwrap.json` (beneath a `hasShrinkwrap: true` package, which npm 7–11 install from that shrinkwrap; `redirect_npm_shrinkwrapped_instance_skipped` / `vendor_shrinkwrapped_instance_skipped`), together with every ref for the same `name@version`) | `resolved: file:.socket/vendor/npm//-.tgz` | `integrity`, required | -| pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required | +| pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required. With pnpm's `lockfile` setting off (#1074, outside Rush) pnpm reads no lock: every hosted reference in the root and member locks keeps no pin, and one `patched_ref_unattributable` per lock names them; vendored references are unaffected. | | yarn | `yarn.lock` (classic and berry) | classic `resolved`; berry entry keyed and resolved `@` + root `package.json` `resolutions` `"@npm:": ""` (older releases: `resolution: …::__archiveUrl=`) | classic `resolved "file:./.socket/vendor/npm/…#"`; berry `file:` entry **plus** a root `package.json` `resolutions` mapping onto the same artifact (without it the entry is orphaned: diagnosed, no ref) | classic `integrity` / `#sha1`, berry `checksum`, required | | bun | `bun.lock`; `bun.lockb` only when there is no `bun.lock` (bun reads exactly one) | URL tuple / binary remote-tarball resolution; version from the URL leaf. A user URL or `file:` tarball tuple / record whose `-.tgz` leaf names the same `name@version` is installed from its own spec, so every ref for that `name@version` is dropped | `.socket/vendor/npm//-.tgz` tuple / local-tarball resolution | `sha512-…`, required. A 2-tuple that Bun < 1.3.10 re-saved without its digest is still a reference, but it attests only from an installed tree. | | vlt | `vlt-lock.json` (lockfileVersion absent, `0` or `1`; a BOM-prefixed, unparseable, non-object or other-version lock is not read: diagnosed, no ref). `vlt.json` (read only for its `modifiers`) and `node_modules/.vlt-lock.json` are never wiring. | a registry node (any segment) whose slot [3] is a `/patch/npm/…` URL on the patch host with the leaf `-.tgz` of its DepID's `name@version`, whose embedded `/` path (when present) is that `name@version`, and slot [1] == name; version from the DepID | a `file` node `.socket/vendor/npm//-/node_modules/` (or a user-installed `-.tgz`) with slot [1] == name; version from the path. A same-`name@version` registry node, or a diagnosed Socket-shaped one, beside it is diagnosed, no ref. | slot [2] `sha512-…`, required (a hosted node without one is no reference). A same-`name@version` node on another registry, or a diagnosed Socket-shaped one, keeps the reference but withholds the lockfile basis: only an installed tree whose every store copy verifies attests. So does a lock some vlt release discards, the conditions of `redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored` and `redirect_vlt_scalar_registry_ignored` (which in-run `--vex` withholds too): every hosted reference in it keeps no pin, and one `patched_ref_unattributable` names them. | diff --git a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs index c6cd5f28a..83d70e82f 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs @@ -1205,6 +1205,95 @@ async fn pnpm11_zero_touch_frozen_install_lands_patched_bytes_via_auto_trust_con assert_fixture_manifestless_vex(&fx, &fresh, "pnpm11 zero-touch"); } +/// #1074: with pnpm's `lockfile` setting off, pnpm neither reads nor +/// writes `pnpm-lock.yaml` and installs the upstream package from the +/// registry, so a hosted pin in the committed lock patches nothing. Over a +/// lock the real pnpm 11 scan pinned, once `lockfile: false` lands in +/// `pnpm-workspace.yaml`: +/// +/// * a re-run of `scan --mode hosted` refuses with +/// `redirect_pnpm_lockfile_disabled` and leaves the lock and the +/// workspace file byte-for-byte; +/// * lock-only `vex` (no node_modules, no manifest, no ledgers: the CI/SBOM +/// case) no longer attests the patch from the lock pin. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"] +async fn pnpm11_disabled_lockfile_is_neither_pinned_nor_attested() { + let Some(fx) = redirect_scanned_pnpm_project( + PNPM_TERTIARY, + "pnpm11-lockfile-off", + false, + false, + HostedDriver::Scan, + ) + .await + else { + return; + }; + let ws_path = fx.proj.join("pnpm-workspace.yaml"); + let mut workspace = std::fs::read_to_string(&ws_path).unwrap_or_default(); + if !workspace.is_empty() && !workspace.ends_with('\n') { + workspace.push('\n'); + } + workspace.push_str("lockfile: false\n"); + std::fs::write(&ws_path, &workspace).unwrap(); + let lock_path = fx.proj.join(&fx.lock_name); + let lock_wired = std::fs::read_to_string(&lock_path).unwrap(); + assert_ne!( + lock_wired, fx.lock_before, + "the fixture scan pinned the lock" + ); + + let (code, stdout, stderr) = run_hosted(HostedDriver::Scan, &fx.proj, &fx._server.uri(), &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!(env["summary"]["applied"], 0, "{env}"); + let codes = warning_codes(&env); + assert!( + codes.contains(&"redirect_pnpm_lockfile_disabled".to_string()) + && !codes.contains(&"redirect_pnpm_trust_lockfile".to_string()), + "{codes:?}\n{env}" + ); + assert_eq!(std::fs::read_to_string(&lock_path).unwrap(), lock_wired); + assert_eq!(std::fs::read_to_string(&ws_path).unwrap(), workspace); + + // A lock-only checkout: package.json, the pinned lock and the workspace + // file pnpm reads the setting from, nothing installed. + let fresh = fx.tmp.path().join("fresh-lockfile-off"); + std::fs::create_dir_all(&fresh).unwrap(); + for file in ["package.json", fx.lock_name.as_str(), "pnpm-workspace.yaml"] { + std::fs::copy(fx.proj.join(file), fresh.join(file)).unwrap(); + } + let patch_server = fx._server.uri(); + off_runtime(|| { + let api = hosted_patch_api(PURL, &fx.patched); + let out = run_vex( + &socket_bin(), + &fresh, + &VexRun { + patch_server_url: Some(patch_server.clone()), + ..VexRun::online(&api) + }, + ); + assert_ne!(out.code, Some(0), "lock-only vex must not attest: {out}"); + assert_absent(out.doc.as_ref(), PURL); + + // Control: the same checkout with the setting removed attests. + std::fs::remove_file(fresh.join("pnpm-workspace.yaml")).unwrap(); + let out = run_vex( + &socket_bin(), + &fresh, + &VexRun { + patch_server_url: Some(patch_server.clone()), + ..VexRun::online(&api) + }, + ); + assert_eq!(out.code, Some(0), "control: {out}"); + assert_attested(out.doc(), PURL, UUID, Marker::Redirected, HOSTED_VULNS); + }); +} + /// `--no-trust-lockfile-config` control: pins the opt-out (the scan writes /// no pnpm-workspace.yaml — asserted inside the fixture helper) AND the old /// behavior it restores. Without the trust config the PLAIN frozen install diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 88a7fdecd..7c38b972a 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -369,8 +369,9 @@ pub struct CandidateFiles { pub gem_refusal: Option, /// Set when the root pnpm lock pins nothing pnpm installs: the /// workspace keeps one lock per member but the members cannot be - /// listed (#492), or `gitBranchLockfile` installs the branch from a - /// `pnpm-lock..yaml` (#556). The root lock was left out of + /// listed (#492), `gitBranchLockfile` installs the branch from a + /// `pnpm-lock..yaml` (#556), or pnpm's `lockfile` setting is + /// off so pnpm reads no lock at all (#1074). The root lock was left out of /// `files`, and the rewrite reports this instead of a redirect or a /// "no lockfile" hint. pub pnpm_refusal: Option, @@ -718,7 +719,22 @@ pub async fn read_candidate_files( } } } else if candidates.iter().any(|c| c.dep.ecosystem == "npm") { - if let Some(branch) = crate::utils::pnpm_workspace::git_branch_locks(view).await { + // Only a pnpm project (a root pnpm lock, or a pnpm-workspace.yaml) + // is refused for pnpm's `lockfile` setting: an npm, yarn or bun + // project keeps its own hints. + let pnpm_lock = ROOT_PNPM_LOCKS.iter().any(|lock| { + out.files.contains_key(*lock) + || out.symlinked_reads.iter().any(|r| r == lock) + || out.unreadable_reads.iter().any(|r| r == lock) + }); + let pnpm_project = pnpm_lock || view.read_text(PNPM_WORKSPACE_REL).await.is_ok(); + let disabled = match pnpm_project { + true => crate::utils::pnpm_workspace::lockfile_disabled(view, true).await, + false => None, + }; + if let Some(source) = disabled { + withhold_root_pnpm_locks(&mut out, disabled_lockfile_refusal(&source, pnpm_lock)); + } else if let Some(branch) = crate::utils::pnpm_workspace::git_branch_locks(view).await { refuse_git_branch_locks(&mut out, &branch); } else { read_pnpm_member_locks(view, unreadable, &mut out).await; @@ -834,23 +850,59 @@ fn refuse_pnpm_members(out: &mut CandidateFiles, why: String) { /// `gitBranchLockfile` with a branch lock present (#556): pnpm installs /// the branch from that lock, which hosted mode cannot pin, so the root /// lock (stale on the branch) is left out and the run refuses every pnpm -/// pin (see [`CandidateFiles::pnpm_refusal`]). In memory the root lock is -/// dropped from the link and unreadable lists too: it is not written. +/// pin (see [`CandidateFiles::pnpm_refusal`]). fn refuse_git_branch_locks( out: &mut CandidateFiles, branch: &crate::utils::pnpm_workspace::GitBranchLocks, ) { - const ROOT_LOCKS: [&str; 2] = ["pnpm-lock.yaml", "shrinkwrap.yaml"]; - for lock in ROOT_LOCKS { + withhold_root_pnpm_locks(out, git_branch_lock_refusal(branch)); +} + +/// The root pnpm locks (see [`withhold_root_pnpm_locks`]). +const ROOT_PNPM_LOCKS: [&str; 2] = ["pnpm-lock.yaml", "shrinkwrap.yaml"]; + +/// Leave every root pnpm lock out of the run and refuse every pnpm pin +/// with `refusal` (see [`CandidateFiles::pnpm_refusal`]): a branch lock +/// (#556) or pnpm's `lockfile` setting off (#1074, where pnpm reads no +/// lock at all; member locks are never read then). In memory the root +/// lock is dropped from the link and unreadable lists too: it is not +/// written. +fn withhold_root_pnpm_locks(out: &mut CandidateFiles, refusal: RewriteWarning) { + for lock in ROOT_PNPM_LOCKS { out.files.remove(lock); } out.symlinked_reads - .retain(|r| !ROOT_LOCKS.contains(&r.as_str())); + .retain(|r| !ROOT_PNPM_LOCKS.contains(&r.as_str())); out.unreadable_reads - .retain(|r| !ROOT_LOCKS.contains(&r.as_str())); - out.pnpm_refusal = Some(git_branch_lock_refusal(branch)); + .retain(|r| !ROOT_PNPM_LOCKS.contains(&r.as_str())); + out.pnpm_refusal = Some(refusal); } +/// The [`PNPM_LOCKFILE_DISABLED`] warning for a `lockfile` setting turned +/// off at `source` (see [`crate::utils::pnpm_workspace::lockfile_disabled`]). +/// Vendored mode is named only when a pnpm lock exists: its pnpm backend +/// wires the lock it finds (pnpm then applies the `overrides` without +/// reading it) and refuses a project with none. +fn disabled_lockfile_refusal(source: &str, pnpm_lock: bool) -> RewriteWarning { + let remedy = if pnpm_lock { + "use `--mode vendored` (pnpm applies the `overrides` it wires without a lock), \ + or remove the setting, run `pnpm install` and re-run" + } else { + "remove the setting, run `pnpm install` so pnpm writes pnpm-lock.yaml, and re-run" + }; + RewriteWarning { + code: PNPM_LOCKFILE_DISABLED.into(), + detail: format!( + "{}; no pnpm lock was rewritten — {remedy}", + crate::utils::pnpm_workspace::describe_lockfile_disabled(source) + ), + } +} + +/// Refusal code for a project whose pnpm `lockfile` setting is off, so +/// pnpm never reads the lock a hosted pin goes into (#1074). +pub const PNPM_LOCKFILE_DISABLED: &str = "redirect_pnpm_lockfile_disabled"; + /// The [`PNPM_GIT_BRANCH_LOCKFILE`] warning for `branch`: the hosted /// rewrite's, and the vendored→hosted takeover's (which keeps the vendored /// wiring rather than revert it into a lock pnpm does not install from). @@ -5536,6 +5588,240 @@ snapshots: assert!(!done.rewrite.files.contains_key(BRANCH_LOCK)); } } + + fn assert_disabled_lockfile_refused(read: &CandidateFiles, done: &Rewritten, source: &str) { + assert!(!read.files.contains_key("pnpm-lock.yaml")); + // Neither the lock nor a `trustLockfile` key is written. + assert!(done.rewrite.files.is_empty(), "{:?}", done.rewrite.files); + assert!(done.confirmed.is_empty(), "{:?}", done.confirmed); + let codes = warning_codes(done); + assert!(codes.contains(&PNPM_LOCKFILE_DISABLED), "{codes:?}"); + assert!( + !codes.contains(&"redirect_pnpm_no_lockfile") + && !codes.contains(&"redirect_npm_no_lockfile") + && !codes.contains(&"redirect_pnpm_trust_lockfile"), + "{codes:?}" + ); + let detail = &done + .rewrite + .warnings + .iter() + .find(|w| w.code == PNPM_LOCKFILE_DISABLED) + .unwrap() + .detail; + assert!( + detail.contains(source) && detail.contains("--mode vendored"), + "{detail}" + ); + } + + /// pnpm's `lockfile` setting off (#1074): pnpm never reads the + /// committed lock and installs the upstream package, so pinning it + /// would confirm (and VEX attest) a patch no install applies. pnpm 11+ + /// read the setting from `pnpm-workspace.yaml`, pnpm 9/10 from + /// `.npmrc`. + #[tokio::test] + async fn a_disabled_lockfile_is_never_pinned() { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), "lockfile: false\n", true); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + let (read, done) = member_rewrite(tmp.path()).await; + assert_disabled_lockfile_refused(&read, &done, "`lockfile: false` in pnpm-workspace.yaml"); + + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), "packages:\n - '.'\n", true); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + std::fs::remove_file(tmp.path().join("pnpm-workspace.yaml")).unwrap(); + write_rel(tmp.path(), ".npmrc", "lockfile=false\n"); + let (read, done) = member_rewrite(tmp.path()).await; + assert_disabled_lockfile_refused(&read, &done, "`lockfile=false` in .npmrc"); + + // The in-memory engine refuses the same project. + let mut project = MemoryProject::new(); + project.insert_text("package.json", r#"{"name":"app"}"#); + project.insert_text("pnpm-workspace.yaml", "lockfile: false\n"); + project.insert_text("pnpm-lock.yaml", v9_member_lock(true)); + let (read, done) = view_rewrite(&ProjectView::Memory(&project)).await; + assert_disabled_lockfile_refused(&read, &done, "`lockfile: false` in pnpm-workspace.yaml"); + } + + /// With the lock off, pnpm reads no member's own lock either + /// (`sharedWorkspaceLockfile: false`, #492): none is pinned. + #[tokio::test] + async fn a_disabled_lockfile_pins_no_member_lock() { + let tmp = tempfile::tempdir().unwrap(); + write_member_workspace( + tmp.path(), + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\nlockfile: false\n", + ); + let (read, done) = member_rewrite(tmp.path()).await; + assert!(read.pnpm_member_lock_keys.is_empty()); + assert!(!read.files.keys().any(|k| k.ends_with("pnpm-lock.yaml"))); + assert_disabled_lockfile_refused(&read, &done, "`lockfile: false` in pnpm-workspace.yaml"); + } + + /// The setting on (or unset) keeps the lock pinned as usual, and a + /// YAML value pnpm's reader takes for no boolean leaves the default. + /// With a known pnpm major only the file that major reads counts: + /// pnpm 11+ ignores `.npmrc`'s `lockfile`, pnpm 10 and older the YAML + /// key. + #[tokio::test] + async fn an_enabled_lockfile_is_pinned() { + for (workspace, npmrc, pm) in [ + ("lockfile: true\n", None, None), + ("packages:\n - '.'\n", Some("lockfile=true\n"), None), + ("lockfile: maybe\n", None, None), + ( + "lockfile: true\n", + Some("lockfile=false\n"), + Some("pnpm@11.2.0"), + ), + ("lockfile: false\n", None, Some("pnpm@10.34.6")), + ( + "lockfile: false\n", + Some("lockfile=true\n"), + Some("pnpm@9.15.9"), + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), workspace, true); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + if let Some(npmrc) = npmrc { + write_rel(tmp.path(), ".npmrc", npmrc); + } + if let Some(pm) = pm { + write_pinned_manifest(tmp.path(), pm); + } + let (_, done) = member_rewrite(tmp.path()).await; + let text = done + .rewrite + .files + .get("pnpm-lock.yaml") + .unwrap_or_else(|| panic!("{workspace} {pm:?}: {:?}", done.rewrite.warnings)); + assert!(text.contains(MEMBER_URL), "{text}"); + assert_eq!(done.confirmed.len(), 1, "{workspace}"); + assert!(!warning_codes(&done).contains(&PNPM_LOCKFILE_DISABLED)); + } + } + + fn write_pinned_manifest(root: &std::path::Path, pm: &str) { + write_rel( + root, + "package.json", + &format!( + r#"{{"name":"app","packageManager":"{pm}","dependencies":{{"is-number":"7.0.0"}}}}"# + ), + ); + } + + /// The file the pinned major reads decides (#1074): pnpm 9 ignores a + /// YAML `lockfile: true` and reads `.npmrc`'s `lockfile=false`; with no + /// major known, either file turning it off refuses. + #[tokio::test] + async fn a_disabled_lockfile_is_read_where_the_pnpm_major_reads_it() { + for (workspace, npmrc, pm, source) in [ + ( + "lockfile: true\n", + "lockfile=false\n", + Some("pnpm@9.15.9"), + "`lockfile=false` in .npmrc", + ), + ( + "lockfile: true\n", + "lockfile=false\n", + None, + "`lockfile=false` in .npmrc", + ), + ( + "lockfile: false\n", + "lockfile=true\n", + Some("pnpm@12.10.1"), + "`lockfile: false` in pnpm-workspace.yaml", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), workspace, true); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + write_rel(tmp.path(), ".npmrc", npmrc); + if let Some(pm) = pm { + write_pinned_manifest(tmp.path(), pm); + } + let (read, done) = member_rewrite(tmp.path()).await; + assert_disabled_lockfile_refused(&read, &done, source); + } + } + + /// pnpm reads `lockfile` only when it is a boolean, else falls back to + /// npm's `package-lock` (`useLockfile` in every pnpm 7–11): the common + /// npm-era `package-lock=false` turns pnpm's lock off too, unless + /// `lockfile=true` says otherwise. + #[tokio::test] + async fn package_lock_false_disables_the_lockfile() { + for (npmrc, refused) in [ + ("package-lock=false\n", true), + ("lockfile=true\npackage-lock=false\n", false), + ("package-lock=true\n", false), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), "packages:\n - '.'\n", true); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + write_rel(tmp.path(), ".npmrc", npmrc); + write_pinned_manifest(tmp.path(), "pnpm@10.33.0"); + let (read, done) = member_rewrite(tmp.path()).await; + if refused { + assert_disabled_lockfile_refused(&read, &done, "`package-lock=false` in .npmrc"); + } else { + assert!( + done.rewrite.files.contains_key("pnpm-lock.yaml"), + "{npmrc}: {:?}", + done.rewrite.warnings + ); + } + } + // pnpm 11+ reads the YAML spelling. + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), "packageLock: false\n", true); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + write_pinned_manifest(tmp.path(), "pnpm@11.28.5"); + let (read, done) = member_rewrite(tmp.path()).await; + assert_disabled_lockfile_refused( + &read, + &done, + "`packageLock: false` in pnpm-workspace.yaml", + ); + } + + /// With no pnpm lock the remedy cannot be vendored mode (its pnpm + /// backend needs a lock to wire), and an npm project's `.npmrc` + /// `lockfile=false` is no pnpm setting at all: it keeps npm's hints. + #[tokio::test] + async fn a_disabled_lockfile_without_a_pnpm_lock() { + let tmp = tempfile::tempdir().unwrap(); + write_branch_lock_project(tmp.path(), "lockfile: false\n", false); + std::fs::remove_file(tmp.path().join(BRANCH_LOCK)).unwrap(); + let (_, done) = member_rewrite(tmp.path()).await; + let detail = &done + .rewrite + .warnings + .iter() + .find(|w| w.code == PNPM_LOCKFILE_DISABLED) + .unwrap_or_else(|| panic!("{:?}", done.rewrite.warnings)) + .detail; + assert!( + !detail.contains("--mode vendored") && detail.contains("pnpm install"), + "{detail}" + ); + + let mut project = MemoryProject::new(); + project.insert_text("package.json", r#"{"name":"app"}"#); + project.insert_text(".npmrc", "lockfile=false\n"); + let (_, done) = view_rewrite(&ProjectView::Memory(&project)).await; + assert!( + !warning_codes(&done).contains(&PNPM_LOCKFILE_DISABLED), + "{:?}", + done.rewrite.warnings + ); + } } /// The one-pass multi-needle confirmation probe answers exactly what the diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index 15ee3aa7f..d60f68ea6 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -34,7 +34,7 @@ mod gem; mod golang; mod gradle; mod maven; -mod npm; +pub(crate) mod npm; mod nuget; mod pypi; mod pypi_locks; diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 688aa9947..f35f87e2c 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -1202,7 +1202,7 @@ impl PnpmTarballPolicy { /// (only pnpm <= 8 writes those), else 11 for a lock carrying pnpm 11+'s /// env lockfile document ahead of the project lock (only pnpm >= 11 /// writes one, so its `.npmrc` pnpm settings are ignored). -fn pnpm_settings_major(text: &str, pm_major: Option) -> Option { +pub(crate) fn pnpm_settings_major(text: &str, pm_major: Option) -> Option { use crate::formats::pnpm::grammar::{is_pnpm_lock_text, main_document}; use crate::formats::pnpm::lock_version_major; @@ -1479,7 +1479,7 @@ fn pnpm_spec_major(spec: &str) -> Option { /// The pnpm major an install record names: `node_modules/.modules.yaml`'s /// `packageManager` (JSON on pnpm 10+, a top-level YAML scalar before). -fn modules_yaml_pnpm_major(text: &str) -> Option { +pub(crate) fn modules_yaml_pnpm_major(text: &str) -> Option { let text = crate::formats::text::strip_bom(text); if let Ok(value) = serde_json::from_str::(text) { return value @@ -1493,7 +1493,7 @@ fn modules_yaml_pnpm_major(text: &str) -> Option { } /// The pnpm major package.json's corepack `packageManager` pins. -fn package_json_pnpm_major(text: &str) -> Option { +pub(crate) fn package_json_pnpm_major(text: &str) -> Option { serde_json::from_str::(crate::formats::text::strip_bom(text)) .ok()? .get("packageManager")? diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 2eb220410..135b64962 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -571,6 +571,96 @@ pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option }) } +/// Where pnpm's `lockfile` setting (or the `package-lock` it falls back +/// to, see [`lockfile_off`]) is turned off (#1074), as a refusal +/// names it (e.g. `` `lockfile: false` in pnpm-workspace.yaml ``); `None` +/// when it is on (the default). With it off pnpm neither reads nor writes +/// `pnpm-lock.yaml` (or a member's own lock): `pnpm install` resolves +/// every dependency from the registry, so a pin in a committed lock is +/// never what installs. +/// +/// Each pnpm major reads the setting from one place: pnpm 11 and newer +/// from `pnpm-workspace.yaml` (`lockfile: false`), pnpm 10 and older from +/// `.npmrc` (`lockfile=false`; pnpm 10 ignores the YAML key). The major is +/// the one that installed the project (`node_modules/.modules.yaml`), else +/// the one package.json pins, else what the root lock's grammar implies +/// (`pnpm_settings_major`). When none says, either source turning the +/// setting off counts: withholding a pin is the safe side. With `env`, on +/// disk, `pnpm_config_lockfile` / `npm_config_lockfile` count too (the +/// hosted engine runs in the install's environment; `vex` need not). +/// +/// Only the project's own files are read, through the view's +/// fingerprinted reader, so a re-scan read cache stays valid; a workspace +/// member with no settings file of its own is not related to its root's. +pub async fn lockfile_disabled(view: &ProjectView<'_>, env: bool) -> Option { + let workspace = view.read_text(PNPM_WORKSPACE).await.ok(); + let npmrc = view.read_text(".npmrc").await.ok(); + let yaml = lockfile_off(|key, _| pnpm_setting(workspace.as_deref(), None, key, key)); + let rc = lockfile_off(|_, key| pnpm_setting(None, npmrc.as_deref(), key, key)); + let env = match view { + ProjectView::Disk(_) | ProjectView::Snapshot(_) if env => { + lockfile_off(|_, key| env_setting(key, |var| std::env::var(var).ok())) + } + _ => None, + }; + if yaml.is_none() && rc.is_none() { + return env; + } + let major = pnpm_major(view).await; + match major { + Some(major) if major >= 11 => yaml, + Some(_) => rc, + None => yaml.or(rc), + } + .or(env) +} + +/// Where one settings source turns pnpm's lock off: its `lockfile` when +/// that reads as a boolean, else npm's `package-lock`, which every pnpm +/// (7 through 11) falls back to for `useLockfile`. `setting` looks a key +/// up in the source by its YAML spelling and its `.npmrc` spelling. +fn lockfile_off(setting: impl Fn(&str, &str) -> Option) -> Option { + let off = |found: PnpmSetting| (found.as_bool() == Some(false)).then_some(found.source); + match setting("lockfile", "lockfile") { + Some(found) if found.as_bool().is_some() => off(found), + _ => setting("packageLock", "package-lock").and_then(off), + } +} + +/// The pnpm major whose settings reading applies to the project at `view` +/// (see [`lockfile_disabled`]). +async fn pnpm_major(view: &ProjectView<'_>) -> Option { + use crate::patch::redirect::upstream::npm::{ + modules_yaml_pnpm_major, package_json_pnpm_major, pnpm_settings_major, + }; + let installed = view + .read_text("node_modules/.modules.yaml") + .await + .ok() + .as_deref() + .and_then(modules_yaml_pnpm_major); + let pinned = match installed { + Some(major) => Some(major), + None => view + .read_text("package.json") + .await + .ok() + .as_deref() + .and_then(package_json_pnpm_major), + }; + let lock = view.read_text(PNPM_LOCK).await.ok().unwrap_or_default(); + pnpm_settings_major(&lock, pinned) +} + +/// What pnpm does with its `lockfile` setting off, for the refusals and +/// diagnostics that name it ([`lockfile_disabled`]'s `source`). +pub fn describe_lockfile_disabled(source: &str) -> String { + format!( + "{source} makes pnpm neither read nor write {PNPM_LOCK}, so `pnpm install` \ + resolves every dependency from the registry whatever the lock pins" + ) +} + /// The `pnpm-lock..yaml` files in root-relative `dir` (`""` for /// the root), as root-relative paths, sorted. async fn branch_lock_names(view: &ProjectView<'_>, dir: &str) -> Vec { diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index f044bb889..7698ca4b0 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -673,21 +673,31 @@ fn entry_ref( /// a file with neither `lockfileVersion:` nor `shrinkwrapVersion:` is not a /// pnpm lock ([`DIAG_LOCKFILE_UNPARSEABLE`]). async fn extract_pnpm(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { + // pnpm's `lockfile` setting off (#1074): pnpm reads neither the root + // lock nor a member's, so their hosted pins keep no lockfile basis. + // Rush runs pnpm with a workspace and `.npmrc` of its own. + let rush = ctx.exists("rush.json").await; + let disabled = if rush { + None + } else { + crate::utils::pnpm_workspace::lockfile_disabled(&ctx.view, false).await + }; + let disabled = disabled.as_deref(); let root_lock = ctx.exists(PNPM_LOCK).await; if root_lock { - extract_pnpm_lock(ctx, PNPM_LOCK, out).await; + extract_pnpm_lock(ctx, PNPM_LOCK, disabled, out).await; // pnpm never reads the debris shrinkwrap: the patches it still names // 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; } else { - extract_pnpm_lock(ctx, PNPM_SHRINKWRAP_LEGACY, out).await; + extract_pnpm_lock(ctx, PNPM_SHRINKWRAP_LEGACY, disabled, out).await; } - if ctx.exists("rush.json").await { + if rush { // The common lock, then each subspace's, sorted for deterministic // diagnostics (stat / list only — the reads below stay on the ctx). for rel in rush_lock_rels(ctx.view).await { - extract_pnpm_lock(ctx, &rel, out).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 @@ -698,13 +708,22 @@ async fn extract_pnpm(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { // member importers) is a stale leftover pnpm never reads, and is // not among `rels`. for rel in rels { - extract_pnpm_lock(ctx, &rel, out).await; + extract_pnpm_lock(ctx, &rel, disabled, out).await; } } } /// One pnpm lock at root-relative `file` (see [`extract_pnpm`]). -async fn extract_pnpm_lock(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discovery) { +/// `disabled` names where pnpm's `lockfile` setting is turned off: its +/// hosted refs then keep no lock pin, and one [`DIAG_REF_UNATTRIBUTABLE`] +/// names them. Vendored refs are untouched: pnpm applies the `overrides` +/// that wire them without a lock. +async fn extract_pnpm_lock( + ctx: &DiscoverCtx<'_>, + file: &str, + disabled: Option<&str>, + out: &mut Discovery, +) { let Some(text) = ctx.read_text(file, out).await else { return; }; @@ -719,12 +738,27 @@ async fn extract_pnpm_lock(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discover } let mut copies: Vec = Vec::new(); let mut bundles: Vec<(&str, Bundled<'_>)> = Vec::new(); + let mut withheld: BTreeSet = BTreeSet::new(); for package in lock.packages() { - pnpm_entry_ref(ctx, file, package, &mut copies, out); + let withhold = disabled.map(|_| &mut withheld); + pnpm_entry_ref(ctx, file, package, &mut copies, withhold, out); bundles.extend(entry_bundled(&package.entry).map(|b| (package.key, b))); } record_pnpm_file_copies(ctx, file, copies, out).await; record_pnpm_bundled_copies(file, bundles, out); + if let (Some(source), false) = (disabled, withheld.is_empty()) { + out.diag( + DIAG_REF_UNATTRIBUTABLE, + file, + format!( + "{file}: {}, so its hosted pins of {} are not attested from the lock alone, \ + only from an installed tree whose every copy verifies; \ + `socket-patch scan --mode vendored` wires them without a lock", + crate::utils::pnpm_workspace::describe_lockfile_disabled(source), + withheld.into_iter().collect::>().join(", ") + ), + ); + } } /// Record the bundled copies a pnpm lock installs ([`UnwiredCopy`]). @@ -887,6 +921,7 @@ fn pnpm_entry_ref( file: &str, package: &PnpmPackage<'_>, copies: &mut Vec, + withheld: Option<&mut BTreeSet>, out: &mut Discovery, ) { let key = package.key; @@ -938,6 +973,14 @@ fn pnpm_entry_ref( ); return; }; + // A lock pnpm does not read pins nothing (see `extract_pnpm_lock`). + let integrity = match withheld { + Some(withheld) => { + withheld.insert(purl.clone()); + None + } + None => integrity, + }; out.push(PatchedRef::hosted( purl, uuid, @@ -3066,6 +3109,59 @@ mod tests { ); } + /// pnpm's `lockfile` setting off (#1074): pnpm neither reads nor writes + /// its lock and installs every dependency from the registry, so a + /// hosted pin in a committed lock keeps no lockfile basis (only an + /// installed tree attests it), and one diagnostic names the pins. Both + /// spellings count (pnpm 11+ `pnpm-workspace.yaml`, pnpm 9/10 `.npmrc`), + /// and so do the member locks of an unshared workspace. + #[tokio::test] + async fn pnpm_disabled_lockfile_withholds_the_lockfile_basis() { + 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 withheld = |out: &Discovery, source: &str| { + assert_refs(out, &[("pkg:npm/ms@1.3.0", UUID_B, WiringMode::Hosted)]); + assert!(!out.refs[0].lockfile_basis_ok()); + let diag = out + .diagnostics + .iter() + .find(|d| d.code == DIAG_REF_UNATTRIBUTABLE) + .unwrap_or_else(|| panic!("{:#?}", out.diagnostics)); + assert!( + diag.detail.contains("pkg:npm/ms@1.3.0") && diag.detail.contains(source), + "{}", + diag.detail + ); + }; + + let p = Project::new(); + p.write("pnpm-lock.yaml", &wired); + let out = run(&p).await; + assert!(out.refs[0].lockfile_basis_ok(), "control: the lock is read"); + p.write("pnpm-workspace.yaml", "lockfile: false\n"); + withheld(&run(&p).await, "`lockfile: false` in pnpm-workspace.yaml"); + + let p = Project::new(); + p.write("pnpm-lock.yaml", &wired); + p.write(".npmrc", "lockfile=false\n"); + withheld(&run(&p).await, "`lockfile=false` in .npmrc"); + + // npm's `package-lock=false`, which pnpm falls back to. + let p = Project::new(); + p.write("pnpm-lock.yaml", &wired); + p.write(".npmrc", "package-lock=false\n"); + withheld(&run(&p).await, "`package-lock=false` in .npmrc"); + + let p = Project::new(); + p.write( + "pnpm-workspace.yaml", + "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\nlockfile: false\n", + ); + p.write("packages/a/package.json", "{}"); + p.write("packages/a/pnpm-lock.yaml", &wired); + withheld(&run(&p).await, "`lockfile: false` in pnpm-workspace.yaml"); + } + /// `shrinkwrap.yaml` (pnpm <= 2) is read only when there is no /// `pnpm-lock.yaml`: beside one it is migration debris, and a stale /// Socket url left in it must not attest a reverted project. diff --git a/docs/ecosystems.md b/docs/ecosystems.md index b5a82cb3e..948177bc7 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -178,6 +178,13 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: `vendor_pnpm_git_branch_lockfile`. Turn the setting off and run `pnpm install --merge-git-branch-lockfiles`, then re-run. With no branch lock, `pnpm-lock.yaml` is the lock pnpm installs from and is pinned as usual. + With the `lockfile` setting off (`lockfile: false` in `pnpm-workspace.yaml` + on pnpm 11 and newer, `lockfile=false` in `.npmrc` on pnpm 10 and older, + or npm's `package-lock=false` that pnpm falls back to), + pnpm never reads `pnpm-lock.yaml` and installs from the registry, so hosted + mode refuses the pnpm pins with `redirect_pnpm_lockfile_disabled` and + lock-only `vex` attests no hosted pin from that lock. Use `--mode vendored`, + which pnpm applies through `overrides` without a lock. For 9.0 root or member locks, the CLI configures `trustLockfile: true` in the root `pnpm-workspace.yaml` unless opted out with `--no-trust-lockfile-config` or explicitly disabled by the project. pnpm >=11 needs this for hosted URLs.