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::collections::HashMap;
12use std::path::Path;
13
14use packageurl::PackageUrl;
15use quick_xml::Reader;
16use quick_xml::XmlVersion;
17use quick_xml::events::{BytesStart, Event};
18use serde_json::Value as JsonValue;
19
20use super::PackageParser;
21use super::metadata::ParserMetadata;
22use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
23use crate::parser_warn as warn;
24use crate::parsers::utils::{
25    CappedIterExt, MAX_ITERATION_COUNT, read_file_to_string, truncate_field,
26};
27
28pub struct IvyXmlParser;
29pub struct IvyDependenciesPropertiesParser;
30
31impl PackageParser for IvyXmlParser {
32    const PACKAGE_TYPE: PackageType = PackageType::Ivy;
33
34    fn is_match(path: &Path) -> bool {
35        path.to_str().is_some_and(|p| p.ends_with("/ivy.xml"))
36    }
37
38    fn extract_packages(path: &Path) -> Vec<PackageData> {
39        let content = match read_file_to_string(path, None) {
40            Ok(content) => content,
41            Err(e) => {
42                warn!("Failed to read ivy.xml at {:?}: {}", path, e);
43                return vec![default_ivy_package_data()];
44            }
45        };
46
47        vec![interpret_ivy_xml(&content, path)]
48    }
49
50    fn metadata() -> Vec<ParserMetadata> {
51        vec![ParserMetadata {
52            description: "Apache Ant/Ivy dependency manifest",
53            file_patterns: &["**/ivy.xml"],
54            package_type: "ivy",
55            primary_language: "Java",
56            documentation_url: Some(
57                "https://ant.apache.org/ivy/history/latest-milestone/ivyfile.html",
58            ),
59        }]
60    }
61}
62
63impl PackageParser for IvyDependenciesPropertiesParser {
64    const PACKAGE_TYPE: PackageType = PackageType::Maven;
65
66    fn is_match(path: &Path) -> bool {
67        path.file_name()
68            .and_then(|name| name.to_str())
69            .is_some_and(is_ivy_dependencies_properties_filename)
70    }
71
72    fn extract_packages(path: &Path) -> Vec<PackageData> {
73        let content = match read_file_to_string(path, None) {
74            Ok(content) => content,
75            Err(e) => {
76                warn!(
77                    "Failed to read dependencies.properties at {:?}: {}",
78                    path, e
79                );
80                return vec![default_ivy_dependencies_properties_data(Vec::new())];
81            }
82        };
83
84        let scope = dependency_scope_from_filename(path);
85        let dependencies = extract_ivy_dependencies_properties(&content, scope.as_deref());
86
87        vec![default_ivy_dependencies_properties_data(dependencies)]
88    }
89
90    fn metadata() -> Vec<ParserMetadata> {
91        vec![ParserMetadata {
92            description: "Ivy-style dependencies.properties Maven dependency list",
93            file_patterns: &["**/dependencies.properties", "**/*-dependencies.properties"],
94            package_type: "maven",
95            primary_language: "Java",
96            documentation_url: Some(
97                "https://github.com/aboutcode-org/scancode-toolkit/issues/3468",
98            ),
99        }]
100    }
101}
102
103fn default_ivy_package_data() -> PackageData {
104    PackageData {
105        package_type: Some(PackageType::Ivy),
106        primary_language: Some("Java".to_string()),
107        datasource_id: Some(DatasourceId::AntIvyXml),
108        ..Default::default()
109    }
110}
111
112fn default_ivy_dependencies_properties_data(dependencies: Vec<Dependency>) -> PackageData {
113    PackageData {
114        package_type: Some(IvyDependenciesPropertiesParser::PACKAGE_TYPE),
115        primary_language: Some("Java".to_string()),
116        dependencies,
117        datasource_id: Some(DatasourceId::AntIvyDependenciesProperties),
118        ..Default::default()
119    }
120}
121
122fn attr_value(element: &BytesStart, key: &[u8]) -> Option<String> {
123    element
124        .attributes()
125        .filter_map(|attr| attr.ok())
126        .find(|attr| attr.key.as_ref() == key)
127        // Decode XML entities (e.g. `&amp;`) so namespaces/purls carry the real value.
128        .and_then(|attr| {
129            attr.normalized_value(XmlVersion::Implicit1_0)
130                .ok()
131                .map(|value| value.into_owned())
132        })
133        .map(|value| value.trim().to_string())
134        .filter(|value| !value.is_empty())
135}
136
137fn interpret_ivy_xml(content: &str, path: &Path) -> PackageData {
138    let mut package_data = default_ivy_package_data();
139
140    let mut reader = Reader::from_str(content);
141    reader.config_mut().trim_text(true);
142
143    let mut namespace: Option<String> = None;
144    let mut name: Option<String> = None;
145    let mut version: Option<String> = None;
146    let mut dependencies: Vec<Dependency> = Vec::new();
147
148    let mut buf = Vec::new();
149    let mut in_dependencies = false;
150    let mut iteration_count: usize = 0;
151
152    loop {
153        iteration_count += 1;
154        if iteration_count > MAX_ITERATION_COUNT {
155            warn!(
156                "Iteration limit exceeded in ivy.xml at {:?}; stopping at {} items",
157                path, MAX_ITERATION_COUNT
158            );
159            break;
160        }
161
162        match reader.read_event_into(&mut buf) {
163            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
164                let tag = e.name();
165                match tag.as_ref() {
166                    b"info" => {
167                        namespace = attr_value(&e, b"organisation");
168                        name = attr_value(&e, b"module");
169                        version = attr_value(&e, b"revision");
170                    }
171                    b"dependencies" => in_dependencies = true,
172                    b"dependency" if in_dependencies => {
173                        if let Some(dep) = parse_ivy_dependency(&e) {
174                            dependencies.push(dep);
175                        }
176                    }
177                    _ => {}
178                }
179            }
180            Ok(Event::End(e)) if e.name().as_ref() == b"dependencies" => {
181                in_dependencies = false;
182            }
183            Ok(Event::Eof) => break,
184            Err(e) => {
185                warn!("Error parsing ivy.xml at {:?}: {}", path, e);
186                break;
187            }
188            _ => {}
189        }
190        buf.clear();
191    }
192
193    package_data.namespace = namespace.map(truncate_field);
194    package_data.name = name.map(truncate_field);
195    package_data.version = version.map(truncate_field);
196    package_data.dependencies = dependencies;
197
198    if let (Some(namespace), Some(name)) = (&package_data.namespace, &package_data.name) {
199        package_data.purl = build_ivy_purl(namespace, name, package_data.version.as_deref());
200    }
201
202    package_data
203}
204
205fn is_ivy_dependencies_properties_filename(name: &str) -> bool {
206    name == "dependencies.properties" || name.ends_with("-dependencies.properties")
207}
208
209fn dependency_scope_from_filename(path: &Path) -> Option<String> {
210    path.file_name()
211        .and_then(|name| name.to_str())
212        .and_then(|name| name.strip_suffix("-dependencies.properties"))
213        .map(str::trim)
214        .filter(|scope| !scope.is_empty())
215        .map(|scope| truncate_field(scope.to_string()))
216}
217
218fn extract_ivy_dependencies_properties(content: &str, scope: Option<&str>) -> Vec<Dependency> {
219    parse_properties_entries(content)
220        .into_iter()
221        .filter_map(|(key, value)| parse_ivy_dependencies_entry(&key, &value, scope))
222        .collect()
223}
224
225fn parse_properties_entries(content: &str) -> Vec<(String, String)> {
226    let mut entries = Vec::new();
227    let mut current = String::new();
228
229    for raw_line in content
230        .lines()
231        .capped("Ivy dependencies.properties raw lines")
232    {
233        let line = raw_line.trim_end_matches('\r');
234        let trimmed_end = line.trim_end();
235        if current.is_empty() && line.trim_start().starts_with(['#', '!']) {
236            continue;
237        }
238
239        let is_continuation = trimmed_end.ends_with('\\');
240        let line_without_slash = if is_continuation {
241            trimmed_end.trim_end_matches('\\')
242        } else {
243            line
244        };
245
246        if !current.is_empty() {
247            current.push_str(line_without_slash.trim_start());
248        } else {
249            current.push_str(line_without_slash);
250        }
251
252        if is_continuation {
253            continue;
254        }
255
256        let logical_line = current.trim();
257        if !logical_line.is_empty()
258            && !logical_line.starts_with('#')
259            && !logical_line.starts_with('!')
260            && let Some((key, value)) = logical_line.split_once('=')
261        {
262            let key = key.trim();
263            let value = strip_inline_comment(value.trim());
264            if !key.is_empty() && !value.is_empty() {
265                entries.push((key.to_string(), value.to_string()));
266            }
267        }
268
269        current.clear();
270    }
271
272    entries
273}
274
275/// Strips a trailing inline annotation from a property value.
276///
277/// Java `.properties` treats `#`/`!` as comments only at the start of a logical line, so the
278/// whole right-hand side is technically the value. In practice these Ivy-style Maven dependency
279/// files carry trailing notes such as `5.0.0-rc16 # 5.0.0 is a bad version do not use`. A Maven
280/// coordinate never embeds whitespace, so a ` #` (or `\t#`) marks the start of human commentary
281/// that must not bleed into the captured version.
282fn strip_inline_comment(value: &str) -> &str {
283    match value.find(" #").or_else(|| value.find("\t#")) {
284        Some(index) => value[..index].trim_end(),
285        None => value,
286    }
287}
288
289fn parse_ivy_dependencies_entry(key: &str, value: &str, scope: Option<&str>) -> Option<Dependency> {
290    if let Some((group, artifact, version)) = parse_maven_gav(value) {
291        return Some(build_maven_dependency(
292            group,
293            artifact,
294            version,
295            Some(key),
296            "value_gav",
297            scope,
298        ));
299    }
300
301    let (group, artifact) = parse_maven_ga(key)?;
302    if value.contains(':') {
303        return None;
304    }
305
306    Some(build_maven_dependency(
307        group,
308        artifact,
309        value,
310        Some(key),
311        "key_ga",
312        scope,
313    ))
314}
315
316fn parse_maven_gav(value: &str) -> Option<(&str, &str, &str)> {
317    let mut parts = value.split(':').map(str::trim);
318    let group = parts.next()?;
319    let artifact = parts.next()?;
320    let version = parts.next()?;
321    if parts.next().is_some() || group.is_empty() || artifact.is_empty() || version.is_empty() {
322        return None;
323    }
324
325    Some((group, artifact, version))
326}
327
328fn parse_maven_ga(value: &str) -> Option<(&str, &str)> {
329    let mut parts = value.split(':').map(str::trim);
330    let group = parts.next()?;
331    let artifact = parts.next()?;
332    if parts.next().is_some() || group.is_empty() || artifact.is_empty() {
333        return None;
334    }
335
336    Some((group, artifact))
337}
338
339fn build_maven_dependency(
340    group: &str,
341    artifact: &str,
342    version: &str,
343    property_name: Option<&str>,
344    coordinate_format: &str,
345    scope: Option<&str>,
346) -> Dependency {
347    let group = truncate_field(group.to_string());
348    let artifact = truncate_field(artifact.to_string());
349    let version = truncate_field(version.to_string());
350
351    let purl = PackageUrl::new("maven", &artifact)
352        .ok()
353        .and_then(|mut purl| {
354            purl.with_namespace(&group).ok()?;
355            purl.with_version(&version).ok()?;
356            Some(truncate_field(purl.to_string()))
357        });
358
359    let mut extra_data = HashMap::from([
360        (
361            "coordinate_format".to_string(),
362            JsonValue::String(coordinate_format.to_string()),
363        ),
364        ("group".to_string(), JsonValue::String(group.clone())),
365        ("artifact".to_string(), JsonValue::String(artifact.clone())),
366    ]);
367    if let Some(property_name) = property_name.filter(|name| !name.trim().is_empty()) {
368        extra_data.insert(
369            "property_name".to_string(),
370            JsonValue::String(truncate_field(property_name.to_string())),
371        );
372    }
373
374    let resolved_package = ResolvedPackage {
375        primary_language: Some("Java".to_string()),
376        datasource_id: Some(DatasourceId::AntIvyDependenciesProperties),
377        purl: purl.clone(),
378        ..ResolvedPackage::new(PackageType::Maven, group, artifact, version.clone())
379    };
380
381    Dependency {
382        purl,
383        extracted_requirement: Some(version),
384        scope: scope.map(|scope| truncate_field(scope.to_string())),
385        is_runtime: None,
386        is_optional: None,
387        is_pinned: Some(true),
388        is_direct: Some(true),
389        resolved_package: Some(Box::new(resolved_package)),
390        extra_data: Some(extra_data),
391    }
392}
393
394fn parse_ivy_dependency(element: &BytesStart) -> Option<Dependency> {
395    let org = attr_value(element, b"org");
396    let name = attr_value(element, b"name")?;
397    let rev = attr_value(element, b"rev");
398    let conf = attr_value(element, b"conf");
399
400    // An ivy dependency without an organisation has no namespace to place, so it
401    // falls back to a name-only PURL — still through the encoder.
402    let purl = match &org {
403        Some(org) => build_ivy_purl(org, &name, None),
404        None => crate::parsers::utils::simple_purl("ivy", &name, None),
405    };
406
407    Some(Dependency {
408        purl,
409        extracted_requirement: rev.map(truncate_field),
410        scope: conf.map(truncate_field),
411        is_runtime: None,
412        is_optional: None,
413        is_pinned: None,
414        is_direct: Some(true),
415        resolved_package: None,
416        extra_data: None,
417    })
418}
419
420/// Builds an ivy PURL through the encoder.
421///
422/// `organisation` and `module` come straight from XML attributes, so they can
423/// hold anything. Hand-formatting them produced strings that silently lost data
424/// on re-parse: a space in either component truncated the PURL, taking the
425/// revision with it.
426fn build_ivy_purl(organisation: &str, module: &str, revision: Option<&str>) -> Option<String> {
427    crate::parsers::utils::namespaced_purl("ivy", organisation, module, revision)
428}