Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,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). 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, and is disabled by `--no-trust-lockfile-config`. The `redirect_pnpm_trust_lockfile` warning explains manual configuration when required and clean reinstall guidance for all pnpm versions. 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). 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. 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` / `<prefix>/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: `<dir>/lib/node_modules/npm/npmrc`, `<dir>\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**: its `file:.socket/vendor/…` resolutions are npm `file` specs, which npm gates by `allow-file` (default `all`), never `allow-remote` — verified by the real npm 12 vendored matrix.

Expand Down
65 changes: 65 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2432,6 +2432,71 @@ mod tests {
}
}

/// #402: every key spelling pnpm reads as `trustLockfile` is the
/// setting — an explicit value is respected, never duplicated.
#[test]
fn plan_workspace_trust_reads_quoted_and_spaced_keys() {
for spelled in [
"packages:\n - '.'\n\"trustLockfile\": false\n",
"packages:\n - '.'\ntrustLockfile : false\n",
"packages:\n - '.'\n'trustLockfile': false # opt out\n",
] {
match plan_workspace_trust(Some(spelled)) {
TrustPlan::UserSet(value) => assert_eq!(value, "false", "{spelled:?}"),
_ => panic!("an explicit false must be respected for {spelled:?}"),
}
}
for spelled in [
"'trustLockfile': true\npackages:\n - '.'\n",
"\"trustLockfile\" : \"true\"\n",
"trustLockfile: true # set by hand\n",
] {
assert!(
matches!(plan_workspace_trust(Some(spelled)), TrustPlan::AlreadyTrue),
"already-true must be a no-op for {spelled:?}"
);
}
}

/// #400: the key goes inside the document — before a `...` marker —
/// and shapes a line append would corrupt are never appended to.
#[test]
fn plan_workspace_trust_respects_the_document_shape() {
match plan_workspace_trust(Some("packages:\n - '.'\n...\n")) {
TrustPlan::Append(text) => {
assert_eq!(text, "packages:\n - '.'\ntrustLockfile: true\n...\n")
}
_ => panic!("a `...`-terminated block mapping must plan an Append"),
}
// The refusal reason reaches the warning with both manual recoveries.
let TrustPlan::Unsupported(why) = plan_workspace_trust(Some("{packages: [.]}\n")) else {
panic!("a flow-style document must be refused");
};
let detail = socket_patch_core::hosted::guidance::pnpm_trust_workspace_unsupported_detail(
"the hosted patch server (patch.test)",
&why,
);
assert!(detail.contains("flow-style"), "{detail}");
assert!(detail.contains("left untouched"), "{detail}");
assert!(detail.contains("--trust-lockfile"), "{detail}");
assert!(detail.contains("trustLockfile: true"), "{detail}");
assert!(detail.contains("pnpm clean --lockfile"), "{detail}");
for text in [
"{packages: [.]}\n",
"--- {packages: [.]}\n",
"packages:\n - '.'\n---\ncatalog: {}\n",
"packages:\n - '.'\n...\n---\ncatalog: {}\n",
] {
assert!(
!matches!(
plan_workspace_trust(Some(text)),
TrustPlan::Append(_) | TrustPlan::Create(_)
),
"{text:?} must not be appended to"
);
}
}

/// The warning variants: the configured text says trust is in place and
/// installs need no flags; the dry-run text says WOULD; both carry the
/// whole-lock tradeoff disclosure and the don't-rebuild caution; the
Expand Down
41 changes: 41 additions & 0 deletions crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,47 @@ async fn hosted_merges_trust_key_into_existing_workspace_yaml_byte_exactly() {
);
}

/// #400 / #402: a workspace file the trust edit must not append to — an
/// explicit opt-out spelled with a quoted key, a `trustLockfile : false`
/// key, or a flow-style document — is left byte-identical (no duplicate key,
/// no block line after a flow mapping), while the lock is still redirected.
/// A `...`-terminated file gains the key inside the document.
#[tokio::test]
#[serial]
async fn hosted_trust_edit_reads_the_workspace_yaml_shape() {
let server = MockServer::start().await;
mock_discovery(&server).await;
mock_reference(&server).await;

for (user_ws, want) in [
("packages:\n - '.'\n\"trustLockfile\": false\n", None),
("packages:\n - '.'\ntrustLockfile : false\n", None),
("{packages: [.]}\n", None),
(
"packages:\n - '.'\n...\n",
Some("packages:\n - '.'\ntrustLockfile: true\n...\n"),
),
] {
let tmp = tempfile::tempdir().unwrap();
write_pnpm_project(tmp.path());
std::fs::write(tmp.path().join("pnpm-workspace.yaml"), user_ws).unwrap();

let code = run(hosted_args(tmp.path(), server.uri())).await;
assert_eq!(code, 0, "scan --mode hosted should succeed for {user_ws:?}");
assert!(
std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml"))
.unwrap()
.contains(HOSTED_URL),
"the lock is still redirected for {user_ws:?}"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap(),
want.unwrap_or(user_ws),
"workspace file for {user_ws:?}"
);
}
}

/// `--dry-run` previews: NOTHING lands on disk — no lock rewrite, no
/// pnpm-workspace.yaml, no ledger — while the envelope still reports both
/// files as would-be-rewritten (`dryRun: true`).
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-core/src/formats/pnpm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
pub(crate) mod grammar;
pub(crate) mod hosted;
pub(crate) mod lines;
pub(crate) mod workspace;

pub(crate) use grammar::{entry_field, is_pnpm_lock_text, Entry, Resolution};
pub(crate) use hosted::plan_hosted;
Expand Down
Loading
Loading