diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 6506b62ad..28346a42f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -210,7 +210,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Path-scoped scans (`scan [PATHS]...`, v5.0)**: what a PATH means depends on the mode. * **Hosted and vendored mode (bare `scan` included) — project directories** (`run_project_dirs`). Each PATH is a directory, or a glob (`*?[`) matching directories, relative to `--cwd`; the set is sorted and deduplicated, and each directory is scanned on its own exactly as if it were `--cwd` (its own lockfiles, ledgers and `.socket/`). With more than one directory, each run is headed `==
): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one).
diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs
index c4eb19534..6a480ff54 100644
--- a/crates/socket-patch-cli/src/commands/rollback.rs
+++ b/crates/socket-patch-cli/src/commands/rollback.rs
@@ -1591,10 +1591,11 @@ pub async fn run(args: RollbackArgs) -> i32 {
global: args.common.global,
global_prefix: args.common.global_prefix.clone(),
};
- let discovered = find_all_packages_for_rollback(
+ let discovered = crate::ecosystem_dispatch::find_package_paths_for_path_scope(
&partitioned,
&crawler_options,
args.common.silent || args.common.json,
+ None,
)
.await;
// One single-pattern scope per raw pattern, compiled once (not per
diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs
index 019c1b29c..f49734e66 100644
--- a/crates/socket-patch-cli/src/commands/scan/mod.rs
+++ b/crates/socket-patch-cli/src/commands/scan/mod.rs
@@ -33,8 +33,7 @@ use std::path::{Path, PathBuf};
use crate::args::{apply_env_toggles, GlobalArgs};
use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs};
use crate::ecosystem_dispatch::{
- crawl_ecosystems, crawl_ecosystems_with_npm, find_all_packages_for_rollback_reusing,
- partition_purls,
+ crawl_ecosystems, crawl_ecosystems_with_npm, find_package_paths_for_path_scope, partition_purls,
};
use crate::json_envelope::{
usage_error, Command as JsonCommand, Envelope, EnvelopeError, PatchAction, PatchEvent,
@@ -2181,7 +2180,7 @@ async fn run_scan(
unresolved.dedup();
if !unresolved.is_empty() {
let partitioned = partition_purls(&unresolved, args.common.ecosystems.as_deref());
- let copies = find_all_packages_for_rollback_reusing(
+ let copies = find_package_paths_for_path_scope(
&partitioned,
&crawler_options,
true,
diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs
index 7279a14b5..d516c0341 100644
--- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs
+++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs
@@ -4,12 +4,12 @@ use socket_patch_core::crawlers::{
use socket_patch_core::utils::purl::strip_purl_qualifiers;
use socket_patch_core::utils::purl_key::PurlKey;
use std::collections::{HashMap, HashSet};
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use crate::args::GlobalArgs;
use socket_patch_core::crawlers::npm_crawler::{
- retain_live_store_copies, with_store_peer_variant_copies,
+ build_npm_purl, parse_package_name, retain_live_store_copies, with_store_peer_variant_copies,
};
use socket_patch_core::crawlers::walk_pool;
use socket_patch_core::crawlers::CargoCrawler;
@@ -634,9 +634,7 @@ pub async fn find_all_packages_for_rollback(
}
/// [`find_all_packages_for_rollback`], taking the npm `node_modules` roots
-/// from `prior` as [`find_packages_for_rollback_reusing`] does. Used by
-/// `scan`'s path scoping, which must see the same copies `rollback`'s path
-/// targets do.
+/// from `prior` as [`find_packages_for_rollback_reusing`] does.
pub async fn find_all_packages_for_rollback_reusing(
partitioned: &HashMap>,
options: &CrawlerOptions,
@@ -649,6 +647,131 @@ pub async fn find_all_packages_for_rollback_reusing(
dispatch_find(partitioned, options, silent, merge_qualified, npm_roots).await
}
+/// The install paths that reach each purl, for `scan`'s and `rollback`'s
+/// path targets: [`find_all_packages_for_rollback_reusing`]'s physical
+/// copies, plus each npm package link directly in a `node_modules` root
+/// (scoped ones one level down) whose target's `package.json` names one
+/// of the npm purls asked for.
+///
+/// The crawler records a plain-name link at its own path
+/// (`packages/a/node_modules/ms`), but never an alias link
+/// (`packages/a/node_modules/lp -> …/.pnpm/left-pad@1.3.0/node_modules/left-pad`):
+/// links are not physical copies, and apply and VEX must see each copy
+/// once (#856). Path scoping asks which paths install the package
+/// instead, and pnpm's isolated linker or npm's `install-strategy=linked`
+/// reach a workspace member's ALIAS dependency only through such a link
+/// (#1266). A link is matched by its target's own identity, not against
+/// the crawled copies, so a link to a store peer variant the crawl
+/// skipped still counts, and the cost is one pass over the root links
+/// however many purls are asked for. The result is for path matching
+/// only: it can name one physical copy more than once.
+pub async fn find_package_paths_for_path_scope(
+ partitioned: &HashMap>,
+ options: &CrawlerOptions,
+ silent: bool,
+ prior: Option<&NpmCrawlSnapshot>,
+) -> HashMap> {
+ let mut wanted: HashMap> = HashMap::new();
+ for purl in partitioned.get(&Ecosystem::Npm).into_iter().flatten() {
+ wanted
+ .entry(PurlKey::new(purl))
+ .or_default()
+ .push(purl.clone());
+ }
+ let npm_roots = match prior.and_then(|p| p.roots_for(options)) {
+ _ if wanted.is_empty() => None,
+ Some(roots) => Some(roots.to_vec()),
+ None => NpmCrawler.get_node_modules_paths(options).await.ok(),
+ };
+ let mut found = dispatch_find(
+ partitioned,
+ options,
+ silent,
+ merge_qualified,
+ npm_roots.as_deref(),
+ )
+ .await;
+ let Some(roots) = npm_roots else {
+ return found;
+ };
+ // A failed enumeration only loses the extra links; the copies stand.
+ let links = tokio::task::spawn_blocking(move || npm_links_to(&wanted, &roots))
+ .await
+ .unwrap_or_default();
+ for (purl, link) in links {
+ let paths = found.entry(purl).or_default();
+ if !paths.contains(&link) {
+ paths.push(link);
+ }
+ }
+ found
+}
+
+/// Every package link directly in one of `roots` (scoped ones one level
+/// down) whose target directory's `package.json` `name@version` is a key
+/// of `wanted`, paired with each purl spelling that key stands for.
+fn npm_links_to(
+ wanted: &HashMap>,
+ roots: &[PathBuf],
+) -> Vec<(String, PathBuf)> {
+ let entries = |dir: &Path| -> Vec<(String, PathBuf, std::fs::FileType)> {
+ let Ok(read) = std::fs::read_dir(dir) else {
+ return Vec::new();
+ };
+ read.filter_map(Result::ok)
+ .filter_map(|e| {
+ let name = e.file_name().to_string_lossy().into_owned();
+ let file_type = e.file_type().ok()?;
+ (!name.starts_with('.')).then(|| (name, e.path(), file_type))
+ })
+ .collect()
+ };
+ let identity = |link: &Path| -> Option {
+ let target = std::fs::canonicalize(link).ok()?;
+ let text =
+ socket_patch_core::utils::fs::read_regular_to_string_sync(&target.join("package.json"))
+ .ok()?;
+ let pkg: serde_json::Value =
+ serde_json::from_str(socket_patch_core::formats::text::strip_bom(&text)).ok()?;
+ let name = pkg.get("name")?.as_str()?;
+ let version = pkg.get("version")?.as_str()?;
+ if name.is_empty() || version.is_empty() {
+ return None;
+ }
+ let (namespace, leaf) = parse_package_name(name);
+ Some(PurlKey::new(&build_npm_purl(
+ namespace.as_deref(),
+ &leaf,
+ version,
+ )))
+ };
+ let mut out = Vec::new();
+ for root in roots {
+ let mut links = Vec::new();
+ for (name, path, file_type) in entries(root) {
+ if name.starts_with('@') && file_type.is_dir() {
+ links.extend(
+ entries(&path)
+ .into_iter()
+ .filter(|(_, _, ft)| ft.is_symlink())
+ .map(|(_, p, _)| p),
+ );
+ } else if file_type.is_symlink() {
+ links.push(path);
+ }
+ }
+ for link in links {
+ let Some(key) = identity(&link) else {
+ continue;
+ };
+ for purl in wanted.get(&key).into_iter().flatten() {
+ out.push((purl.clone(), link.clone()));
+ }
+ }
+ }
+ out
+}
+
/// Qualified-aware PURL resolution for rollback, vendor, repair and
/// narrow-release lookups: remaps qualified PURLs (PyPI `?artifact_id=`,
/// RubyGems `?platform=`, Maven `?classifier=&ext=`) to the base PURL the
diff --git a/crates/socket-patch-cli/tests/rollback/rollback_duality_invariants.rs b/crates/socket-patch-cli/tests/rollback/rollback_duality_invariants.rs
index f58b0c3c3..875d29ae8 100644
--- a/crates/socket-patch-cli/tests/rollback/rollback_duality_invariants.rs
+++ b/crates/socket-patch-cli/tests/rollback/rollback_duality_invariants.rs
@@ -905,3 +905,97 @@ fn uuid_and_purl_targets_still_work() {
assert!(m["patches"].get("pkg:npm/dual-b@1.0.0").is_none(), "{m}");
assert!(m["patches"].get("pkg:npm/dual-a@1.0.0").is_some(), "{m}");
}
+
+// ---------------------------------------------------------------------------
+// #1266: a member's alias link into the root store is in the member's scope
+// ---------------------------------------------------------------------------
+
+/// pnpm's isolated linker (and npm `install-strategy=linked`) put a
+/// member's alias dependency (`"lp": "npm:path-alias-pkg@1.0.0"`) at
+/// `packages/a/node_modules/lp`, a symlink into the root store. That link is
+/// what member a loads, so `rollback packages/a` must restore the package,
+/// exactly as it does for a plain-name link.
+#[cfg(unix)]
+#[test]
+fn path_scoped_rollback_selects_member_alias_link() {
+ // pnpm's `.pnpm` store, then npm's linked `.store` (npm 10 names an
+ // alias's entry and dir after the alias).
+ for (store_entry, dir) in [
+ (".pnpm/path-alias-pkg@1.0.0", "path-alias-pkg"),
+ (".store/lp@1.0.0-0a1b2c3d", "lp"),
+ ] {
+ assert_member_alias_link_rolled_back(store_entry, dir);
+ }
+}
+
+#[cfg(unix)]
+fn assert_member_alias_link_rolled_back(store_entry: &str, dir: &str) {
+ let before: &[u8] = b"path-alias-original\n";
+ let after: &[u8] = b"path-alias-patched\n";
+ let before_hash = git_sha256(before);
+ let after_hash = git_sha256(after);
+ let purl = "pkg:npm/path-alias-pkg@1.0.0";
+
+ let tmp = tempfile::tempdir().expect("tempdir");
+ write_root_package_json(tmp.path());
+ std::fs::write(
+ tmp.path().join("pnpm-workspace.yaml"),
+ "packages: ['packages/*']\n",
+ )
+ .expect("write pnpm-workspace.yaml");
+ let store_pkg = tmp
+ .path()
+ .join(format!("node_modules/{store_entry}/node_modules/{dir}"));
+ std::fs::create_dir_all(&store_pkg).expect("create store package dir");
+ std::fs::write(
+ store_pkg.join("package.json"),
+ r#"{ "name": "path-alias-pkg", "version": "1.0.0" }"#,
+ )
+ .expect("write package.json");
+ std::fs::write(store_pkg.join("index.js"), after).expect("write index.js");
+ let member_nm = tmp.path().join("packages/a/node_modules");
+ std::fs::create_dir_all(&member_nm).expect("create member node_modules");
+ std::fs::write(
+ tmp.path().join("packages/a/package.json"),
+ r#"{ "name": "a", "version": "1.0.0", "dependencies": { "lp": "npm:path-alias-pkg@1.0.0" } }"#,
+ )
+ .expect("write member package.json");
+ std::os::unix::fs::symlink(
+ format!("../../../node_modules/{store_entry}/node_modules/{dir}"),
+ member_nm.join("lp"),
+ )
+ .expect("link member alias");
+ let socket = write_socket_manifest(
+ tmp.path(),
+ &[manifest_entry(
+ purl,
+ "33333333-3333-4333-8333-333333333333",
+ &before_hash,
+ &after_hash,
+ )],
+ false,
+ );
+ stage_blob(&socket, &before_hash, before);
+ stage_blob(&socket, &after_hash, after);
+
+ let (code, stdout, stderr) = run(tmp.path(), &["--json", "--offline", "--yes", "packages/a"]);
+ assert_eq!(
+ code, 0,
+ "rollback of the member must select its alias link ({store_entry}); stdout=\n{stdout}\nstderr=\n{stderr}"
+ );
+ let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
+ assert_eq!(v["status"], "success", "stdout=\n{stdout}");
+ assert_eq!(
+ crate::rollback_json::manifest_removed(&v),
+ serde_json::json!([purl]),
+ "the aliased entry is in scope; stdout=\n{stdout}"
+ );
+ let content = std::fs::read(member_nm.join("lp/index.js")).expect("read alias file");
+ assert_eq!(
+ git_sha256(&content),
+ before_hash,
+ "member a's alias restored"
+ );
+ let content = std::fs::read(store_pkg.join("index.js")).expect("read store file");
+ assert_eq!(git_sha256(&content), before_hash, "one physical copy");
+}
diff --git a/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs
index a0f7d7adc..781a8de6b 100644
--- a/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs
+++ b/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs
@@ -886,3 +886,183 @@ async fn paths_scope_selects_every_nested_member_copy() {
assert_eq!(v["scannedPackages"], 1, "scan {pattern}: {v}");
}
}
+
+/// #1266: a workspace member's ALIAS dependency (`"lp":
+/// "npm:left-pad@1.3.0"`) under a linked layout is a symlink
+/// `packages/a/node_modules/lp` into the root store, just like its
+/// plain-name link `packages/a/node_modules/ms`. Scoping to the member must
+/// select the aliased package too, not only the plain-name one. Left-pad
+/// lives at `node_modules///node_modules/`.
+#[cfg(unix)]
+async fn assert_member_alias_link_in_scope(store: &str, lp_entry: &str, lp_dir: &str) {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = tmp.path();
+ std::fs::write(
+ root.join("package.json"),
+ r#"{ "name": "root", "version": "1.0.0", "private": true, "workspaces": ["packages/*"] }"#,
+ )
+ .unwrap();
+ std::fs::write(
+ root.join("pnpm-workspace.yaml"),
+ "packages: ['packages/*']\n",
+ )
+ .unwrap();
+ // The aliased package's store copy: its own package.json names the
+ // real package, whatever the entry and dir are called.
+ let lp_pkg = root
+ .join("node_modules")
+ .join(store)
+ .join(lp_entry)
+ .join("node_modules")
+ .join(lp_dir);
+ std::fs::create_dir_all(&lp_pkg).unwrap();
+ std::fs::write(
+ lp_pkg.join("package.json"),
+ r#"{ "name": "left-pad", "version": "1.3.0" }"#,
+ )
+ .unwrap();
+ for (member, deps, links) in [
+ (
+ "a",
+ r#"{ "lp": "npm:left-pad@1.3.0", "ms": "2.1.2" }"#,
+ vec![
+ ("lp", format!("{store}/{lp_entry}/node_modules/{lp_dir}")),
+ ("ms", format!("{store}/ms@2.1.2/node_modules/ms")),
+ ],
+ ),
+ (
+ "b",
+ r#"{ "is-number": "7.0.0" }"#,
+ vec![(
+ "is-number",
+ format!("{store}/is-number@7.0.0/node_modules/is-number"),
+ )],
+ ),
+ ] {
+ let member_dir = root.join("packages").join(member);
+ std::fs::create_dir_all(member_dir.join("node_modules")).unwrap();
+ std::fs::write(
+ member_dir.join("package.json"),
+ format!(r#"{{ "name": "{member}", "version": "1.0.0", "dependencies": {deps} }}"#),
+ )
+ .unwrap();
+ for (link, target) in links {
+ std::os::unix::fs::symlink(
+ format!("../../../node_modules/{target}"),
+ member_dir.join("node_modules").join(link),
+ )
+ .unwrap();
+ }
+ }
+ for (prefix, name, version) in [
+ (format!("node_modules/{store}/ms@2.1.2"), "ms", "2.1.2"),
+ (
+ format!("node_modules/{store}/is-number@7.0.0"),
+ "is-number",
+ "7.0.0",
+ ),
+ ] {
+ write_npm_package_at(root, &prefix, name, version);
+ }
+
+ for pattern in ["packages/a", "packages/a/node_modules/lp"] {
+ let (bodies, v) = scoped_agent_scan(root, pattern).await;
+ assert_eq!(
+ bodies.len(),
+ 1,
+ "scan {pattern} must query the batch API once; envelope {v}"
+ );
+ assert!(
+ bodies[0].contains("pkg:npm/left-pad@1.3.0"),
+ "scan {pattern} must select member a's aliased left-pad ({store}/{lp_entry}); body: {}",
+ bodies[0]
+ );
+ assert!(
+ !bodies[0].contains("is-number"),
+ "scan {pattern} must not select member b's is-number; body: {}",
+ bodies[0]
+ );
+ if pattern == "packages/a" {
+ assert!(
+ bodies[0].contains("pkg:npm/ms@2.1.2"),
+ "scan packages/a must still select the plain-name link; body: {}",
+ bodies[0]
+ );
+ assert_eq!(v["scannedPackages"], 2, "scan packages/a: {v}");
+ }
+ }
+}
+
+/// #1266, pnpm's default isolated linker.
+#[cfg(unix)]
+#[tokio::test]
+async fn paths_scope_selects_pnpm_member_alias_link() {
+ assert_member_alias_link_in_scope(".pnpm", "left-pad@1.3.0", "left-pad").await;
+}
+
+/// #1266, npm `install-strategy=linked`: npm 11+ names the store entry
+/// after the real package, npm 10 after the alias.
+#[cfg(unix)]
+#[tokio::test]
+async fn paths_scope_selects_npm_linked_member_alias_link() {
+ assert_member_alias_link_in_scope(".store", "left-pad@1.3.0-0a1b2c3d", "left-pad").await;
+ assert_member_alias_link_in_scope(".store", "lp@1.3.0-0a1b2c3d", "lp").await;
+}
+
+/// #1266: the root's own `left-pad` link resolves to one pnpm peer
+/// variant, member a's alias `lp` to another. The crawl stops at the
+/// root's copy and never lists member a's variant, yet member a loads it,
+/// so scoping to member a must still select left-pad.
+#[cfg(unix)]
+#[tokio::test]
+async fn paths_scope_selects_member_alias_link_to_a_peer_variant() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = tmp.path();
+ std::fs::write(
+ root.join("package.json"),
+ r#"{ "name": "root", "version": "1.0.0", "private": true, "dependencies": { "left-pad": "1.3.0" } }"#,
+ )
+ .unwrap();
+ std::fs::write(
+ root.join("pnpm-workspace.yaml"),
+ "packages: ['packages/*']\n",
+ )
+ .unwrap();
+ for variant in ["left-pad@1.3.0_react@17.0.2", "left-pad@1.3.0_react@18.2.0"] {
+ write_npm_package_at(
+ root,
+ &format!("node_modules/.pnpm/{variant}"),
+ "left-pad",
+ "1.3.0",
+ );
+ }
+ std::os::unix::fs::symlink(
+ ".pnpm/left-pad@1.3.0_react@17.0.2/node_modules/left-pad",
+ root.join("node_modules/left-pad"),
+ )
+ .unwrap();
+ let member_dir = root.join("packages/a");
+ std::fs::create_dir_all(member_dir.join("node_modules")).unwrap();
+ std::fs::write(
+ member_dir.join("package.json"),
+ r#"{ "name": "a", "version": "1.0.0", "dependencies": { "lp": "npm:left-pad@1.3.0" } }"#,
+ )
+ .unwrap();
+ std::os::unix::fs::symlink(
+ "../../../node_modules/.pnpm/left-pad@1.3.0_react@18.2.0/node_modules/left-pad",
+ member_dir.join("node_modules/lp"),
+ )
+ .unwrap();
+
+ let (bodies, v) = scoped_agent_scan(root, "packages/a").await;
+ assert_eq!(
+ bodies.len(),
+ 1,
+ "scan packages/a must query the batch API once; envelope {v}"
+ );
+ assert!(
+ bodies[0].contains("pkg:npm/left-pad@1.3.0"),
+ "scan packages/a must select the peer variant member a's alias loads; body: {}",
+ bodies[0]
+ );
+}