Skip to content
Draft
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 @@ -272,7 +272,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc

**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 `pnpm_config_git_branch_lockfile` (pnpm 11 and newer) or `npm_config_git_branch_lockfile` (pnpm 10 and older; either when the major is unknown, read as for the `lockfile` setting above) 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.
`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, when no `pnpm-lock.yaml` sits beside them. pnpm >= 3 never reads a `shrinkwrap.yaml` it has a `pnpm-lock.yaml` for, so that one is shadowed (unless the project pins pnpm 1 or 2 in `node_modules/.modules.yaml` or package.json's `packageManager`: those read only `shrinkwrap.yaml`, so there the stray `pnpm-lock.yaml` is the shadowed one): hosted mode never rewrites it and leaves it out of the all-or-nothing gate, so an entry it cannot repoint (or pnpm 1's early shrinkwrap head) refuses nothing, and `vex` only sweeps it for stale Socket identities. Vendored mode never writes it either, and refuses a project that pins pnpm 1 or 2 beside a `shrinkwrap.yaml` (`vendor_lockfile_version_unsupported`: it does not write the shrinkwrap grammar, and the stray `pnpm-lock.yaml` would patch nothing pnpm installs).

**vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`, or `redirect_vlt_custom_registry_skipped` alone). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. `rollback` / `remove` restore each hosted node's slots [2] and [3] from the version document of the registry the node resolves against (slot [3] is its `dist.tarball`, as vlt writes it; `upstream_registry_fallback` when that registry can't be read), following the lock's own slot-[3] convention (see "Hosted unwind coverage"). Tested releases: `docs/testing/vlt-compatibility.md`.

Expand Down
59 changes: 47 additions & 12 deletions crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Hermetic hosted → vendored takeover tests through the built binary for
//! pnpm projects whose shape the pnpm vendored backend refuses although
//! hosted mode accepts it (#853): a `catalog:` dependency
//! (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml`
//! (`vendor_lock_entry_unsupported`), a `pnpm-lock.yaml` mixing CRLF and LF
//! (`vendor_lockfile_crlf_unsupported`) and a conflicting user override
//! (a range) in `pnpm-workspace.yaml` (`vendor_override_conflict`).
//!
Expand Down Expand Up @@ -54,6 +54,8 @@ enum Shape {
Catalog,
/// A plain dependency whose lock is converted to CRLF after hosting.
Crlf,
/// A plain dependency whose lock mixes CRLF and LF after hosting.
MixedEol,
/// A plain dependency plus a conflicting user
/// `overrides: { left-pad: ^1.3.0 }` in pnpm-workspace.yaml.
Override,
Expand Down Expand Up @@ -90,7 +92,7 @@ fn write_pnpm_project(root: &Path, shape: Shape) {
Shape::Catalog => format!("packages:\n - .\ncatalog:\n {NAME}: {VERSION}\n"),
Shape::Override => format!("overrides:\n {NAME}: ^{VERSION}\n"),
Shape::ExactPin => format!("overrides:\n {NAME}: {VERSION}\n"),
Shape::Crlf | Shape::Plain | Shape::TwoDocument => String::new(),
Shape::Crlf | Shape::MixedEol | Shape::Plain | Shape::TwoDocument => String::new(),
};
if !workspace.is_empty() {
std::fs::write(root.join("pnpm-workspace.yaml"), workspace).unwrap();
Expand All @@ -117,7 +119,7 @@ fn write_pnpm_project(root: &Path, shape: Shape) {
)),
Shape::Override => lock.push_str(&format!("overrides:\n {NAME}: ^{VERSION}\n\n")),
Shape::ExactPin => lock.push_str(&format!("overrides:\n {NAME}: {VERSION}\n\n")),
Shape::Crlf | Shape::Plain | Shape::TwoDocument => {}
Shape::Crlf | Shape::MixedEol | Shape::Plain | Shape::TwoDocument => {}
}
let specifier = match shape {
Shape::Catalog => "'catalog:'".to_string(),
Expand Down Expand Up @@ -305,7 +307,8 @@ fn snapshot(root: &Path) -> Vec<(&'static str, Option<Vec<u8>>)> {

/// The hosted project: pristine lock, then a real `scan --mode hosted`
/// (and, for [`Shape::Crlf`], the lock converted to CRLF as a
/// `core.autocrlf` checkout would leave it). Returns the hosted wiring.
/// `core.autocrlf` checkout would leave it; for [`Shape::MixedEol`], its
/// first line only). Returns the hosted wiring.
fn host_project(root: &Path, api: &str, shape: Shape) -> Vec<(&'static str, Option<Vec<u8>>)> {
write_pnpm_project(root, shape);
let (code, env) = run_mode(root, api, "scan", "hosted", &[]);
Expand All @@ -316,8 +319,10 @@ fn host_project(root: &Path, api: &str, shape: Shape) -> Vec<(&'static str, Opti
lock.contains(HOSTED_URL),
"hosted mode must pin the {shape:?} lock entry:\n{lock}\n{env:#}"
);
if matches!(shape, Shape::Crlf) {
std::fs::write(&lock_path, lock.replace('\n', "\r\n")).unwrap();
match shape {
Shape::Crlf => std::fs::write(&lock_path, lock.replace('\n', "\r\n")).unwrap(),
Shape::MixedEol => std::fs::write(&lock_path, lock.replacen('\n', "\r\n", 1)).unwrap(),
_ => {}
}
snapshot(root)
}
Expand Down Expand Up @@ -402,17 +407,21 @@ async fn get_vendored_over_hosted_pnpm_catalog_dep_keeps_the_hosted_pin() {
refused_takeover_keeps_hosted_pin(Shape::Catalog, "get", "vendor_lock_entry_unsupported").await;
}

/// #853: a CRLF `pnpm-lock.yaml` (a `core.autocrlf` checkout).
/// #853: a `pnpm-lock.yaml` mixing CRLF and LF. (A CRLF lock, a
/// `core.autocrlf` checkout, takes over since lock-model 2b: see
/// [`scan_vendored_over_hosted_pnpm_crlf_lock_takes_over_and_stays_crlf`].)
#[tokio::test(flavor = "multi_thread")]
async fn scan_vendored_over_hosted_pnpm_crlf_lock_keeps_the_hosted_pin() {
refused_takeover_keeps_hosted_pin(Shape::Crlf, "scan", "vendor_lockfile_crlf_unsupported")
async fn scan_vendored_over_hosted_pnpm_mixed_eol_lock_keeps_the_hosted_pin() {
refused_takeover_keeps_hosted_pin(Shape::MixedEol, "scan", "vendor_lockfile_crlf_unsupported")
.await;
}

/// #853: `get --mode vendored` over a CRLF lock (a project-level refusal).
/// #853: `get --mode vendored` over a mixed-ending lock (a project-level
/// refusal).
#[tokio::test(flavor = "multi_thread")]
async fn get_vendored_over_hosted_pnpm_crlf_lock_keeps_the_hosted_pin() {
refused_takeover_keeps_hosted_pin(Shape::Crlf, "get", "vendor_lockfile_crlf_unsupported").await;
async fn get_vendored_over_hosted_pnpm_mixed_eol_lock_keeps_the_hosted_pin() {
refused_takeover_keeps_hosted_pin(Shape::MixedEol, "get", "vendor_lockfile_crlf_unsupported")
.await;
}

/// #853: a conflicting user override (a range) in `pnpm-workspace.yaml`.
Expand Down Expand Up @@ -522,6 +531,32 @@ async fn scan_vendored_over_hosted_pnpm_plain_dep_still_takes_over() {
);
}

/// Lock-model 2b (divergence N15): a CRLF `pnpm-lock.yaml` (a
/// `core.autocrlf` checkout) takes over like its LF twin, and the vendored
/// lock keeps its CRLF endings. It used to be refused
/// (`vendor_lockfile_crlf_unsupported`) and keep the hosted pin.
#[tokio::test(flavor = "multi_thread")]
async fn scan_vendored_over_hosted_pnpm_crlf_lock_takes_over_and_stays_crlf() {
let server = MockServer::start().await;
mock_api(&server).await;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
host_project(root, &server.uri(), Shape::Crlf);

let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]);
assert_eq!(exit, 0, "the CRLF takeover must succeed: {env:#}");
let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap();
assert!(!lock.contains(HOSTED_URL), "{lock}");
assert!(
lock.contains(&format!(".socket/vendor/npm/{UUID}/")),
"the lock must point at the vendored artifact:\n{lock}"
);
assert!(
!lock.replace("\r\n", "").contains('\n'),
"the lock keeps its CRLF endings: {lock:?}"
);
}

/// #854: a user exact pin equal to the vendored version in
/// `pnpm-workspace.yaml` (the pnpm 10.5+/11/12 override map) is taken over
/// like the same pin in package.json: the workspace value becomes the
Expand Down
32 changes: 0 additions & 32 deletions crates/socket-patch-core/src/formats/pnpm/grammar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,6 @@ use crate::formats::text::strip_bom;

use super::lines::ENV_DOC_IMPORTER_FIELDS;

/// Early pnpm 1 writes shrinkwrapVersion 3 without a minor version and
/// unconditionally drops registry tarball URLs on install. Its frozen flag
/// cannot preserve this redirect (verified with pnpm 1.0.0).
pub(crate) fn unsupported_early_shrinkwrap(content: &str) -> bool {
let content = strip_bom(content);
let version = content
.lines()
.find_map(|line| line.strip_prefix("shrinkwrapVersion:"));
let minor = content
.lines()
.find_map(|line| line.strip_prefix("shrinkwrapMinorVersion:"));
version.is_some_and(|v| v.trim().trim_matches(['\'', '"']) == "3")
&& !minor.is_some_and(|v| v.trim().parse::<u32>().is_ok_and(|v| v > 0))
}

pub(crate) struct Entry<'a> {
pub key: &'a str,
pub body: &'a str,
Expand Down Expand Up @@ -270,23 +255,6 @@ pub(crate) fn suffix<'a>(key: &'a str, name: &str, version: &str) -> Option<&'a
Some(suffix)
}

pub(crate) fn supported_suffix(suffix: &str) -> bool {
if suffix.is_empty() || suffix.starts_with('_') {
return true;
}
let mut depth = 0usize;
for c in suffix.chars() {
match c {
'(' => depth += 1,
')' if depth > 0 => depth -= 1,
')' => return false,
_ if depth == 0 => return false,
_ => {}
}
}
depth == 0
}

pub(crate) struct Resolution<'a> {
pub range: Range<usize>,
pub fields: Vec<(&'a str, &'a str)>,
Expand Down
Loading
Loading