Skip to content
Merged
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
4 changes: 3 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc

**pnpm per-member locks (#492)**: a workspace whose root `pnpm-workspace.yaml` sets `sharedWorkspaceLockfile: false` (or, when the YAML leaves the key unset, whose root `.npmrc` sets `shared-workspace-lockfile=false`; on disk, when neither file sets it, `pnpm_config_shared_workspace_lockfile` / `npm_config_shared_workspace_lockfile`; booleans in any letter case, as pnpm's YAML reader takes them) installs each member from the member's own `pnpm-lock.yaml`. Run from the workspace root, hosted mode reads every member lock the `packages:` globs name (quoted `!` negations applied; `node_modules`, `bower_components`, `test` and `tests` trees and symlinked directories skipped) beside the root lock, which may be absent (pnpm 7): each is rewritten like the root lock, a 9.0 member lock engages the `trustLockfile: true` auto-config in the ROOT `pnpm-workspace.yaml` (heal-on-rerun included), and discovery (`list`, `vex`, `rollback`, `remove`) reads the same member locks. A root lock whose `importers:` lists a member is a shared lock, so member locks beside it are stale and never read or attested. When the members' locks cannot be listed or read (no `packages:` key, whose pnpm <= 8 default finds projects in every directory; a `packages:` value that is not a plain list of globs; no member lock and no root lock, or a member lock that exists but is not readable text), every pnpm lock (the root's included) is left untouched and the run warns `redirect_pnpm_member_locks_unresolved` instead of `redirect_pnpm_no_lockfile`; no pnpm pin is confirmed. The lockfile inventory reads the same member locks, so their dependencies are discovered without an installed tree. The in-memory engine gives the same result as a disk run from the workspace root: a directory whose `pnpm-lock.yaml` has no `pnpm-workspace.yaml` beside it is a workspace member, never a root of its own, when (with `npm` among the `ecosystems`) the nearest ancestor `pnpm-workspace.yaml` is readable and its `packages:` globs (read as above, no braces, classes or extglobs) list it, it holds a `package.json` / `package.yaml` / `package.json5`, the workspace root installs it from its own lock (`sharedWorkspaceLockfile: false` with the member list resolved) or from a root lock beside which its lock is a stale leftover, and socket.yml admits the workspace root. Its lock is then demoted into that workspace root, which becomes a root even with no lock of its own (pnpm 7), the way a Cargo member's lock is demoted; the member stays a root only when it holds another lock marker, without its pnpm lock. The workspace root then pins (or refuses) its member locks as above, trusts them in its own file, refuses a member's branch lock (#556) and leaves a member lock beside a shared root lock unread. A directory that fails any of those tests keeps its lock as a root of its own (pinned like any root, or refused with `redirect_pnpm_settings_elsewhere` while the trust auto-config is on and the ancestor file may list it). Path selection adds the workspace root above every candidate to `roots`, fetches it, and fetches the member locks, branch locks and manifests below it in every directory holding a package manifest; given those `roots` as `projectRoots`, the session drops a confirmed member named beside its workspace root (unless it holds another lock) and a lockless workspace root that pins no member, so it runs the roots it would detect. A confirmed member named in `projectRoots` without its workspace root is not pinned: its lock is dropped and the run-level `pnpm_member_lock_ignored` warning names the workspace root to scan.

**pnpm with its lockfile off (#1074)**: pnpm's `lockfile` setting off makes pnpm neither read nor write `pnpm-lock.yaml` (nor a member's own lock): it resolves every dependency from the registry, so a hosted pin in a committed lock never installs. Each pnpm major reads the setting from one file: pnpm 11 and newer a top-level `lockfile: false` in the project's `pnpm-workspace.yaml`, pnpm 10 and older `lockfile=false` in its `.npmrc`; when `lockfile` is unset (or not a boolean) pnpm falls back to npm's `package-lock` (`packageLock: false` / `package-lock=false`), which counts the same way. The major is the one `node_modules/.modules.yaml` records, else package.json's `packageManager` pin, else what the root lock's grammar implies (a pre-9 lock or `shrinkwrap.yaml`: pnpm 8 or older; an env lockfile document: pnpm 11 or newer); when none says, either file turning it off counts. Hosted mode also reads `npm_config_lockfile` / `pnpm_config_lockfile` from its environment. Only the project's own files are read (a workspace member with no settings file of its own does not inherit its root's). In a pnpm project (a root pnpm lock or a `pnpm-workspace.yaml`), hosted mode then leaves every pnpm lock and `pnpm-workspace.yaml` untouched (no `trustLockfile` key is added), confirms no npm pin from them and warns `redirect_pnpm_lockfile_disabled` (in place of `redirect_pnpm_no_lockfile`), exiting 0 like the per-branch lock refusal below; the detail names the setting, and the remedy is `--mode vendored` when a pnpm lock exists (pnpm applies the `overrides` it wires without a lock), else removing the setting and running `pnpm install`. The check comes before the per-branch lock check. Manifest-less `vex` keeps the hosted references of such a lock but withholds their lockfile basis (see the pnpm row of the discovery table). Rush locks are not affected: Rush runs pnpm with its own settings. Vendored mode is unchanged.

**pnpm per-branch locks (#556)**: with `gitBranchLockfile` on (a top-level `gitBranchLockfile: true` in the root `pnpm-workspace.yaml`; when the YAML leaves the key unset, `git-branch-lockfile=true` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `npm_config_git_branch_lockfile` / `pnpm_config_git_branch_lockfile` set to `true`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm installs a git branch from its own `pnpm-lock.<branch>.yaml` whenever that lock exists, in every directory it installs a lock from: the root, and under `sharedWorkspaceLockfile: false` each member's (#492). When the setting is on AND at least one such `pnpm-lock.<branch>.yaml` exists (at the root, or in a member that installs from its own lock), hosted mode leaves every root pnpm lock (`pnpm-lock.yaml`, `shrinkwrap.yaml`) and `pnpm-workspace.yaml` untouched (every member lock too), confirms no npm pin from them and warns `redirect_pnpm_git_branch_lockfile` (in place of `redirect_pnpm_no_lockfile` when the branch lock is the only one); the detail names the setting and the branch lock(s), and the remedy is to turn `gitBranchLockfile` off and fold the branch locks into `pnpm-lock.yaml` with `pnpm install --merge-git-branch-lockfiles`, then re-run. A vendored→hosted takeover of a pnpm-vendored purl is refused with the same code (the vendored wiring and ledger stay byte-for-byte). Vendored mode refuses the same projects with `vendor_pnpm_git_branch_lockfile`, before any write and in the pre-flight. The setting with no branch lock (main, or a branch whose dependencies never changed) installs from `pnpm-lock.yaml`, which both modes wire as usual; a stray branch lock with the setting off is ignored. The in-memory engine fetches root `pnpm-lock.*.yaml` names presence-only and refuses the same way.

`redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers.
Expand Down Expand Up @@ -417,7 +419,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma
| Ecosystem | Files read | Hosted reference | Vendored reference | Hosted pin (`integrity_required`) |
|---|---|---|---|---|
| npm | `package-lock.json` and `npm-shrinkwrap.json` (both when both exist) | `resolved` on the patch host (`packages` in v2/v3; `dependencies` only in v1; `link` / `inBundle` / `bundled` entries skipped, and so is any entry npm installs from a git, URL or `file:` spec or from a dependency's own `npm-shrinkwrap.json` (beneath a `hasShrinkwrap: true` package, which npm 7–11 install from that shrinkwrap; `redirect_npm_shrinkwrapped_instance_skipped` / `vendor_shrinkwrapped_instance_skipped`), together with every ref for the same `name@version`) | `resolved: file:.socket/vendor/npm/<uuid>/<name>-<ver>.tgz` | `integrity`, required |
| pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required |
| pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required. With pnpm's `lockfile` setting off (#1074, outside Rush) pnpm reads no lock: every hosted reference in the root and member locks keeps no pin, and one `patched_ref_unattributable` per lock names them; vendored references are unaffected. |
| yarn | `yarn.lock` (classic and berry) | classic `resolved`; berry entry keyed and resolved `<name>@<url>` + root `package.json` `resolutions` `"<name>@npm:<range>": "<url>"` (older releases: `resolution: …::__archiveUrl=<url>`) | classic `resolved "file:./.socket/vendor/npm/…#<sha1>"`; berry `file:` entry **plus** a root `package.json` `resolutions` mapping onto the same artifact (without it the entry is orphaned: diagnosed, no ref) | classic `integrity` / `#sha1`, berry `checksum`, required |
| bun | `bun.lock`; `bun.lockb` only when there is no `bun.lock` (bun reads exactly one) | URL tuple / binary remote-tarball resolution; version from the URL leaf. A user URL or `file:` tarball tuple / record whose `<name>-<ver>.tgz` leaf names the same `name@version` is installed from its own spec, so every ref for that `name@version` is dropped | `.socket/vendor/npm/<uuid>/<name>-<ver>.tgz` tuple / local-tarball resolution | `sha512-…`, required. A 2-tuple that Bun < 1.3.10 re-saved without its digest is still a reference, but it attests only from an installed tree. |
| vlt | `vlt-lock.json` (lockfileVersion absent, `0` or `1`; a BOM-prefixed, unparseable, non-object or other-version lock is not read: diagnosed, no ref). `vlt.json` (read only for its `modifiers`) and `node_modules/.vlt-lock.json` are never wiring. | a registry node (any segment) whose slot [3] is a `/patch/npm/…` URL on the patch host with the leaf `<bare>-<ver>.tgz` of its DepID's `name@version`, whose embedded `<name>/<ver>` path (when present) is that `name@version`, and slot [1] == name; version from the DepID | a `file` node `.socket/vendor/npm/<uuid>/<name>-<ver>/node_modules/<name>` (or a user-installed `<name>-<ver>.tgz`) with slot [1] == name; version from the path. A same-`name@version` registry node, or a diagnosed Socket-shaped one, beside it is diagnosed, no ref. | slot [2] `sha512-…`, required (a hosted node without one is no reference). A same-`name@version` node on another registry, or a diagnosed Socket-shaped one, keeps the reference but withholds the lockfile basis: only an installed tree whose every store copy verifies attests. So does a lock some vlt release discards, the conditions of `redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored` and `redirect_vlt_scalar_registry_ignored` (which in-run `--vex` withholds too): every hosted reference in it keeps no pin, and one `patched_ref_unattributable` names them. |
Expand Down
89 changes: 89 additions & 0 deletions crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,95 @@ async fn pnpm11_zero_touch_frozen_install_lands_patched_bytes_via_auto_trust_con
assert_fixture_manifestless_vex(&fx, &fresh, "pnpm11 zero-touch");
}

/// #1074: with pnpm's `lockfile` setting off, pnpm neither reads nor
/// writes `pnpm-lock.yaml` and installs the upstream package from the
/// registry, so a hosted pin in the committed lock patches nothing. Over a
/// lock the real pnpm 11 scan pinned, once `lockfile: false` lands in
/// `pnpm-workspace.yaml`:
///
/// * a re-run of `scan --mode hosted` refuses with
/// `redirect_pnpm_lockfile_disabled` and leaves the lock and the
/// workspace file byte-for-byte;
/// * lock-only `vex` (no node_modules, no manifest, no ledgers: the CI/SBOM
/// case) no longer attests the patch from the lock pin.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
#[ignore = "wall-bound real-pnpm install (~60s); runs on all 3 OSes as an e2e CI matrix leg"]
async fn pnpm11_disabled_lockfile_is_neither_pinned_nor_attested() {
let Some(fx) = redirect_scanned_pnpm_project(
PNPM_TERTIARY,
"pnpm11-lockfile-off",
false,
false,
HostedDriver::Scan,
)
.await
else {
return;
};
let ws_path = fx.proj.join("pnpm-workspace.yaml");
let mut workspace = std::fs::read_to_string(&ws_path).unwrap_or_default();
if !workspace.is_empty() && !workspace.ends_with('\n') {
workspace.push('\n');
}
workspace.push_str("lockfile: false\n");
std::fs::write(&ws_path, &workspace).unwrap();
let lock_path = fx.proj.join(&fx.lock_name);
let lock_wired = std::fs::read_to_string(&lock_path).unwrap();
assert_ne!(
lock_wired, fx.lock_before,
"the fixture scan pinned the lock"
);

let (code, stdout, stderr) = run_hosted(HostedDriver::Scan, &fx.proj, &fx._server.uri(), &[]);
assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}");
let env = parse_envelope(&stdout);
assert_eq!(env["summary"]["applied"], 0, "{env}");
let codes = warning_codes(&env);
assert!(
codes.contains(&"redirect_pnpm_lockfile_disabled".to_string())
&& !codes.contains(&"redirect_pnpm_trust_lockfile".to_string()),
"{codes:?}\n{env}"
);
assert_eq!(std::fs::read_to_string(&lock_path).unwrap(), lock_wired);
assert_eq!(std::fs::read_to_string(&ws_path).unwrap(), workspace);

// A lock-only checkout: package.json, the pinned lock and the workspace
// file pnpm reads the setting from, nothing installed.
let fresh = fx.tmp.path().join("fresh-lockfile-off");
std::fs::create_dir_all(&fresh).unwrap();
for file in ["package.json", fx.lock_name.as_str(), "pnpm-workspace.yaml"] {
std::fs::copy(fx.proj.join(file), fresh.join(file)).unwrap();
}
let patch_server = fx._server.uri();
off_runtime(|| {
let api = hosted_patch_api(PURL, &fx.patched);
let out = run_vex(
&socket_bin(),
&fresh,
&VexRun {
patch_server_url: Some(patch_server.clone()),
..VexRun::online(&api)
},
);
assert_ne!(out.code, Some(0), "lock-only vex must not attest: {out}");
assert_absent(out.doc.as_ref(), PURL);

// Control: the same checkout with the setting removed attests.
std::fs::remove_file(fresh.join("pnpm-workspace.yaml")).unwrap();
let out = run_vex(
&socket_bin(),
&fresh,
&VexRun {
patch_server_url: Some(patch_server.clone()),
..VexRun::online(&api)
},
);
assert_eq!(out.code, Some(0), "control: {out}");
assert_attested(out.doc(), PURL, UUID, Marker::Redirected, HOSTED_VULNS);
});
}

/// `--no-trust-lockfile-config` control: pins the opt-out (the scan writes
/// no pnpm-workspace.yaml — asserted inside the fixture helper) AND the old
/// behavior it restores. Without the trust config the PLAIN frozen install
Expand Down
Loading
Loading