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&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&b</artifactId>
<version>
1.0.0
</version>
<dependencies>
<dependency>
<groupId>org.<x></groupId>
<artifactId>"y'</artifactId>
<version>2.0</version>
<classifier> linux&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")]
);
}
}