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
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