Skip to content

Commit b92456b

Browse files
committed
Fix npm store traversal cycles and repeated variant scans
1 parent a513449 commit b92456b

3 files changed

Lines changed: 488 additions & 6 deletions

File tree

‎crates/socket-patch-cli/src/commands/vex_consumed.rs‎

Lines changed: 234 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use std::collections::{BTreeMap, HashMap};
4141
use std::path::{Path, PathBuf};
4242

43+
#[cfg(not(test))]
4344
use socket_patch_core::crawlers::npm_crawler::with_store_peer_variant_copies;
4445
use socket_patch_core::crawlers::{
4546
CargoCrawler, CrawlerOptions, Ecosystem, GoCrawler, MavenCrawler, NpmCrawler,
@@ -50,6 +51,8 @@ use socket_patch_core::vendor::go_mod_edit::{
5051
};
5152
use socket_patch_core::vendor::lock_inventory::LockIntegrity;
5253
use socket_patch_core::vex::HostedCopies;
54+
#[cfg(test)]
55+
use tests::recording_store_variants as with_store_peer_variant_copies;
5356

5457
use crate::args::GlobalArgs;
5558
use crate::commands::vex_sources::HostedWiring;
@@ -61,8 +64,9 @@ use crate::ecosystem_dispatch::{
6164
/// module docs), under the same crawler options and `--ecosystems` scope as
6265
/// the installed-tree lookup. `installed` is that lookup's every-copy
6366
/// result ([`crate::ecosystem_dispatch::find_manifest_package_copies_reusing`] over
64-
/// the record view, which holds every hosted purl): the shared-location
65-
/// ecosystems read it instead of crawling the tree a second time. `prior`
67+
/// the record view, which holds every hosted purl), including npm store
68+
/// variants. The shared-location ecosystems read it instead of crawling
69+
/// the tree a second time. `prior`
6670
/// (embedded hosted `scan --vex` only) is scan's npm crawl of the same
6771
/// tree: the alias walk takes its `node_modules` roots and the identity
6872
/// fallback its packages instead of walking the tree again.
@@ -107,9 +111,33 @@ pub(crate) async fn hosted_consumed_copies(
107111
let npm: Vec<&String> = shared.get(&Ecosystem::Npm).into_iter().flatten().collect();
108112
for purl in shared.values().flatten() {
109113
let mut paths = all.remove(purl).unwrap_or_default();
110-
paths.extend(aliases.remove(purl).unwrap_or_default());
111-
if npm.contains(&purl) {
114+
let extra = aliases.remove(purl).unwrap_or_default();
115+
if npm.contains(&purl) && installed.get(purl).is_some_and(|p| !p.is_empty()) {
116+
// The installed lookup already expanded these copies.
117+
// Expanding its N variants again scans the store N times.
118+
// Only aliases are new; expand them before merging so a
119+
// different alias/store can still contribute more copies.
120+
if !extra.is_empty() {
121+
let added = with_store_peer_variant_copies(extra).await;
122+
let mut seen = std::collections::HashSet::new();
123+
for path in &paths {
124+
seen.insert(tokio::fs::canonicalize(path).await.unwrap_or(path.clone()));
125+
}
126+
for path in added {
127+
let canonical =
128+
tokio::fs::canonicalize(&path).await.unwrap_or(path.clone());
129+
if seen.insert(canonical) {
130+
paths.push(path);
131+
}
132+
}
133+
}
134+
} else if npm.contains(&purl) {
135+
// No installed copies: the identity fallback and aliases
136+
// have not had their store variants enumerated yet.
137+
paths.extend(extra);
112138
paths = with_store_peer_variant_copies(paths).await;
139+
} else {
140+
paths.extend(extra);
113141
}
114142
out.insert(
115143
purl.clone(),
@@ -576,6 +604,208 @@ async fn maven_copies(options: &CrawlerOptions, purl: &str, wiring: &HostedWirin
576604
mod tests {
577605
use super::*;
578606

607+
tokio::task_local! {
608+
// Observe real expansion work only in the regression's own task;
609+
// concurrent tests keep calling the production helper normally.
610+
static VARIANT_INPUTS: std::cell::RefCell<Vec<Vec<PathBuf>>>;
611+
}
612+
613+
pub(super) async fn recording_store_variants(paths: Vec<PathBuf>) -> Vec<PathBuf> {
614+
let _ = VARIANT_INPUTS.try_with(|calls| calls.borrow_mut().push(paths.clone()));
615+
socket_patch_core::crawlers::npm_crawler::with_store_peer_variant_copies(paths).await
616+
}
617+
618+
#[cfg(unix)]
619+
async fn tracked_npm_hosted(
620+
common: &GlobalArgs,
621+
installed: &HashMap<String, Vec<PathBuf>>,
622+
) -> (Vec<PathBuf>, Vec<Vec<PathBuf>>) {
623+
let purl = "pkg:npm/left-pad@1.3.0".to_string();
624+
let hosted = BTreeMap::from([(
625+
purl.clone(),
626+
HostedWiring {
627+
uuid: "11111111-1111-4111-8111-111111111111".to_string(),
628+
refs: Vec::new(),
629+
},
630+
)]);
631+
VARIANT_INPUTS
632+
.scope(std::cell::RefCell::new(Vec::new()), async {
633+
let mut found = hosted_consumed_copies(common, &hosted, installed, None).await;
634+
let paths = found.remove(&purl).unwrap().paths;
635+
let calls = VARIANT_INPUTS.with(|inputs| inputs.borrow().clone());
636+
(paths, calls)
637+
})
638+
.await
639+
}
640+
641+
#[cfg(unix)]
642+
fn peer_copies(store: &Path, count: usize) -> Vec<PathBuf> {
643+
(0..count)
644+
.map(|i| {
645+
let path = store.join(format!(
646+
"left-pad@1.3.0(peer@1.0.{i})/node_modules/left-pad"
647+
));
648+
pkg(&path, "left-pad", "1.3.0");
649+
path
650+
})
651+
.collect()
652+
}
653+
654+
#[cfg(unix)]
655+
#[tokio::test]
656+
async fn hosted_reuses_expanded_npm_copies_and_merges_alias_variants() {
657+
let tmp = tempfile::tempdir().unwrap();
658+
let nm = tmp.path().canonicalize().unwrap().join("node_modules");
659+
let peers = peer_copies(&nm.join(".pnpm"), 8);
660+
std::os::unix::fs::symlink(&peers[0], nm.join("left-pad")).unwrap();
661+
let common = GlobalArgs {
662+
cwd: tmp.path().canonicalize().unwrap(),
663+
ecosystems: Some(vec!["npm".to_string()]),
664+
..GlobalArgs::default()
665+
};
666+
let purl = "pkg:npm/left-pad@1.3.0".to_string();
667+
let installed = crate::ecosystem_dispatch::find_manifest_package_copies_reusing(
668+
std::slice::from_ref(&purl),
669+
&common,
670+
true,
671+
None,
672+
)
673+
.await;
674+
assert_eq!(installed[&purl].len(), peers.len());
675+
let (paths, calls) = tracked_npm_hosted(&common, &installed).await;
676+
assert_eq!(paths, installed[&purl]);
677+
assert!(
678+
calls.is_empty(),
679+
"already-expanded copies were rescanned: {calls:?}"
680+
);
681+
682+
// A real alias is absent from the name-keyed installed set. Its
683+
// store variants overlap that set canonically, including the
684+
// importer link's physical copy; keep the alias once and preserve
685+
// the original importer-first path choices.
686+
let alias = nm.join("lp");
687+
pkg(&alias, "left-pad", "1.3.0");
688+
let (paths, calls) = tracked_npm_hosted(&common, &installed).await;
689+
assert_eq!(calls, vec![vec![alias.clone()]]);
690+
let mut expected = installed[&purl].clone();
691+
expected.push(alias);
692+
assert_eq!(paths, expected);
693+
694+
// An alias beneath a real nested host can reach another store.
695+
// The installed root copy makes the name-keyed resolver skip
696+
// those peers, so alias expansion must still add them even when
697+
// installed copies are already present.
698+
let host = nm.join("host");
699+
pkg(&host, "host", "1.0.0");
700+
let host_nm = host.join("node_modules");
701+
let nested_peers = peer_copies(&host_nm.join(".pnpm"), 2);
702+
let nested_alias = host_nm.join("lp");
703+
pkg(&nested_alias, "left-pad", "1.3.0");
704+
let installed_again = crate::ecosystem_dispatch::find_manifest_package_copies_reusing(
705+
std::slice::from_ref(&purl),
706+
&common,
707+
true,
708+
None,
709+
)
710+
.await;
711+
assert_eq!(installed_again, installed);
712+
let (paths, calls) = tracked_npm_hosted(&common, &installed_again).await;
713+
assert_eq!(calls.len(), 1);
714+
let mut inputs = calls[0].clone();
715+
inputs.sort();
716+
let mut aliases = vec![nm.join("lp"), nested_alias.clone()];
717+
aliases.sort();
718+
assert_eq!(inputs, aliases);
719+
assert_eq!(&paths[..installed[&purl].len()], installed[&purl]);
720+
expected.push(nested_alias);
721+
expected.extend(nested_peers);
722+
let mut actual = paths.clone();
723+
actual.sort();
724+
expected.sort();
725+
assert_eq!(actual, expected);
726+
assert_eq!(
727+
paths
728+
.iter()
729+
.map(|path| path.canonicalize().unwrap())
730+
.collect::<std::collections::HashSet<_>>()
731+
.len(),
732+
paths.len()
733+
);
734+
}
735+
736+
#[cfg(unix)]
737+
#[tokio::test]
738+
async fn hosted_expands_alias_only_copies() {
739+
let tmp = tempfile::tempdir().unwrap();
740+
let store = tmp
741+
.path()
742+
.canonicalize()
743+
.unwrap()
744+
.join("node_modules/.pnpm");
745+
let peers = peer_copies(&store, 2);
746+
// Run within a store package whose nested dependency is an alias.
747+
// The sibling peer copies are outside its project-root search.
748+
let root = store.join("host@1.0.0/node_modules/host");
749+
let alias = root.join("node_modules/lp");
750+
pkg(&alias, "left-pad", "1.3.0");
751+
let common = GlobalArgs {
752+
cwd: root,
753+
ecosystems: Some(vec!["npm".to_string()]),
754+
..GlobalArgs::default()
755+
};
756+
let purl = "pkg:npm/left-pad@1.3.0".to_string();
757+
let installed = crate::ecosystem_dispatch::find_manifest_package_copies_reusing(
758+
std::slice::from_ref(&purl),
759+
&common,
760+
true,
761+
None,
762+
)
763+
.await;
764+
assert!(installed.is_empty(), "{installed:?}");
765+
let (mut paths, calls) = tracked_npm_hosted(&common, &installed).await;
766+
assert_eq!(calls, vec![vec![alias.clone()]]);
767+
let mut expected = peers;
768+
expected.push(alias);
769+
paths.sort();
770+
expected.sort();
771+
assert_eq!(paths, expected);
772+
}
773+
774+
#[cfg(unix)]
775+
#[tokio::test]
776+
async fn hosted_expands_identity_fallback_with_empty_installed_entry() {
777+
let tmp = tempfile::tempdir().unwrap();
778+
let peers = peer_copies(
779+
&tmp.path()
780+
.canonicalize()
781+
.unwrap()
782+
.join("external/node_modules/.pnpm"),
783+
2,
784+
);
785+
let root = tmp.path().canonicalize().unwrap().join("project");
786+
let alias = root.join("node_modules/lp");
787+
std::fs::create_dir_all(alias.parent().unwrap()).unwrap();
788+
std::os::unix::fs::symlink(&peers[0], &alias).unwrap();
789+
let common = GlobalArgs {
790+
cwd: root,
791+
ecosystems: Some(vec!["npm".to_string()]),
792+
..GlobalArgs::default()
793+
};
794+
let purl = "pkg:npm/left-pad@1.3.0".to_string();
795+
assert!(
796+
npm_alias_copies(&common.crawler_options(), std::slice::from_ref(&purl))
797+
.await
798+
.is_empty()
799+
);
800+
let installed = HashMap::from([(purl, Vec::new())]);
801+
let (mut paths, calls) = tracked_npm_hosted(&common, &installed).await;
802+
assert_eq!(calls, vec![vec![alias.clone()]]);
803+
let mut expected = vec![alias, peers[1].clone()];
804+
paths.sort();
805+
expected.sort();
806+
assert_eq!(paths, expected);
807+
}
808+
579809
fn pkg(dir: &Path, name: &str, version: &str) {
580810
std::fs::create_dir_all(dir).unwrap();
581811
std::fs::write(

0 commit comments

Comments
 (0)