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

nordisk/znippy

plugins/native/znippy-plugin-maven/src/pom.rs

//! Fast POM dependency parser using quick-xml.
//!
//! Parses `<dependencies>` from a Maven POM file and provides
//! Maven Central URL construction for each dependency.

use quick_xml::escape::resolve_predefined_entity;
use quick_xml::events::Event;
use quick_xml::Reader;

/// Appends what a text or reference event carries to `out`.
///
/// Since quick-xml 0.38 the reader splits text around every `&...;`: `a&amp;b` comes as
/// `Text("a")`, `GeneralRef("amp")`, `Text("b")`. So a value is gathered here piece by
/// piece and trimmed once at its end tag. A character reference resolves to its char, a
/// predefined entity to its text; an unknown entity (a pom declares no DTD) is dropped.
fn push_text(event: &Event<'_>, out: &mut String) {
    match event {
        Event::Text(e) => {
            if let Ok(t) = e.xml10_content() {
                out.push_str(&t);
            }
        }
        Event::GeneralRef(e) => match e.resolve_char_ref() {
            Ok(Some(c)) => out.push(c),
            Ok(None) => {
                if let Some(t) = e.decode().ok().as_deref().and_then(resolve_predefined_entity) {
                    out.push_str(t);
                }
            }
            Err(_) => {}
        },
        _ => {}
    }
}

/// A Maven coordinate (GAV).
#[derive(Debug, Clone, PartialEq)]
pub struct MavenCoord {
    pub group_id: String,
    pub artifact_id: String,
    pub version: String,
    pub packaging: String,
    pub classifier: Option<String>,
}

impl MavenCoord {
    /// Construct the Maven Central download URL for this coordinate.
    /// e.g. https://repo1.maven.org/maven2/org/apache/spark/spark-core_2.13/3.5.1/spark-core_2.13-3.5.1.jar
    pub fn central_url(&self) -> String {
        let group_path = self.group_id.replace('.', "/");
        let ext = if self.packaging.is_empty() { "jar" } else { &self.packaging };
        let classifier_suffix = match &self.classifier {
            Some(c) if !c.is_empty() => format!("-{}", c),
            _ => String::new(),
        };
        format!(
            "https://repo1.maven.org/maven2/{}/{}/{}/{}-{}{}.{}",
            group_path, self.artifact_id, self.version,
            self.artifact_id, self.version, classifier_suffix, ext
        )
    }

    /// Filename for this artifact (e.g. "spark-core_2.13-3.5.1.jar")
    pub fn filename(&self) -> String {
        let ext = if self.packaging.is_empty() { "jar" } else { &self.packaging };
        let classifier_suffix = match &self.classifier {
            Some(c) if !c.is_empty() => format!("-{}", c),
            _ => String::new(),
        };
        format!("{}-{}{}.{}", self.artifact_id, self.version, classifier_suffix, ext)
    }
}

/// Parse all `<dependency>` entries from a POM XML string.
/// Only returns dependencies with groupId, artifactId, and version present.
/// Skips test/provided scope dependencies.
pub fn parse_pom_dependencies(xml: &[u8]) -> Vec<MavenCoord> {
    let mut reader = Reader::from_reader(xml);

    let mut deps = Vec::new();
    let mut in_dependencies = false;
    let mut in_dependency = false;
    let mut depth = 0;
    // The depth of the open `<dependency>`'s own children; an end tag deeper than that
    // (an `<exclusion>`'s groupId/artifactId) names another artifact (QXML-2).
    let mut field_depth = 0;

    // Current dependency being parsed
    let mut group_id = String::new();
    let mut artifact_id = String::new();
    let mut version = String::new();
    let mut packaging = String::new();
    let mut classifier: Option<String> = None;
    let mut scope = String::new();

    // The text of the innermost element seen so far, gathered until its end tag.
    let mut text = String::new();
    let mut buf = Vec::new();

    loop {
        match reader.read_event_into(&mut buf) {
            Ok(Event::Start(ref e)) => {
                text.clear();
                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
                if name == "dependencies" && depth == 1 {
                    in_dependencies = true;
                } else if name == "dependency" && in_dependencies {
                    in_dependency = true;
                    field_depth = depth + 1;
                    group_id.clear();
                    artifact_id.clear();
                    version.clear();
                    packaging.clear();
                    classifier = None;
                    scope.clear();
                }
                depth += 1;
            }
            Ok(Event::End(ref e)) => {
                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
                depth -= 1;
                if in_dependency && depth == field_depth {
                    let value = text.trim().to_string();
                    match name.as_str() {
                        "groupId" => group_id = value,
                        "artifactId" => artifact_id = value,
                        "version" => version = value,
                        "type" | "packaging" => packaging = value,
                        "classifier" => classifier = Some(value),
                        "scope" => scope = value,
                        _ => {}
                    }
                }
                text.clear();
                if name == "dependencies" {
                    in_dependencies = false;
                } else if name == "dependency" && in_dependency {
                    in_dependency = false;
                    // Skip test/provided/system scope
                    if !group_id.is_empty()
                        && !artifact_id.is_empty()
                        && !version.is_empty()
                        && scope != "test"
                        && scope != "provided"
                        && scope != "system"
                    {
                        if packaging.is_empty() {
                            packaging = "jar".to_string();
                        }
                        deps.push(MavenCoord {
                            group_id: group_id.clone(),
                            artifact_id: artifact_id.clone(),
                            version: version.clone(),
                            packaging: packaging.clone(),
                            classifier: classifier.clone(),
                        });
                    }
                }
            }
            Ok(ref e @ (Event::Text(_) | Event::GeneralRef(_))) => push_text(e, &mut text),
            Ok(Event::Eof) => break,
            Err(_) => break,
            _ => {}
        }
        buf.clear();
    }
    deps
}

/// Parse the project's own GAV from a POM (top-level groupId/artifactId/version).
pub fn parse_pom_project(xml: &[u8]) -> Option<MavenCoord> {
    let mut reader = Reader::from_reader(xml);

    let mut group_id = String::new();
    let mut artifact_id = String::new();
    let mut version = String::new();
    let mut packaging = String::from("jar");

    // The text of the innermost element seen so far, gathered until its end tag.
    let mut text = String::new();
    let mut depth = 0;
    let mut buf = Vec::new();

    loop {
        match reader.read_event_into(&mut buf) {
            Ok(Event::Start(_)) => {
                text.clear();
                depth += 1;
            }
            Ok(Event::End(ref e)) => {
                // Only capture top-level project fields (depth == 2: <project><X>)
                if depth == 2 {
                    let value = text.trim().to_string();
                    match e.name().as_ref() {
                        b"groupId" => group_id = value,
                        b"artifactId" => artifact_id = value,
                        b"version" => version = value,
                        b"packaging" => packaging = value,
                        _ => {}
                    }
                }
                depth -= 1;
                text.clear();
            }
            Ok(ref e @ (Event::Text(_) | Event::GeneralRef(_))) => push_text(e, &mut text),
            Ok(Event::Eof) => break,
            Err(_) => break,
            _ => {}
        }
        buf.clear();
    }

    if !group_id.is_empty() && !artifact_id.is_empty() && !version.is_empty() {
        Some(MavenCoord {
            group_id,
            artifact_id,
            version,
            packaging,
            classifier: None,
        })
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_simple_pom() {
        let pom = br#"<?xml version="1.0"?>
<project>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>my-app</artifactId>
    <version>1.0.0</version>
    <dependencies>
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-core_2.13</artifactId>
            <version>3.5.1</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>"#;

        let deps = parse_pom_dependencies(pom);
        assert_eq!(deps.len(), 1); // junit skipped (test scope)
        assert_eq!(deps[0].group_id, "org.apache.spark");
        assert_eq!(deps[0].artifact_id, "spark-core_2.13");
        assert_eq!(deps[0].version, "3.5.1");
        assert_eq!(
            deps[0].central_url(),
            "https://repo1.maven.org/maven2/org/apache/spark/spark-core_2.13/3.5.1/spark-core_2.13-3.5.1.jar"
        );
    }

    #[test]
    fn test_parse_project_gav() {
        let pom = br#"<project>
    <groupId>com.example</groupId>
    <artifactId>my-app</artifactId>
    <version>2.1.0</version>
    <packaging>war</packaging>
</project>"#;

        let proj = parse_pom_project(pom).unwrap();
        assert_eq!(proj.group_id, "com.example");
        assert_eq!(proj.packaging, "war");
    }

    #[test]
    fn test_coord_filename() {
        let c = MavenCoord {
            group_id: "io.netty".into(),
            artifact_id: "netty-all".into(),
            version: "4.1.109.Final".into(),
            packaging: "jar".into(),
            classifier: None,
        };
        assert_eq!(c.filename(), "netty-all-4.1.109.Final.jar");
    }

    /// RUSTSEC-2026-0194 (quadratic duplicate-attribute check) and RUSTSEC-2026-0195
    /// (unbounded namespace-declaration allocation) are both fixed in quick-xml 0.41.0,
    /// which added `name::DEFAULT_MAX_DECLARATIONS_PER_ELEMENT` (256) with the cap. Holger
    /// parses the pom.xml of packages an attacker can upload, so the quick-xml this crate
    /// builds against must be the fixed one; this does not compile against 0.36.
    #[test]
    fn the_quick_xml_in_the_build_carries_the_rustsec_2026_0194_and_0195_fixes() {
        assert_eq!(quick_xml::name::DEFAULT_MAX_DECLARATIONS_PER_ELEMENT, 256);
    }

    /// The advisories' input shape: one start tag with a flood of distinct attributes
    /// (0194) and of `xmlns:*` declarations (0195). The pom parser never iterates a tag's
    /// attributes and does not resolve namespaces, so such a pom costs time linear in its
    /// size and the coordinates still come out.
    #[test]
    fn a_pom_with_a_flood_of_attributes_and_namespace_declarations_parses_fast() {
        let mut pom = String::from("<project");
        for i in 0..20_000 {
            pom.push_str(&format!(" xmlns:p{i}=\"urn:p{i}\""));
        }
        pom.push_str("><groupId>com.example</groupId><artifactId>flood</artifactId><version>1.0</version><dependencies><dependency");
        for i in 0..80_000 {
            pom.push_str(&format!(" a{i}=\"v\""));
        }
        pom.push_str("><groupId>org.x</groupId><artifactId>y</artifactId><version>2.0</version></dependency></dependencies></project>");

        let t = std::time::Instant::now();
        let deps = parse_pom_dependencies(pom.as_bytes());
        let proj = parse_pom_project(pom.as_bytes()).unwrap();
        let took = t.elapsed();

        assert_eq!(deps.len(), 1);
        assert_eq!((deps[0].group_id.as_str(), deps[0].artifact_id.as_str(), deps[0].version.as_str()), ("org.x", "y", "2.0"));
        assert_eq!((proj.group_id.as_str(), proj.artifact_id.as_str(), proj.version.as_str()), ("com.example", "flood", "1.0"));
        // Linear work on ~2 MB is milliseconds; the quadratic check would take seconds.
        assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
    }

    /// Entity and character references in a value are resolved, and the whitespace
    /// around a value is trimmed, whichever way the reader splits the text.
    #[test]
    fn a_pom_value_with_entity_and_character_references_is_unescaped() {
        let pom = br#"<project>
    <groupId>com.example</groupId>
    <artifactId>a&amp;b</artifactId>
    <version>
        1.0&#46;0
    </version>
    <dependencies>
        <dependency>
            <groupId>org.&lt;x&gt;</groupId>
            <artifactId>&quot;y&apos;</artifactId>
            <version>2&#x2E;0</version>
            <classifier>  linux&amp;x86  </classifier>
        </dependency>
    </dependencies>
</project>"#;

        let proj = parse_pom_project(pom).unwrap();
        assert_eq!(proj.artifact_id, "a&b");
        assert_eq!(proj.version, "1.0.0");

        let deps = parse_pom_dependencies(pom);
        assert_eq!(deps.len(), 1);
        assert_eq!(deps[0].group_id, "org.<x>");
        assert_eq!(deps[0].artifact_id, "\"y'");
        assert_eq!(deps[0].version, "2.0");
        assert_eq!(deps[0].classifier.as_deref(), Some("linux&x86"));
    }
    /// QXML-2: the groupId/artifactId of an `<exclusion>` belong to the excluded artifact,
    /// not to the dependency that carries the `<exclusions>`; only the dependency's own
    /// children make its coordinate.
    #[test]
    fn an_exclusion_does_not_overwrite_its_dependencys_coordinate() {
        let pom = br#"<project>
    <groupId>com.example</groupId>
    <artifactId>my-app</artifactId>
    <version>1.0.0</version>
    <dependencies>
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-core_2.13</artifactId>
            <version>3.5.1</version>
            <exclusions>
                <exclusion>
                    <groupId>log4j</groupId>
                    <artifactId>log4j</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <exclusions>
                <exclusion>
                    <groupId>org.slf4j</groupId>
                    <artifactId>slf4j-api</artifactId>
                </exclusion>
            </exclusions>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>4.1.109.Final</version>
        </dependency>
    </dependencies>
</project>"#;

        let deps = parse_pom_dependencies(pom);
        let gavs: Vec<_> = deps
            .iter()
            .map(|d| (d.group_id.as_str(), d.artifact_id.as_str(), d.version.as_str()))
            .collect();
        assert_eq!(
            gavs,
            vec![("org.apache.spark", "spark-core_2.13", "3.5.1"), ("io.netty", "netty-all", "4.1.109.Final")]
        );
    }
}