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
138 changes: 138 additions & 0 deletions crates/socket-patch-cli/tests/in_process_alternate_installers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,144 @@ async fn yarn_berry_node_modules_linker_apply_patches_file() {
assert_patched(&ms_index, &patched, &before_hash, &after_hash);
}

// ---------------------------------------------------------------------------
// npm workspace members named like build dirs (hand-built npm layout)
// ---------------------------------------------------------------------------

/// The layout `npm install` writes for the #1627 repro: the root pins
/// `ms@2.1.3` and the members `packages/app` and `packages/build` (or
/// `dist`, ...) each need `ms@2.0.0`, which npm can't hoist and installs
/// into each member's own `node_modules`. Returns the two member copies'
/// `index.js`, the patched bytes and the record hashes.
fn stage_skip_dir_member_workspace(
root: &Path,
member: &str,
) -> (PathBuf, PathBuf, Vec<u8>, String, String) {
std::fs::write(
root.join("package.json"),
r#"{"name":"root","version":"1.0.0","private":true,"workspaces":["packages/*"],"dependencies":{"ms":"2.1.3"}}"#,
)
.unwrap();
let stage_ms = |nm: PathBuf, version: &str| {
let dir = nm.join("ms");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("package.json"),
format!(r#"{{"name":"ms","version":"{version}"}}"#),
)
.unwrap();
std::fs::write(
dir.join("index.js"),
format!("module.exports = '{version}';\n"),
)
.unwrap();
dir.join("index.js")
};
stage_ms(root.join("node_modules"), "2.1.3");
let mut copies = Vec::new();
for name in ["app", member] {
let dir = root.join("packages").join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("package.json"),
format!(r#"{{"name":"{name}","version":"1.0.0","dependencies":{{"ms":"2.0.0"}}}}"#),
)
.unwrap();
copies.push(stage_ms(dir.join("node_modules"), "2.0.0"));
}
let original = std::fs::read(&copies[0]).unwrap();
let before_hash = git_sha256(&original);
let mut patched = original;
patched.extend_from_slice(b"// socket-patched\n");
let after_hash = git_sha256(&patched);
let socket = root.join(".socket");
write_manifest(&socket, "pkg:npm/ms@2.0.0", &before_hash, &after_hash);
// A vulnerability, so `vex` has a statement to make.
let manifest = std::fs::read_to_string(socket.join("manifest.json")).unwrap();
let manifest = manifest.replacen(
r#""vulnerabilities": {}"#,
r#""vulnerabilities": { "GHSA-skip-dir-member": { "cves": ["CVE-2026-1627"],
"summary": "s", "severity": "high", "description": "d" } }"#,
1,
);
std::fs::write(socket.join("manifest.json"), manifest).unwrap();
std::fs::create_dir_all(socket.join("blobs")).unwrap();
std::fs::write(socket.join("blobs").join(&after_hash), &patched).unwrap();
let member_copy = copies.pop().unwrap();
(
copies.pop().unwrap(),
member_copy,
patched,
before_hash,
after_hash,
)
}

/// #1627: agent-mode apply patches the nested copy of a declared workspace
/// member whose directory is named `build`, `dist`, `vendor`, `tmp`, `temp`
/// or `coverage`, not just the other members' copies.
#[tokio::test]
#[serial]
async fn apply_patches_workspace_member_named_like_a_build_dir() {
for member in ["build", "dist", "vendor", "tmp", "temp", "coverage"] {
let tmp = tempfile::tempdir().unwrap();
let (app_copy, member_copy, patched, before_hash, after_hash) =
stage_skip_dir_member_workspace(tmp.path(), member);

let code = apply_run(default_apply(tmp.path())).await;
assert_eq!(code, 0, "apply (member packages/{member})");
assert_patched(&app_copy, &patched, &before_hash, &after_hash);
assert_patched(&member_copy, &patched, &before_hash, &after_hash);
}
}

/// #1627: with only the other member's copy patched, `apply --check`
/// reports the `packages/build` copy as drift (exit 1), and `vex` attests
/// nothing as `not_affected` until that copy is patched too.
#[tokio::test]
#[serial]
async fn apply_check_and_vex_see_unpatched_member_named_like_a_build_dir() {
let tmp = tempfile::tempdir().unwrap();
let (app_copy, member_copy, patched, _, _) =
stage_skip_dir_member_workspace(tmp.path(), "build");
std::fs::write(&app_copy, &patched).unwrap();
assert_ne!(std::fs::read(&member_copy).unwrap(), patched);

let mut args = default_apply(tmp.path());
args.check = true;
let code = apply_run(args).await;
assert_eq!(
code, 1,
"apply --check must report the unpatched member copy"
);

let vex_path = tmp.path().join("vex.json");
let vex = || async {
let _ = std::fs::remove_file(&vex_path);
let code = socket_patch_cli::commands::vex::run(socket_patch_cli::commands::vex::VexArgs {
common: default_apply(tmp.path()).common,
output: Some(vex_path.clone()),
product: Some("pkg:npm/root@1.0.0".to_string()),
no_verify: false,
doc_id: None,
compact: false,
})
.await;
(code, std::fs::read_to_string(&vex_path).unwrap_or_default())
};
let (code, doc) = vex().await;
assert!(
!doc.contains("not_affected"),
"VEX must not attest not_affected while packages/build's copy is unpatched \
(exit {code}):\n{doc}"
);
// The control: with both copies patched, the same run attests it.
std::fs::write(&member_copy, &patched).unwrap();
let (code, doc) = vex().await;
assert_eq!(code, 0, "{doc}");
assert!(doc.contains("not_affected"), "{doc}");
}

// ---------------------------------------------------------------------------
// Rush pnpm symlink farm (hand-built, no real installer)
// ---------------------------------------------------------------------------
Expand Down
144 changes: 144 additions & 0 deletions crates/socket-patch-core/src/crawlers/npm_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,147 @@ pub(super) fn configured_install_roots(start_path: &Path) -> Vec<PathBuf> {
roots
}

/// The `node_modules` dirs of declared workspace members that the
/// workspace walk prunes by name (#1627): members whose path crosses a
/// [`SKIP_DIRS`] name (`packages/build`, `vendor/lib`). npm, yarn and Bun
/// install a member's unhoistable deps into `<member>/node_modules` from
/// the root lock, so such a copy is as live as any other member's. The
/// workspace roots are `start_path` and the project of every `walked`
/// root (`<project>/node_modules`), so a workspace nested below the scan
/// root counts too. Sorted and deduplicated.
pub(super) fn skipped_workspace_member_roots(
start_path: &Path,
walked: &[PathBuf],
) -> Vec<PathBuf> {
let mut projects = vec![start_path];
projects.extend(
walked
.iter()
.filter(|nm| nm.file_name().is_some_and(|n| n == "node_modules"))
.filter_map(|nm| nm.parent()),
);
let mut roots: Vec<PathBuf> = projects
.into_iter()
.collect::<HashSet<_>>()
.into_iter()
.flat_map(skipped_members_of)
.collect();
roots.sort();
roots.dedup();
roots
}

/// [`skipped_workspace_member_roots`] for the one workspace root `root`. A
/// member is a dir the root `package.json` `workspaces` patterns list in
/// any package manager's reading ([`WorkspacesDialect::ALL`]: an extra root
/// can only keep a copy, never drop one) that holds a `package.json`. Each
/// pattern is walked from its literal prefix to the depth its segments
/// allow (any depth for `**`, within [`WORKSPACE_GLOB_DIR_BUDGET`] dirs per
/// pattern), with the walk's own rules: real dirs only (never a symlink),
/// never a hidden dir, a `node_modules` or a tagged cache dir below the
/// root, literal prefix included. Names are
/// read from the listings, so the [`SKIP_DIRS`] test sees each dir as the
/// walk does even when a pattern spells it in another case.
fn skipped_members_of(root: &Path) -> Vec<PathBuf> {
use crate::utils::workspace_globs::{workspaces_include_as, WorkspacesDialect};

let Some(patterns) = crate::utils::fs::read_regular_to_string_sync(&root.join("package.json"))
.ok()
.and_then(|text| crate::hosted::governing_root::workspace_patterns(&text))
else {
return Vec::new();
};
let walkable = |entry: &ListedEntry| {
entry.file_type.is_some_and(|ft| ft.is_dir())
&& entry.name_str != "node_modules"
&& !entry.name_str.starts_with('.')
};
let mut roots = Vec::new();
// A leading `!` is a negation in some readings and part of an extglob
// or a literal name in others; the dirs it can reach are walked either
// way, and `workspaces_include_as` decides.
for pattern in &patterns {
let segments: Vec<&str> = pattern
.trim_start_matches('!')
.split(['/', '\\'])
.filter(|s| !s.is_empty() && *s != ".")
.collect();
let is_glob = |s: &str| s.contains(['*', '?', '[', ']', '{', '}', '(', ')', '!', '+', '@']);
let literal = segments.iter().take_while(|s| !is_glob(s)).count();
// The literal prefix, each component as its parent lists it (an
// exact name first, else the ASCII-case-insensitive match a
// case-insensitive filesystem resolves). A prefix that climbs out
// (`..`) or is not walkable names nothing here.
let mut dir = root.to_path_buf();
let mut rel: Vec<String> = Vec::new();
let mut reachable = true;
for segment in &segments[..literal] {
let entries: Vec<ListedEntry> = list_dir_sync(&dir)
.entries
.into_iter()
.filter(|e| walkable(e))
.collect();
let found = entries.iter().find(|e| e.name_str == *segment).or_else(|| {
entries
.iter()
.find(|e| e.name_str.eq_ignore_ascii_case(segment))
});
let Some(entry) = found else {
reachable = false;
break;
};
dir.push(&entry.name);
rel.push(entry.name_str.clone());
if is_tagged_cache_dir(&dir, &list_dir_sync(&dir)) {
reachable = false;
break;
}
}
if !reachable {
continue;
}
let rest = &segments[literal..];
let max_depth = if rest.contains(&"**") {
usize::MAX
} else {
rest.len()
};
let mut budget = WORKSPACE_GLOB_DIR_BUDGET;
let mut stack = vec![(dir, rel, 0usize)];
while let Some((dir, rel, depth)) = stack.pop() {
let listing = list_dir_sync(&dir);
// The workspace root itself is exempt, as the walk always
// crawls the scan root (and never reaches a nested project
// inside a tagged dir).
if !rel.is_empty() && is_tagged_cache_dir(&dir, &listing) {
continue;
Comment thread
mikolalysenko marked this conversation as resolved.
}
if rel.iter().any(|seg| SKIP_DIRS.contains(&seg.as_str()))
&& WorkspacesDialect::ALL
.iter()
.any(|&dialect| workspaces_include_as(&patterns, &rel, dialect))
&& dir.join("package.json").is_file()
&& has_node_modules_dir(&dir, &listing)
{
roots.push(dir.join("node_modules"));
}
if depth >= max_depth {
continue;
}
for entry in listing.entries.into_iter().filter(|e| walkable(e)) {
let Some(left) = budget.checked_sub(1) else {
break;
};
budget = left;
let mut child_rel = rel.clone();
child_rel.push(entry.name_str.clone());
stack.push((dir.join(&entry.name), child_rel, depth + 1));
}
}
}
roots
}

/// Whether the installed pnpm tree of the project at `project` keeps its
/// virtual store where the crawler does not look: a `.modules.yaml` in
/// `node_modules` or a pnpm modules dir
Expand Down Expand Up @@ -2892,6 +3033,9 @@ impl NpmCrawler {

// Recursively search for workspace node_modules
results.extend(Self::find_workspace_node_modules(start_path, listing));
// Plus the declared members the walk prunes by name (#1627).
let members = skipped_workspace_member_roots(start_path, &results);
results.extend(members);

merge_configured_install_roots(results, configured_install_roots(start_path))
}
Expand Down
11 changes: 7 additions & 4 deletions crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ use std::path::{Path, PathBuf};

use super::{
build_npm_purl, configured_install_roots, is_legacy_pnpm_store_dir_name, is_safe_npm_component,
merge_configured_install_roots, parse_package_name, read_package_json, NpmCrawler, StoreEntry,
Target, NESTED_STORE_MAX_DEPTH, NESTED_STORE_MAX_DIRS, SKIP_DIRS, VLT_STORE_NAME,
merge_configured_install_roots, parse_package_name, read_package_json,
skipped_workspace_member_roots, NpmCrawler, StoreEntry, Target, NESTED_STORE_MAX_DEPTH,
NESTED_STORE_MAX_DIRS, SKIP_DIRS, VLT_STORE_NAME,
};
use crate::crawlers::types::{CrawledPackage, CrawlerOptions};
use crate::utils::fs::is_dir;
Expand Down Expand Up @@ -493,9 +494,11 @@ impl LegacyNpmCrawler {

// Recursively search for workspace node_modules
Self::find_workspace_node_modules(start_path, &mut results).await;

// The package-manager-configured roots are shared with the parent
// The declared members the walk prunes by name (#1627) and the
// package-manager-configured roots are shared with the parent
// module (not part of the walk this oracle checks).
let members = skipped_workspace_member_roots(start_path, &results);
results.extend(members);
merge_configured_install_roots(results, configured_install_roots(start_path))
}

Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-core/src/hosted/governing_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1006,7 +1006,7 @@ fn vlt_workspace_patterns(vlt_json: &str) -> Option<Vec<Vec<String>>> {
/// yarn, Bun) or the object form's `packages` array (yarn classic's
/// `nohoist` shape, Bun's catalogs shape). `None` when the field is absent
/// or the manifest does not parse.
fn workspace_patterns(package_json: &str) -> Option<Vec<String>> {
pub(crate) fn workspace_patterns(package_json: &str) -> Option<Vec<String>> {
let text = strip_bom(package_json);
let doc: serde_json::Value = serde_json::from_str(text).ok()?;
let field = doc.get("workspaces")?;
Expand Down
Loading
Loading