Skip to content
Draft
1 change: 1 addition & 0 deletions crates/socket-patch-cli/src/commands/scan/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,7 @@ mod tests {
resolved: None,
integrity: LockIntegrity::None,
source_kind: SourceKind::Unspecified,
pnpm_env: false,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-core/src/formats/cargo/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,7 @@ impl CargoLock {
out.push(LockfileEntry {
ecosystem: "cargo",
source_kind,
pnpm_env: false,
purl,
name: pkg.name.clone(),
version,
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-core/src/formats/composer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ impl<'a> ComposerLock<'a> {
out.push(LockfileEntry {
ecosystem: "composer",
source_kind: SourceKind::Unspecified,
pnpm_env: false,
name,
version,
purl,
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-core/src/formats/gem/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ impl<'t> GemfileLock<'t> {
out.push(LockfileEntry {
ecosystem: "gem",
source_kind: SourceKind::Unspecified,
pnpm_env: false,
purl,
resolved,
name: name.to_string(),
Expand Down
100 changes: 88 additions & 12 deletions crates/socket-patch-core/src/formats/pnpm/grammar.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
//! The `packages:` entry grammar: read pnpm's package blocks without
//! reserializing unrelated YAML. The old shrinkwrap and lockfile 5.1/5.2
//! formats use block resolutions; newer locks use flow mappings. Peer
//! suffixes are identities, not part of the version. The hosted planner
//! ([`super::hosted`]), the lock inventory and lockfile discovery all read
//! entries through this one walk.
//! suffixes are identities, not part of the version. Every reader reads
//! entries through [`super::PnpmLock`], which wraps this walk; the writers
//! that splice byte ranges (the hosted planner, [`super::hosted`], and the
//! hosted unwind) call it directly (see `architecture_tests` in
//! `formats/pnpm/mod.rs`).

use std::ops::Range;

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).
Expand Down Expand Up @@ -88,24 +92,96 @@ pub(crate) fn is_pnpm_lock_text(text: &str) -> bool {
.any(|line| line.starts_with("lockfileVersion:") || line.starts_with("shrinkwrapVersion:"))
}

/// The main document of a pnpm lock: the text after its last column-0
/// `---` marker, else all of it. pnpm 11+ writes the env lockfile
/// (`configDependencies` / `packageManagerDependencies`, whose resolutions
/// never carry `tarball:`) as a first document ahead of the project lock.
pub(crate) fn main_document(content: &str) -> &str {
/// Which document of a pnpm lock a span belongs to ([`documents`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum PnpmDocument {
/// The project lock: what `pnpm install` links into `node_modules`.
Project,
/// pnpm 11+'s env lockfile: the `configDependencies` /
/// `packageManagerDependencies` pnpm installs into its config store
/// and its own package-manager install, not into `node_modules`. Its
/// own install tree (docs/design/lock-model-unification.md, Decisions).
Env,
}

/// The documents of a pnpm lock, in file order: the byte range of each
/// (separator lines excluded, a leading BOM inside the first) and what it
/// is. A text without a column-0 `---` line is one project document. A
/// blank document (the empty span before a leading `---`) is dropped.
///
/// pnpm >= 11 writes the env lockfile as the FIRST document, ahead of the
/// project lock, and reads it from there (as the vendored planners'
/// `lines::split_project_document` does). So the env document is told by
/// position AND content: in a lock of two or more documents, the first one
/// is [`PnpmDocument::Env`] when its `importers:` carry only
/// [`ENV_DOC_IMPORTER_FIELDS`] (at least one). Every other document is
/// [`PnpmDocument::Project`], an env-shaped later one included: no
/// document can opt out of the project tree's contests, and a crafted lock
/// cannot hide entries by adding documents, since each one is read. CRLF
/// reads like LF.
pub(crate) fn documents(content: &str) -> Vec<(Range<usize>, PnpmDocument)> {
let mut spans = Vec::new();
let mut start = 0;
let mut offset = 0;
for line in content.split_inclusive('\n') {
offset += line.len();
for (i, line) in content.split_inclusive('\n').enumerate() {
let text = line.trim_end_matches(['\r', '\n']);
let text = if i == 0 { strip_bom(text) } else { text };
if text
.strip_prefix("---")
.is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t']))
{
start = offset;
spans.push(start..offset);
start = offset + line.len();
}
offset += line.len();
}
spans.push(start..content.len());
spans.retain(|span| {
strip_bom(&content[span.clone()]).lines().any(|line| {
let line = line.trim();
!line.is_empty() && !line.starts_with('#')
})
});
if spans.is_empty() {
return vec![(0..content.len(), PnpmDocument::Project)];
}
let several = spans.len() > 1;
spans
.into_iter()
.enumerate()
.map(|(i, span)| {
let kind = if several && i == 0 && is_env_document(&content[span.clone()]) {
PnpmDocument::Env
} else {
PnpmDocument::Project
};
(span, kind)
})
.collect()
}

/// Whether one document's `importers:` carry only the env lockfile's
/// fields ([`ENV_DOC_IMPORTER_FIELDS`]), at least one of them.
fn is_env_document(doc: &str) -> bool {
let mut in_importers = false;
let mut env_fields = 0usize;
for line in strip_bom(doc).split('\n') {
let line = line.strip_suffix('\r').unwrap_or(line);
if !line.is_empty() && !line.starts_with([' ', '#']) {
in_importers = line == "importers:";
continue;
}
if !in_importers {
continue;
}
if let Some((field, _, _)) = super::lines::parse_key_line(line, 4) {
if !ENV_DOC_IMPORTER_FIELDS.contains(&field) {
return false;
}
env_fields += 1;
}
}
&content[start..]
env_fields > 0
}

/// The value of the entry-level (four-space) `field:` line of a packages
Expand Down
91 changes: 53 additions & 38 deletions crates/socket-patch-core/src/formats/pnpm/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! rewriter's pnpm leg).

use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashSet};

use serde_json::Value;

Expand Down Expand Up @@ -44,6 +44,9 @@ struct PnpmLockState<'f> {
pending: Vec<(std::ops::Range<usize>, String)>,
spliced: std::collections::HashSet<usize>,
changed: bool,
/// [`vendored_packages`] of `text`, computed on the first unmatched dep
/// and dropped when [`PnpmLockState::materialize`] replaces `text`.
vendored: Option<HashSet<(String, String)>>,
}

impl<'f> PnpmLockState<'f> {
Expand All @@ -57,6 +60,7 @@ impl<'f> PnpmLockState<'f> {
pending: Vec::new(),
spliced: Default::default(),
changed: false,
vendored: None,
};
state.reindex();
state
Expand All @@ -80,6 +84,17 @@ impl<'f> PnpmLockState<'f> {
self.sorted = sorted;
}

/// Whether the lock vendors `fname@version` ([`vendored_packages`]).
/// Read from `text` without folding `pending` in: a resolution splice
/// never touches a packages key or a `name:` / `version:` line (see
/// the type docs), so the answer is the post-splice lock's.
fn vendors(&mut self, fname: &str, version: &str) -> bool {
let text: &str = &self.text;
self.vendored
.get_or_insert_with(|| vendored_packages(text))
.contains(&(fname.to_string(), version.to_string()))
}

fn entry(&self, i: usize) -> pnpm::Entry<'_> {
let (key, body) = &self.entries[i];
pnpm::Entry {
Expand Down Expand Up @@ -147,6 +162,7 @@ impl<'f> PnpmLockState<'f> {
out.push_str(&self.text[cursor..]);
self.text = Cow::Owned(out);
self.spliced.clear();
self.vendored = None;
self.reindex();
#[cfg(debug_assertions)]
debug_assert_eq!(
Expand Down Expand Up @@ -338,44 +354,14 @@ pub(crate) fn plan_hosted(
// is named as such: `socket-patch vendor` removes the registry
// resolution this grammar looks for (v9 respells the packages key
// `<name>@file:.socket/vendor/…`; v5/v6 rekey it to a bare `file:`
// key but keep the `<name>@<version>: file:…` overrides line), so
// the generic not-locked wording would send users on a wild-goose
// `pnpm install` when the real path is a mode switch. Fail-closed
// either way: nothing is rewritten for the dep.
// key with a `name:` line), so the generic not-locked wording would
// send users on a wild-goose `pnpm install` when the real path is a
// mode switch. Fail-closed either way: nothing is rewritten for the
// dep.
if !matched_any {
let v9_vendored_key = format!("{fname}@file:");
let override_key = format!("{fname}@{}", dep.version);
// Scanned over the post-splice text, so fold pending splices in.
for lock in locks.iter_mut() {
lock.materialize();
}
let vendored = locks.iter().any(|lock| {
lock.text.lines().any(|line| {
let t = line.trim_start();
let t = t.strip_prefix('\'').unwrap_or(t);
// v9 packages/snapshots key (leading `/` in v6 spelling).
// The vendor backend always writes the RELATIVE
// `file:.socket/vendor/…` spelling here, so anchoring on
// it keeps a user's own `file:` dep of the same name
// from being misreported as vendored.
let key = t.strip_prefix('/').unwrap_or(t);
if key
.strip_prefix(&v9_vendored_key)
.is_some_and(|rest| rest.starts_with(".socket/vendor/"))
{
return true;
}
// overrides / root-dep line: `<name>@<version>: file:…`
// (pnpm <=8 absolutizes the value, so only the
// `.socket/vendor/` tail is stable enough to match).
t.strip_prefix(&override_key)
.map(|rest| rest.strip_prefix('\'').unwrap_or(rest))
.and_then(|rest| rest.strip_prefix(':'))
.is_some_and(|rest| {
rest.contains("file:") && rest.contains(".socket/vendor/")
})
})
});
let vendored = locks
.iter_mut()
.any(|lock| lock.vendors(&fname, &dep.version));
if vendored {
result.warnings.push(RewriteWarning {
code: "redirect_pnpm_entry_vendored".into(),
Expand Down Expand Up @@ -404,6 +390,35 @@ pub(crate) fn plan_hosted(
}
}

/// The `(name, version)` of every packages entry of `text` the vendor
/// backends rekeyed: v9's `<name>@file:<artifact>` key or legacy's bare
/// `file:<artifact>` key with a `name:` line, each with its `version:`
/// line, whose path is this project's vendored npm artifact
/// ([`crate::origin::vendor_ref`]: root-anchored, canonical uuid,
/// traversal-safe leaf) — the one rule lockfile discovery reads vendored
/// pnpm keys with. A user's own `file:` dep of the same name, a
/// `../.socket/vendor/…` path from another checkout, and an `overrides:`
/// line alone (pnpm mirrors it from package.json whether or not anything
/// matches) are not vendoring.
fn vendored_packages(text: &str) -> HashSet<(String, String)> {
use super::{classify_pnpm_key, PnpmKey};
let lock = super::PnpmLock::parse_keys(text);
lock.packages()
.iter()
.filter_map(|package| {
let (name, path) = match classify_pnpm_key(package.key) {
PnpmKey::V9File { name, path } => (name.trim_start_matches('/'), path),
PnpmKey::LegacyFile { path } => (pnpm::entry_field(&package.entry, "name")?, path),
PnpmKey::Registry { .. } | PnpmKey::Other => return None,
};
let version = pnpm::entry_field(&package.entry, "version")?;
crate::origin::vendor_ref(path)
.filter(|v| v.eco == "npm")
.map(|_| (name.to_string(), version.to_string()))
})
.collect()
}

/// Test-only reference for the residual gate: every instance of this exact
/// name@version in `content` that does not resolve to `artifact_url`.
/// Production judges the same predicate inline, per instance, on each
Expand Down
3 changes: 2 additions & 1 deletion crates/socket-patch-core/src/formats/pnpm/lines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ pub(crate) fn section_bounds(lines: &[String], name: &str) -> Option<(usize, usi

/// The importer dependency fields pnpm writes into the env document of a
/// two-document lock.
const ENV_DOC_IMPORTER_FIELDS: [&str; 2] = ["configDependencies", "packageManagerDependencies"];
pub(crate) const ENV_DOC_IMPORTER_FIELDS: [&str; 2] =
["configDependencies", "packageManagerDependencies"];

/// Split a lock into `(prefix, project document)`, `prefix + project ==
/// text`. pnpm >= 11 writes `pnpm-lock.yaml` as two YAML documents when the
Expand Down
Loading
Loading