server/lib/src/pull_intake/pom.rs
//! **A Maven version's dependency closure** (lane HOL-DEPSET-APPROVE): every file Maven
//! reads to use one version, resolved from the POMs the way Maven 3.9 resolves them, so a
//! pull brings the whole closure into the intake chain at once and the approvers judge it as
//! ONE set ([`super::depset`]).
//!
//! Before this, a pull took one version's POM and JAR; Maven then asked for the next level
//! (a parent POM, a BOM, a dependency), one approval round each: resolving `commons-lang3`
//! through Holger took more than 30 rounds and about 100 approvals (field 2026-11-01).
//!
//! What is followed, as Maven 3.9 follows it (maven-resolver 1.9, `ClassicDependencyManager`,
//! the depth-first collector):
//!
//! * the **model** of each POM: its parent chain, `properties` (child over parent) with the
//! `project.*` values, `dependencyManagement` inherited and `import`-scoped BOMs (each BOM
//! and its parents read too), the inherited `dependencies`, and profiles active on any build
//! (`activeByDefault`, a JDK range that holds on JDK 21, `!property`);
//! * the **graph**: test, provided, system and import dependencies are left out at every
//! level, an optional dependency below the first level is left out, `exclusions` (with `*`)
//! ride down their path, and the requested version's own `dependencyManagement` sets the
//! versions from the second level down;
//! * **POMs** of EVERY version the graph visits (Maven reads the POMs of the versions it
//! then mediates away), and the **artifact** (the JAR) of each version that wins nearest-wins
//! mediation and is reachable through winners only;
//! * for a **Maven plugin**, the JARs of the artifacts Maven's core exports to every plugin
//! (`maven-plugin-api`, `maven-model`, Sisu, Guice, classworlds …) are not fetched: Maven
//! gives the plugin its own.
//!
//! Network-free: the bytes come from a [`PomSource`]. The XML reader is a small tree reader
//! for POMs (elements, text, CDATA, comments, the five entities and character references).
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
// ── a small XML tree ────────────────────────────────────────────────────────
/// One element: its local name, its text and its children.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct El {
pub name: String,
pub text: String,
pub kids: Vec<El>,
}
impl El {
/// The first child named `n`.
pub fn child(&self, n: &str) -> Option<&El> {
self.kids.iter().find(|k| k.name == n)
}
/// Every child named `n`.
pub fn all<'a>(&'a self, n: &'a str) -> impl Iterator<Item = &'a El> + 'a {
self.kids.iter().filter(move |k| k.name == n)
}
/// The trimmed text of the child named `n`; `None` when absent or empty.
pub fn text_of(&self, n: &str) -> Option<String> {
self.child(n)
.map(|c| c.text.trim().to_string())
.filter(|t| !t.is_empty())
}
}
fn decode_entities(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
let tail = &rest[i..];
let Some(end) = tail.find(';').filter(|e| *e <= 12) else {
out.push('&');
rest = &tail[1..];
continue;
};
let ent = &tail[1..end];
let ch = match ent {
"lt" => Some('<'),
"gt" => Some('>'),
"amp" => Some('&'),
"quot" => Some('"'),
"apos" => Some('\''),
e if e.starts_with("#x") || e.starts_with("#X") => u32::from_str_radix(&e[2..], 16)
.ok()
.and_then(char::from_u32),
e if e.starts_with('#') => e[1..].parse::<u32>().ok().and_then(char::from_u32),
_ => None,
};
match ch {
Some(c) => {
out.push(c);
rest = &tail[end + 1..];
}
None => {
out.push('&');
rest = &tail[1..];
}
}
}
out.push_str(rest);
out
}
/// **Read an XML document into a tree.** Attributes are skipped (a POM keeps nothing in
/// them that resolution reads); a namespace prefix is dropped from a name.
pub fn parse_xml(s: &str) -> anyhow::Result<El> {
let s = s.strip_prefix('\u{feff}').unwrap_or(s);
let mut stack: Vec<El> = vec![El {
name: "#document".into(),
..El::default()
}];
let mut i = 0;
let b = s.as_bytes();
while i < b.len() {
if b[i] != b'<' {
let end = s[i..].find('<').map(|e| i + e).unwrap_or(b.len());
let text = decode_entities(&s[i..end]);
if let Some(top) = stack.last_mut() {
top.text.push_str(&text);
}
i = end;
continue;
}
let rest = &s[i..];
if rest.starts_with("<!--") {
let e = rest
.find("-->")
.ok_or_else(|| anyhow::anyhow!("an XML comment is not closed"))?;
i += e + 3;
} else if rest.starts_with("<![CDATA[") {
let e = rest
.find("]]>")
.ok_or_else(|| anyhow::anyhow!("a CDATA section is not closed"))?;
if let Some(top) = stack.last_mut() {
top.text.push_str(&rest[9..e]);
}
i += e + 3;
} else if rest.starts_with("<?") {
let e = rest
.find("?>")
.ok_or_else(|| anyhow::anyhow!("a processing instruction is not closed"))?;
i += e + 2;
} else if rest.starts_with("<!") {
// A DOCTYPE, with an internal subset or not. Read up to its own `>` (D3: the `[`
// was searched for in the whole rest of the document, once per `<!`).
let c = rest
.find('>')
.ok_or_else(|| anyhow::anyhow!("a DOCTYPE is not closed"))?;
let e = if rest[..c].contains('[') {
rest.find("]>")
.map(|e| e + 2)
.ok_or_else(|| anyhow::anyhow!("a DOCTYPE is not closed"))?
} else {
c + 1
};
i += e;
} else if let Some(close) = rest.strip_prefix("</") {
let e = close
.find('>')
.ok_or_else(|| anyhow::anyhow!("a closing tag is not closed"))?;
let name = local(close[..e].trim());
let done = stack
.pop()
.ok_or_else(|| anyhow::anyhow!("a closing tag </{name}> closes nothing"))?;
if done.name != name || stack.is_empty() {
anyhow::bail!("</{name}> closes <{}>", done.name);
}
if let Some(top) = stack.last_mut() {
top.kids.push(done);
}
i += 2 + e + 1;
} else {
// An opening tag: up to its `>`, quotes respected.
let mut j = i + 1;
let mut quote: Option<u8> = None;
while j < b.len() {
match (quote, b[j]) {
(None, b'"') | (None, b'\'') => quote = Some(b[j]),
(Some(q), c) if c == q => quote = None,
(None, b'>') => break,
_ => {}
}
j += 1;
}
if j >= b.len() {
anyhow::bail!("a tag is not closed");
}
let inner = &s[i + 1..j];
let self_closing = inner.ends_with('/');
let inner = inner.trim_end_matches('/');
let name = local(
inner
.split(|c: char| c.is_whitespace())
.next()
.unwrap_or(""),
);
if name.is_empty() {
anyhow::bail!("a tag has no name");
}
if stack.len() > 64 {
anyhow::bail!("the document nests deeper than 64 elements");
}
let el = El {
name: name.to_string(),
..El::default()
};
if self_closing {
if let Some(top) = stack.last_mut() {
top.kids.push(el);
}
} else {
stack.push(el);
}
i = j + 1;
}
}
if stack.len() != 1 {
anyhow::bail!(
"<{}> is not closed",
stack.last().map(|e| e.name.as_str()).unwrap_or("")
);
}
let doc = stack.pop().unwrap_or_default();
doc.kids
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("the document has no element"))
}
fn local(name: &str) -> &str {
name.rsplit(':').next().unwrap_or(name)
}
// ── coordinates ─────────────────────────────────────────────────────────────
/// A Maven version: `org.apache.commons:commons-lang3:3.17.0` (the group dotted).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Gav {
pub group: String,
pub artifact: String,
pub version: String,
}
impl Gav {
pub fn new(group: &str, artifact: &str, version: &str) -> Self {
Self {
group: group.into(),
artifact: artifact.into(),
version: version.into(),
}
}
/// The directory below the repository: `org/apache/commons/commons-lang3/3.17.0`.
pub fn dir(&self) -> String {
format!(
"{}/{}/{}",
self.group.replace('.', "/"),
self.artifact,
self.version
)
}
/// The POM's path below the repository.
pub fn pom_path(&self) -> String {
self.file_path(None, "pom")
}
/// A file of this version: `<dir>/<artifact>-<version>[-<classifier>].<ext>`.
pub fn file_path(&self, classifier: Option<&str>, ext: &str) -> String {
format!(
"{}/{}-{}{}.{ext}",
self.dir(),
self.artifact,
self.version,
classifier.map(|c| format!("-{c}")).unwrap_or_default()
)
}
}
impl std::fmt::Display for Gav {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}:{}", self.group, self.artifact, self.version)
}
}
fn segment_ok(s: &str) -> bool {
!s.is_empty()
&& s != "."
&& s != ".."
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
}
fn gav_ok(g: &Gav) -> bool {
g.group.split('.').all(segment_ok) && segment_ok(&g.artifact) && segment_ok(&g.version)
}
/// **Where the POMs come from.** `Ok(None)`: the repository does not publish that file (a
/// hold); `Err`: the repository could not be read (the pull is retried).
pub trait PomSource {
/// The bytes at `path` below the repository (a `.pom`).
fn pom(&self, path: &str) -> anyhow::Result<Option<Vec<u8>>>;
/// The versions `<group path>/<artifact>/maven-metadata.xml` lists (a version range).
fn versions(&self, group_path: &str, artifact: &str) -> anyhow::Result<Option<Vec<String>>>;
}
// ── the model ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Dep {
group: String,
artifact: String,
version: Option<String>,
kind: Option<String>,
classifier: Option<String>,
scope: Option<String>,
optional: Option<String>,
exclusions: Vec<(String, String)>,
}
impl Dep {
fn read(e: &El) -> Option<Dep> {
Some(Dep {
group: e.text_of("groupId")?,
artifact: e.text_of("artifactId")?,
version: e.text_of("version"),
kind: e.text_of("type"),
classifier: e.text_of("classifier"),
scope: e.text_of("scope"),
optional: e.text_of("optional"),
exclusions: e
.child("exclusions")
.map(|x| {
x.all("exclusion")
.map(|x| {
(
x.text_of("groupId").unwrap_or_else(|| "*".into()),
x.text_of("artifactId").unwrap_or_else(|| "*".into()),
)
})
.collect()
})
.unwrap_or_default(),
})
}
fn kind(&self) -> &str {
self.kind.as_deref().unwrap_or("jar")
}
/// Maven's management key: `group:artifact:type[:classifier]`.
fn key(&self) -> String {
format!(
"{}:{}:{}:{}",
self.group,
self.artifact,
self.kind(),
self.classifier.as_deref().unwrap_or("")
)
}
fn interpolate(&mut self, p: &dyn Fn(&str) -> String) {
self.group = p(&self.group);
self.artifact = p(&self.artifact);
for f in [
&mut self.version,
&mut self.kind,
&mut self.classifier,
&mut self.scope,
&mut self.optional,
] {
if let Some(v) = f.as_mut() {
*v = p(v);
}
}
for (g, a) in &mut self.exclusions {
*g = p(g);
*a = p(a);
}
}
}
/// One POM as written, its active profiles folded in.
#[derive(Debug, Clone, Default)]
struct Raw {
group: Option<String>,
artifact: String,
version: Option<String>,
packaging: Option<String>,
parent: Option<Gav>,
props: Vec<(String, String)>,
mgmt: Vec<Dep>,
deps: Vec<Dep>,
relocation: Option<(Option<String>, Option<String>, Option<String>)>,
}
/// The JDK Maven runs on, for `<activation><jdk>`.
const JDK: u32 = 21;
fn jdk_active(spec: &str) -> bool {
let spec = spec.trim();
if let Some(neg) = spec.strip_prefix('!') {
return !jdk_active(neg);
}
let major = |v: &str| -> Option<u32> {
let v = v.trim();
let v = v.strip_prefix("1.").unwrap_or(v);
v.split('.').next()?.parse().ok()
};
if spec.starts_with('[') || spec.starts_with('(') {
let lo_inc = spec.starts_with('[');
let hi_inc = spec.ends_with(']');
let inner = &spec[1..spec.len().saturating_sub(1)];
let (lo, hi) = inner.split_once(',').unwrap_or((inner, inner));
let lo_ok = match major(lo) {
None => true,
Some(l) => {
if lo_inc {
JDK >= l
} else {
JDK > l
}
}
};
let hi_ok = match major(hi) {
None => true,
Some(h) => {
if hi_inc {
JDK <= h
} else {
JDK < h
}
}
};
return lo_ok && hi_ok;
}
// A prefix: `1.8`, `11`, `21`.
major(spec) == Some(JDK)
}
fn profile_active(p: &El) -> bool {
let Some(a) = p.child("activation") else {
return false;
};
if a.text_of("activeByDefault").as_deref() == Some("true") {
return true;
}
if let Some(j) = a.text_of("jdk") {
return jdk_active(&j);
}
if let Some(prop) = a.child("property") {
// No `-D` is set: `!name` holds, `name` (with or without a value) does not.
return prop
.text_of("name")
.is_some_and(|n| n.starts_with('!') && prop.text_of("value").is_none());
}
if let Some(os) = a.child("os") {
let fam = os
.text_of("family")
.unwrap_or_default()
.to_ascii_lowercase();
let name = os.text_of("name").unwrap_or_default().to_ascii_lowercase();
let fam_ok = fam.is_empty()
|| fam == "unix"
|| fam == "!windows"
|| fam == "!mac"
|| (fam.starts_with('!') && fam != "!unix");
let name_ok = name.is_empty() || name == "linux" || name == "!windows";
return fam_ok && name_ok;
}
false
}
fn read_raw(bytes: &[u8]) -> anyhow::Result<Raw> {
let text = String::from_utf8_lossy(bytes);
let root = parse_xml(&text)?;
if root.name != "project" {
anyhow::bail!("the document is a <{}>, not a <project>", root.name);
}
let parent = root.child("parent").and_then(|p| {
Some(Gav::new(
&p.text_of("groupId")?,
&p.text_of("artifactId")?,
&p.text_of("version")?,
))
});
let mut raw = Raw {
group: root.text_of("groupId"),
artifact: root
.text_of("artifactId")
.ok_or_else(|| anyhow::anyhow!("the POM names no artifactId"))?,
version: root.text_of("version"),
packaging: root.text_of("packaging"),
parent,
..Raw::default()
};
let fold = |e: &El, raw: &mut Raw| {
if let Some(p) = e.child("properties") {
for k in &p.kids {
raw.props.push((k.name.clone(), k.text.trim().to_string()));
}
}
if let Some(m) = e
.child("dependencyManagement")
.and_then(|m| m.child("dependencies"))
{
raw.mgmt.extend(m.all("dependency").filter_map(Dep::read));
}
if let Some(d) = e.child("dependencies") {
raw.deps.extend(d.all("dependency").filter_map(Dep::read));
}
};
fold(&root, &mut raw);
if let Some(ps) = root.child("profiles") {
for p in ps.all("profile").filter(|p| profile_active(p)) {
fold(p, &mut raw);
}
}
if let Some(r) = root
.child("distributionManagement")
.and_then(|d| d.child("relocation"))
{
raw.relocation = Some((
r.text_of("groupId"),
r.text_of("artifactId"),
r.text_of("version"),
));
}
Ok(raw)
}
/// One version's effective model: what resolution reads of it.
#[derive(Debug, Clone)]
struct Model {
packaging: String,
deps: Vec<Dep>,
mgmt: Vec<Dep>,
relocation: Option<Gav>,
}
/// The most bytes one interpolated value may hold (SEC-REVIEW 2026-11-01 D1): a POM's real
/// values are a version, a group, a path. Past it the value is a property bomb.
pub const MAX_PROPERTY_BYTES: usize = 64 * 1024;
/// The most bytes a model's interpolated properties may hold together (D1).
pub const MAX_MODEL_PROPERTY_BYTES: usize = 1024 * 1024;
/// The largest POM read (SEC-REVIEW 2026-11-01 D3): a real one is kilobytes; past this the
/// version is unresolved and its set held.
pub const MAX_POM_BYTES: u64 = 1024 * 1024;
/// The deepest chain of BOM imports followed (D3); past it the version is unresolved.
const MAX_IMPORT_DEPTH: usize = 16;
/// The most rounds one value is interpolated (a property naming a property naming …).
const MAX_INTERPOLATION_ROUNDS: usize = 12;
/// `s` with every `${key}` of `props` replaced, at most [`MAX_INTERPOLATION_ROUNDS`] deep;
/// `None` when it grows past [`MAX_PROPERTY_BYTES`] (a property bomb: fail closed).
fn interpolate(s: &str, props: &HashMap<String, String>) -> Option<String> {
if s.len() > MAX_PROPERTY_BYTES {
return None;
}
let mut out = s.to_string();
for _ in 0..MAX_INTERPOLATION_ROUNDS {
if !out.contains("${") {
break;
}
let mut next = String::with_capacity(out.len());
let mut rest = out.as_str();
let mut changed = false;
while let Some(i) = rest.find("${") {
next.push_str(&rest[..i]);
let tail = &rest[i + 2..];
match tail.find('}') {
Some(e) => {
let key = &tail[..e];
match props.get(key) {
Some(v) => {
if next.len() + v.len() > MAX_PROPERTY_BYTES {
return None;
}
next.push_str(v);
changed = true;
}
None => {
next.push_str("${");
next.push_str(key);
next.push('}');
}
}
rest = &tail[e + 1..];
}
None => {
next.push_str(&rest[i..]);
rest = "";
}
}
if next.len() > MAX_PROPERTY_BYTES {
return None;
}
}
next.push_str(rest);
if next.len() > MAX_PROPERTY_BYTES {
return None;
}
out = next;
if !changed {
break;
}
}
Some(out)
}
// ── the closure ────────────────────────────────────────────────────────────
/// Why a file is in the closure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Role {
/// The version asked for.
Requested,
/// A parent POM.
Parent,
/// A BOM a `dependencyManagement` imports.
Bom,
/// A dependency's POM (every version the graph visits).
Descriptor,
/// A dependency's artifact (a version that wins mediation).
Dependency,
}
impl Role {
/// The role [`Role::name`] names.
pub fn from_name(n: &str) -> Option<Self> {
[
Role::Requested,
Role::Parent,
Role::Bom,
Role::Descriptor,
Role::Dependency,
]
.into_iter()
.find(|r| r.name() == n)
}
pub fn name(self) -> &'static str {
match self {
Role::Requested => "requested",
Role::Parent => "parent",
Role::Bom => "bom",
Role::Descriptor => "descriptor",
Role::Dependency => "dependency",
}
}
}
/// One file of the closure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClosureFile {
/// The path below the repository.
pub path: String,
pub role: Role,
/// The version that needed it (`group:artifact:version`).
pub via: String,
}
/// **A version's closure**: the files in the order they were found, and what could not be
/// resolved (each holds the set, fail closed).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Closure {
pub files: Vec<ClosureFile>,
/// `(coordinate, why)`.
pub unresolved: Vec<(String, String)>,
}
impl Closure {
/// Every path, in order.
pub fn paths(&self) -> Vec<String> {
self.files.iter().map(|f| f.path.clone()).collect()
}
}
/// The versions a `maven-metadata.xml` lists (`versioning/versions/version`); none when it
/// does not read.
pub fn metadata_versions(text: &str) -> Vec<String> {
parse_xml(text)
.ok()
.and_then(|x| {
x.child("versioning")
.and_then(|v| v.child("versions"))
.map(|v| {
v.all("version")
.map(|e| e.text.trim().to_string())
.collect()
})
})
.unwrap_or_default()
}
/// The most nodes one closure visits; past it the closure is held (fail closed).
pub const MAX_NODES: usize = 20_000;
/// The artifacts Maven's core exports to every plugin realm: a plugin's own copy of them is
/// never fetched (`maven-core`'s `META-INF/maven/extension.xml` and the classworlds,
/// Sisu and Guice it runs on).
fn core_export(group: &str, artifact: &str) -> bool {
match group {
"org.apache.maven" => matches!(
artifact,
"maven-artifact"
| "maven-builder-support"
| "maven-core"
| "maven-model"
| "maven-model-builder"
| "maven-plugin-api"
| "maven-repository-metadata"
| "maven-resolver-provider"
| "maven-settings"
| "maven-settings-builder"
| "maven-aether-provider"
| "maven-compat"
| "maven-embedder"
),
"org.sonatype.sisu" | "org.eclipse.sisu" | "org.sonatype.sisu.inject" => true,
"org.codehaus.plexus" => artifact == "plexus-classworlds",
"classworlds" | "org.sonatype.aether" | "org.eclipse.aether" => true,
"com.google.inject" => artifact == "guice",
"javax.annotation" => artifact == "javax.annotation-api",
_ => false,
}
}
/// The file a dependency's `type` (and classifier) names: `(classifier, extension)`;
/// `None` for a POM-only dependency.
fn artifact_of(kind: &str, classifier: Option<&str>) -> Option<(Option<String>, String)> {
let c = classifier.map(str::to_string);
match kind {
"pom" => None,
"jar" | "maven-plugin" | "ejb" | "bundle" | "eclipse-plugin" | "takari-jar" => {
Some((c, "jar".into()))
}
"test-jar" => Some((Some(c.unwrap_or_else(|| "tests".into())), "jar".into())),
"ejb-client" => Some((Some(c.unwrap_or_else(|| "client".into())), "jar".into())),
"java-source" => Some((Some(c.unwrap_or_else(|| "sources".into())), "jar".into())),
"javadoc" => Some((Some(c.unwrap_or_else(|| "javadoc".into())), "jar".into())),
other => Some((c, other.to_string())),
}
}
/// A `packaging` → the artifact a dependency on the version fetches.
fn packaging_artifact(packaging: &str) -> Option<String> {
match packaging {
"pom" => None,
"jar"
| "maven-plugin"
| "bundle"
| "ejb"
| "eclipse-plugin"
| "takari-jar"
| "maven-archetype"
| "takari-maven-plugin" => Some("jar".into()),
other => Some(other.to_string()),
}
}
struct Resolver<'a> {
src: &'a dyn PomSource,
raw: HashMap<Gav, Result<Raw, String>>,
models: HashMap<Gav, Result<Model, String>>,
building: HashSet<Gav>,
files: Vec<ClosureFile>,
seen: HashSet<String>,
}
impl<'a> Resolver<'a> {
fn add(&mut self, path: String, role: Role, via: &str) {
if self.seen.insert(path.clone()) {
self.files.push(ClosureFile {
path,
role,
via: via.to_string(),
});
}
}
fn raw(&mut self, gav: &Gav, role: Role, via: &str) -> anyhow::Result<Result<Raw, String>> {
if let Some(r) = self.raw.get(gav).cloned() {
if r.is_ok() {
self.add(gav.pom_path(), role, via);
}
return Ok(r);
}
let r = if !gav_ok(gav) {
Err(format!("'{gav}' is not a Maven coordinate Holger reads"))
} else {
match self.src.pom(&gav.pom_path())? {
None => Err(format!(
"the repository does not publish {}",
gav.pom_path()
)),
Some(b) if b.len() as u64 > MAX_POM_BYTES => Err(format!(
"{} is larger than the {MAX_POM_BYTES}-byte POM cap",
gav.pom_path()
)),
Some(b) => read_raw(&b)
.map_err(|e| format!("{} is no POM Holger reads: {e}", gav.pom_path())),
}
};
if r.is_ok() {
self.add(gav.pom_path(), role, via);
}
self.raw.insert(gav.clone(), r.clone());
Ok(r)
}
/// The effective model of `gav` (its parents and BOMs are read as it is built).
fn model(&mut self, gav: &Gav, role: Role, via: &str) -> anyhow::Result<Result<Model, String>> {
if let Some(m) = self.models.get(gav).cloned() {
// Read again for this role: its files are in the closure already.
let _ = self.raw(gav, role, via)?;
return Ok(m);
}
if self.building.len() >= MAX_IMPORT_DEPTH {
return Ok(Err(format!(
"{gav}: its BOM imports nest deeper than {MAX_IMPORT_DEPTH}"
)));
}
if !self.building.insert(gav.clone()) {
return Ok(Err(format!("{gav} imports itself")));
}
let out = self.build(gav, role, via);
self.building.remove(gav);
let out = out?;
self.models.insert(gav.clone(), out.clone());
Ok(out)
}
fn build(&mut self, gav: &Gav, role: Role, via: &str) -> anyhow::Result<Result<Model, String>> {
let me = gav.to_string();
let first = match self.raw(gav, role, via)? {
Ok(r) => r,
Err(e) => return Ok(Err(e)),
};
// The lineage, the version itself first.
let mut lineage = vec![first];
while let Some(p) = lineage.last().and_then(|r| r.parent.clone()) {
if lineage.len() > 32 {
return Ok(Err(format!("{gav}: its parents nest deeper than 32")));
}
match self.raw(&p, Role::Parent, &me)? {
Ok(r) => lineage.push(r),
Err(e) => return Ok(Err(format!("{gav}: its parent {p}: {e}"))),
}
}
// Properties, the top ancestor first so a child overrides.
let mut props: HashMap<String, String> = HashMap::new();
for r in lineage.iter().rev() {
for (k, v) in &r.props {
props.insert(k.clone(), v.clone());
}
}
let own = &lineage[0];
let group = own
.group
.clone()
.or_else(|| own.parent.as_ref().map(|p| p.group.clone()))
.unwrap_or_else(|| gav.group.clone());
let version = own
.version
.clone()
.or_else(|| own.parent.as_ref().map(|p| p.version.clone()))
.unwrap_or_else(|| gav.version.clone());
for (k, v) in [
("groupId", group.clone()),
("artifactId", own.artifact.clone()),
("version", version.clone()),
] {
props.insert(format!("project.{k}"), v.clone());
props.insert(format!("pom.{k}"), v.clone());
props.insert(k.to_string(), v);
}
if let Some(p) = &own.parent {
for (k, v) in [
("groupId", &p.group),
("artifactId", &p.artifact),
("version", &p.version),
] {
props.insert(format!("project.parent.{k}"), v.clone());
props.insert(format!("parent.{k}"), v.clone());
}
}
// The values Maven's own build sets.
props
.entry("java.version".into())
.or_insert_with(|| JDK.to_string());
props
.entry("project.build.sourceEncoding".into())
.or_insert_with(|| "UTF-8".into());
// Raw values refer to each other: interpolate the map itself first, bounded (D1: a
// property bomb in this POM, a parent or a BOM holds the version, it never grows the
// server's memory without a limit).
let bomb = |what: &str| {
format!(
"{gav}: {what} expands past {MAX_PROPERTY_BYTES} bytes a value or \
{MAX_MODEL_PROPERTY_BYTES} bytes the model (a property bomb)"
)
};
let snapshot = props.clone();
let mut total = 0usize;
for (k, v) in props.iter_mut() {
match interpolate(v, &snapshot) {
Some(x) if total + x.len() <= MAX_MODEL_PROPERTY_BYTES => {
total += x.len();
*v = x;
}
_ => return Ok(Err(bomb(&format!("the property `{k}`")))),
}
}
let over = std::cell::Cell::new(false);
let p = |s: &str| {
interpolate(s, &props).unwrap_or_else(|| {
over.set(true);
String::new()
})
};
// Inheritance: management and dependencies by key, the child's first.
let mut mgmt: Vec<Dep> = Vec::new();
let mut deps: Vec<Dep> = Vec::new();
let mut mkeys = BTreeSet::new();
let mut dkeys = BTreeSet::new();
for r in &lineage {
for d in &r.mgmt {
let mut d = d.clone();
d.interpolate(&p);
if mkeys.insert(d.key()) {
mgmt.push(d);
}
}
for d in &r.deps {
let mut d = d.clone();
d.interpolate(&p);
if dkeys.insert(d.key()) {
deps.push(d);
}
}
}
if over.get() {
return Ok(Err(bomb("a dependency's coordinate")));
}
// Imports, in declaration order: an earlier entry wins.
let mut merged: Vec<Dep> = Vec::new();
let mut keys = BTreeSet::new();
let mut imports = Vec::new();
for d in mgmt {
if d.scope.as_deref() == Some("import") && d.kind() == "pom" {
imports.push(d);
} else if keys.insert(d.key()) {
merged.push(d);
}
}
for d in imports {
let Some(v) = d.version.clone().filter(|v| !v.contains("${")) else {
return Ok(Err(format!(
"{gav}: the imported BOM {}:{} names no version Holger can read",
d.group, d.artifact
)));
};
let bom = Gav::new(&d.group, &d.artifact, &v);
match self.model(&bom, Role::Bom, &me)? {
Ok(m) => {
for e in m.mgmt {
if keys.insert(e.key()) {
merged.push(e);
}
}
}
Err(e) => return Ok(Err(format!("{gav}: its imported BOM {bom}: {e}"))),
}
}
// Management fills what a dependency leaves out.
let by_key: HashMap<String, Dep> = merged.iter().map(|d| (d.key(), d.clone())).collect();
for d in &mut deps {
if let Some(m) = by_key.get(&d.key()) {
if d.version.is_none() {
d.version = m.version.clone();
}
if d.scope.is_none() {
d.scope = m.scope.clone();
}
if d.optional.is_none() {
d.optional = m.optional.clone();
}
for x in &m.exclusions {
if !d.exclusions.contains(x) {
d.exclusions.push(x.clone());
}
}
}
}
let relocation = own.relocation.as_ref().map(|(g, a, v)| {
Gav::new(
&g.as_deref().map(&p).unwrap_or_else(|| group.clone()),
&a.as_deref().map(&p).unwrap_or_else(|| own.artifact.clone()),
&v.as_deref().map(&p).unwrap_or_else(|| version.clone()),
)
});
let packaging = own
.packaging
.as_deref()
.map(&p)
.unwrap_or_else(|| "jar".into());
if over.get() {
return Ok(Err(bomb("its packaging or relocation")));
}
Ok(Ok(Model {
packaging,
deps,
mgmt: merged,
relocation: relocation.filter(|r| r != gav),
}))
}
}
/// A Maven version (`1.2`, `1.2.3-beta-1`), compared the way `ComparableVersion` orders the
/// common cases: numbers numerically, a qualifier before the release it qualifies.
pub fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
fn items(v: &str) -> Vec<(u8, u64, String)> {
v.split(['.', '-'])
.map(|s| {
let l = s.to_ascii_lowercase();
match l.parse::<u64>() {
Ok(n) => (2, n, String::new()),
Err(_) => {
let rank = match l.as_str() {
"alpha" | "a" => 0,
"beta" | "b" => 0,
"milestone" | "m" | "rc" | "cr" | "snapshot" => 0,
"" | "ga" | "final" | "release" => 2,
"sp" => 3,
_ => 1,
};
(rank, 0, l)
}
}
})
.collect()
}
let (x, y) = (items(a), items(b));
for i in 0..x.len().max(y.len()) {
let pad = (2u8, 0u64, String::new());
let (p, q) = (x.get(i).unwrap_or(&pad), y.get(i).unwrap_or(&pad));
let o = p.cmp(q);
if o != std::cmp::Ordering::Equal {
return o;
}
}
std::cmp::Ordering::Equal
}
/// The highest of `versions` in a Maven range (`[1.0,2.0)`, `[1.5,)`, `(,1.0]`, `[1.2]`).
fn pick_in_range(range: &str, versions: &[String]) -> Option<String> {
use std::cmp::Ordering::*;
let in_one = |spec: &str, v: &str| -> bool {
let spec = spec.trim();
if spec.len() < 2 {
return false;
}
let lo_inc = spec.starts_with('[');
let hi_inc = spec.ends_with(']');
let inner = &spec[1..spec.len() - 1];
match inner.split_once(',') {
None => compare_versions(v, inner.trim()) == Equal,
Some((lo, hi)) => {
let (lo, hi) = (lo.trim(), hi.trim());
let lo_ok = lo.is_empty()
|| match compare_versions(v, lo) {
Greater => true,
Equal => lo_inc,
Less => false,
};
let hi_ok = hi.is_empty()
|| match compare_versions(v, hi) {
Less => true,
Equal => hi_inc,
Greater => false,
};
lo_ok && hi_ok
}
}
};
// `[1,2),[3,4)`: split on the comma that follows a closing bracket.
let mut specs = Vec::new();
let mut cur = String::new();
for c in range.chars() {
cur.push(c);
if c == ']' || c == ')' {
specs.push(std::mem::take(&mut cur));
}
}
let specs: Vec<String> = specs
.into_iter()
.map(|s| s.trim_start_matches(',').trim().to_string())
.collect();
versions
.iter()
.filter(|v| !v.to_ascii_uppercase().contains("SNAPSHOT"))
.filter(|v| specs.iter().any(|s| in_one(s, v)))
.max_by(|a, b| compare_versions(a, b))
.cloned()
}
/// One visit of the graph: a version, the exclusions on its path, its child nodes.
type Visit = (Gav, Vec<(String, String)>, Vec<usize>);
/// One node of the graph: a dependency where it was met.
#[derive(Debug, Clone)]
struct Node {
gav: Gav,
kind: String,
classifier: Option<String>,
depth: usize,
/// The node's visit (its expansion), by `(gav, exclusions)`.
visit: usize,
}
fn excluded(ex: &[(String, String)], group: &str, artifact: &str) -> bool {
ex.iter()
.any(|(g, a)| (g == "*" || g == group) && (a == "*" || a == artifact))
}
/// **The closure of `root`** (`group:artifact:version`), as Maven 3.9 reads it to use that
/// version: as a plugin when its packaging is `maven-plugin`, as a dependency otherwise.
/// `Err` only when the repository could not be read (the pull is retried); a file the
/// repository does not publish, or a POM Holger cannot read, is in `unresolved`.
pub fn closure(src: &dyn PomSource, root: &Gav) -> anyhow::Result<Closure> {
let mut r = Resolver {
src,
raw: HashMap::new(),
models: HashMap::new(),
building: HashSet::new(),
files: Vec::new(),
seen: HashSet::new(),
};
let mut unresolved: Vec<(String, String)> = Vec::new();
let root_model = match r.model(root, Role::Requested, &root.to_string())? {
Ok(m) => m,
Err(e) => {
return Ok(Closure {
files: r.files,
unresolved: vec![(root.to_string(), e)],
})
}
};
let plugin = root_model.packaging == "maven-plugin";
// The requested version's own management, applied from the second level down.
let managed: HashMap<String, Dep> = root_model
.mgmt
.iter()
.map(|d| (d.key(), d.clone()))
.collect();
// visits[i] = (gav, exclusions, children node indexes)
let mut visits: Vec<Visit> = Vec::new();
let mut visit_of: HashMap<(Gav, Vec<(String, String)>), usize> = HashMap::new();
let mut nodes: Vec<Node> = Vec::new();
let mut queue: VecDeque<(usize, usize)> = VecDeque::new(); // (visit, depth)
visits.push((root.clone(), Vec::new(), Vec::new()));
visit_of.insert((root.clone(), Vec::new()), 0);
queue.push_back((0, 0));
let mut models: HashMap<Gav, Model> = HashMap::new();
models.insert(root.clone(), root_model);
while let Some((vi, depth)) = queue.pop_front() {
if nodes.len() > MAX_NODES {
unresolved.push((
root.to_string(),
format!("the graph has more than {MAX_NODES} nodes; held"),
));
break;
}
let (gav, excl) = (visits[vi].0.clone(), visits[vi].1.clone());
let model = match models.get(&gav) {
Some(m) => m.clone(),
None => match r.model(&gav, Role::Descriptor, &gav.to_string())? {
Ok(m) => {
models.insert(gav.clone(), m.clone());
m
}
Err(e) => {
unresolved.push((gav.to_string(), e));
continue;
}
},
};
let mut deps = model.deps.clone();
if let Some(to) = &model.relocation {
// A relocated version names its new coordinates: they are what is used.
deps = vec![Dep {
group: to.group.clone(),
artifact: to.artifact.clone(),
version: Some(to.version.clone()),
..Dep::default()
}];
}
let via = gav.to_string();
for mut d in deps {
let child_depth = depth + 1;
if child_depth >= 2 {
if let Some(m) = managed.get(&d.key()) {
if m.version.is_some() {
d.version = m.version.clone();
}
if m.scope.is_some() {
d.scope = m.scope.clone();
}
}
}
let scope = d.scope.clone().unwrap_or_else(|| "compile".into());
if matches!(scope.as_str(), "test" | "provided" | "system" | "import") {
continue;
}
if child_depth >= 2 && d.optional.as_deref() == Some("true") {
continue;
}
if excluded(&excl, &d.group, &d.artifact) {
continue;
}
let Some(mut version) = d.version.clone() else {
unresolved.push((
format!("{}:{}", d.group, d.artifact),
format!("{via} names it with no version, and no management gives one"),
));
continue;
};
if version.contains("${") {
unresolved.push((
format!("{}:{}:{version}", d.group, d.artifact),
format!("{via} names a version Holger cannot interpolate"),
));
continue;
}
if version.starts_with('[') || version.starts_with('(') {
let listed = src
.versions(&d.group.replace('.', "/"), &d.artifact)?
.unwrap_or_default();
match pick_in_range(&version, &listed) {
Some(v) => version = v,
None => {
unresolved.push((
format!("{}:{}:{version}", d.group, d.artifact),
format!("{via}: the repository lists no version in that range"),
));
continue;
}
}
}
let child = Gav::new(&d.group, &d.artifact, &version);
let mut child_excl = excl.clone();
for x in &d.exclusions {
if !child_excl.contains(x) {
child_excl.push(x.clone());
}
}
child_excl.sort();
let key = (child.clone(), child_excl.clone());
let cv = match visit_of.get(&key) {
Some(v) => *v,
None => {
// A cycle back to a version on this path is not followed again.
let v = visits.len();
visits.push((child.clone(), child_excl, Vec::new()));
visit_of.insert(key, v);
queue.push_back((v, child_depth));
v
}
};
let ni = nodes.len();
nodes.push(Node {
gav: child,
kind: d.kind().to_string(),
classifier: d.classifier.clone(),
depth: child_depth,
visit: cv,
});
visits[vi].2.push(ni);
}
}
// Nearest wins (the first met at the least depth), by group:artifact:type:classifier.
let ga_key = |n: &Node| {
format!(
"{}:{}:{}:{}",
n.gav.group,
n.gav.artifact,
n.kind,
n.classifier.as_deref().unwrap_or("")
)
};
let mut winner: BTreeMap<String, (usize, usize)> = BTreeMap::new(); // key → (depth, node)
for (i, n) in nodes.iter().enumerate() {
let k = ga_key(n);
match winner.get(&k) {
Some((d, _)) if *d <= n.depth => {}
_ => {
winner.insert(k, (n.depth, i));
}
}
}
let root_art = packaging_artifact(
&models
.get(root)
.map(|m| m.packaging.clone())
.unwrap_or_default(),
);
let mut art_paths: Vec<ClosureFile> = Vec::new();
if let Some(ext) = root_art.as_deref().filter(|e| !segment_ok(e)) {
unresolved.push((
root.to_string(),
format!("its packaging '{ext}' is not a Maven path segment"),
));
}
if let Some(ext) = root_art.filter(|e| segment_ok(e)) {
art_paths.push(ClosureFile {
path: root.file_path(None, &ext),
role: Role::Requested,
via: root.to_string(),
});
}
// Reachable through winners only.
let mut seen_visit = HashSet::new();
let mut stack = vec![0usize];
seen_visit.insert(0usize);
let mut taken = HashSet::new();
while let Some(v) = stack.pop() {
for &ni in &visits[v].2 {
let n = &nodes[ni];
if winner
.get(&ga_key(n))
.map(|(_, w)| nodes[*w].gav.version.as_str())
!= Some(n.gav.version.as_str())
{
continue;
}
if taken.insert(ni) {
let skip = plugin && core_export(&n.gav.group, &n.gav.artifact);
if !skip {
if let Some((cls, ext)) = artifact_of(&n.kind, n.classifier.as_deref()) {
// D2: the classifier and the type are path segments of this version's
// directory, nothing else (no `/`, `..`, `?` or `#`).
if cls.as_deref().is_none_or(segment_ok) && segment_ok(&ext) {
art_paths.push(ClosureFile {
path: n.gav.file_path(cls.as_deref(), &ext),
role: Role::Dependency,
via: visits[v].0.to_string(),
});
} else {
unresolved.push((
n.gav.to_string(),
format!(
"its classifier '{}' or type '{ext}' is not a Maven path segment",
cls.as_deref().unwrap_or_default()
),
));
}
}
}
}
if seen_visit.insert(n.visit) {
stack.push(n.visit);
}
}
}
for f in art_paths {
r.add(f.path, f.role, &f.via);
}
unresolved.sort();
unresolved.dedup();
Ok(Closure {
files: r.files,
unresolved,
})
}
#[cfg(test)]
mod tests {
use super::*;
const FX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/depset");
/// What a real `mvn -B dependency:get -Dartifact=org.apache.commons:commons-lang3:3.17.0`
/// (Maven 3.9.16, an empty local repository, Maven Central, 2026-11-01) downloaded, in
/// order: the plugin's closure, then commons-lang3's.
const TRUTH: &str = "mvn-3.9.16-dependency-get-commons-lang3.txt";
const LANG3_POM: &str = "org/apache/commons/commons-lang3/3.17.0/commons-lang3-3.17.0.pom";
/// The real POMs Maven read, from Maven Central.
struct Fixtures;
impl PomSource for Fixtures {
fn pom(&self, path: &str) -> anyhow::Result<Option<Vec<u8>>> {
Ok(std::fs::read(std::path::Path::new(FX).join("maven").join(path)).ok())
}
fn versions(&self, _: &str, _: &str) -> anyhow::Result<Option<Vec<String>>> {
Ok(None)
}
}
fn truth() -> (BTreeSet<String>, BTreeSet<String>) {
let text = std::fs::read_to_string(std::path::Path::new(FX).join(TRUTH)).unwrap();
let lines: Vec<String> = text.lines().map(str::to_string).collect();
let at = lines.iter().position(|l| l == LANG3_POM).unwrap();
(
lines[..at].iter().cloned().collect(),
lines[at..].iter().cloned().collect(),
)
}
fn same(what: &str, got: &Closure, want: &BTreeSet<String>) {
let got_set: BTreeSet<String> = got.paths().into_iter().collect();
let missing: Vec<&String> = want.difference(&got_set).collect();
let extra: Vec<&String> = got_set.difference(want).collect();
assert!(
missing.is_empty() && extra.is_empty() && got.unresolved.is_empty(),
"{what}: {} files resolved, Maven read {}\nmissing ({}): {missing:#?}\nextra ({}): {extra:#?}\nunresolved: {:#?}",
got_set.len(),
want.len(),
missing.len(),
extra.len(),
got.unresolved
);
}
/// **The plugin's closure is exactly what real Maven reads**: 170 POMs (every version
/// the graph visits, its parents and BOMs) and 51 JARs (the mediation winners, less what
/// Maven's core gives every plugin), from the real POMs of Maven Central.
#[test]
fn the_plugin_closure_is_what_real_maven_reads() {
let (plugin, _) = truth();
let c = closure(
&Fixtures,
&Gav::new(
"org.apache.maven.plugins",
"maven-dependency-plugin",
"3.7.0",
),
)
.unwrap();
same("maven-dependency-plugin 3.7.0", &c, &plugin);
assert_eq!(c.files[0].role, Role::Requested);
}
/// commons-lang3 3.17.0: its POM, its parents (commons-parent 73, apache 33), the BOM
/// commons-parent imports (junit-bom 5.11.0) and its JAR; its test and provided
/// dependencies are not read.
#[test]
fn the_commons_lang3_closure_is_what_real_maven_reads() {
let (_, lang) = truth();
let c = closure(
&Fixtures,
&Gav::new("org.apache.commons", "commons-lang3", "3.17.0"),
)
.unwrap();
same("commons-lang3 3.17.0", &c, &lang);
let roles: BTreeMap<String, Role> =
c.files.iter().map(|f| (f.path.clone(), f.role)).collect();
assert_eq!(roles[LANG3_POM], Role::Requested);
assert_eq!(
roles["org/apache/commons/commons-parent/73/commons-parent-73.pom"],
Role::Parent
);
assert_eq!(
roles["org/junit/junit-bom/5.11.0/junit-bom-5.11.0.pom"],
Role::Bom
);
}
/// **A dependency's classifier or type never leaves its version's directory** (SEC-REVIEW
/// 2026-11-01 D2). `artifact_of` kept any type as the extension and the classifier went
/// unchecked into the path, so `classifier = x/../../…/log4j-core/2.14.1/log4j-core-2.14.1`
/// fetched another artifact under a misleading name. Such a dependency is unresolved
/// (it holds the set) and no path with it is fetched.
#[test]
fn a_classifier_or_type_with_a_path_in_it_is_unresolved() {
for (cls, kind) in [
(
"x/../../../org/apache/logging/log4j/log4j-core/2.14.1/log4j-core-2.14.1",
"jar",
),
("ok", "jar/../../evil"),
("a?b", "jar"),
("a#b", "jar"),
] {
let src = bomb(
"",
&format!(
"<dependency><groupId>g</groupId><artifactId>a</artifactId><version>1</version>\
<classifier>{cls}</classifier><type>{kind}</type></dependency>"
),
);
let c = closure(&OneOrStub(src), &Gav::new("evil", "bomb", "1")).unwrap();
assert!(
c.files.iter().all(|f| !f.path.contains("..")
&& !f.path.contains('?')
&& !f.path.contains('#')),
"{cls} {kind}: {:?}",
c.paths()
);
assert!(
c.unresolved
.iter()
.any(|(_, why)| why.contains("not a Maven path segment")),
"{cls} {kind}: {:?}",
c.unresolved
);
}
}
/// `evil:bomb:1` from [`OnePom`], and a plain POM for any other version asked.
struct OneOrStub(OnePom);
impl PomSource for OneOrStub {
fn pom(&self, path: &str) -> anyhow::Result<Option<Vec<u8>>> {
if let Some(b) = self.0.pom(path)? {
return Ok(Some(b));
}
let parts: Vec<&str> = path.split('/').collect();
let n = parts.len();
if n < 4 {
return Ok(None);
}
Ok(Some(
format!(
"<project><modelVersion>4.0.0</modelVersion><groupId>{}</groupId>\
<artifactId>{}</artifactId><version>{}</version></project>",
parts[..n - 3].join("."),
parts[n - 3],
parts[n - 2]
)
.into_bytes(),
))
}
fn versions(&self, _: &str, _: &str) -> anyhow::Result<Option<Vec<String>>> {
Ok(None)
}
}
/// `b:bom:<n>` imports `b:bom:<n+1>` up to `last`, which imports nothing.
struct BomChain(usize);
impl PomSource for BomChain {
fn pom(&self, path: &str) -> anyhow::Result<Option<Vec<u8>>> {
let Some(n) = path
.strip_prefix("b/bom/")
.and_then(|r| r.split('/').next())
.and_then(|n| n.parse::<usize>().ok())
else {
return Ok(None);
};
let import = if n < self.0 {
format!(
"<dependencyManagement><dependencies><dependency><groupId>b</groupId>\
<artifactId>bom</artifactId><version>{}</version><type>pom</type>\
<scope>import</scope></dependency></dependencies></dependencyManagement>",
n + 1
)
} else {
String::new()
};
Ok(Some(
format!(
"<project><modelVersion>4.0.0</modelVersion><groupId>b</groupId>\
<artifactId>bom</artifactId><version>{n}</version><packaging>pom</packaging>{import}</project>"
)
.into_bytes(),
))
}
fn versions(&self, _: &str, _: &str) -> anyhow::Result<Option<Vec<String>>> {
Ok(None)
}
}
/// **A POM's size and its BOM imports are bounded** (SEC-REVIEW 2026-11-01 D3). A POM is
/// read whole in the server process, and its cap was the source's `max_bytes` (4 GiB); BOM
/// imports recursed without a depth limit. Past 1 MiB a POM is unresolved, and imports
/// nesting deeper than 16 hold the version, by name.
#[test]
fn a_pom_past_its_cap_or_an_import_chain_past_its_depth_is_unresolved() {
let big = bomb(&format!("<pad>{}</pad>", "x".repeat(1024 * 1024)), "");
let c = closure(&big, &Gav::new("evil", "bomb", "1")).unwrap();
assert!(
c.unresolved
.iter()
.any(|(_, why)| why.contains("larger than")),
"{:?}",
c.unresolved
);
let deep = closure(&BomChain(40), &Gav::new("b", "bom", "0")).unwrap();
assert!(
deep.unresolved
.iter()
.any(|(_, why)| why.contains("nest deeper")),
"{:?}",
deep.unresolved
);
let shallow = closure(&BomChain(4), &Gav::new("b", "bom", "0")).unwrap();
assert!(shallow.unresolved.is_empty(), "{:?}", shallow.unresolved);
}
/// A DOCTYPE is read up to its own `>`: many `<!x>` tokens parse in one pass (D3).
#[test]
fn many_doctype_tokens_parse() {
let doc = format!("{}<project><a>1</a></project>", "<!x>".repeat(20_000));
let x = parse_xml(&doc).unwrap();
assert_eq!(x.name, "project");
}
/// A POM the repository does not publish holds the closure, by name (fail closed).
#[test]
fn a_missing_pom_is_named_unresolved() {
let c = closure(&Fixtures, &Gav::new("org.example", "nowhere", "1.0")).unwrap();
assert!(c.files.is_empty());
assert_eq!(c.unresolved.len(), 1);
assert!(
c.unresolved[0].1.contains("does not publish"),
"{:?}",
c.unresolved
);
}
#[test]
fn the_xml_reader_reads_a_pom() {
let x = r#"<?xml version="1.0" encoding="UTF-8"?>
<!-- a comment -->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<artifactId>a&b</artifactId>
<description><![CDATA[x < y]]></description>
<empty/>
<properties><x.y>1 A</x.y></properties>
</project>"#;
let e = parse_xml(x).unwrap();
assert_eq!(e.name, "project");
assert_eq!(e.text_of("artifactId").as_deref(), Some("a&b"));
assert_eq!(e.text_of("description").as_deref(), Some("x < y"));
assert!(e.child("empty").is_some());
assert_eq!(
e.child("properties").unwrap().text_of("x.y").as_deref(),
Some("1 A")
);
assert!(parse_xml("<a><b></a>").is_err());
assert!(parse_xml("<a>").is_err());
}
#[test]
fn versions_and_ranges_order_as_maven_does() {
use std::cmp::Ordering::*;
assert_eq!(compare_versions("1.10", "1.9"), Greater);
assert_eq!(compare_versions("1.0-beta-1", "1.0"), Less);
assert_eq!(compare_versions("1.0", "1.0.0"), Equal);
let v: Vec<String> = ["1.0", "1.5", "2.0", "2.1-SNAPSHOT"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(pick_in_range("[1.0,2.0)", &v).as_deref(), Some("1.5"));
assert_eq!(pick_in_range("[1.0,)", &v).as_deref(), Some("2.0"));
assert_eq!(pick_in_range("[1.0]", &v).as_deref(), Some("1.0"));
assert_eq!(pick_in_range("(,1.0)", &v), None);
}
/// One POM, in memory, at `evil:bomb:1`.
struct OnePom(String);
impl PomSource for OnePom {
fn pom(&self, path: &str) -> anyhow::Result<Option<Vec<u8>>> {
Ok((path == "evil/bomb/1/bomb-1.pom").then(|| self.0.clone().into_bytes()))
}
fn versions(&self, _: &str, _: &str) -> anyhow::Result<Option<Vec<String>>> {
Ok(None)
}
}
fn bomb(props: &str, deps: &str) -> OnePom {
OnePom(format!(
"<project><modelVersion>4.0.0</modelVersion><groupId>evil</groupId>\
<artifactId>bomb</artifactId><version>1</version><packaging>pom</packaging>\
<properties>{props}</properties><dependencies>{deps}</dependencies></project>"
))
}
/// **A property bomb holds the closure, it never grows past a bound** (SEC-REVIEW
/// 2026-11-01 D1). The model interpolated every property by its RAW value for 12 rounds,
/// so `<a>${a}${a}${a}${a}</a>` held 4^12 tokens (64 MiB) after the map's own pass, and
/// with ×100 and one use in a dependency it aborted holger-server on OOM. Past 64 KiB a
/// value (or 1 MiB the model's properties) the version is unresolved, so the set is held.
#[test]
fn a_property_bomb_is_unresolved_and_bounded() {
let c = closure(
&bomb("<a>${a}${a}${a}${a}</a>", ""),
&Gav::new("evil", "bomb", "1"),
)
.unwrap();
assert!(
c.unresolved
.iter()
.any(|(who, why)| who.contains("evil:bomb:1") && why.contains("property")),
"the bomb holds the closure by name: {:?}",
c.unresolved
);
}
/// The review's proof, ×100 and used by a dependency: before the bound this aborted the
/// process on OOM by round 4.
#[test]
fn the_reviews_property_bomb_never_reaches_the_allocator() {
let a = "${a}".repeat(100);
let c = closure(
&bomb(
&format!("<a>{a}</a>"),
"<dependency><groupId>x</groupId><artifactId>y</artifactId><version>${a}</version></dependency>",
),
&Gav::new("evil", "bomb", "1"),
)
.unwrap();
assert!(
c.unresolved
.iter()
.any(|(_, why)| why.contains("property bomb")),
"{:?}",
c.unresolved
);
let ok = interpolate(
"${v}",
&HashMap::from([("v".to_string(), "1.0".to_string())]),
);
assert_eq!(
ok.as_deref(),
Some("1.0"),
"a real value still interpolates"
);
}
#[test]
fn a_profile_is_active_where_maven_would_activate_it() {
assert!(jdk_active("[1.8,)"));
assert!(jdk_active("[11,)"));
assert!(!jdk_active("[1.8,9)"));
assert!(!jdk_active("1.8"));
assert!(jdk_active("21"));
assert!(!jdk_active("!21"));
}
}