Skip to main content

provenant/parsers/
ivy.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for Apache Ant/Ivy `ivy.xml` dependency manifests.
5//!
6//! Recovers the module identity from `<info organisation/module/revision>` and
7//! direct dependencies from `<dependencies><dependency org/name/rev/conf>`.
8//! Parsing is static and bounded: the file size is capped before reading and the
9//! XML event loop is bounded by `MAX_ITERATION_COUNT`.
10
11use std::path::Path;
12
13use quick_xml::Reader;
14use quick_xml::XmlVersion;
15use quick_xml::events::{BytesStart, Event};
16
17use super::PackageParser;
18use super::metadata::ParserMetadata;
19use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
20use crate::parser_warn as warn;
21use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
22
23pub struct IvyXmlParser;
24
25impl PackageParser for IvyXmlParser {
26    const PACKAGE_TYPE: PackageType = PackageType::Ivy;
27
28    fn is_match(path: &Path) -> bool {
29        path.to_str().is_some_and(|p| p.ends_with("/ivy.xml"))
30    }
31
32    fn extract_packages(path: &Path) -> Vec<PackageData> {
33        let content = match read_file_to_string(path, None) {
34            Ok(content) => content,
35            Err(e) => {
36                warn!("Failed to read ivy.xml at {:?}: {}", path, e);
37                return vec![default_ivy_package_data()];
38            }
39        };
40
41        vec![interpret_ivy_xml(&content, path)]
42    }
43
44    fn metadata() -> Vec<ParserMetadata> {
45        vec![ParserMetadata {
46            description: "Apache Ant/Ivy dependency manifest",
47            file_patterns: &["**/ivy.xml"],
48            package_type: "ivy",
49            primary_language: "Java",
50            documentation_url: Some(
51                "https://ant.apache.org/ivy/history/latest-milestone/ivyfile.html",
52            ),
53        }]
54    }
55}
56
57fn default_ivy_package_data() -> PackageData {
58    PackageData {
59        package_type: Some(PackageType::Ivy),
60        primary_language: Some("Java".to_string()),
61        datasource_id: Some(DatasourceId::AntIvyXml),
62        ..Default::default()
63    }
64}
65
66fn attr_value(element: &BytesStart, key: &[u8]) -> Option<String> {
67    element
68        .attributes()
69        .filter_map(|attr| attr.ok())
70        .find(|attr| attr.key.as_ref() == key)
71        // Decode XML entities (e.g. `&amp;`) so namespaces/purls carry the real value.
72        .and_then(|attr| {
73            attr.normalized_value(XmlVersion::Implicit1_0)
74                .ok()
75                .map(|value| value.into_owned())
76        })
77        .map(|value| value.trim().to_string())
78        .filter(|value| !value.is_empty())
79}
80
81fn interpret_ivy_xml(content: &str, path: &Path) -> PackageData {
82    let mut package_data = default_ivy_package_data();
83
84    let mut reader = Reader::from_str(content);
85    reader.config_mut().trim_text(true);
86
87    let mut namespace: Option<String> = None;
88    let mut name: Option<String> = None;
89    let mut version: Option<String> = None;
90    let mut dependencies: Vec<Dependency> = Vec::new();
91
92    let mut buf = Vec::new();
93    let mut in_dependencies = false;
94    let mut iteration_count: usize = 0;
95
96    loop {
97        iteration_count += 1;
98        if iteration_count > MAX_ITERATION_COUNT {
99            warn!(
100                "Iteration limit exceeded in ivy.xml at {:?}; stopping at {} items",
101                path, MAX_ITERATION_COUNT
102            );
103            break;
104        }
105
106        match reader.read_event_into(&mut buf) {
107            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
108                let tag = e.name();
109                match tag.as_ref() {
110                    b"info" => {
111                        namespace = attr_value(&e, b"organisation");
112                        name = attr_value(&e, b"module");
113                        version = attr_value(&e, b"revision");
114                    }
115                    b"dependencies" => in_dependencies = true,
116                    b"dependency" if in_dependencies => {
117                        if let Some(dep) = parse_ivy_dependency(&e) {
118                            dependencies.push(dep);
119                        }
120                    }
121                    _ => {}
122                }
123            }
124            Ok(Event::End(e)) if e.name().as_ref() == b"dependencies" => {
125                in_dependencies = false;
126            }
127            Ok(Event::Eof) => break,
128            Err(e) => {
129                warn!("Error parsing ivy.xml at {:?}: {}", path, e);
130                break;
131            }
132            _ => {}
133        }
134        buf.clear();
135    }
136
137    package_data.namespace = namespace.map(truncate_field);
138    package_data.name = name.map(truncate_field);
139    package_data.version = version.map(truncate_field);
140    package_data.dependencies = dependencies;
141
142    if let (Some(namespace), Some(name)) = (&package_data.namespace, &package_data.name) {
143        package_data.purl = Some(truncate_field(build_ivy_purl(
144            namespace,
145            name,
146            package_data.version.as_deref(),
147        )));
148    }
149
150    package_data
151}
152
153fn parse_ivy_dependency(element: &BytesStart) -> Option<Dependency> {
154    let org = attr_value(element, b"org");
155    let name = attr_value(element, b"name")?;
156    let rev = attr_value(element, b"rev");
157    let conf = attr_value(element, b"conf");
158
159    let purl = match &org {
160        Some(org) => build_ivy_purl(org, &name, None),
161        None => format!("pkg:ivy/{}", name),
162    };
163
164    Some(Dependency {
165        purl: Some(truncate_field(purl)),
166        extracted_requirement: rev.map(truncate_field),
167        scope: conf.map(truncate_field),
168        is_runtime: None,
169        is_optional: None,
170        is_pinned: None,
171        is_direct: Some(true),
172        resolved_package: None,
173        extra_data: None,
174    })
175}
176
177fn build_ivy_purl(organisation: &str, module: &str, revision: Option<&str>) -> String {
178    let mut purl = format!("pkg:ivy/{}/{}", organisation, module);
179    if let Some(revision) = revision.filter(|value| !value.trim().is_empty()) {
180        purl.push('@');
181        purl.push_str(revision);
182    }
183    purl
184}