diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 304a83fa3..de68e3dd4 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -157,7 +157,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments Each matching package instance is spliced, including scoped, quoted and nested-peer keys, one `redirect_pnpm_resolution` edit per changed instance (`rollback` / `remove` restore each from the npm registry — see "Hosted unwind coverage"). LF/CRLF and unrelated lock bytes are preserved. Unsupported matching instances refuse that dependency across the lockfile set; an already-hosted URL elsewhere cannot confirm a partial rewrite. -For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with a root-only `packages:` scaffold, or appended while preserving user bytes). An existing file with no keys at all — empty, comments only, bare `---` / `...` / `%` directive lines — reads to pnpm like no file, but pnpm 8.x–10.4 refuse every command once it holds a key without `packages` ("packages field missing or empty", #1096), so it gets the same root-only `packages:` scaffold spliced in with the key, and follows the no-file rules below. pnpm 8–10.4 read a keyless file as every nested package (pnpm >= 10.5 as the root alone), so when `pnpm-lock.yaml` lists importers besides `.` the file is left untouched and the warning says to list the members under `packages:` after upgrading to pnpm >= 11 (or install with `--trust-lockfile`); the vendored mirror skips the file the same way. A keyless file holding several documents is not spliced (the shapes rule below). A project with no `pnpm-workspace.yaml` (or a keyless one) whose every pnpm pin — the installed `node_modules/.modules.yaml` `packageManager`, and package.json `packageManager`, `devEngines.packageManager` and `engines.pnpm` (at least one present) — names pnpm 9.0–10.4 gets no file (#734): those releases never read the key, and a created root-only workspace makes `pnpm add ` fail with ERR_PNPM_ADDING_TO_ROOT there; the warning names the pins and says to re-run after upgrading to pnpm >=11 (or install with `--trust-lockfile`). A pin naming a later pnpm, a range reaching past 10.4, an unparseable pin, or no pin keeps the scaffold, whose warning notes that pnpm 9.0–10.4 then need `pnpm add -w ` and that pinning that pnpm in package.json `packageManager` (then deleting the file and re-running) avoids it — provided every other pin names 9.0–10.4 too (the "every pin" rule above). **Known residual (#734)**: an unpinned project with no install record (no `node_modules/.modules.yaml`, no package.json pnpm pin — e.g. a fresh clone) still gets the root-only scaffold, since nothing shows its pnpm cannot read it; on pnpm 9.0–10.4 such a project needs `pnpm add -w ` to add dependencies, or a `packageManager` pin naming that pnpm so the scaffold is never created. pnpm >=11 requires this to accept hosted URLs; it disables registry re-verification for the whole lock, while sha512 tarball integrity remains enforced. The write (edit kind `redirect_pnpm_workspace_trust`) respects `--dry-run`, skips legacy locks and Rush repos, preserves explicit user settings (an existing top-level key in any YAML spelling: quoted, `trustLockfile :`, with a trailing comment), and is disabled by `--no-trust-lockfile-config`. The key goes inside the document (before a `...` end marker); a file a line append would corrupt (a flow-style root, an indented root, several documents) is left untouched and the warning gives the manual recoveries. The vendored `overrides:` mirror in `pnpm-workspace.yaml` reads keys the same way and refuses those shapes before writing. The `redirect_pnpm_trust_lockfile` warning explains manual configuration when required and clean reinstall guidance for all pnpm versions. When every lock the run spliced is a Rush lock, the same warning code carries the Rush remedy instead (rush runs pnpm in `common/temp`, so neither the repo-root key nor `--trust-lockfile` reaches it): `pnpm_config_trust_lockfile=true rush install`, plus `"usePnpmFrozenLockfileForRushInstall": true` in `common/config/rush/experiments.json` on pnpm 11 (whose default Rush install otherwise re-resolves the hosted entries to upstream with exit 0), and `rush purge` before reinstalling. A re-run over a 9.0 Rush lock an earlier run already redirected (nothing spliced) re-issues the same Rush remedy; a run that also spliced a non-Rush pnpm lock keeps the generic text plus a Rush note. Existing installs and warm stores can retain upstream files; use a clean install tree and empty store, then verify installed files with `socket-patch vex`. Neither a successful install nor a local VEX export guarantees hosted SBOM recognition or changes dashboard alert actions/counts. +For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with a root-only `packages:` scaffold, or appended while preserving user bytes). An existing file with no keys at all — empty, comments only, bare `---` / `...` / `%` directive lines — reads to pnpm like no file, but pnpm 8.x–10.4 refuse every command once it holds a key without `packages` ("packages field missing or empty", #1096), so it gets the same root-only `packages:` scaffold spliced in with the key, and follows the no-file rules below. pnpm 8–10.4 read a keyless file as every nested package (pnpm >= 10.5 as the root alone), so when `pnpm-lock.yaml` lists importers besides `.` the file is left untouched and the warning says to list the members under `packages:` after upgrading to pnpm >= 11 (or install with `--trust-lockfile`); the vendored mirror skips the file the same way. A keyless file holding several documents is not spliced (the shapes rule below). A project with no `pnpm-workspace.yaml` (or a keyless one) whose every pnpm pin — the installed `node_modules/.modules.yaml` `packageManager`, and package.json `packageManager`, `devEngines.packageManager` and `engines.pnpm` (at least one present) — names pnpm 9.0–10.4 gets no file (#734): those releases never read the key, and a created root-only workspace makes `pnpm add ` fail with ERR_PNPM_ADDING_TO_ROOT there; the warning names the pins and says to re-run after upgrading to pnpm >=11 (or install with `--trust-lockfile`). A pin naming a later pnpm, a range reaching past 10.4, an unparseable pin, or no pin keeps the scaffold, whose warning notes that pnpm 9.0–10.4 then need `pnpm add -w ` and that pinning that pnpm in package.json `packageManager` (then deleting the file and re-running) avoids it — provided every other pin names 9.0–10.4 too (the "every pin" rule above). **Known residual (#734)**: an unpinned project with no install record (no `node_modules/.modules.yaml`, no package.json pnpm pin — e.g. a fresh clone) still gets the root-only scaffold, since nothing shows its pnpm cannot read it; on pnpm 9.0–10.4 such a project needs `pnpm add -w ` to add dependencies, or a `packageManager` pin naming that pnpm so the scaffold is never created. pnpm >=11 requires this to accept hosted URLs; it disables registry re-verification for the whole lock, while sha512 tarball integrity remains enforced. The write (edit kind `redirect_pnpm_workspace_trust`) respects `--dry-run`, skips legacy locks and Rush repos, preserves explicit user settings (an existing top-level key in any YAML spelling: quoted, `trustLockfile :`, with a trailing comment; and a `pnpm_config_trust_lockfile` / `PNPM_CONFIG_TRUST_LOCKFILE` environment variable set to anything but `true` when the project's pnpm is >=11 or unknown, which pnpm reads ahead of the file, so nothing is written and the warning names the variable, #1514), and is disabled by `--no-trust-lockfile-config`. The key goes inside the document (before a `...` end marker); a file a line append would corrupt (a flow-style root, an indented root, several documents) is left untouched and the warning gives the manual recoveries. The vendored `overrides:` mirror in `pnpm-workspace.yaml` reads keys the same way and refuses those shapes before writing. The `redirect_pnpm_trust_lockfile` warning explains manual configuration when required and clean reinstall guidance for all pnpm versions. When every lock the run spliced is a Rush lock, the same warning code carries the Rush remedy instead (rush runs pnpm in `common/temp`, so neither the repo-root key nor `--trust-lockfile` reaches it): `pnpm_config_trust_lockfile=true rush install`, plus `"usePnpmFrozenLockfileForRushInstall": true` in `common/config/rush/experiments.json` on pnpm 11 (whose default Rush install otherwise re-resolves the hosted entries to upstream with exit 0), and `rush purge` before reinstalling. A re-run over a 9.0 Rush lock an earlier run already redirected (nothing spliced) re-issues the same Rush remedy; a run that also spliced a non-Rush pnpm lock keeps the generic text plus a Rush note. Existing installs and warm stores can retain upstream files; use a clean install tree and empty store, then verify installed files with `socket-patch vex`. Neither a successful install nor a local VEX export guarantees hosted SBOM recognition or changes dashboard alert actions/counts. **npm hosted-mode `allow-remote` contract**: npm >=12 defaults `allow-remote=none` and refuses (EALLOWREMOTE) every lockfile entry whose `resolved` tarball is not served by the configured registry — exactly what a hosted redirect writes into `package-lock.json` / `npm-shrinkwrap.json`. Whenever a run leaves a ROOT npm lock carrying a granted hosted artifact URL (spliced this run, or already redirected by an earlier one — a missed config heals on re-run), the CLI ensures `allow-remote=all` in the project-root `.npmrc`: the file is created holding exactly `allow-remote=all\n` when absent, otherwise one `allow-remote=all` line is spliced in after the last non-empty top-level line (before any ini `[section]` header), in the file's own line ending, with the BOM, CRLF and trailing-newline shape preserved. The write lands in `redirect.rewrittenFiles` (edit kind `redirect_npmrc_allow_remote`), respects `--dry-run` (nothing written; the warning says what would be — including for a vendored → hosted takeover the dry run only previews), and is disabled by `--no-npm-allow-remote-config` / `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG`. The `.npmrc` grammar is npm's own `ini` parser's (cross-checked against it): lines split on any run of `\r` / `\n` (a bare `\r` ends a line), only the exact key `allow-remote` counts after ini unquoting (npm ignores `allow_remote` / `ALLOW-REMOTE` in a `.npmrc`; such a line is left alone and the real key appended), comment lines are ignored, a `[section]` header is recognized only as npm does — on the UNTRIMMED line (an indented or BOM-prefixed `[sec]` is a plain top-level key) — and ends the top-level scope, quotes and inline comments are stripped, the LAST top-level assignment wins, and the value is case-sensitive. An explicit other value (`allow-remote=none` / `root` / anything but `all`) is RESPECTED and never rewritten — the pnpm `trustLockfile: false` precedent — in the project `.npmrc` AND in every other npm config layer npm would consult: an `npm_config_allow_remote` environment variable (any spelling npm normalizes; it beats every `.npmrc`, so a project write could not take effect), and — when the project file sets nothing — the user (`npm_config_userconfig` / `~/.npmrc`), global (`npm_config_globalconfig` / `/etc/npmrc`, prefix from `npm_config_prefix`, the user/builtin config, `PREFIX` or the `node` binary's install root) and builtin (npm's own `npmrc` beside the `node` binary: `/lib/node_modules/npm/npmrc`, `\node_modules\npm\npmrc` on Windows) config files — path values `${VAR}`-expanded and `~`-expanded like npm, env names case-insensitive on Windows, where a committed project line would silently override a machine / org policy. A symlinked, non-regular or unreadable `.npmrc`, or one with bare-`\r` line endings (npm splits on them, the line splice does not), is left untouched. Every variant emits the `redirect_npm_allow_remote` warning (written / would write / already set / explicit value respected — naming the project file, the env var, or the user/global/builtin config path — / opted out / unreadable or unsupported), always with the tradeoff: `allow-remote=all` lets npm install ANY url-resolved dependency, not just Socket's patched ones, while the per-entry sha512 integrity pins stay enforced; the remedy for the non-writing variants is `allow-remote=all` in `.npmrc` or `npm ci --allow-remote=all`. npm <=11 is unaffected (11 defaults to `all`, <=10 has no such setting). **Unwind (v5.0: no ledger)**: once `rollback`, `remove` or a hosted → vendored takeover has restored the last hosted entry of the root `package-lock.json` / `npm-shrinkwrap.json` to its upstream registry entry (see "Hosted unwind coverage"), a project `.npmrc` holding exactly `allow-remote=all\n` (the file hosted mode creates) is deleted; any other `.npmrc` that still has a top-level `allow-remote=all` line is left untouched and the `npm_allow_remote_left` warning says the line may be removed if nothing else needs it (v5 keeps no record of whether hosted mode added it, so it is never removed behind the user's back). The rewrite's stage file is created with the `.npmrc`'s own permission bits (a 0600 token-bearing file is never staged world-readable). **Vendored mode is unaffected by `allow-remote`**: its `file:.socket/vendor/…` resolutions are npm `file` specs, which npm >= 11.14 gates by `allow-file` (default `all`), never `allow-remote` — verified by the real npm 12 vendored matrix. **npm vendored `allow-file` contract (#969)**: `allow-file=none` refuses every `file:` dependency, and any other non-`all` value (`root`) admits one only when the project root or a workspace declares it and resolves to that very lock node (arborist's `_isRoot`); otherwise every `npm ci` / `npm install` fails EALLOWFILE. The effective value is read from the same npm config layers as `allow-remote` (an `npm_config_allow_file` environment variable, then the project `.npmrc`, then the user / global / builtin config files). When it refuses a `package-lock.json` / `npm-shrinkwrap.json` instance of a vendored `name@version` (the set vendoring rewires), the vendored run (`vendor`, `scan` / `get --mode vendored`, dry runs and in-sync re-runs included) still vendors, leaves the setting untouched, and records the `vendor_npm_allow_file` advisory naming the setting's source, the refused lock entries and the remedy (`allow-file=all` in the project `.npmrc`, unsetting the env var, or `npm ci --allow-file=all`); `vendor --check` fails that entry `vendor_check_failed` with the same reason. Other npm-family flavors (yarn, pnpm, bun, vlt) do not read `allow-file` and are not checked. diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 069dc5ceb..3b5fba8b1 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -949,11 +949,18 @@ async fn run_redirect_selected_untracked( // workspace / `lockfile-dir`, cargo workspace): the rewriters would // read only the member, so refuse before any takeover or write. The // same goes for a pnpm member whose `trustLockfile` setting lives in - // the workspace root's pnpm-workspace.yaml. + // the workspace root's pnpm-workspace.yaml, unless a + // `pnpm_config_trust_lockfile` in this environment overrides that file + // (#1514): adding the key there would not help, and the rewrite's + // trust warning names the variable instead. + let trust_env_off = socket_patch_core::utils::pnpm_workspace::trust_lockfile_env(None, |var| { + std::env::var(var).ok() + }) + .is_some_and(|found| found.as_bool() != Some(true)); if let Some(refusal) = socket_patch_core::hosted::governing_root::refusal( &view, &candidates, - !common.no_trust_lockfile_config, + !common.no_trust_lockfile_config && !trust_env_off, ) .await { @@ -1297,6 +1304,9 @@ async fn run_redirect_selected_untracked( // parent directory's and `$CARGO_HOME`'s): a directory source or local // registry in any of them refuses the cargo rewrite like a project one. let cargo_outer = || CargoCrawler::crates_io_source_for(&common.cwd); + // The scan runs in the install's environment: a pnpm setting there + // (`pnpm_config_trust_lockfile`) overrides the project's files. + let pnpm_env = |var: &str| std::env::var(var).ok(); let rewrite_options = || { RewriteOptions { dry_run: common.dry_run, @@ -1311,6 +1321,7 @@ async fn run_redirect_selected_untracked( npm_outer: &npm_outer, yarn_classic_outer: &yarn_classic_outer, cargo_outer: &cargo_outer, + pnpm_env: &pnpm_env, blocking: true, takeover_uuids: takeover_uuids.clone(), patch_server_origins: patch_server_origins.clone(), 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 8851a7d29..2303108a0 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs @@ -1350,6 +1350,84 @@ async fn pnpm11_no_trust_config_opt_out_frozen_install_needs_manual_trust_lockfi assert_fixture_manifestless_vex(&fx, &fresh, "pnpm11 --trust-lockfile"); } +/// #1514: pnpm 11+ read `pnpm_config_trust_lockfile` ahead of +/// pnpm-workspace.yaml, so a scan whose environment sets it to `false` must +/// not write a `trustLockfile: true` that installs there ignore, nor claim +/// they need no extra flags. Over a lock the real pnpm 11 scan pinned +/// without the trust config, a re-run in that environment (the heal path, +/// which would otherwise write the key) writes no workspace file and names +/// the variable in the trust warning. +#[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_trust_lockfile_env_override_is_respected() { + let Some(fx) = redirect_scanned_pnpm_project( + PNPM_TERTIARY, + "pnpm11-trust-env", + false, + true, + HostedDriver::Scan, + ) + .await + else { + return; + }; + let ws_path = fx.proj.join("pnpm-workspace.yaml"); + assert!( + !ws_path.exists(), + "the opted-out fixture scan wrote no workspace file" + ); + let lock_path = fx.proj.join(&fx.lock_name); + let lock_wired = std::fs::read_to_string(&lock_path).unwrap(); + + let api_url = fx._server.uri(); + let args = [ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + fx.proj.to_str().unwrap(), + "--api-url", + &api_url, + "--org", + ORG, + "--api-token", + "fake", + ]; + let env = [( + "pnpm_config_trust_lockfile".to_string(), + "false".to_string(), + )]; + let (code, stdout, stderr) = run_socket_env(&fx.proj, &args, &env); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let envelope = parse_envelope(&stdout); + assert!( + !ws_path.exists(), + "a trustLockfile key the environment overrides must not be written:\n{envelope}" + ); + assert_eq!(std::fs::read_to_string(&lock_path).unwrap(), lock_wired); + let detail = envelope["warnings"] + .as_array() + .into_iter() + .flatten() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_else(|| panic!("redirect_pnpm_trust_lockfile:\n{envelope}")) + .to_string(); + assert!( + detail.contains("`pnpm_config_trust_lockfile=false`") && !detail.contains("no extra flags"), + "{detail}" + ); + + // Control: the same re-run without the variable heals the config. + let (code, stdout, stderr) = run_socket_env(&fx.proj, &args, &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let workspace = std::fs::read_to_string(&ws_path).unwrap_or_default(); + assert!(workspace.contains("trustLockfile: true"), "{workspace}"); +} + /// Legacy pnpm@7 leg: the fixture install emits a lockfileVersion 5.4 lock /// (`/name/version:` path-style key), the scan splices its resolution like /// any other grammar, and the fresh dead-registry frozen install proves diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index ccc0ec501..f443bc187 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -1481,6 +1481,10 @@ pub struct RewriteOptions<'a> { /// resolved only when a cargo dep is rewritten (the cargo rewriter's /// offline-source refusal). pub cargo_outer: &'a (dyn Fn() -> CratesIoSource + Send + Sync), + /// A variable of the environment pnpm installs the project in, by + /// name: a `pnpm_config_trust_lockfile` there beats the + /// pnpm-workspace.yaml key the trust auto-config writes (#1514). + pub pnpm_env: &'a (dyn Fn(&str) -> Option + Send + Sync), /// Run the rewriters on the blocking pool (the disk flow: pure CPU over /// every lock text). pub blocking: bool, @@ -2509,19 +2513,23 @@ fn pnpm_trust( // ignored (#880). The governing-root pre-check refused every root // file but these two, so nothing is written here. let root_file = root_file.display().to_string(); - match read_workspace_for_trust(std::path::Path::new(&root_file)) - .ok() - .flatten() - .map(|text| plan_workspace_trust(Some(&text))) - { - Some(TrustPlan::AlreadyTrue) => { - pnpm_rerun_only = spliced_pnpm_locks == 0; - pnpm_trust_already_true_detail(&server, &root_file) - } - Some(TrustPlan::UserSet(value)) => { - pnpm_trust_user_set_detail(&server, &root_file, &value) + if let Some(found) = pnpm_trust_env_override(view, files, options.pnpm_env) { + pnpm_trust_env_set_detail(&server, &found.source) + } else { + match read_workspace_for_trust(std::path::Path::new(&root_file)) + .ok() + .flatten() + .map(|text| plan_workspace_trust(Some(&text))) + { + Some(TrustPlan::AlreadyTrue) => { + pnpm_rerun_only = spliced_pnpm_locks == 0; + pnpm_trust_already_true_detail(&server, &root_file) + } + Some(TrustPlan::UserSet(value)) => { + pnpm_trust_user_set_detail(&server, &root_file, &value) + } + _ => pnpm_trust_manual_guidance(&server), } - _ => pnpm_trust_manual_guidance(&server), } } else { let (workspace, symlinked) = read_workspace(view); @@ -2552,6 +2560,7 @@ fn pnpm_trust( // A keyless file the lock was installed from as a multi-package // workspace: only pnpm <= 10.4 reads one that way (every nested // package), and the root-only scaffold would drop those members. + let env_override = pnpm_trust_env_override(view, files, options.pnpm_env); let keyless_members = pinned_pre_10_5.is_none() && !symlinked && matches!(&workspace, Ok(Some(text)) if crate::formats::pnpm::workspace::is_keyless(text)) @@ -2563,6 +2572,16 @@ fn pnpm_trust( pinned_pre_10_5.as_deref().unwrap_or_default(), options.dry_run, ), + // pnpm 11+ read the environment's `trustLockfile` ahead of + // the file (#1514): an explicit setting nothing written there + // could override. + Ok(_) if env_override.is_some() => pnpm_trust_env_set_detail( + &server, + &env_override + .as_ref() + .map(|found| found.source.clone()) + .unwrap_or_default(), + ), // Present but UNREADABLE: never plan a Create (it would // overwrite the user's workspace file) — fall back to // warning-only guidance naming the file and the error. @@ -2691,6 +2710,39 @@ fn pnpm_trust_user_set_detail(server: &str, file: &str, value: &str) -> String { ) } +/// A `pnpm_config_trust_lockfile` variable of the install's environment +/// (`env`) that the project's pnpm reads and that is not true (#1514). pnpm +/// 11+ read it ahead of pnpm-workspace.yaml, so it is an explicit user +/// setting a written key could not override: respected like a +/// `trustLockfile: false` in the file. The project's files are read for +/// its pnpm major only when such a variable is set. +fn pnpm_trust_env_override( + view: &ProjectView<'_>, + files: &BTreeMap, + env: &(dyn Fn(&str) -> Option + Send + Sync), +) -> Option { + use crate::utils::pnpm_workspace::{pnpm_major_from, trust_lockfile_env}; + let off = |major| trust_lockfile_env(major, env).filter(|found| found.as_bool() != Some(true)); + off(None)?; + off(pnpm_major_from(|rel| match files.get(rel) { + Some(text) => Some(text.clone()), + None => read_advisory_text(view, rel), + })) +} + +/// The trust detail for a `pnpm_config_trust_lockfile` override in the +/// environment (`source`, as [`pnpm_trust_env_override`] names it). +fn pnpm_trust_env_set_detail(server: &str, source: &str) -> String { + format!( + "{}. {source} sets pnpm's `trustLockfile`, which was respected: pnpm >=11 \ + reads it ahead of {PNPM_WORKSPACE_REL}, so a `trustLockfile: true` there \ + would not take effect in this environment, and nothing was written — unset \ + the variable or set it to `true` wherever the project installs, or re-run \ + the scan with `--mode vendored`. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(server), + ) +} + /// The pnpm pins of a project with no pnpm-workspace.yaml, when every one /// is a release that refuses `pnpm add` in a root-only workspace (see /// [`pnpm_root_only_workspace_breaks_add`]). Reads the root package.json @@ -3250,6 +3302,10 @@ mod tests { CratesIoSource::Registry } + fn no_env(_: &str) -> Option { + None + } + const GRANT: &str = "GRANTTOKEN0123"; const PATCH_UUID: &str = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; @@ -3511,6 +3567,7 @@ mod tests { npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -3583,6 +3640,7 @@ mod tests { npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -3876,6 +3934,15 @@ mod tests { async fn npm_rewrite( view: &ProjectView<'_>, unreadable: &BTreeSet, + ) -> (CandidateFiles, Rewritten) { + npm_rewrite_in(view, unreadable, &no_env).await + } + + /// [`npm_rewrite`] with pnpm's environment looked up through `env`. + async fn npm_rewrite_in( + view: &ProjectView<'_>, + unreadable: &BTreeSet, + env: &(dyn Fn(&str) -> Option + Send + Sync), ) -> (CandidateFiles, Rewritten) { let outer = OuterAllowRemote::default; let options = RewriteOptions { @@ -3888,6 +3955,7 @@ mod tests { npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + pnpm_env: env, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -4065,6 +4133,101 @@ snapshots: ); } + /// #1514: pnpm 11+ read a `pnpm_config_trust_lockfile` (or + /// `PNPM_CONFIG_TRUST_LOCKFILE`) variable ahead of pnpm-workspace.yaml. + /// One set to anything but true in the scan's environment is an + /// explicit user setting: nothing is written (a key the variable + /// overrides would not take effect) and the warning names the variable + /// and its remedies instead of claiming installs need no extra flags. + /// The member-lock branch respects it too; a true one, an npm-family + /// one, and a project on pnpm 10 keep the auto-config. + #[tokio::test] + async fn issue_1514_a_trust_lockfile_env_override_is_respected() { + fn off(var: &str) -> Option { + (var == "pnpm_config_trust_lockfile").then(|| "false".to_string()) + } + fn upper_off(var: &str) -> Option { + (var == "PNPM_CONFIG_TRUST_LOCKFILE").then(|| "false".to_string()) + } + fn on(var: &str) -> Option { + (var == "pnpm_config_trust_lockfile").then(|| "true".to_string()) + } + fn npm_off(var: &str) -> Option { + (var == "npm_config_trust_lockfile").then(|| "false".to_string()) + } + let mut root = MemoryProject::new(); + root.insert_text("pnpm-lock.yaml", LEFT_PAD_V9_LOCK); + for (env, var) in [ + ( + off as fn(&str) -> Option, + "pnpm_config_trust_lockfile", + ), + (upper_off, "PNPM_CONFIG_TRUST_LOCKFILE"), + ] { + let (_, done) = + npm_rewrite_in(&ProjectView::Memory(&root), &BTreeSet::new(), &env).await; + assert!(done.rewrite.files.contains_key("pnpm-lock.yaml")); + assert!( + !done.rewrite.files.contains_key(PNPM_WORKSPACE_REL), + "{:?}", + done.rewrite.files.keys() + ); + let detail = pnpm_trust_detail(&done); + assert!(detail.contains(&format!("`{var}=false`")), "{detail}"); + assert!(!detail.contains("no extra flags"), "{detail}"); + assert!(detail.contains("--mode vendored"), "{detail}"); + } + // An existing `trustLockfile: true` is overridden all the same. + let mut trusted = root.clone(); + trusted.insert_text( + PNPM_WORKSPACE_REL, + "packages:\n - '.'\ntrustLockfile: true\n", + ); + let (_, done) = + npm_rewrite_in(&ProjectView::Memory(&trusted), &BTreeSet::new(), &off).await; + assert!(!done.rewrite.files.contains_key(PNPM_WORKSPACE_REL)); + let detail = pnpm_trust_detail(&done); + assert!( + detail.contains("`pnpm_config_trust_lockfile=false`"), + "{detail}" + ); + + // A true override, the npm spelling (no pnpm reads it) and a + // project installed by pnpm 10 (which never verifies) keep the write. + for env in [on as fn(&str) -> Option, npm_off] { + let (_, done) = + npm_rewrite_in(&ProjectView::Memory(&root), &BTreeSet::new(), &env).await; + assert!(done.rewrite.files.contains_key(PNPM_WORKSPACE_REL)); + assert!(pnpm_trust_detail(&done).contains("no extra flags")); + } + let mut pnpm10 = root.clone(); + pnpm10.insert_text( + "package.json", + r#"{"name":"app","packageManager":"pnpm@10.34.6"}"#, + ); + let (_, done) = npm_rewrite_in(&ProjectView::Memory(&pnpm10), &BTreeSet::new(), &off).await; + assert!(done.rewrite.files.contains_key(PNPM_WORKSPACE_REL)); + + // A workspace member's own lock (#880): the root file is not + // written either way, and the env override is what is reported. + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join(PNPM_WORKSPACE_REL), + "packages:\n - 'app'\nsharedWorkspaceLockfile: false\ntrustLockfile: true\n", + ) + .unwrap(); + let member = tmp.path().join("app"); + std::fs::create_dir_all(&member).unwrap(); + std::fs::write(member.join("pnpm-lock.yaml"), LEFT_PAD_V9_LOCK).unwrap(); + let (_, done) = npm_rewrite_in(&ProjectView::Disk(&member), &BTreeSet::new(), &off).await; + assert!(done.rewrite.files.contains_key("pnpm-lock.yaml")); + let detail = pnpm_trust_detail(&done); + assert!( + detail.contains("`pnpm_config_trust_lockfile=false`"), + "{detail}" + ); + } + /// REGRESSION (#367), binary lock: a `bun.lockb`-only project's root /// manifest is read for its `patchedDependencies`, and a package the /// project patches itself with `bun patch` keeps its registry record, @@ -4123,6 +4286,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -4227,6 +4391,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -4402,6 +4567,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -4742,6 +4908,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), patch_server_origins: configured.iter().map(|o| o.to_string()).collect(), @@ -4957,6 +5124,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -5011,6 +5179,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), @@ -5507,6 +5676,7 @@ snapshots: npm_outer: &outer, yarn_classic_outer: &OuterYarnMirror::default, cargo_outer: &no_cargo_outer, + pnpm_env: &no_env, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), diff --git a/crates/socket-patch-core/src/hosted/memory/stages.rs b/crates/socket-patch-core/src/hosted/memory/stages.rs index 44dabd722..63811b36b 100644 --- a/crates/socket-patch-core/src/hosted/memory/stages.rs +++ b/crates/socket-patch-core/src/hosted/memory/stages.rs @@ -377,6 +377,8 @@ pub(crate) async fn rewrite( yarn_classic_outer: &yarn_classic_outer, // The in-memory engine has no project on disk to read. cargo_outer: &|| CratesIoSource::Registry, + // Nor the environment the project's installs run in. + pnpm_env: &|_| None, blocking: false, takeover_uuids: Default::default(), patch_server_origins: Vec::new(), diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 42e2d2d27..651470dec 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -23,6 +23,7 @@ //! installs from its own `pnpm-lock..yaml`, which neither mode can //! pin, so both refuse ([`git_branch_locks`], #556). +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use crate::formats::pnpm::workspace::read_package_globs; @@ -156,6 +157,30 @@ fn env_setting( }) } +/// pnpm's `trustLockfile` as the environment sets it, for a project on +/// pnpm `major` (#1514): `pnpm_config_trust_lockfile`, or its uppercase +/// spelling, which pnpm 11 and newer read ahead of pnpm-workspace.yaml. +/// `None` on pnpm 10 and older, which never verify lock URLs, and for +/// `npm_config_trust_lockfile`, which no pnpm reads. +pub fn trust_lockfile_env( + major: Option, + env: impl Fn(&str) -> Option, +) -> Option { + if major.is_some_and(|major| major < 11) { + return None; + } + // pnpm 11's spelling, then the uppercase one pnpm 12 reads too. + env_setting("trust-lockfile", Some(major.unwrap_or(11)), &env).or_else(|| { + let var = "PNPM_CONFIG_TRUST_LOCKFILE"; + let value = env(var)?.trim().to_string(); + Some(PnpmSetting { + source: format!("`{var}={value}` in the environment"), + value, + yaml: false, + }) + }) +} + /// [`pnpm_setting`], falling back to the environment ([`env_setting`], /// for the project's pnpm major) on disk, where the CLI runs pnpm's /// environment. @@ -674,25 +699,35 @@ fn lockfile_off(setting: impl Fn(&str, &str) -> Option) -> Option) -> Option { + let mut texts = BTreeMap::new(); + for rel in [MODULES_YAML, "package.json", PNPM_LOCK] { + if let Ok(text) = view.read_text(rel).await { + texts.insert(rel, text); + } + } + pnpm_major_from(|rel| texts.get(rel).cloned()) +} + +/// The installed pnpm's record of the project. +pub(crate) const MODULES_YAML: &str = "node_modules/.modules.yaml"; + +/// The pnpm major whose settings reading applies to a project whose files +/// `read` returns by project-relative path: the one that installed it +/// ([`MODULES_YAML`]), else the one package.json pins, else what the root +/// lock's grammar implies (`pnpm_settings_major`). +pub(crate) fn pnpm_major_from(read: impl Fn(&str) -> Option) -> 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() + let pinned = read(MODULES_YAML) .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(); + .and_then(modules_yaml_pnpm_major) + .or_else(|| { + read("package.json") + .as_deref() + .and_then(package_json_pnpm_major) + }); + let lock = read(PNPM_LOCK).unwrap_or_default(); pnpm_settings_major(&lock, pinned) } @@ -912,6 +947,31 @@ mod tests { assert_eq!(env_setting("git-branch-lockfile", None, env(&[])), None); } + /// #1514: pnpm 11 and newer read `trustLockfile` from + /// `pnpm_config_trust_lockfile` (either case); pnpm 10 and older never + /// verify, and no pnpm reads `npm_config_trust_lockfile`. + #[test] + fn the_environment_sets_trust_lockfile_only_for_pnpm_11_and_newer() { + const LOWER: &[(&str, &str)] = &[("pnpm_config_trust_lockfile", "false")]; + const UPPER: &[(&str, &str)] = &[("PNPM_CONFIG_TRUST_LOCKFILE", " false ")]; + const NPM: &[(&str, &str)] = &[("npm_config_trust_lockfile", "false")]; + for major in [Some(11), Some(12), None] { + let found = trust_lockfile_env(major, env(LOWER)).expect("lowercase"); + assert_eq!(found.as_bool(), Some(false)); + assert!(found.source.contains("pnpm_config_trust_lockfile=false")); + let found = trust_lockfile_env(major, env(UPPER)).expect("uppercase"); + assert_eq!(found.as_bool(), Some(false)); + assert!(found.source.contains("PNPM_CONFIG_TRUST_LOCKFILE=false")); + assert_eq!(trust_lockfile_env(major, env(NPM)), None); + } + assert_eq!(trust_lockfile_env(Some(10), env(LOWER)), None); + let on = &[("pnpm_config_trust_lockfile", "true")]; + assert_eq!( + trust_lockfile_env(Some(11), env(on)).and_then(|s| s.as_bool()), + Some(true) + ); + } + /// #1460: pnpm 11 and newer read only `pnpm_config_*`, pnpm 10 and /// older only `npm_config_*`. #[test] diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 5f9a6a86e..dd73bbbe8 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -187,7 +187,10 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: 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. + explicitly disabled by the project or, for pnpm >=11, by a + `pnpm_config_trust_lockfile` environment variable the scan runs with (pnpm + reads it ahead of the file, so nothing is written and the warning names the + variable). pnpm >=11 needs this for hosted URLs. This skips registry re-verification for the whole lock; tarball integrity remains enforced. pnpm <=10 does not need the setting. An existing `pnpm-workspace.yaml` with no keys (empty or only comments) gets the