Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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&amp;b</artifactId>
  <description><![CDATA[x < y]]></description>
  <empty/>
  <properties><x.y>1 &#x41;</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"));
    }
}