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 = Some(truncate_field(build_ivy_purl(
200            namespace,
201            name,
202            package_data.version.as_deref(),
203        )));
204    }
205
206    package_data
207}
208
209fn is_ivy_dependencies_properties_filename(name: &str) -> bool {
210    name == "dependencies.properties" || name.ends_with("-dependencies.properties")
211}
212
213fn dependency_scope_from_filename(path: &Path) -> Option<String> {
214    path.file_name()
215        .and_then(|name| name.to_str())
216        .and_then(|name| name.strip_suffix("-dependencies.properties"))
217        .map(str::trim)
218        .filter(|scope| !scope.is_empty())
219        .map(|scope| truncate_field(scope.to_string()))
220}
221
222fn extract_ivy_dependencies_properties(content: &str, scope: Option<&str>) -> Vec<Dependency> {
223    parse_properties_entries(content)
224        .into_iter()
225        .filter_map(|(key, value)| parse_ivy_dependencies_entry(&key, &value, scope))
226        .collect()
227}
228
229fn parse_properties_entries(content: &str) -> Vec<(String, String)> {
230    let mut entries = Vec::new();
231    let mut current = String::new();
232
233    for raw_line in content
234        .lines()
235        .capped("Ivy dependencies.properties raw lines")
236    {
237        let line = raw_line.trim_end_matches('\r');
238        let trimmed_end = line.trim_end();
239        if current.is_empty() && line.trim_start().starts_with(['#', '!']) {
240            continue;
241        }
242
243        let is_continuation = trimmed_end.ends_with('\\');
244        let line_without_slash = if is_continuation {
245            trimmed_end.trim_end_matches('\\')
246        } else {
247            line
248        };
249
250        if !current.is_empty() {
251            current.push_str(line_without_slash.trim_start());
252        } else {
253            current.push_str(line_without_slash);
254        }
255
256        if is_continuation {
257            continue;
258        }
259
260        let logical_line = current.trim();
261        if !logical_line.is_empty()
262            && !logical_line.starts_with('#')
263            && !logical_line.starts_with('!')
264            && let Some((key, value)) = logical_line.split_once('=')
265        {
266            let key = key.trim();
267            let value = strip_inline_comment(value.trim());
268            if !key.is_empty() && !value.is_empty() {
269                entries.push((key.to_string(), value.to_string()));
270            }
271        }
272
273        current.clear();
274    }
275
276    entries
277}
278
279/// Strips a trailing inline annotation from a property value.
280///
281/// Java `.properties` treats `#`/`!` as comments only at the start of a logical line, so the
282/// whole right-hand side is technically the value. In practice these Ivy-style Maven dependency
283/// files carry trailing notes such as `5.0.0-rc16 # 5.0.0 is a bad version do not use`. A Maven
284/// coordinate never embeds whitespace, so a ` #` (or `\t#`) marks the start of human commentary
285/// that must not bleed into the captured version.
286fn strip_inline_comment(value: &str) -> &str {
287    match value.find(" #").or_else(|| value.find("\t#")) {
288        Some(index) => value[..index].trim_end(),
289        None => value,
290    }
291}
292
293fn parse_ivy_dependencies_entry(key: &str, value: &str, scope: Option<&str>) -> Option<Dependency> {
294    if let Some((group, artifact, version)) = parse_maven_gav(value) {
295        return Some(build_maven_dependency(
296            group,
297            artifact,
298            version,
299            Some(key),
300            "value_gav",
301            scope,
302        ));
303    }
304
305    let (group, artifact) = parse_maven_ga(key)?;
306    if value.contains(':') {
307        return None;
308    }
309
310    Some(build_maven_dependency(
311        group,
312        artifact,
313        value,
314        Some(key),
315        "key_ga",
316        scope,
317    ))
318}
319
320fn parse_maven_gav(value: &str) -> Option<(&str, &str, &str)> {
321    let mut parts = value.split(':').map(str::trim);
322    let group = parts.next()?;
323    let artifact = parts.next()?;
324    let version = parts.next()?;
325    if parts.next().is_some() || group.is_empty() || artifact.is_empty() || version.is_empty() {
326        return None;
327    }
328
329    Some((group, artifact, version))
330}
331
332fn parse_maven_ga(value: &str) -> Option<(&str, &str)> {
333    let mut parts = value.split(':').map(str::trim);
334    let group = parts.next()?;
335    let artifact = parts.next()?;
336    if parts.next().is_some() || group.is_empty() || artifact.is_empty() {
337        return None;
338    }
339
340    Some((group, artifact))
341}
342
343fn build_maven_dependency(
344    group: &str,
345    artifact: &str,
346    version: &str,
347    property_name: Option<&str>,
348    coordinate_format: &str,
349    scope: Option<&str>,
350) -> Dependency {
351    let group = truncate_field(group.to_string());
352    let artifact = truncate_field(artifact.to_string());
353    let version = truncate_field(version.to_string());
354
355    let purl = PackageUrl::new("maven", &artifact)
356        .ok()
357        .and_then(|mut purl| {
358            purl.with_namespace(&group).ok()?;
359            purl.with_version(&version).ok()?;
360            Some(truncate_field(purl.to_string()))
361        });
362
363    let mut extra_data = HashMap::from([
364        (
365            "coordinate_format".to_string(),
366            JsonValue::String(coordinate_format.to_string()),
367        ),
368        ("group".to_string(), JsonValue::String(group.clone())),
369        ("artifact".to_string(), JsonValue::String(artifact.clone())),
370    ]);
371    if let Some(property_name) = property_name.filter(|name| !name.trim().is_empty()) {
372        extra_data.insert(
373            "property_name".to_string(),
374            JsonValue::String(truncate_field(property_name.to_string())),
375        );
376    }
377
378    let resolved_package = ResolvedPackage {
379        primary_language: Some("Java".to_string()),
380        datasource_id: Some(DatasourceId::AntIvyDependenciesProperties),
381        purl: purl.clone(),
382        ..ResolvedPackage::new(PackageType::Maven, group, artifact, version.clone())
383    };
384
385    Dependency {
386        purl,
387        extracted_requirement: Some(version),
388        scope: scope.map(|scope| truncate_field(scope.to_string())),
389        is_runtime: None,
390        is_optional: None,
391        is_pinned: Some(true),
392        is_direct: Some(true),
393        resolved_package: Some(Box::new(resolved_package)),
394        extra_data: Some(extra_data),
395    }
396}
397
398fn parse_ivy_dependency(element: &BytesStart) -> Option<Dependency> {
399    let org = attr_value(element, b"org");
400    let name = attr_value(element, b"name")?;
401    let rev = attr_value(element, b"rev");
402    let conf = attr_value(element, b"conf");
403
404    let purl = match &org {
405        Some(org) => build_ivy_purl(org, &name, None),
406        None => format!("pkg:ivy/{}", name),
407    };
408
409    Some(Dependency {
410        purl: Some(truncate_field(purl)),
411        extracted_requirement: rev.map(truncate_field),
412        scope: conf.map(truncate_field),
413        is_runtime: None,
414        is_optional: None,
415        is_pinned: None,
416        is_direct: Some(true),
417        resolved_package: None,
418        extra_data: None,
419    })
420}
421
422fn build_ivy_purl(organisation: &str, module: &str, revision: Option<&str>) -> String {
423    let mut purl = format!("pkg:ivy/{}/{}", organisation, module);
424    if let Some(revision) = revision.filter(|value| !value.trim().is_empty()) {
425        purl.push('@');
426        purl.push_str(revision);
427    }
428    purl
429}