diff --git a/crates/socket-patch-bench/src/fixtures/other.rs b/crates/socket-patch-bench/src/fixtures/other.rs index aadeb6376..367463314 100644 --- a/crates/socket-patch-bench/src/fixtures/other.rs +++ b/crates/socket-patch-bench/src/fixtures/other.rs @@ -762,7 +762,20 @@ fn jvm_pom(arts: &[Pkg], p: &Pkg) -> String { pub fn build_maven(t: &mut Tree, size: Size) -> std::io::Result { let arts = jvm_universe("maven", size); let mut pom = String::from("\n\n 4.0.0\n dev.socket.bench\n bench-app\n 1.0.0\n jar\n\n \n 17\n \n\n \n"); - for p in arts.iter().filter(|p| p.direct) { + // A project-mode crawl keeps only what the project's poms reach (#265), + // so every cached artifact the direct dependencies do not reach is + // declared directly too: the fixture's whole cache is the project's. + let mut reached = vec![false; arts.len()]; + let mut queue: Vec = (0..arts.len()).filter(|&i| arts[i].direct).collect(); + while let Some(i) = queue.pop() { + if !std::mem::replace(&mut reached[i], true) { + queue.extend(arts[i].deps.iter().copied()); + } + } + for (i, p) in arts.iter().enumerate() { + if !p.direct && reached[i] { + continue; + } let (g, a) = ga(p); let _ = write!(pom, " \n {g}\n {a}\n {}\n \n", p.version); } diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b9921c653..aafa14f49 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -557,7 +557,9 @@ own coordinates spell their path); then every **Ivy** cache (`-Dsbt.ivy.home= `/cache`, then `~/.ivy2/cache`, whose package directory is the module's `jars/` / `bundles/` / `orbits/`). Every existing location is kept (an empty value counts as unset; a relative one resolves against the project); an Ivy home whose path does not end in `<…ivy…>/cache` is skipped. The crawl is **not scoped** to -the build: as for `~/.m2`, every cached GAV is queried and patched. `--global-prefix` names exactly one +the build: every cached GAV is queried and patched (a pure Maven build's `~/.m2` crawl is +scoped to the coordinates its poms reach; see `docs/ecosystems.md`, "A Maven project's scan is +scoped to its dependency graph"). `--global-prefix` names exactly one root, its layout read from its path. Patch keys are whole files in the package directory (`-.jar`), the same parity as `~/.m2`. A GAV cached in several roots (say `~/.m2`, a Coursier cache and an Ivy cache) is patched and restored in **every** root, one summary event per copy, since the build loads whichever its diff --git a/crates/socket-patch-cli/tests/e2e_maven.rs b/crates/socket-patch-cli/tests/e2e_maven.rs index 9cffa68ea..5878e539b 100644 --- a/crates/socket-patch-cli/tests/e2e_maven.rs +++ b/crates/socket-patch-cli/tests/e2e_maven.rs @@ -152,12 +152,17 @@ async fn scan_discovers_maven_artifacts() { ) .unwrap(); - // Create a pom.xml in the project directory so local mode activates + // A pom.xml in the project directory activates local mode; it declares + // both artifacts, since a project-mode crawl keeps only what the + // project's poms reach (#265). let project_dir = dir.path().join("project"); std::fs::create_dir_all(&project_dir).unwrap(); std::fs::write( project_dir.join("pom.xml"), - r#"4.0.0"#, + r#"4.0.0 + org.apache.commonscommons-lang33.12.0 + com.google.guavaguava32.1.2-jre +"#, ) .unwrap(); diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index 08aa56b58..c00440980 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -1431,9 +1431,16 @@ async fn scan_discovers_maven_and_nuget_in_every_mode() { let tmp = tempfile::tempdir().unwrap(); write_root_package_json(tmp.path()); - // Maven: java-project marker + a local repository the crawler reaches - // via MAVEN_REPO_LOCAL (verified by the version dir's `.pom`). - std::fs::write(tmp.path().join("pom.xml"), "\n").unwrap(); + // Maven: a pom declaring the artifact (a project-mode crawl keeps only + // what the project's poms reach, #265) + a local repository the crawler + // reaches via MAVEN_REPO_LOCAL (verified by the version dir's `.pom`). + std::fs::write( + tmp.path().join("pom.xml"), + "org.example\ + foo1.0.0\ + \n", + ) + .unwrap(); let artifact_dir = tmp.path().join("m2repo/org/example/foo/1.0.0"); std::fs::create_dir_all(&artifact_dir).unwrap(); std::fs::write(artifact_dir.join("foo-1.0.0.pom"), "").unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index 07803afcd..3575adf6d 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -921,16 +921,72 @@ impl MavenCrawler { /// Crawl all discovered Maven repository paths and return every /// package found. + /// + /// In project mode for a Maven build (a `pom.xml`, no Gradle or + /// Scala-tool build beside it), a Maven local repository is shared by + /// every project on the machine, so only the coordinates the project's + /// dependency graph reaches are kept ([`maven_scope`](super::maven_scope), + /// #265): another project's cached artifacts are not this project's + /// packages. A root pom that is not readable leaves the crawl unscoped. pub async fn crawl_all(&self, options: &CrawlerOptions) -> Vec { + self.crawl_all_with(options, &JvmEnv::from_process()).await + } + + /// [`Self::crawl_all`] under the caches `env` names. + pub async fn crawl_all_with( + &self, + options: &CrawlerOptions, + env: &JvmEnv, + ) -> Vec { let mut packages = Vec::new(); let mut seen = HashSet::new(); + let scoped = options.global_prefix.is_none() && !options.global && { + let cwd = options.cwd.clone(); + run_walk(move || { + layout::has_build(&cwd, BuildTool::Maven) + && !layout::has_build(&cwd, BuildTool::Gradle) + && !layout::is_scala_tool_build(&cwd) + }) + .await + }; - for root in self.get_jvm_cache_roots(options).await { + for root in self.get_jvm_cache_roots_with(options, env).await { + let scope_cwd = + (scoped && root.layout == JvmCacheLayout::Maven2 && !coursier_spelled(&root.path)) + .then(|| options.cwd.clone()); // The walkdir walk and POM reads are blocking: run each repo // on the walk pool so concurrently crawled ecosystems keep // making progress (the dedup set rides along and comes back). let (found, returned_seen) = run_walk(move || { - let found = MavenCrawler.scan_cache_root(&root, &mut seen); + // The scope reads a pom per reachable artifact while the scan + // only walks directories: run them side by side. + let shared = std::sync::Mutex::new(seen); + let mut halves = par_map(vec![false, true], |scope_half| { + if scope_half { + let scope = scope_cwd + .as_ref() + .and_then(|cwd| super::maven_scope::project_scope(cwd, &root.path)); + (None, scope) + } else { + let mut seen = shared.lock().unwrap_or_else(|e| e.into_inner()); + (Some(MavenCrawler.scan_cache_root(&root, &mut seen)), None) + } + }); + let scope = halves.pop().and_then(|(_, scope)| scope); + let mut found = halves + .pop() + .and_then(|(found, _)| found) + .unwrap_or_default(); + let seen = shared.into_inner().unwrap_or_else(|e| e.into_inner()); + if let Some(scope) = scope { + found.retain(|p| { + scope.admits( + p.namespace.as_deref().unwrap_or_default(), + &p.name, + &p.version, + ) + }); + } (found, seen) }) .await; @@ -2483,6 +2539,90 @@ mod tests { assert!(purls.contains("pkg:maven/com.google.guava/guava@32.1.3-jre")); } + /// #265: in project mode a Maven build's crawl keeps only what its + /// poms reach; the rest of the shared local repository (cached by other + /// projects) is not this project's. `--global` still lists everything. + #[tokio::test] + #[serial_test::serial] + async fn project_mode_crawl_keeps_only_the_projects_graph() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("app"), dir.path().join("m2")); + let pom = |g: &str, a: &str, v: &str, deps: &str| { + format!( + "4.0.0{g}\ + {a}{v}\ + {deps}" + ) + }; + let dep = |g: &str, a: &str, v: &str, scope: &str| { + format!( + "{g}{a}\ + {v}{scope}" + ) + }; + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::write( + cwd.join("pom.xml"), + pom( + "com.example", + "unrelated-app", + "1.0.0", + &dep("junit", "junit", "4.13.2", "test"), + ), + ) + .unwrap(); + for (g, a, v, deps) in [ + ( + "junit", + "junit", + "4.13.2", + dep("org.hamcrest", "hamcrest-core", "1.3", "compile"), + ), + ("org.hamcrest", "hamcrest-core", "1.3", String::new()), + // Cached by another project. + ( + "org.apache.commons", + "commons-lang3", + "3.12.0", + String::new(), + ), + ] { + let d = repo.join(g.replace('.', "/")).join(a).join(v); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join(format!("{a}-{v}.pom")), pom(g, a, v, &deps)).unwrap(); + } + let env = JvmEnv { + m2_repo: Some(repo.clone()), + gradle: None, + }; + let purls = |packages: Vec| { + let mut p: Vec = packages.into_iter().map(|p| p.purl).collect(); + p.sort(); + p + }; + let local = CrawlerOptions { + cwd: cwd.clone(), + global: false, + global_prefix: None, + }; + assert_eq!( + purls(MavenCrawler::new().crawl_all_with(&local, &env).await), + [ + "pkg:maven/junit/junit@4.13.2", + "pkg:maven/org.hamcrest/hamcrest-core@1.3", + ] + ); + let global = CrawlerOptions { + global: true, + ..local + }; + assert_eq!( + purls(MavenCrawler::new().crawl_all_with(&global, &env).await).len(), + 3, + "a global crawl is not scoped" + ); + } + #[tokio::test] async fn test_crawl_all_deduplication() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/maven_scope.rs b/crates/socket-patch-core/src/crawlers/maven_scope.rs new file mode 100644 index 000000000..6524e5f37 --- /dev/null +++ b/crates/socket-patch-core/src/crawlers/maven_scope.rs @@ -0,0 +1,818 @@ +//! The project-mode scope of the Maven local repository (#265, the Maven +//! child of #595). +//! +//! A local repository is shared by every project on the machine, so in +//! project mode its contents are not this project's packages: a hosted scan +//! would pin, and VEX attest, whatever some other build cached. Maven has no +//! lockfile, so the scope is the dependency graph the project's poms +//! declare, walked through the poms the local repository already holds +//! (Maven caches the pom of every artifact, parent and BOM it resolves): +//! +//! - **Seeds**: every `` of the reactor (the root `pom.xml`, +//! its `` / `` recursively, every profile, and the +//! `` each inherits from its parents), any scope. +//! - **Edges**: an artifact's own non-optional `compile` / `runtime` +//! dependencies (and those its parents declare), which is what Maven +//! resolves transitively. +//! - **Versions**: the literal, interpolated through the pom's properties +//! and its parent chain; a version-less declaration takes the managed +//! version (the pom's parent chain, then its imported BOMs), and a +//! management entry of the reactor applies to transitives as Maven +//! applies it. A version this cannot determine (an undefined property, a +//! range, a pom not in the repository) admits every cached version of +//! that artifact instead: the scope over-approximates within an artifact +//! the project names, never across artifacts it does not. +//! +//! Exclusions and mediation are not modelled (both only narrow what Maven +//! resolves), so the scope is a superset of the resolved graph. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::vendor::jvm::maven_reactor::{pom_model, PomDecl, PomModel}; + +/// `(groupId, artifactId, version)`. +type Gav = (String, String, String); + +/// Most artifacts one walk visits; a bigger graph is left unscoped. +const MAX_NODES: usize = 50_000; +/// Deepest parent chain / BOM import nesting followed. +const MAX_DEPTH: usize = 32; +/// Property interpolation depth cap. +const MAX_INTERPOLATION: usize = 16; +/// Most reactor poms read. +const MAX_REACTOR: usize = 4_096; + +/// The coordinates a project-mode crawl of the local repository keeps. +#[derive(Debug, Default)] +pub(crate) struct ProjectScope { + gavs: HashSet, + /// Artifacts admitted at every cached version. + any_version: HashSet<(String, String)>, +} + +impl ProjectScope { + pub(crate) fn admits(&self, group: &str, artifact: &str, version: &str) -> bool { + let ga = (group.to_string(), artifact.to_string()); + self.any_version.contains(&ga) || self.gavs.contains(&(ga.0, ga.1, version.to_string())) + } +} + +/// The scope of the Maven project at `cwd` over the local repository +/// `repo`; `None` when `cwd/pom.xml` is not a readable pom or the graph is +/// too big to walk (the crawl then stays unscoped). +pub(crate) fn project_scope(cwd: &Path, repo: &Path) -> Option { + let mut walk = Walk { + cwd, + repo, + models: HashMap::new(), + scope: ProjectScope::default(), + queue: VecDeque::new(), + queued: HashSet::new(), + reactor_chains: Vec::new(), + reactor_managed: HashMap::new(), + }; + walk.reactor()?; + walk.run()?; + Some(walk.scope) +} + +/// A pom and where it was read from. +#[derive(Clone)] +struct Node { + model: Arc, + /// The pom's file (for relative parent paths of reactor poms). + path: PathBuf, + /// Read from the checkout (a reactor pom or a local parent). + local: bool, +} + +/// A pom and its parents, nearest first. +type Chain = Vec; + +struct Walk<'w> { + cwd: &'w Path, + repo: &'w Path, + /// Parsed poms by path (`None`: missing or unreadable). + models: HashMap>>, + scope: ProjectScope, + queue: VecDeque, + queued: HashSet, + /// The reactor poms' chains: their management applies to transitives. + reactor_chains: Vec, + /// [`Walk::managed_version`] of each reactor chain, by artifact. + reactor_managed: HashMap<(usize, String, String), Option>>, +} + +impl Walk<'_> { + fn model(&mut self, path: &Path, include_profiles: bool) -> Option> { + if let Some(found) = self.models.get(path) { + return found.clone(); + } + let parsed = read_model(path, include_profiles); + self.models.insert(path.to_path_buf(), parsed.clone()); + parsed + } + + /// Read and parse the repository poms of `gavs` not read yet. + fn prefetch(&mut self, gavs: &[Gav]) { + let paths: Vec = gavs + .iter() + .filter_map(|gav| self.repo_pom(gav)) + .filter(|path| !self.models.contains_key(path)) + .collect(); + let parsed = paths.into_iter().map(|path| { + let model = read_model(&path, false); + (path, model) + }); + self.models.extend(parsed); + } + + /// Whether `path` stays inside the checkout (lexically). + fn inside(&self, path: &Path) -> bool { + match (normalize(path), normalize(self.cwd)) { + (Some(p), Some(root)) => p.starts_with(root), + _ => false, + } + } + + fn repo_pom(&self, (g, a, v): &Gav) -> Option { + let safe = |s: &str| { + !s.is_empty() + && s != "." + && s != ".." + && !s.contains(['/', '\\', '$', '{', '}']) + && !s.contains("..") + }; + if !(safe(g) && safe(a) && safe(v)) { + return None; + } + Some( + self.repo + .join(g.replace('.', "/")) + .join(a) + .join(v) + .join(format!("{a}-{v}.pom")), + ) + } + + /// `node` and its parents: a reactor pom's local parent by + /// `relativePath` (default `../pom.xml`) when that pom is the declared + /// one, else the parent's pom in the repository. + fn chain(&mut self, node: Node) -> Chain { + let mut chain = vec![node]; + while chain.len() < MAX_DEPTH { + let cur = chain.last().expect("non-empty").clone(); + let Some((pg, pa, pv, rel)) = cur.model.parent.clone() else { + break; + }; + let props = chain.clone(); + let resolve = |v: &Option| v.as_deref().and_then(|v| interpolate(v, &props)); + let (pg, pa, pv) = (resolve(&pg), resolve(&pa), resolve(&pv)); + let local = if cur.local && rel.as_deref() != Some("") { + let rel = rel.unwrap_or_else(|| "../pom.xml".to_string()); + let dir = cur.path.parent().unwrap_or(self.cwd); + let mut path = dir.join(&rel); + if !rel.ends_with(".xml") { + path = path.join("pom.xml"); + } + let inside = self.inside(&path); + inside + .then(|| self.model(&path, true).map(|m| (m, path))) + .flatten() + .filter(|(m, _)| m.artifact.is_some() && m.artifact == pa) + } else { + None + }; + let next = match local { + Some((model, path)) => Node { + model, + path, + local: true, + }, + None => { + let (Some(pg), Some(pa), Some(pv)) = (pg, pa, pv) else { + break; + }; + let gav = (pg, pa, pv); + self.admit(&gav); + let Some(path) = self.repo_pom(&gav) else { + break; + }; + let Some(model) = self.model(&path, false) else { + break; + }; + Node { + model, + path, + local: false, + } + } + }; + chain.push(next); + } + chain + } + + /// Read the reactor and queue every declaration it makes. + fn reactor(&mut self) -> Option<()> { + let root = self.cwd.join("pom.xml"); + let model = self.model(&root, true)?; + let mut pending = vec![Node { + model, + path: root.clone(), + local: true, + }]; + let mut seen: HashSet = HashSet::from([root]); + while let Some(node) = pending.pop() { + if seen.len() > MAX_REACTOR { + return None; + } + let dir = node.path.parent().unwrap_or(self.cwd).to_path_buf(); + for module in node.model.modules.clone() { + if module.is_empty() || module.contains("${") { + continue; + } + let mut path = dir.join(&module); + if !module.ends_with(".xml") { + path = path.join("pom.xml"); + } + if !self.inside(&path) || !seen.insert(path.clone()) { + continue; + } + if let Some(model) = self.model(&path, true) { + pending.push(Node { + model, + path, + local: true, + }); + } + } + let chain = self.chain(node); + self.reactor_chains.push(chain); + } + let chains = std::mem::take(&mut self.reactor_chains); + for chain in &chains { + for decl in chain.iter().flat_map(|n| n.model.deps.clone()) { + self.declare(&decl, chain, true); + } + // Imported BOMs and parents are part of the build too. + for decl in chain.iter().flat_map(|n| n.model.managed.clone()) { + if is_import(&decl) { + if let Some(gav) = decl_gav(&decl, chain) { + self.admit(&gav); + } + } + } + } + self.reactor_chains = chains; + Some(()) + } + + /// Queue `decl` read in `chain`'s context. A transitive edge also takes + /// any version the reactor's management assigns its artifact. + fn declare(&mut self, decl: &PomDecl, chain: &Chain, direct: bool) { + // Coordinates interpolate like the version (`${project.groupId}`): + // a raw placeholder never names a repository path or crawl entry. + let group = interpolate(&decl.group, chain).unwrap_or_else(|| decl.group.clone()); + let artifact = interpolate(&decl.artifact, chain).unwrap_or_else(|| decl.artifact.clone()); + if !direct { + let reactor = std::mem::take(&mut self.reactor_chains); + for (i, rc) in reactor.iter().enumerate() { + let key = (i, group.clone(), artifact.clone()); + let managed = match self.reactor_managed.get(&key) { + Some(found) => found.clone(), + None => { + let found = self.managed_version(rc, &group, &artifact, 0); + self.reactor_managed.insert(key, found.clone()); + found + } + }; + if let Some(version) = managed { + self.enqueue(&group, &artifact, version); + } + } + self.reactor_chains = reactor; + } + let version = match &decl.version { + Some(raw) => Some(interpolate(raw, chain)), + None => self + .managed_version(chain, &group, &artifact, 0) + .or(Some(None)), + }; + self.enqueue(&group, &artifact, version.flatten()); + } + + /// The version `chain` manages `g:a` at: its parent chain's management, + /// then its imported BOMs'. `None`: not managed; `Some(None)`: managed + /// at a version this cannot determine. + fn managed_version( + &mut self, + chain: &Chain, + g: &str, + a: &str, + depth: usize, + ) -> Option> { + let is_ga = |d: &PomDecl| { + let coord = |raw: &str, want: &str| { + raw == want || interpolate(raw, chain).is_some_and(|v| v == want) + }; + coord(&d.group, g) && coord(&d.artifact, a) && !is_import(d) + }; + for node in chain { + if let Some(decl) = node.model.managed.iter().find(|d| is_ga(d)) { + return Some(decl.version.as_deref().and_then(|v| interpolate(v, chain))); + } + } + if depth >= MAX_DEPTH { + return Some(None); + } + let imports: Vec = chain + .iter() + .flat_map(|n| n.model.managed.iter().filter(|d| is_import(d)).cloned()) + .collect(); + for decl in imports { + let Some(gav) = decl_gav(&decl, chain) else { + continue; + }; + let Some(path) = self.repo_pom(&gav) else { + continue; + }; + let Some(model) = self.model(&path, false) else { + continue; + }; + let bom = self.chain(Node { + model, + path, + local: false, + }); + if let Some(found) = self.managed_version(&bom, g, a, depth + 1) { + return Some(found); + } + } + None + } + + /// Queue `g:a` at `version` (every cached version when unknown). + fn enqueue(&mut self, g: &str, a: &str, version: Option) { + match version.filter(|v| !is_range(v)) { + Some(v) => { + // A hosted pin `-socket.` (written by an earlier + // scan) resolves the patched base release: keep the base in + // scope, so a rescan still sees the package it pinned. + if let Some((base, _)) = crate::formats::maven::split_socket_version(&v) { + self.enqueue(g, a, Some(base.to_string())); + } + let gav = (g.to_string(), a.to_string(), v); + if self.queued.insert(gav.clone()) { + self.queue.push_back(gav); + } + } + None => { + if !self + .scope + .any_version + .insert((g.to_string(), a.to_string())) + { + return; + } + let dir = self.repo.join(g.replace('.', "/")).join(a); + let Ok(entries) = std::fs::read_dir(&dir) else { + return; + }; + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let v = entry.file_name().to_string_lossy().into_owned(); + let gav = (g.to_string(), a.to_string(), v); + if self.queued.insert(gav.clone()) { + self.queue.push_back(gav); + } + } + } + } + } + + fn admit(&mut self, gav: &Gav) { + self.scope.gavs.insert(gav.clone()); + } + + /// Walk the queued artifacts' own dependencies. + fn run(&mut self) -> Option<()> { + while !self.queue.is_empty() { + let wave: Vec = self.queue.drain(..).collect(); + self.prefetch(&wave); + for gav in wave { + self.visit(gav)?; + } + } + Some(()) + } + + /// Admit `gav` and queue its own transitive dependencies. + fn visit(&mut self, gav: Gav) -> Option<()> { + { + if self.scope.gavs.len() > MAX_NODES { + return None; + } + self.admit(&gav); + let Some(path) = self.repo_pom(&gav) else { + return Some(()); + }; + let Some(model) = self.model(&path, false) else { + return Some(()); + }; + let chain = self.chain(Node { + model, + path, + local: false, + }); + let deps: Vec = chain.iter().flat_map(|n| n.model.deps.clone()).collect(); + for decl in deps { + let transitive = + matches!(decl.scope.as_deref(), None | Some("compile" | "runtime")); + if transitive && !decl.optional { + self.declare(&decl, &chain, false); + } + } + } + Some(()) + } +} + +/// The pom at `path` parsed, when it is readable. +fn read_model(path: &Path, include_profiles: bool) -> Option> { + crate::utils::fs::read_regular_to_bytes_sync(path) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .and_then(|text| pom_model(&text, include_profiles).ok()) + .map(Arc::new) +} + +fn is_import(decl: &PomDecl) -> bool { + decl.scope.as_deref() == Some("import") && decl.kind.as_deref() == Some("pom") +} + +fn decl_gav(decl: &PomDecl, chain: &Chain) -> Option { + Some(( + interpolate(&decl.group, chain)?, + interpolate(&decl.artifact, chain)?, + interpolate(decl.version.as_deref()?, chain)?, + )) +} + +fn is_range(version: &str) -> bool { + version.starts_with(['[', '(']) || version.contains(',') +} + +/// `${name}` interpolation in `chain`'s context: the model's own version +/// expressions, then `` up the chain (nearest first). +fn interpolate(value: &str, chain: &Chain) -> Option { + interpolate_at(value, chain, 0) +} + +fn interpolate_at(value: &str, chain: &Chain, depth: usize) -> Option { + if !value.contains("${") { + return Some(value.to_string()); + } + if depth > MAX_INTERPOLATION { + return None; + } + let own = &chain.first()?.model; + let parent_field = |i: usize| -> Option { + let (g, a, v, _) = own.parent.as_ref()?; + [g, a, v][i].clone() + }; + let lookup = |name: &str| -> Option { + match name { + "project.version" | "pom.version" | "version" => { + own.version.clone().or_else(|| parent_field(2)) + } + "project.groupId" | "pom.groupId" | "groupId" => { + own.group.clone().or_else(|| parent_field(0)) + } + "project.artifactId" | "pom.artifactId" | "artifactId" => own.artifact.clone(), + "project.parent.version" | "parent.version" => parent_field(2), + "project.parent.groupId" | "parent.groupId" => parent_field(0), + _ => chain.iter().find_map(|n| n.model.props.get(name).cloned()), + } + }; + let mut out = String::new(); + let mut rest = value; + while let Some(at) = rest.find("${") { + out.push_str(&rest[..at]); + let after = &rest[at + 2..]; + let close = after.find('}')?; + let raw = lookup(&after[..close])?; + out.push_str(&interpolate_at(&raw, chain, depth + 1)?); + rest = &after[close + 1..]; + } + out.push_str(rest); + Some(out) +} + +/// `path` with `.` / `..` components folded (no filesystem access); +/// `None` when it climbs above its root. +fn normalize(path: &Path) -> Option { + use std::path::Component; + let mut out = PathBuf::new(); + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir => { + if !out.pop() { + return None; + } + } + other => out.push(other.as_os_str()), + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(root: &Path, rel: &str, body: &str) { + let p = root.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, body).unwrap(); + } + + fn dep(g: &str, a: &str, v: Option<&str>, extra: &str) -> String { + let v = v.map_or(String::new(), |v| format!("{v}")); + format!( + "{g}{a}{v}{extra}" + ) + } + + fn pom(g: &str, a: &str, v: &str, body: &str) -> String { + format!( + "4.0.0{g}\ + {a}{v}{body}" + ) + } + + /// Cache `g:a:v` in `repo` with `body` as its pom's extra content. + fn cache(repo: &Path, g: &str, a: &str, v: &str, body: &str) { + write( + repo, + &format!("{}/{a}/{v}/{a}-{v}.pom", g.replace('.', "/")), + &pom(g, a, v, body), + ); + } + + fn deps(list: &[String]) -> String { + format!("{}", list.concat()) + } + + #[test] + fn property_coordinates_are_interpolated() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("p"), dir.path().join("m2")); + write( + &cwd, + "pom.xml", + &pom( + "com.example", + "app", + "1", + &deps(&[dep("${project.groupId}", "${lib.name}", Some("2"), "")]).replace( + "", + "lib", + ), + ), + ); + cache( + &repo, + "com.example", + "lib", + "2", + &deps(&[dep( + "${project.groupId}", + "lib-core", + Some("${project.version}"), + "", + )]), + ); + cache(&repo, "com.example", "lib-core", "2", ""); + let scope = project_scope(&cwd, &repo).unwrap(); + assert!(scope.admits("com.example", "lib", "2")); + assert!(scope.admits("com.example", "lib-core", "2")); + } + + #[test] + fn the_scope_is_the_declared_graph_not_the_cache() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("p"), dir.path().join("m2")); + write( + &cwd, + "pom.xml", + &pom( + "com.example", + "app", + "1", + &deps(&[ + dep("junit", "junit", Some("4.13.2"), "test"), + dep("org.apache.commons", "commons-text", Some("${ct}"), ""), + ]) + .replace( + "", + "1.10.0", + ), + ), + ); + cache( + &repo, + "junit", + "junit", + "4.13.2", + &deps(&[dep("org.hamcrest", "hamcrest-core", Some("1.3"), "")]), + ); + cache(&repo, "org.hamcrest", "hamcrest-core", "1.3", ""); + cache( + &repo, + "org.apache.commons", + "commons-text", + "1.10.0", + &deps(&[ + dep("org.apache.commons", "commons-lang3", Some("3.12.0"), ""), + dep("org.example", "test-only", Some("1"), "test"), + dep( + "org.example", + "optional", + Some("1"), + "true", + ), + ]), + ); + cache(&repo, "org.apache.commons", "commons-lang3", "3.12.0", ""); + // Cached by some other project. + cache(&repo, "org.apache.commons", "commons-lang3", "3.11", ""); + cache(&repo, "com.unrelated", "lib", "2.0", ""); + let scope = project_scope(&cwd, &repo).unwrap(); + for (g, a, v) in [ + ("junit", "junit", "4.13.2"), + ("org.hamcrest", "hamcrest-core", "1.3"), + ("org.apache.commons", "commons-text", "1.10.0"), + ("org.apache.commons", "commons-lang3", "3.12.0"), + ] { + assert!(scope.admits(g, a, v), "{g}:{a}:{v}"); + } + for (g, a, v) in [ + ("org.apache.commons", "commons-lang3", "3.11"), + ("com.unrelated", "lib", "2.0"), + ("org.example", "test-only", "1"), + ("org.example", "optional", "1"), + ] { + assert!(!scope.admits(g, a, v), "{g}:{a}:{v}"); + } + } + + #[test] + fn modules_parents_boms_and_management_are_followed() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("p"), dir.path().join("m2")); + // Root: an external parent, a BOM import and reactor management of + // a transitive. + write( + &cwd, + "pom.xml", + &format!( + "4.0.0com.corp\ + corp-parent3\ + rootpom\ + a\ + {}{}\ + ", + dep( + "com.corp", + "corp-bom", + Some("${project.version}"), + "pomimport" + ), + dep("org.example", "managed-transitive", Some("9"), ""), + ), + ); + write( + &cwd, + "a/pom.xml", + &format!( + "4.0.0com.corp\ + root3\ + a{}", + deps(&[ + dep("org.example", "from-bom", None, ""), + dep("org.example", "from-parent", None, ""), + ]) + ), + ); + cache( + &repo, + "com.corp", + "corp-parent", + "3", + &format!( + "{}", + dep("org.example", "from-parent", Some("2"), "") + ), + ); + cache( + &repo, + "com.corp", + "corp-bom", + "3", + &format!( + "{}", + dep("org.example", "from-bom", Some("1"), "") + ), + ); + cache( + &repo, + "org.example", + "from-bom", + "1", + &deps(&[dep("org.example", "managed-transitive", Some("8"), "")]), + ); + cache(&repo, "org.example", "from-parent", "2", ""); + cache(&repo, "org.example", "from-parent", "1", ""); + cache(&repo, "org.example", "managed-transitive", "9", ""); + let scope = project_scope(&cwd, &repo).unwrap(); + for (g, a, v) in [ + ("com.corp", "corp-parent", "3"), + ("com.corp", "corp-bom", "3"), + ("org.example", "from-bom", "1"), + ("org.example", "from-parent", "2"), + ("org.example", "managed-transitive", "9"), + ] { + assert!(scope.admits(g, a, v), "{g}:{a}:{v}"); + } + assert!(!scope.admits("org.example", "from-parent", "1")); + } + + #[test] + fn an_undeterminable_version_admits_every_cached_version_of_that_artifact() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("p"), dir.path().join("m2")); + write( + &cwd, + "pom.xml", + &pom( + "com.example", + "app", + "1", + &deps(&[ + dep("org.example", "undefined", Some("${nowhere}"), ""), + dep("org.example", "ranged", Some("[1,2)"), ""), + ]), + ), + ); + for (a, v) in [("undefined", "1"), ("undefined", "2"), ("ranged", "1.5")] { + cache(&repo, "org.example", a, v, ""); + } + cache(&repo, "org.example", "other", "1", ""); + let scope = project_scope(&cwd, &repo).unwrap(); + assert!(scope.admits("org.example", "undefined", "1")); + assert!(scope.admits("org.example", "undefined", "2")); + assert!(scope.admits("org.example", "ranged", "1.5")); + assert!(!scope.admits("org.example", "other", "1")); + } + + #[test] + fn a_hosted_pin_keeps_its_base_release_in_scope() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("p"), dir.path().join("m2")); + write( + &cwd, + "pom.xml", + &pom( + "com.example", + "app", + "1", + &deps(&[dep("org.example", "lib", Some("1.0-socket.4d5e6f70"), "")]), + ), + ); + cache( + &repo, + "org.example", + "lib", + "1.0", + &deps(&[dep("org.example", "transitive", Some("2"), "")]), + ); + cache(&repo, "org.example", "transitive", "2", ""); + let scope = project_scope(&cwd, &repo).unwrap(); + assert!(scope.admits("org.example", "lib", "1.0")); + assert!(scope.admits("org.example", "transitive", "2")); + } + + #[test] + fn no_readable_root_pom_is_unscoped() { + let dir = tempfile::tempdir().unwrap(); + assert!(project_scope(dir.path(), dir.path()).is_none()); + write(dir.path(), "pom.xml", ""); + assert!(project_scope(dir.path(), dir.path()).is_none()); + } +} diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs index 7647d5275..b657910e5 100644 --- a/crates/socket-patch-core/src/crawlers/mod.rs +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -12,6 +12,7 @@ mod listing; pub mod maven_crawler; #[cfg(test)] mod maven_pom_equivalence_tests; +pub(crate) mod maven_scope; pub mod npm_crawler; pub mod nuget_crawler; #[cfg(test)] diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs index 920959c46..b9ce2d87a 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -668,6 +668,106 @@ pub fn contains_module(read: ReadFn<'_>, rel: &str) -> bool { pub(crate) type Gav = (String, String, String); +/// `(groupId, artifactId, version, relativePath)` of a pom's ``. +pub(crate) type PomParent = ( + Option, + Option, + Option, + Option, +); + +/// One `` of a [`PomModel`], as written (no interpolation). +#[derive(Debug, Clone)] +pub(crate) struct PomDecl { + pub group: String, + pub artifact: String, + pub version: Option, + pub scope: Option, + pub optional: bool, + pub kind: Option, +} + +/// The parts of a pom a dependency-graph walk reads: coordinates, parent, +/// ``, ``, `` and modules +/// of the project (and of its profiles when asked). Plugin dependencies and +/// exclusions are never declarations. +#[derive(Debug, Clone)] +pub(crate) struct PomModel { + pub group: Option, + pub artifact: Option, + pub version: Option, + /// `(groupId, artifactId, version, relativePath)` of ``. + pub parent: Option, + pub props: BTreeMap, + pub deps: Vec, + pub managed: Vec, + pub modules: Vec, +} + +/// [`PomModel`] of `text`; `include_profiles` adds every profile's +/// dependencies, management and modules (an over-approximation of the +/// active ones). +pub(crate) fn pom_model(text: &str, include_profiles: bool) -> Result { + let doc = Doc::parse(text.to_string())?; + let project = doc.project; + let mut roots = vec![project]; + if include_profiles { + if let Some(profiles) = doc.child(project, "profiles") { + roots.extend(doc.children(profiles, "profile")); + } + } + let decl = |dep: usize| -> Option { + Some(PomDecl { + group: doc.child_text(dep, "groupId")?, + artifact: doc.child_text(dep, "artifactId")?, + version: doc.child_text(dep, "version").filter(|v| !v.is_empty()), + scope: doc.child_text(dep, "scope").filter(|v| !v.is_empty()), + optional: doc.child_text(dep, "optional").as_deref() == Some("true"), + kind: doc.child_text(dep, "type").filter(|v| !v.is_empty()), + }) + }; + let (mut deps, mut managed, mut modules) = (Vec::new(), Vec::new(), Vec::new()); + for &root in &roots { + if let Some(list) = doc.child(root, "dependencies") { + deps.extend(doc.children(list, "dependency").filter_map(decl)); + } + if let Some(list) = doc + .child(root, "dependencyManagement") + .and_then(|dm| doc.child(dm, "dependencies")) + { + managed.extend(doc.children(list, "dependency").filter_map(decl)); + } + for (list, item) in [("modules", "module"), ("subprojects", "subproject")] { + if let Some(list) = doc.child(root, list) { + modules.extend(doc.children(list, item).map(|m| doc.text_of(m))); + } + } + } + let mut props = BTreeMap::new(); + if let Some(p) = doc.child(project, "properties") { + for &c in &doc.nodes[p].children { + props.insert(doc.nodes[c].name.clone(), doc.text_of(c)); + } + } + Ok(PomModel { + group: doc.child_text(project, "groupId"), + artifact: doc.child_text(project, "artifactId"), + version: doc.child_text(project, "version"), + parent: doc.child(project, "parent").map(|p| { + ( + doc.child_text(p, "groupId"), + doc.child_text(p, "artifactId"), + doc.child_text(p, "version"), + doc.child(p, "relativePath").map(|r| doc.text_of(r)), + ) + }), + props, + deps, + managed, + modules, + }) +} + /// Poms from outside the checkout (parents, imported BOMs) by GAV, as a /// caller fetched them: `None` when looked up and unavailable. pub type ExternalPoms = BTreeMap<(String, String, String), Option>>; diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 13b9f4f49..0800c7ddf 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -681,6 +681,20 @@ Honest limits of the Maven and NuGet flows — documented behavior, not bugs: `originAware=false` and `failIfMissing=false`, so one checksum matches the artifact from any repository and a dependency with no committed checksum still resolves — only a *mismatch* fails. +* **A Maven project's scan is scoped to its dependency graph.** The Maven local + repository is shared by every project on the machine, so in project mode (no + `--global` / `--global-prefix`) a Maven build's crawl (a `pom.xml`, no Gradle or + sbt / Mill / scala-cli build beside it) keeps only the coordinates its poms reach: + every reactor declaration (modules and profiles included, any scope), then each + artifact's own non-optional `compile` / `runtime` dependencies, read from the poms the + repository already holds, with versions from properties, parents, management and + imported BOMs (the reactor's management applies to transitives, as in Maven). A + version that cannot be determined (an undefined property, a range, a pom not in the + repository) admits every cached version of that artifact. Artifacts another project + cached are not scanned, so hosted mode never pins them and `vex` never attests them. + A project that has never been resolved therefore finds nothing; resolve it once + (`mvn -q dependency:resolve`) and scan again. An unreadable root `pom.xml` leaves the + crawl unscoped. * **Local-repository discovery reads coordinates from the path.** `scan` (and every other crawl of `~/.m2/repository`) takes a POM's groupId / artifactId / version from its directory when the file sits at the canonical