From a3790b889d7d78aaffe0c5f138bc2fffc4990737 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:23:16 +0000 Subject: [PATCH 1/4] Start fix for #1871 Assisted-by: Claude Code:claude-opus-5-5 From f35f93020a8a570a2c00514123f46d407c8cc14d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:40:28 +0000 Subject: [PATCH 2/4] Read omit-lockfile-registry-resolved like npm The hosted npm run already reads replace-registry-host from every npm config layer (env, project, user, global, builtin). Read omit-lockfile-registry-resolved the same way, through one shared effective-setting lookup instead of a second copy, and parse its value as npm's ini + nopt Boolean reading does (a bare key, `yes` or `False` are true; `false`, `null` and numeric zero are not). Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/npmrc.rs | 245 ++++++++++++++++-- 1 file changed, 230 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/npmrc.rs b/crates/socket-patch-core/src/patch/redirect/npmrc.rs index a29b93df7..8b8acfbe1 100644 --- a/crates/socket-patch-core/src/patch/redirect/npmrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/npmrc.rs @@ -282,6 +282,9 @@ pub struct OuterAllowRemote { /// `replace-registry-host` in the same outer layers (see /// [`effective_replace_registry_host`]). pub replace_registry_host: OuterSetting, + /// `omit-lockfile-registry-resolved` in the same outer layers (see + /// [`effective_omit_lockfile_registry_resolved`]). + pub omit_lockfile_registry_resolved: OuterSetting, /// The process facts the layers were resolved with: npm env-replaces /// a value against them (see [`NpmConfigEnv::config_value`]), /// whichever layer sets it, the project `.npmrc` included. @@ -528,6 +531,10 @@ pub fn resolve_outer_allow_remote( env: env.npm_config(REPLACE_REGISTRY_HOST, "npmjs"), file: outer_file_value(&layers, REPLACE_REGISTRY_HOST), }, + omit_lockfile_registry_resolved: OuterSetting { + env: env.npm_config(OMIT_LOCKFILE_REGISTRY_RESOLVED, "false"), + file: outer_file_value(&layers, OMIT_LOCKFILE_REGISTRY_RESOLVED), + }, config_env: env.clone(), } } @@ -631,7 +638,15 @@ fn outer_file_value(layers: &[OuterLayer], key: &str) -> Option /// rewritten that way is fetched from the registry, which 404s. pub const REPLACE_REGISTRY_HOST: &str = "replace-registry-host"; -/// Where the effective `replace-registry-host` value comes from. +/// npm's (>= 8) `omit-lockfile-registry-resolved` config key (Boolean). +/// When true, `@npmcli/arborist` drops `resolved` from every +/// registry-spec dependency each time it saves the lock +/// (`shrinkwrap.js` → `overrideResolves()`), whatever the URL's host, +/// while keeping `integrity`. A hosted pin then keeps the patched +/// integrity but fetches the registry tarball: EINTEGRITY. +pub const OMIT_LOCKFILE_REGISTRY_RESOLVED: &str = "omit-lockfile-registry-resolved"; + +/// Where an effective npm config value comes from. #[derive(Debug, Clone, PartialEq)] pub enum SettingSource { /// An `npm_config_*` environment variable (named). @@ -645,8 +660,8 @@ pub enum SettingSource { }, } -/// The effective `replace-registry-host` setting: where it is set, the -/// value as written there, and the value npm uses. +/// An effective npm config setting: where it is set, the value as written +/// there, and the value npm uses. #[derive(Debug, Clone, PartialEq)] pub struct EffectiveSetting { /// The value npm uses: [`raw`](Self::raw) trimmed and env-replaced. @@ -656,21 +671,20 @@ pub struct EffectiveSetting { pub source: SettingSource, } -/// The `replace-registry-host` value npm would use, following its layer -/// order (env > project > user > global > builtin), or `None` when no -/// layer sets it (npm's default `npmjs`). npm env-replaces the value in -/// every layer (#1233), so `${RRH}` reads as what `RRH` holds; an unset -/// `${RRH}` stays literal, as it does for npm. -pub fn effective_replace_registry_host( +/// The value npm would use for `key`, following its layer order (env > +/// project > user > global > builtin), or `None` when no layer sets it +/// (npm's default applies). npm env-replaces the value in every layer +/// (#1233), so `${RRH}` reads as what `RRH` holds; an unset `${RRH}` +/// stays literal, as it does for npm. +fn effective_setting( project: Option<&str>, - outer: &OuterAllowRemote, + key: &str, + setting: &OuterSetting, + config_env: &NpmConfigEnv, ) -> Option { - let setting = &outer.replace_registry_host; let (raw, source) = if let Some((var, value)) = &setting.env { (value.clone(), SettingSource::Env(var.clone())) - } else if let Some(value) = - project.and_then(|t| npmrc_top_level_value(t, REPLACE_REGISTRY_HOST)) - { + } else if let Some(value) = project.and_then(|t| npmrc_top_level_value(t, key)) { (value, SettingSource::Project) } else { let f = setting.file.as_ref()?; @@ -683,12 +697,92 @@ pub fn effective_replace_registry_host( ) }; Some(EffectiveSetting { - value: outer.config_env.config_value(&raw), + value: config_env.config_value(&raw), raw, source, }) } +/// The `replace-registry-host` value npm would use (see +/// [`effective_setting`]), or `None` for npm's default `npmjs`. +pub fn effective_replace_registry_host( + project: Option<&str>, + outer: &OuterAllowRemote, +) -> Option { + effective_setting( + project, + REPLACE_REGISTRY_HOST, + &outer.replace_registry_host, + &outer.config_env, + ) +} + +/// The `omit-lockfile-registry-resolved` setting npm would use (see +/// [`effective_setting`]) when npm reads it as TRUE ([`npm_config_bool`]); +/// `None` when it is unset (npm's default `false`) or false. +pub fn effective_omit_lockfile_registry_resolved( + project: Option<&str>, + outer: &OuterAllowRemote, +) -> Option { + effective_setting( + project, + OMIT_LOCKFILE_REGISTRY_RESOLVED, + &outer.omit_lockfile_registry_resolved, + &outer.config_env, + ) + .filter(|s| npm_config_bool(&s.value)) +} + +/// A Boolean npm config value as npm reads it: `ini` turns `true` / +/// `false` / `null` into JSON values, then nopt's Boolean validation +/// makes `false` and `null` false, a numeric string `!!Number(v)`, and +/// every other string true (so `False` and `no` are TRUE to npm). +pub fn npm_config_bool(value: &str) -> bool { + if value == "false" || value == "null" { + return false; + } + js_number(value).is_none_or(|n| n != 0.0) +} + +/// JavaScript's `Number(s)` for a string, `None` where it is `NaN`. +fn js_number(s: &str) -> Option { + let t = js_trim(s); + if t.is_empty() { + return Some(0.0); + } + let (sign, unsigned) = match t.as_bytes()[0] { + b'-' => (-1.0, &t[1..]), + b'+' => (1.0, &t[1..]), + _ => (1.0, t), + }; + if unsigned == "Infinity" { + return Some(sign * f64::INFINITY); + } + for (prefix, radix) in [ + ("0x", 16), + ("0X", 16), + ("0o", 8), + ("0O", 8), + ("0b", 2), + ("0B", 2), + ] { + if let Some(digits) = t.strip_prefix(prefix) { + // `Number` takes no sign before a radix prefix. + return u128::from_str_radix(digits, radix) + .ok() + .filter(|_| !digits.starts_with(['+', '-'])) + .map(|n| n as f64); + } + } + if !unsigned + .bytes() + .all(|b| b.is_ascii_digit() || b"eE+-.".contains(&b)) + { + return None; + } + t.parse::().ok() +} + /// Whether npm's `replace-registry-host=` rewrites a `resolved` URL /// whose authority is `host` (`host[:port]`, as [`url_host`] returns it): /// `always`, or the URL's hostname (port dropped) equal to the value @@ -1459,6 +1553,127 @@ mod tests { /// #1233: npm env-replaces every config value (`@npmcli/config` /// `parseField` -> `envReplace`), in every layer. A + /// #1871: `omit-lockfile-registry-resolved` is read from every npm + /// config layer in npm's order (env > project > user > global > + /// builtin) and reported only when npm reads it as true. + #[test] + fn omit_lockfile_registry_resolved_layers() { + fn effective( + project: Option<&str>, + outer: &OuterAllowRemote, + ) -> Option<(String, SettingSource)> { + effective_omit_lockfile_registry_resolved(project, outer).map(|s| (s.value, s.source)) + } + use std::collections::HashMap; + use std::path::{Path, PathBuf}; + let files: HashMap = HashMap::from([ + ( + PathBuf::from("/home/u/.npmrc"), + "omit-lockfile-registry-resolved=true\n", + ), + ( + PathBuf::from("/opt/node/etc/npmrc"), + "omit-lockfile-registry-resolved=false\n", + ), + ]); + let read = |p: &Path| files.get(p).map(|s| s.to_string()); + + // Unset anywhere: npm's default (`false`). + let outer = resolve_outer_allow_remote(&cfg_env(&[]), |_| None); + assert_eq!(effective(None, &outer), None); + // The project file alone; a bare key is `true` to ini. + for text in [ + "omit-lockfile-registry-resolved=true\n", + "omit-lockfile-registry-resolved\n", + "omit-lockfile-registry-resolved = true\r\n", + ] { + assert_eq!( + effective(Some(text), &outer), + Some(("true".into(), SettingSource::Project)), + "{text:?}" + ); + } + // Scoped under a section it is not a top-level setting. + assert_eq!( + effective(Some("[x]\nomit-lockfile-registry-resolved=true\n"), &outer), + None + ); + + // The user file beats the global one... + let outer = resolve_outer_allow_remote(&cfg_env(&[]), read); + assert_eq!( + effective(Some("fund=false\n"), &outer), + Some(( + "true".into(), + SettingSource::File { + layer: "user", + path: "/home/u/.npmrc".into() + } + )) + ); + // ...a project `false` beats both... + assert_eq!( + effective(Some("omit-lockfile-registry-resolved=false\n"), &outer), + None + ); + // ...and the env beats every file, in any spelling npm normalizes. + for var in [ + "npm_config_omit_lockfile_registry_resolved", + "NPM_CONFIG_OMIT_LOCKFILE_REGISTRY_RESOLVED", + ] { + let outer = resolve_outer_allow_remote(&cfg_env(&[(var, "true")]), read); + assert_eq!( + effective(Some("omit-lockfile-registry-resolved=false\n"), &outer), + Some(("true".into(), SettingSource::Env(var.into()))) + ); + let outer = resolve_outer_allow_remote(&cfg_env(&[(var, "false")]), read); + assert_eq!(effective(None, &outer), None); + } + // Env-replaced like every npm value. + let outer = resolve_outer_allow_remote(&cfg_env(&[("OMIT", "true")]), |_| None); + let got = effective_omit_lockfile_registry_resolved( + Some("omit-lockfile-registry-resolved=${OMIT}\n"), + &outer, + ) + .unwrap(); + assert_eq!((got.raw.as_str(), got.value.as_str()), ("${OMIT}", "true")); + // The other keys' resolution is unaffected by the new key. + assert_eq!((outer.env, outer.file), (None, None)); + assert_eq!(outer.replace_registry_host, OuterSetting::default()); + } + + /// nopt's Boolean validation after `ini`: only `false`, `null` and a + /// numeric zero are false. + #[test] + fn npm_config_bool_matches_nopt() { + for v in [ + "true", + "1", + "-1", + "yes", + "False", + "FALSE", + "no", + "off", + "0x1", + "1e3", + "Infinity", + "-Infinity", + " 2 ", + "[a, b]", + "0x", + "-0x1", + ".", + ] { + assert!(npm_config_bool(v), "{v:?}"); + } + for v in [ + "false", "null", "0", "", " ", "-0", "0.0", "0e5", "0x0", "0b0", "0o0", "+0", + ] { + assert!(!npm_config_bool(v), "{v:?}"); + } + } + /// `replace-registry-host=${RRH}` with `RRH=always` is `always` to npm, /// so it must read as `always` here too, wherever it is set, while the /// spelling the user wrote is kept for the warning. From 025fc7cba11a8f3fca29f2b691c53083e04f540d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:40:29 +0000 Subject: [PATCH 3/4] Warn when npm will strip hosted lock pins With omit-lockfile-registry-resolved=true, the next npm install (even a no-op) drops the hosted `resolved` URL but keeps the patched integrity, so every cold-cache install fails EINTEGRITY and vex loses the patch. The hosted scan said nothing and exited 0. It now warns redirect_npm_omit_lockfile_registry_resolved, naming the config layer that sets it and the remedies (set it to false in the project .npmrc, unset the env var, or use vendored mode). Documented in CLI_CONTRACT.md, docs/ecosystems.md and the npm test matrix. Fixes #1871 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 3 + .../tests/vendor/redirect_npm_allow_remote.rs | 136 ++++++++++++++++++ crates/socket-patch-core/src/hosted/engine.rs | 27 +++- .../socket-patch-core/src/hosted/guidance.rs | 81 +++++++++-- docs/ecosystems.md | 7 + docs/testing/npm-compatibility.md | 2 +- 6 files changed, 237 insertions(+), 19 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 47abe95a3..4e688c70d 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -264,6 +264,8 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **npm hosted-mode `replace-registry-host` contract**: npm >=8 rewrites the origin of a lock's `resolved` URL to the configured registry when its `replace-registry-host` setting is `always`, or equals the URL's hostname (`npmjs`, the default, stands for `registry.npmjs.org`; `never` matches nothing). A hosted pin rewritten that way is fetched from `/patch/npm/…` and every `npm ci` / `npm install` fails E404 (closed: never unpatched bytes; a warm npm cache can hide it locally). Whenever a run leaves a root npm lock carrying a granted hosted artifact URL (the same trigger as the `allow-remote` contract, so an "already on hosted patches" re-run reports it too), the CLI resolves `replace-registry-host` across npm's layers — an `npm_config_replace_registry_host` environment variable (any spelling npm normalizes), then the project `.npmrc`, then the user / global / builtin config files (located exactly like the `allow-remote` layers), its value env-replaced the way npm reads it (`${VAR}` / `${VAR?}`: `replace-registry-host=${RRH}` with `RRH=always` counts as `always`, and the warning quotes both spellings; an unset `${VAR}` stays literal, as in npm) — and, when the effective value rewrites a pinned host, emits the `redirect_npm_replace_registry_host` warning naming the layer (env var / project file / config path) and the remedies: `replace-registry-host=npmjs` in the project `.npmrc` (or unsetting the env var), or vendored mode, whose `file:` resolutions npm never rewrites. The setting is never rewritten; the redirect itself still lands and the exit status is unchanged. +**npm hosted-mode `omit-lockfile-registry-resolved` contract (#1871)**: npm >=8 with `omit-lockfile-registry-resolved` true drops `resolved` from every registry-spec dependency each time it saves the lock (any `npm install`, even one that changes nothing), whatever the URL's host, and keeps `integrity`. A hosted pin is then left with the patched sha512 but no hosted URL, so npm fetches the registry tarball and every install without a warm cache fails EINTEGRITY (closed: never unpatched bytes), and `vex` no longer finds the hosted reference. On the same trigger as the `replace-registry-host` contract, the CLI resolves `omit-lockfile-registry-resolved` across the same layers (an `npm_config_omit_lockfile_registry_resolved` environment variable, then the project `.npmrc`, then the user / global / builtin config files, env-replaced the same way) and reads the value as npm does (`ini`, then nopt's Boolean: a bare key and any string other than `false`, `null` or a numeric zero are true). When it is true, the run emits the `redirect_npm_omit_lockfile_registry_resolved` warning naming the layer and the remedies: `omit-lockfile-registry-resolved=false` in the project `.npmrc` (or unsetting the env var), or vendored mode, whose `file:` resolutions npm keeps. A hosted re-run re-pins a stripped lock. The setting is never rewritten; the redirect itself still lands and the exit status is unchanged. + **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` on pnpm 11 and newer, `npm_config_shared_workspace_lockfile` on pnpm 10 and older, either when the major is unknown (#1460, the major as for the `lockfile` setting below); 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 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. @@ -1730,6 +1732,7 @@ command's integration tests check its output. | `redirect_vlt_lockfile_version_missing` / `redirect_vlt_old_lockfile_ignored` / `redirect_vlt_scalar_registry_ignored` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): the lock has no `lockfileVersion` (vlt ≥ 1.0.0-rc.15 re-resolves it) / a legacy default-registry id without `"modifiers"` in `vlt.json` (vlt 0.0.0-16 … 0.0.0-24 ignore the lock) / a scalar `registry` option that vlt 1.0.0-rc.7 … rc.29 honor over the lock. The deps stay redirected, but the run's `--vex` does not attest them. | | `redirect_vlt_sibling_lockfiles` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` and another npm-family lock are both present and vlt's install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is not, so both locks were rewritten and the other lock's rules confirm. | | `redirect_npm_replace_registry_host` | `warnings[]` (warning) | scan/get `--mode hosted` (npm): the effective npm `replace-registry-host` (env var, project `.npmrc`, or user / global / builtin config) is `always` or a pinned hosted hostname, so npm rewrites the hosted pins to the configured registry and every install fails E404; the detail names the layer and the remedies (`replace-registry-host=npmjs` in the project `.npmrc`, or vendored mode). See the npm `replace-registry-host` contract. | +| `redirect_npm_omit_lockfile_registry_resolved` | `warnings[]` (warning) | scan/get `--mode hosted` (npm, #1871): the effective npm `omit-lockfile-registry-resolved` (env var, project `.npmrc`, or user / global / builtin config) is true, so npm's next lock save drops the hosted pins' `resolved` while keeping the patched `integrity` and every cold-cache install fails EINTEGRITY; the detail names the layer and the remedies (`omit-lockfile-registry-resolved=false` in the project `.npmrc`, or vendored mode). See the npm `omit-lockfile-registry-resolved` contract. | | `redirect_npm_allow_scripts` | `warnings[]` (warning) | scan/get `--mode hosted` (npm, #1595): a root npm lock pins a patched package to its hosted artifact, a lock entry for it has `hasInstallScript`, and package.json `allowScripts` approves it only by version (`"pkg@x.y.z"` or `||`-joined exact versions) with no name-only approval and no deny. npm 12 matches a version pin only against a registry tarball URL, so the approved install script stops running (every install fails under `strict-allow-scripts`). The policy is never rewritten; the detail names the pinned keys and the name-only remedy. | | `redirect_vlt_no_lockfile` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt.json` or vlt's install state is present without `vlt-lock.json`; replaces `redirect_npm_no_lockfile` for vlt projects. | | `redirect_npm_shrinkwrap_only` | `warnings[]` (warning) | scan/get `--mode hosted` (npm, #899): the root `npm-shrinkwrap.json` is the only npm lock and carries a hosted redirect (this run's or an earlier one's — every run repeats it until the project gains a `package-lock.json`). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; npm <= 11 installs the patch. Rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. | diff --git a/crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs b/crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs index 88d461484..614e686e9 100644 --- a/crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs +++ b/crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs @@ -45,6 +45,7 @@ const UPSTREAM_SHA512: &str = "sha512-UPSTREAMupstream=="; const CODE: &str = "redirect_npm_allow_remote"; const LEFT: &str = "npm_allow_remote_left"; const REPLACE_HOST: &str = "redirect_npm_replace_registry_host"; +const OMIT_RESOLVED: &str = "redirect_npm_omit_lockfile_registry_resolved"; fn hosted_url() -> String { format!( @@ -191,6 +192,14 @@ fn npm_isolation(root: &Path) -> Vec<(String, String)> { ("npm_config_allow_remote".into(), String::new()), ("NPM_CONFIG_REPLACE_REGISTRY_HOST".into(), String::new()), ("npm_config_replace_registry_host".into(), String::new()), + ( + "NPM_CONFIG_OMIT_LOCKFILE_REGISTRY_RESOLVED".into(), + String::new(), + ), + ( + "npm_config_omit_lockfile_registry_resolved".into(), + String::new(), + ), ] } @@ -1002,6 +1011,133 @@ async fn replace_registry_host_via_env_interpolation_is_warned() { assert_eq!(replace_host_warning(&doc), None, "{doc:#}"); } +fn omit_resolved_warning(doc: &Value) -> Option<&str> { + doc["warnings"] + .as_array() + .into_iter() + .flatten() + .find(|w| w["code"] == OMIT_RESOLVED) + .and_then(|w| w["detail"].as_str()) +} + +/// #1871: npm >= 8's `omit-lockfile-registry-resolved=true` makes the next +/// lock save (even a no-op `npm install`) drop the hosted pin's `resolved` +/// while keeping its patched `integrity`, so every cold-cache install +/// fails EINTEGRITY and `vex` loses the hosted reference. The hosted run +/// said nothing and exited 0. It now warns +/// `redirect_npm_omit_lockfile_registry_resolved` (JSON + human), naming +/// the layer that sets it, on the first run and the "already hosted" +/// re-run; a value npm reads as false stays quiet. +#[tokio::test] +async fn omit_lockfile_registry_resolved_is_warned() { + let server = MockServer::start().await; + mock_api(&server).await; + + // Project `.npmrc`: first run and the idempotent re-run both warn. + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + std::fs::write( + tmp.path().join(".npmrc"), + "omit-lockfile-registry-resolved=true\n", + ) + .unwrap(); + for run in 0..2 { + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + let detail = omit_resolved_warning(&doc) + .unwrap_or_else(|| panic!("run {run}: no {OMIT_RESOLVED}: {doc:#}")); + assert!( + detail.contains("patch.test") + && detail + .contains("the project .npmrc sets `omit-lockfile-registry-resolved=true`") + && detail.contains("EINTEGRITY") + && detail.contains("omit-lockfile-registry-resolved=false") + && detail.contains("--mode vendored"), + "{detail}" + ); + assert!(allow_remote_warning(&doc).is_some(), "{doc:#}"); + assert_eq!(replace_host_warning(&doc), None, "{doc:#}"); + } + let (code, _, stderr) = scan_hosted(tmp.path(), &server.uri(), &[]); + assert_eq!(code, 0, "{stderr}"); + assert!( + stderr.contains("Warning: ") && stderr.contains("`omit-lockfile-registry-resolved=true`"), + "{stderr}" + ); + + // npm reads the value through ini + nopt's Boolean: a bare key and any + // non-`false` string are true; `false`, `null` and `0` are not. + for (line, warns) in [ + ("omit-lockfile-registry-resolved", true), + ("omit-lockfile-registry-resolved=1", true), + ("omit-lockfile-registry-resolved=false", false), + ("omit-lockfile-registry-resolved=null", false), + ("omit-lockfile-registry-resolved=0", false), + ("[scope]\nomit-lockfile-registry-resolved=true", false), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + std::fs::write(tmp.path().join(".npmrc"), format!("{line}\n")).unwrap(); + let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!( + omit_resolved_warning(&doc).is_some(), + warns, + "{line}: {doc:#}" + ); + } + + // User config (relocated the way npm allows). + let tmp = tempfile::tempdir().unwrap(); + let cfg = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + let user = cfg.path().join("user.npmrc"); + std::fs::write(&user, "omit-lockfile-registry-resolved=true\n").unwrap(); + let user_s = user.to_str().unwrap(); + let user_env = [ + ("NPM_CONFIG_USERCONFIG", user_s), + ("npm_config_userconfig", user_s), + ]; + let (code, doc, _) = scan_hosted_env(tmp.path(), &server.uri(), &["--json"], &user_env); + assert_eq!(code, 0, "{doc:#}"); + let detail = + omit_resolved_warning(&doc).unwrap_or_else(|| panic!("no {OMIT_RESOLVED}: {doc:#}")); + assert!( + detail.contains("the user npm config") + && detail.contains(user_s) + && detail.contains("in the project .npmrc (it outranks the user config)"), + "{detail}" + ); + // ...which a project `false` overrides, as it does for npm. + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), "package-lock.json"); + std::fs::write( + tmp.path().join(".npmrc"), + "omit-lockfile-registry-resolved=false\n", + ) + .unwrap(); + let (code, doc, _) = scan_hosted_env(tmp.path(), &server.uri(), &["--json"], &user_env); + assert_eq!(code, 0, "{doc:#}"); + assert_eq!(omit_resolved_warning(&doc), None, "{doc:#}"); + + // The env var beats a project `false`. + let (code, doc, _) = scan_hosted_env( + tmp.path(), + &server.uri(), + &["--json"], + &[("npm_config_omit_lockfile_registry_resolved", "true")], + ); + assert_eq!(code, 0, "{doc:#}"); + let detail = + omit_resolved_warning(&doc).unwrap_or_else(|| panic!("no {OMIT_RESOLVED}: {doc:#}")); + assert!( + detail.to_ascii_lowercase().contains( + "npm_config_omit_lockfile_registry_resolved sets `omit-lockfile-registry-resolved=true`" + ), + "{detail}" + ); +} + /// Review finding: `remove` dropped the hosted leg's warnings. A /// redirect-created `.npmrc` the user has since added to is no longer the /// scaffold, so the restore keeps it byte-for-byte and warns that the diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index f214e686d..90ce82cc9 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -30,8 +30,9 @@ use crate::constants::npm_family::{ use crate::formats::cargo::source_replacement::CratesIoSource; use crate::formats::governing_locks::PYPI_REQUIREMENTS; use crate::patch::redirect::npmrc::{ - effective_replace_registry_host, plan_npmrc_allow_remote_with, replace_registry_host_rewrites, - NpmrcPlan, OuterAllowRemote, NPMRC_ALLOW_REMOTE_EDIT_KIND, NPMRC_REL, + effective_omit_lockfile_registry_resolved, effective_replace_registry_host, + plan_npmrc_allow_remote_with, replace_registry_host_rewrites, NpmrcPlan, OuterAllowRemote, + NPMRC_ALLOW_REMOTE_EDIT_KIND, NPMRC_REL, }; use crate::patch::redirect::presence::groups_present; use crate::patch::redirect::yarnrc::OuterYarnMirror; @@ -48,15 +49,17 @@ use super::guidance::{ npm_allow_remote_already_detail, npm_allow_remote_configured_detail, npm_allow_remote_env_set_detail, npm_allow_remote_manual_detail, npm_allow_remote_outer_set_detail, npm_allow_remote_unreadable_detail, - npm_allow_remote_user_set_detail, npm_lock_url_needles, npm_replace_registry_host_detail, - plan_workspace_trust, pnpm_heal_root, pnpm_is_shrinkwrap_lock, pnpm_lock_may_need_store_flag, - pnpm_lock_version_major, pnpm_root_only_workspace_breaks_add, pnpm_trust_configured_detail, + npm_allow_remote_user_set_detail, npm_lock_url_needles, npm_omit_lockfile_resolved_detail, + npm_replace_registry_host_detail, plan_workspace_trust, pnpm_heal_root, + pnpm_is_shrinkwrap_lock, pnpm_lock_may_need_store_flag, pnpm_lock_version_major, + pnpm_root_only_workspace_breaks_add, pnpm_trust_configured_detail, pnpm_trust_keyless_members_detail, pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, pnpm_trust_not_needed_detail, pnpm_trust_policy_preamble, pnpm_trust_rush_detail, pnpm_trust_scaffolded_detail, pnpm_trust_workspace_unreadable_detail, pnpm_trust_workspace_unsupported_detail, read_npmrc_for_allow_remote, read_workspace_for_trust, - TrustPlan, NPM_LOCKS, NPM_REPLACE_REGISTRY_HOST_CODE, PNPM_TRUST_RUSH_MIXED_NOTE, - PNPM_TRUST_TRADEOFF_AND_CAUTION, PNPM_WORKSPACE_REL, REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND, + TrustPlan, NPM_LOCKS, NPM_OMIT_LOCKFILE_RESOLVED_CODE, NPM_REPLACE_REGISTRY_HOST_CODE, + PNPM_TRUST_RUSH_MIXED_NOTE, PNPM_TRUST_TRADEOFF_AND_CAUTION, PNPM_WORKSPACE_REL, + REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND, }; use super::vlt::bun_lockb_present; @@ -3178,6 +3181,16 @@ fn npm_allow_remote( )); } } + // #1871: npm >= 8's `omit-lockfile-registry-resolved` strips every + // registry dependency's `resolved` (the hosted pins included) on the + // next lock save, keeping the patched `integrity`. Reported, never + // overridden, like `replace-registry-host`. + if let Some(setting) = effective_omit_lockfile_registry_resolved(project, &outer) { + npm_warnings.push(warning( + NPM_OMIT_LOCKFILE_RESOLVED_CODE, + npm_omit_lockfile_resolved_detail(&npm_hosts, &setting), + )); + } (npm_warnings, npmrc_config_write) } diff --git a/crates/socket-patch-core/src/hosted/guidance.rs b/crates/socket-patch-core/src/hosted/guidance.rs index 9917ee668..be3ba1147 100644 --- a/crates/socket-patch-core/src/hosted/guidance.rs +++ b/crates/socket-patch-core/src/hosted/guidance.rs @@ -563,17 +563,7 @@ pub fn npm_replace_registry_host_detail( setting: &crate::patch::redirect::npmrc::EffectiveSetting, ) -> String { use crate::patch::redirect::npmrc::SettingSource; - // Name the spelling the user can find (`${RRH}`), and what npm makes - // of it. - let value = if setting.raw.trim() == setting.value { - format!("`replace-registry-host={}`", setting.value) - } else { - format!( - "`replace-registry-host={}` (npm expands it to `{}`)", - setting.raw.trim(), - setting.value - ) - }; + let value = npm_setting_spelling("replace-registry-host", setting); let (where_, fix) = match &setting.source { SettingSource::Env(var) => ( format!("the environment variable {var} sets"), @@ -606,6 +596,75 @@ pub fn npm_replace_registry_host_detail( ) } +/// `key=value` as the user wrote it (`${RRH}`), and what npm expands it +/// to when that differs. +fn npm_setting_spelling( + key: &str, + setting: &crate::patch::redirect::npmrc::EffectiveSetting, +) -> String { + if setting.raw.trim() == setting.value { + format!("`{key}={}`", setting.value) + } else { + format!( + "`{key}={}` (npm expands it to `{}`)", + setting.raw.trim(), + setting.value + ) + } +} + +/// Warning code for a hosted npm pin that npm's +/// `omit-lockfile-registry-resolved` setting strips on the next lock save +/// (#1871). +pub const NPM_OMIT_LOCKFILE_RESOLVED_CODE: &str = "redirect_npm_omit_lockfile_registry_resolved"; + +/// npm (>= 8) `omit-lockfile-registry-resolved=true` drops `resolved` from +/// every registry dependency the next time npm saves the lock, but keeps +/// the patched `integrity`: npm then fetches the registry tarball and the +/// install fails EINTEGRITY (closed: never unpatched bytes). Like +/// `replace-registry-host`, the setting is the user's, so the warning +/// names where it is set and the remedies instead of overriding it. +pub fn npm_omit_lockfile_resolved_detail( + hosts: &[&str], + setting: &crate::patch::redirect::npmrc::EffectiveSetting, +) -> String { + use crate::patch::redirect::npmrc::SettingSource; + let value = npm_setting_spelling("omit-lockfile-registry-resolved", setting); + let (where_, fix) = match &setting.source { + SettingSource::Env(var) => ( + format!("the environment variable {var} sets"), + format!( + "unset {var} (npm's environment layer overrides every .npmrc) or set it to \ + `false`" + ), + ), + SettingSource::Project => ( + "the project .npmrc sets".to_string(), + "change it to `omit-lockfile-registry-resolved=false` (npm's default) or remove it" + .to_string(), + ), + SettingSource::File { layer, path } => ( + format!("the {layer} npm config ({}) sets", path.display()), + format!( + "set `omit-lockfile-registry-resolved=false` in the project .npmrc (it outranks \ + the {layer} config) or change the {layer} config" + ), + ), + }; + format!( + "the npm lockfile now resolves patched dependencies from the hosted patch server ({}), \ + but {where_} {value}, which makes npm >=8 drop the `resolved` URL of every registry \ + dependency the next time it saves the lockfile (any `npm install`, even one that \ + changes nothing) while keeping the patched `integrity`: npm then fetches the \ + registry tarball, so every `npm ci` / `npm install` without a warm cache fails \ + EINTEGRITY and `socket-patch vex` no longer finds the hosted patches. To keep the \ + hosted patches, {fix}; or switch this project to vendored patches (`socket-patch \ + scan --mode vendored`), whose `file:` resolutions npm keeps. Re-running the hosted \ + scan re-pins a lockfile npm has stripped", + hosts.join(", "), + ) +} + /// The project `.npmrc` read, classified for the allow-remote auto-config: /// `Ok(Some(text))` — a regular file read fine; `Ok(None)` — ABSENT (the /// only state where planning a Create is safe); `Err(why)` — present but diff --git a/docs/ecosystems.md b/docs/ecosystems.md index ea59c4dc2..33a0ca5f4 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -138,6 +138,13 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: E404: the hosted run reads it from the same env / project / user / global / builtin layers and warns `redirect_npm_replace_registry_host` (set `replace-registry-host=npmjs` in the project `.npmrc`, or use vendored mode). + npm >= 8's `omit-lockfile-registry-resolved=true` makes the next lock save + (any `npm install`) drop the hosted pins' `resolved` URLs but keep their + patched `integrity`, so every cold-cache install fails EINTEGRITY: the hosted + run reads it from the same layers and warns + `redirect_npm_omit_lockfile_registry_resolved` (set + `omit-lockfile-registry-resolved=false` in the project `.npmrc`, or use + vendored mode; a hosted re-run re-pins a stripped lock). npm 12 runs a dependency's install script only when `package.json` `allowScripts` approves it, and matches a version-pinned key (`"esbuild@0.19.12": true`, what `npm approve-scripts` writes) only against a diff --git a/docs/testing/npm-compatibility.md b/docs/testing/npm-compatibility.md index fb081666d..97ec7ce6b 100644 --- a/docs/testing/npm-compatibility.md +++ b/docs/testing/npm-compatibility.md @@ -102,7 +102,7 @@ attest the package while that unpatched copy installs | `e2e_redirect_npm_build` (`#[ignore]`) | real | scan / get-uuid / get-ghsa hosted redirects, shrinkwrap flavor, tampered-tarball rejection, fresh-checkout `npm ci`, manifest-less VEX tail | | `e2e_vendor_npm_build` | real | vendor / get-vendored, shrinkwrap flavor, npm 6 × v2 lock, idempotency, byte-exact revert, manifest-less VEX tail | | `e2e_vex_lockfile::npm` | none | tamper / spoof / mismatch / pin cells over lockfileVersion 1, 2, 3, shrinkwrap and dual-lock shapes | -| `redirect_npm_allow_remote` | none | the npm 12 `allow-remote` auto-config: `.npmrc` create / append (BOM, CRLF), explicit values respected (project file, user / global config, env var), unhonored spellings, bare-CR and indented-section files, section-scoped copies, opt-out flag + env, dry run, symlinked `.npmrc`, `--silent`, rollback/removal deleting a standalone setting or warning `npm_allow_remote_left` when other settings remain; `replace-registry-host` rewriting the hosted pin (project file, user config, env var) warned `redirect_npm_replace_registry_host` | +| `redirect_npm_allow_remote` | none | the npm 12 `allow-remote` auto-config: `.npmrc` create / append (BOM, CRLF), explicit values respected (project file, user / global config, env var), unhonored spellings, bare-CR and indented-section files, section-scoped copies, opt-out flag + env, dry run, symlinked `.npmrc`, `--silent`, rollback/removal deleting a standalone setting or warning `npm_allow_remote_left` when other settings remain; `replace-registry-host` rewriting the hosted pin (project file, user config, env var) warned `redirect_npm_replace_registry_host`; `omit-lockfile-registry-resolved` stripping it on the next lock save (project file, user config, env var; npm's Boolean reading) warned `redirect_npm_omit_lockfile_registry_resolved` | | `e2e_hosted_production` / `e2e_vendored_production` (`#[ignore]`) | real (ambient) | the same flows against production, ending in the manifest-less VEX tail | The manifest-less VEX tail (`tests/npm_e2e_common/manifestless.rs`) runs four From 161b4e41655baf4eb09298f036c1a0cc0eef6389 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:30:31 +0000 Subject: [PATCH 4/4] Read npm Boolean config values exactly as npm Code review, checked against npm 10.9.4: npm reads a blank omit-lockfile-registry-resolved= as true and a literal `undefined` as false, because it checks those keywords on the written value before expanding ${VAR}. The scan read them the other way round, so a blank value got no warning and `undefined` got a false one. Also share the where-is-it-set remedy text between the replace-registry-host and omit-lockfile-registry-resolved warnings, replace the full JS number parser with a numeric-zero check, and restore the #1233 test comment the new test had split. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/vendor/redirect_npm_allow_remote.rs | 7 +- .../socket-patch-core/src/hosted/guidance.rs | 75 +++++----- .../src/patch/redirect/npmrc.rs | 133 +++++++++++------- 4 files changed, 117 insertions(+), 100 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 4e688c70d..ff446e8c4 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -264,7 +264,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **npm hosted-mode `replace-registry-host` contract**: npm >=8 rewrites the origin of a lock's `resolved` URL to the configured registry when its `replace-registry-host` setting is `always`, or equals the URL's hostname (`npmjs`, the default, stands for `registry.npmjs.org`; `never` matches nothing). A hosted pin rewritten that way is fetched from `/patch/npm/…` and every `npm ci` / `npm install` fails E404 (closed: never unpatched bytes; a warm npm cache can hide it locally). Whenever a run leaves a root npm lock carrying a granted hosted artifact URL (the same trigger as the `allow-remote` contract, so an "already on hosted patches" re-run reports it too), the CLI resolves `replace-registry-host` across npm's layers — an `npm_config_replace_registry_host` environment variable (any spelling npm normalizes), then the project `.npmrc`, then the user / global / builtin config files (located exactly like the `allow-remote` layers), its value env-replaced the way npm reads it (`${VAR}` / `${VAR?}`: `replace-registry-host=${RRH}` with `RRH=always` counts as `always`, and the warning quotes both spellings; an unset `${VAR}` stays literal, as in npm) — and, when the effective value rewrites a pinned host, emits the `redirect_npm_replace_registry_host` warning naming the layer (env var / project file / config path) and the remedies: `replace-registry-host=npmjs` in the project `.npmrc` (or unsetting the env var), or vendored mode, whose `file:` resolutions npm never rewrites. The setting is never rewritten; the redirect itself still lands and the exit status is unchanged. -**npm hosted-mode `omit-lockfile-registry-resolved` contract (#1871)**: npm >=8 with `omit-lockfile-registry-resolved` true drops `resolved` from every registry-spec dependency each time it saves the lock (any `npm install`, even one that changes nothing), whatever the URL's host, and keeps `integrity`. A hosted pin is then left with the patched sha512 but no hosted URL, so npm fetches the registry tarball and every install without a warm cache fails EINTEGRITY (closed: never unpatched bytes), and `vex` no longer finds the hosted reference. On the same trigger as the `replace-registry-host` contract, the CLI resolves `omit-lockfile-registry-resolved` across the same layers (an `npm_config_omit_lockfile_registry_resolved` environment variable, then the project `.npmrc`, then the user / global / builtin config files, env-replaced the same way) and reads the value as npm does (`ini`, then nopt's Boolean: a bare key and any string other than `false`, `null` or a numeric zero are true). When it is true, the run emits the `redirect_npm_omit_lockfile_registry_resolved` warning naming the layer and the remedies: `omit-lockfile-registry-resolved=false` in the project `.npmrc` (or unsetting the env var), or vendored mode, whose `file:` resolutions npm keeps. A hosted re-run re-pins a stripped lock. The setting is never rewritten; the redirect itself still lands and the exit status is unchanged. +**npm hosted-mode `omit-lockfile-registry-resolved` contract (#1871)**: npm >=8 with `omit-lockfile-registry-resolved` true drops `resolved` from every registry-spec dependency each time it saves the lock (any `npm install`, even one that changes nothing), whatever the URL's host, and keeps `integrity`. A hosted pin is then left with the patched sha512 but no hosted URL, so npm fetches the registry tarball and every install without a warm cache fails EINTEGRITY (closed: never unpatched bytes), and `vex` no longer finds the hosted reference. On the same trigger as the `replace-registry-host` contract, the CLI resolves `omit-lockfile-registry-resolved` across the same layers (an `npm_config_omit_lockfile_registry_resolved` environment variable, then the project `.npmrc`, then the user / global / builtin config files, env-replaced the same way) and reads the value as npm does (`@npmcli/config`'s `parseField`, then nopt's Boolean: a bare key or an empty value is true, `false` / `null` / `undefined` as written are false, and otherwise any env-replaced value but `false`, `null` or a numeric zero is true). When it is true, the run emits the `redirect_npm_omit_lockfile_registry_resolved` warning naming the layer and the remedies: `omit-lockfile-registry-resolved=false` in the project `.npmrc` (or unsetting the env var), or vendored mode, whose `file:` resolutions npm keeps. A hosted re-run re-pins a stripped lock. The setting is never rewritten; the redirect itself still lands and the exit status is unchanged. **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` on pnpm 11 and newer, `npm_config_shared_workspace_lockfile` on pnpm 10 and older, either when the major is unknown (#1460, the major as for the `lockfile` setting below); 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. diff --git a/crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs b/crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs index 614e686e9..1f7e65d01 100644 --- a/crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs +++ b/crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs @@ -1065,11 +1065,14 @@ async fn omit_lockfile_registry_resolved_is_warned() { "{stderr}" ); - // npm reads the value through ini + nopt's Boolean: a bare key and any - // non-`false` string are true; `false`, `null` and `0` are not. + // npm reads the value through `parseField` + nopt's Boolean: a bare + // key, a blank value and any other string are true; `false`, `null`, + // `undefined` and `0` are not (measured on npm 10.9.4). for (line, warns) in [ ("omit-lockfile-registry-resolved", true), ("omit-lockfile-registry-resolved=1", true), + ("omit-lockfile-registry-resolved=", true), + ("omit-lockfile-registry-resolved=undefined", false), ("omit-lockfile-registry-resolved=false", false), ("omit-lockfile-registry-resolved=null", false), ("omit-lockfile-registry-resolved=0", false), diff --git a/crates/socket-patch-core/src/hosted/guidance.rs b/crates/socket-patch-core/src/hosted/guidance.rs index be3ba1147..411a3b6cf 100644 --- a/crates/socket-patch-core/src/hosted/guidance.rs +++ b/crates/socket-patch-core/src/hosted/guidance.rs @@ -562,28 +562,8 @@ pub fn npm_replace_registry_host_detail( hosts: &[&str], setting: &crate::patch::redirect::npmrc::EffectiveSetting, ) -> String { - use crate::patch::redirect::npmrc::SettingSource; let value = npm_setting_spelling("replace-registry-host", setting); - let (where_, fix) = match &setting.source { - SettingSource::Env(var) => ( - format!("the environment variable {var} sets"), - format!( - "unset {var} (npm's environment layer overrides every .npmrc) or set it to \ - `npmjs`" - ), - ), - SettingSource::Project => ( - "the project .npmrc sets".to_string(), - "change it to `replace-registry-host=npmjs` (npm's default) or remove it".to_string(), - ), - SettingSource::File { layer, path } => ( - format!("the {layer} npm config ({}) sets", path.display()), - format!( - "set `replace-registry-host=npmjs` (npm's default) in the project .npmrc (it \ - outranks the {layer} config) or change the {layer} config" - ), - ), - }; + let (where_, fix) = npm_setting_remedy("replace-registry-host", "npmjs", setting); format!( "the npm lockfile now resolves patched dependencies from the hosted patch server ({}), \ but {where_} {value}, which makes npm >=8 rewrite those \ @@ -613,44 +593,53 @@ fn npm_setting_spelling( } } -/// Warning code for a hosted npm pin that npm's -/// `omit-lockfile-registry-resolved` setting strips on the next lock save -/// (#1871). -pub const NPM_OMIT_LOCKFILE_RESOLVED_CODE: &str = "redirect_npm_omit_lockfile_registry_resolved"; - -/// npm (>= 8) `omit-lockfile-registry-resolved=true` drops `resolved` from -/// every registry dependency the next time npm saves the lock, but keeps -/// the patched `integrity`: npm then fetches the registry tarball and the -/// install fails EINTEGRITY (closed: never unpatched bytes). Like -/// `replace-registry-host`, the setting is the user's, so the warning -/// names where it is set and the remedies instead of overriding it. -pub fn npm_omit_lockfile_resolved_detail( - hosts: &[&str], +/// Where an npm `key` setting comes from (`"the project .npmrc sets"`) +/// and how to reset it to its npm default `safe` there. +fn npm_setting_remedy( + key: &str, + safe: &str, setting: &crate::patch::redirect::npmrc::EffectiveSetting, -) -> String { +) -> (String, String) { use crate::patch::redirect::npmrc::SettingSource; - let value = npm_setting_spelling("omit-lockfile-registry-resolved", setting); - let (where_, fix) = match &setting.source { + match &setting.source { SettingSource::Env(var) => ( format!("the environment variable {var} sets"), format!( "unset {var} (npm's environment layer overrides every .npmrc) or set it to \ - `false`" + `{safe}`" ), ), SettingSource::Project => ( "the project .npmrc sets".to_string(), - "change it to `omit-lockfile-registry-resolved=false` (npm's default) or remove it" - .to_string(), + format!("change it to `{key}={safe}` (npm's default) or remove it"), ), SettingSource::File { layer, path } => ( format!("the {layer} npm config ({}) sets", path.display()), format!( - "set `omit-lockfile-registry-resolved=false` in the project .npmrc (it outranks \ - the {layer} config) or change the {layer} config" + "set `{key}={safe}` (npm's default) in the project .npmrc (it outranks the \ + {layer} config) or change the {layer} config" ), ), - }; + } +} + +/// Warning code for a hosted npm pin that npm's +/// `omit-lockfile-registry-resolved` setting strips on the next lock save +/// (#1871). +pub const NPM_OMIT_LOCKFILE_RESOLVED_CODE: &str = "redirect_npm_omit_lockfile_registry_resolved"; + +/// npm (>= 8) `omit-lockfile-registry-resolved=true` drops `resolved` from +/// every registry dependency the next time npm saves the lock, but keeps +/// the patched `integrity`: npm then fetches the registry tarball and the +/// install fails EINTEGRITY (closed: never unpatched bytes). Like +/// `replace-registry-host`, the setting is the user's, so the warning +/// names where it is set and the remedies instead of overriding it. +pub fn npm_omit_lockfile_resolved_detail( + hosts: &[&str], + setting: &crate::patch::redirect::npmrc::EffectiveSetting, +) -> String { + let value = npm_setting_spelling("omit-lockfile-registry-resolved", setting); + let (where_, fix) = npm_setting_remedy("omit-lockfile-registry-resolved", "false", setting); format!( "the npm lockfile now resolves patched dependencies from the hosted patch server ({}), \ but {where_} {value}, which makes npm >=8 drop the `resolved` URL of every registry \ diff --git a/crates/socket-patch-core/src/patch/redirect/npmrc.rs b/crates/socket-patch-core/src/patch/redirect/npmrc.rs index 8b8acfbe1..5706dda48 100644 --- a/crates/socket-patch-core/src/patch/redirect/npmrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/npmrc.rs @@ -730,57 +730,50 @@ pub fn effective_omit_lockfile_registry_resolved( &outer.omit_lockfile_registry_resolved, &outer.config_env, ) - .filter(|s| npm_config_bool(&s.value)) + .filter(npm_config_bool) } -/// A Boolean npm config value as npm reads it: `ini` turns `true` / -/// `false` / `null` into JSON values, then nopt's Boolean validation -/// makes `false` and `null` false, a numeric string `!!Number(v)`, and -/// every other string true (so `False` and `no` are TRUE to npm). -pub fn npm_config_bool(value: &str) -> bool { - if value == "false" || value == "null" { - return false; +/// A Boolean npm config setting as npm reads it (`@npmcli/config` +/// `parseField`, then nopt's Boolean validation; measured on npm 10.9.4). +/// On the value as written (trimmed, before env replacement): empty is +/// TRUE (like a bare `--flag`), `true` true, `false` / `null` / +/// `undefined` false. Otherwise the env-replaced value is false when it is +/// a numeric zero (or empty) and TRUE for any other string, so `False`, +/// `no` and `undefined` reached through `${VAR}` are all true to npm. +pub fn npm_config_bool(setting: &EffectiveSetting) -> bool { + match js_trim(&setting.raw) { + "" | "true" => true, + "false" | "null" | "undefined" => false, + _ => { + !matches!(setting.value.as_str(), "false" | "null") && !js_numeric_zero(&setting.value) + } } - js_number(value).is_none_or(|n| n != 0.0) } -/// JavaScript's `Number(s)` for a string, `None` where it is `NaN`. -fn js_number(s: &str) -> Option { +/// Whether JavaScript's `Number(s)` is zero: blank, or an optionally +/// signed decimal zero (`0`, `-0.0`, `.0e5`), or an unsigned `0x0` / +/// `0o0` / `0b0`. +fn js_numeric_zero(s: &str) -> bool { let t = js_trim(s); - if t.is_empty() { - return Some(0.0); - } - let (sign, unsigned) = match t.as_bytes()[0] { - b'-' => (-1.0, &t[1..]), - b'+' => (1.0, &t[1..]), - _ => (1.0, t), - }; - if unsigned == "Infinity" { - return Some(sign * f64::INFINITY); - } - for (prefix, radix) in [ - ("0x", 16), - ("0X", 16), - ("0o", 8), - ("0O", 8), - ("0b", 2), - ("0B", 2), - ] { - if let Some(digits) = t.strip_prefix(prefix) { - // `Number` takes no sign before a radix prefix. - return u128::from_str_radix(digits, radix) - .ok() - .filter(|_| !digits.starts_with(['+', '-'])) - .map(|n| n as f64); - } - } - if !unsigned - .bytes() - .all(|b| b.is_ascii_digit() || b"eE+-.".contains(&b)) + if let Some(digits) = ["0x", "0X", "0o", "0O", "0b", "0B"] + .iter() + .find_map(|p| t.strip_prefix(p)) { - return None; + return !digits.is_empty() && digits.bytes().all(|b| b == b'0'); } - t.parse::().ok() + if t.is_empty() { + return true; + } + let t = t.strip_prefix(['+', '-']).unwrap_or(t); + let (mantissa, exponent) = t.split_once(['e', 'E']).unwrap_or((t, "0")); + let exponent = exponent.strip_prefix(['+', '-']).unwrap_or(exponent); + let (int, frac) = mantissa.split_once('.').unwrap_or((mantissa, "")); + let zeros = |d: &str| d.bytes().all(|b| b == b'0'); + !(int.is_empty() && frac.is_empty()) + && zeros(int) + && zeros(frac) + && !exponent.is_empty() + && exponent.bytes().all(|b| b.is_ascii_digit()) } /// Whether npm's `replace-registry-host=` rewrites a `resolved` URL @@ -1551,8 +1544,6 @@ mod tests { assert!(replace_registry_host_rewrites("[::1]", "[::1]")); } - /// #1233: npm env-replaces every config value (`@npmcli/config` - /// `parseField` -> `envReplace`), in every layer. A /// #1871: `omit-lockfile-registry-resolved` is read from every npm /// config layer in npm's order (env > project > user > global > /// builtin) and reported only when npm reads it as true. @@ -1642,11 +1633,21 @@ mod tests { assert_eq!(outer.replace_registry_host, OuterSetting::default()); } - /// nopt's Boolean validation after `ini`: only `false`, `null` and a - /// numeric zero are false. + /// npm's Boolean reading, as measured on npm 10.9.4 (`c.flat` after + /// `validate()`): keywords and blank on the written value, then nopt + /// on the env-replaced one. #[test] - fn npm_config_bool_matches_nopt() { + fn npm_config_bool_matches_npm() { + let bool_of = |raw: &str, value: &str| { + npm_config_bool(&EffectiveSetting { + value: value.into(), + raw: raw.into(), + source: SettingSource::Project, + }) + }; for v in [ + "", + " ", "true", "1", "-1", @@ -1656,24 +1657,48 @@ mod tests { "no", "off", "0x1", - "1e3", + "1e0", "Infinity", "-Infinity", - " 2 ", "[a, b]", "0x", - "-0x1", + "-0x0", ".", + "-", + "e5", + "0e", + "${UNSET}", ] { - assert!(npm_config_bool(v), "{v:?}"); + assert!(bool_of(v, js_trim(v)), "{v:?}"); } for v in [ - "false", "null", "0", "", " ", "-0", "0.0", "0e5", "0x0", "0b0", "0o0", "+0", + "false", + "null", + "undefined", + "0", + "-0", + "+0", + "0.0", + ".0", + "0.", + "0e5", + "0E-1", + "0x0", + "0b00", + "0o0", ] { - assert!(!npm_config_bool(v), "{v:?}"); + assert!(!bool_of(v, v), "{v:?}"); } + // Keywords count only as written: `${V}` expanding to `false` / + // `null` is false (nopt), to `undefined` true, to blank false. + assert!(!bool_of("${V}", "false")); + assert!(!bool_of("${V}", "null")); + assert!(bool_of("${V}", "undefined")); + assert!(!bool_of("${V}", "")); } + /// #1233: npm env-replaces every config value (`@npmcli/config` + /// `parseField` -> `envReplace`), in every layer. A /// `replace-registry-host=${RRH}` with `RRH=always` is `always` to npm, /// so it must read as `always` here too, wherever it is set, while the /// spelling the user wrote is kept for the warning.