Skip to main content

provenant/parsers/
about.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Parser for AboutCode .ABOUT metadata files.
8//!
9//! Extracts package metadata from AboutCode .ABOUT YAML files which describe
10//! software components, licenses, and related information.
11//!
12//! # Supported Formats
13//! - .ABOUT (case-sensitive uppercase extension)
14//!
15//! # Key Features
16//! - YAML-based metadata parsing
17//! - Package URL (purl) parsing for type/namespace extraction
18//! - Owner party information
19//! - File reference tracking (about_resource field)
20//! - License expression extraction
21//! - Flexible field mapping (home_url/homepage_url)
22//!
23//! # Implementation Notes
24//! - Uses yaml_serde for YAML parsing
25//! - Uses packageurl crate for purl parsing
26//! - Extension is case-sensitive and must be uppercase (.ABOUT not .about)
27//! - Type can be overridden by 'type' field or extracted from 'purl' field
28//! - Graceful error handling: logs warnings and returns default on parse failure
29
30use crate::models::{DatasourceId, FileReference, PackageData, PackageType, Party, PartyType};
31use crate::parser_warn as warn;
32use crate::parsers::utils::{read_file_to_string, truncate_field};
33use packageurl::PackageUrl;
34use std::path::Path;
35use std::str::FromStr;
36use url::Url;
37use yaml_serde::Value;
38
39use super::PackageParser;
40use super::license_normalization::{
41    DeclaredLicenseMatchMetadata, build_declared_license_data, normalize_spdx_declared_license,
42    normalize_spdx_expression,
43};
44
45const FIELD_TYPE: &str = "type";
46const FIELD_PURL: &str = "purl";
47const FIELD_PACKAGE_URL: &str = "package_url";
48const FIELD_NAMESPACE: &str = "namespace";
49const FIELD_NAME: &str = "name";
50const FIELD_VERSION: &str = "version";
51const FIELD_HOME_URL: &str = "home_url";
52const FIELD_HOMEPAGE_URL: &str = "homepage_url";
53const FIELD_DOWNLOAD_URL: &str = "download_url";
54const FIELD_COPYRIGHT: &str = "copyright";
55const FIELD_LICENSE_EXPRESSION: &str = "license_expression";
56const FIELD_OWNER: &str = "owner";
57const FIELD_ABOUT_RESOURCE: &str = "about_resource";
58
59/// AboutCode .ABOUT file parser.
60///
61/// Parses AboutCode metadata files that contain package information,
62/// licensing, and file references in YAML format.
63pub struct AboutFileParser;
64
65#[derive(Clone)]
66struct InferredAboutIdentity {
67    package_type: PackageType,
68    namespace: Option<String>,
69    name: Option<String>,
70    version: Option<String>,
71}
72
73impl PackageParser for AboutFileParser {
74    const PACKAGE_TYPE: PackageType = PackageType::About;
75
76    fn extract_packages(path: &Path) -> Vec<PackageData> {
77        let yaml = match read_and_parse_yaml(path) {
78            Ok(yaml) => yaml,
79            Err(e) => {
80                warn!("Failed to read or parse .ABOUT file at {:?}: {}", path, e);
81                return vec![default_package_data()];
82            }
83        };
84
85        // Extract type and purl information
86        let about_type = yaml
87            .get(FIELD_TYPE)
88            .and_then(|v| v.as_str())
89            .map(String::from);
90
91        let about_namespace = yaml
92            .get(FIELD_NAMESPACE)
93            .and_then(|v| v.as_str())
94            .map(|v| truncate_field(v.to_string()));
95
96        let purl_string = yaml
97            .get(FIELD_PURL)
98            .and_then(|v| v.as_str())
99            .or_else(|| yaml.get(FIELD_PACKAGE_URL).and_then(|v| v.as_str()))
100            .map(|v| truncate_field(v.to_string()));
101
102        // Parse purl if present
103        let (purl_type, purl_namespace, purl_name, purl_version) =
104            if let Some(ref purl_str) = purl_string {
105                match PackageUrl::from_str(purl_str) {
106                    Ok(purl) => (
107                        Some(truncate_field(purl.ty().to_string())),
108                        purl.namespace().map(|v| truncate_field(v.to_string())),
109                        Some(truncate_field(purl.name().to_string())),
110                        purl.version().map(|v| truncate_field(v.to_string())),
111                    ),
112                    Err(e) => {
113                        warn!("Failed to parse purl '{}': {}", purl_str, e);
114                        (None, None, None, None)
115                    }
116                }
117            } else {
118                (None, None, None, None)
119            };
120
121        let inferred = infer_about_from_download_url(
122            yaml.get(FIELD_DOWNLOAD_URL).and_then(|v| v.as_str()),
123            yaml.get(FIELD_NAME)
124                .and_then(yaml_value_to_string)
125                .as_deref(),
126            yaml.get(FIELD_VERSION)
127                .and_then(yaml_value_to_string)
128                .as_deref(),
129        );
130
131        let explicit_package_type = about_type
132            .clone()
133            .and_then(|s| s.parse::<crate::models::PackageType>().ok());
134        let parsed_purl_type = purl_type
135            .clone()
136            .and_then(|s| s.parse::<crate::models::PackageType>().ok());
137        let has_parsed_purl_identity = parsed_purl_type.is_some()
138            || purl_namespace.is_some()
139            || purl_name.is_some()
140            || purl_version.is_some();
141        let inferred_identity = if explicit_package_type.is_none() && !has_parsed_purl_identity {
142            inferred
143        } else {
144            None
145        };
146
147        let package_type = explicit_package_type
148            .or(parsed_purl_type)
149            .or_else(|| {
150                inferred_identity
151                    .as_ref()
152                    .map(|identity| identity.package_type)
153            })
154            .unwrap_or(Self::PACKAGE_TYPE);
155
156        // Priority: about_namespace > purl_namespace
157        let namespace = about_namespace
158            .clone()
159            .or(purl_namespace.clone())
160            .or_else(|| {
161                inferred_identity
162                    .as_ref()
163                    .and_then(|identity| identity.namespace.clone())
164            })
165            .map(truncate_field);
166
167        // Name and version from YAML or purl
168        let name = yaml
169            .get(FIELD_NAME)
170            .and_then(yaml_value_to_string)
171            .or(purl_name.clone())
172            .or_else(|| {
173                inferred_identity
174                    .as_ref()
175                    .and_then(|identity| identity.name.clone())
176            })
177            .map(truncate_field);
178
179        let version = yaml
180            .get(FIELD_VERSION)
181            .and_then(yaml_value_to_string)
182            .or(purl_version.clone())
183            .or_else(|| {
184                inferred_identity
185                    .as_ref()
186                    .and_then(|identity| identity.version.clone())
187            })
188            .map(truncate_field);
189
190        // Homepage URL (two possible field names)
191        let homepage_url = yaml
192            .get(FIELD_HOME_URL)
193            .and_then(|v| v.as_str())
194            .or_else(|| yaml.get(FIELD_HOMEPAGE_URL).and_then(|v| v.as_str()))
195            .map(|v| truncate_field(v.to_string()));
196
197        let download_url = yaml
198            .get(FIELD_DOWNLOAD_URL)
199            .and_then(|v| v.as_str())
200            .map(|v| truncate_field(v.to_string()));
201
202        let copyright = yaml
203            .get(FIELD_COPYRIGHT)
204            .and_then(|v| v.as_str())
205            .map(|v| truncate_field(v.to_string()));
206
207        let extracted_license_statement = yaml
208            .get(FIELD_LICENSE_EXPRESSION)
209            .and_then(|v| v.as_str())
210            .map(|v| truncate_field(v.to_string()));
211        let file_references = extract_file_references(&yaml);
212        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
213            extracted_license_statement
214                .as_deref()
215                .and_then(normalize_spdx_expression)
216                .map(|normalized| {
217                    build_declared_license_data(
218                        normalized,
219                        DeclaredLicenseMatchMetadata::single_line(
220                            extracted_license_statement.as_deref().unwrap_or_default(),
221                        ),
222                    )
223                })
224                .unwrap_or_else(|| {
225                    normalize_spdx_declared_license(extracted_license_statement.as_deref())
226                });
227
228        let vcs_url = yaml
229            .get(Value::String("vcs_url".to_string()))
230            .and_then(|v| v.as_str())
231            .map(|v| truncate_field(v.to_string()));
232
233        let extra_data = build_extra_data(&yaml);
234
235        let purl = purl_string
236            .or_else(|| {
237                let name = yaml
238                    .get(FIELD_NAME)
239                    .and_then(yaml_value_to_string)
240                    .or(purl_name.clone())
241                    .or_else(|| {
242                        inferred_identity
243                            .as_ref()
244                            .and_then(|identity| identity.name.clone())
245                    });
246                let version = yaml
247                    .get(FIELD_VERSION)
248                    .and_then(yaml_value_to_string)
249                    .or(purl_version.clone())
250                    .or_else(|| {
251                        inferred_identity
252                            .as_ref()
253                            .and_then(|identity| identity.version.clone())
254                    });
255                let namespace = about_namespace.clone().or_else(|| {
256                    inferred_identity
257                        .as_ref()
258                        .and_then(|identity| identity.namespace.clone())
259                });
260                build_about_purl(
261                    package_type,
262                    namespace.as_deref(),
263                    name.as_deref(),
264                    version.as_deref(),
265                )
266            })
267            .map(truncate_field);
268
269        // Owner party
270        let parties = extract_owner_party(&yaml);
271
272        // File references
273        vec![PackageData {
274            package_type: Some(package_type),
275            namespace,
276            name,
277            version,
278            qualifiers: None,
279            subpath: None,
280            primary_language: None,
281            description: None,
282            release_date: None,
283            parties,
284            keywords: Vec::new(),
285            homepage_url,
286            download_url,
287            size: None,
288            sha1: None,
289            md5: None,
290            sha256: None,
291            sha512: None,
292            bug_tracking_url: None,
293            code_view_url: None,
294            vcs_url,
295            copyright,
296            holder: None,
297            declared_license_expression,
298            declared_license_expression_spdx,
299            license_detections,
300            other_license_expression: None,
301            other_license_expression_spdx: None,
302            other_license_detections: Vec::new(),
303            extracted_license_statement,
304            notice_text: None,
305            source_packages: Vec::new(),
306            file_references,
307            is_private: false,
308            is_virtual: false,
309            extra_data,
310            dependencies: Vec::new(),
311            repository_homepage_url: None,
312            repository_download_url: None,
313            api_data_url: None,
314            datasource_id: Some(DatasourceId::AboutFile),
315            purl,
316        }]
317    }
318
319    fn is_match(path: &Path) -> bool {
320        path.extension()
321            .and_then(|ext| ext.to_str())
322            .is_some_and(|ext| ext == "ABOUT")
323    }
324
325    fn metadata() -> Vec<super::metadata::ParserMetadata> {
326        vec![super::metadata::ParserMetadata {
327            description: "AboutCode .ABOUT metadata file",
328            file_patterns: &["**/*.ABOUT"],
329            package_type: "about",
330            primary_language: "",
331            documentation_url: Some(
332                "https://aboutcode-toolkit.readthedocs.io/en/latest/specification.html",
333            ),
334        }]
335    }
336}
337
338/// Reads and parses a YAML file.
339fn read_and_parse_yaml(path: &Path) -> Result<yaml_serde::Mapping, String> {
340    let content =
341        read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
342
343    parse_yaml_mapping(&content)
344        .or_else(|yaml_error| parse_shallow_scalar_mapping(&content).ok_or(yaml_error))
345}
346
347fn parse_yaml_mapping(content: &str) -> Result<yaml_serde::Mapping, String> {
348    let value: Value =
349        yaml_serde::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
350
351    match value {
352        Value::Mapping(map) => Ok(map),
353        _ => Err("Expected YAML mapping at root".to_string()),
354    }
355}
356
357fn parse_shallow_scalar_mapping(content: &str) -> Option<yaml_serde::Mapping> {
358    let mut map = yaml_serde::Mapping::new();
359    let mut saw_mapping_entry = false;
360
361    for line in content.lines() {
362        let trimmed = line.trim();
363        if trimmed.is_empty() || trimmed.starts_with('#') {
364            continue;
365        }
366        if line.starts_with(char::is_whitespace) {
367            return None;
368        }
369
370        let (raw_key, raw_value) = trimmed.split_once(':')?;
371        let key = raw_key.trim();
372        if key.is_empty()
373            || !key.chars().all(|character| {
374                character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
375            })
376        {
377            return None;
378        }
379
380        let value = raw_value.trim();
381        if value.is_empty() {
382            return None;
383        }
384
385        saw_mapping_entry = true;
386        map.insert(
387            Value::String(key.to_string()),
388            Value::String(unquote_yaml_scalar(value)),
389        );
390    }
391
392    saw_mapping_entry.then_some(map)
393}
394
395fn unquote_yaml_scalar(value: &str) -> String {
396    if value.len() >= 2 {
397        let mut characters = value.chars();
398        let first = characters.next();
399        let last = value.chars().last();
400        if matches!(
401            (first, last),
402            (Some('"'), Some('"')) | (Some('\''), Some('\''))
403        ) {
404            return value[1..value.len() - 1].to_string();
405        }
406    }
407    value.to_string()
408}
409
410/// Converts a YAML value to a string, handling strings, numbers, and booleans.
411fn yaml_value_to_string(value: &Value) -> Option<String> {
412    match value {
413        Value::String(s) => Some(s.clone()),
414        Value::Number(n) => Some(n.to_string()),
415        Value::Bool(b) => Some(b.to_string()),
416        _ => None,
417    }
418}
419
420/// Extracts owner party information from YAML.
421fn extract_owner_party(yaml: &yaml_serde::Mapping) -> Vec<Party> {
422    let owner = yaml
423        .get(Value::String(FIELD_OWNER.to_string()))
424        .map(|v| match v {
425            Value::String(s) => truncate_field(s.clone()),
426            _ => truncate_field(format!("{:?}", v)),
427        });
428
429    if let Some(owner_name) = owner {
430        if !owner_name.is_empty() {
431            vec![Party {
432                r#type: Some(PartyType::Person),
433                role: Some("owner".to_string()),
434                name: Some(owner_name),
435                email: None,
436                url: None,
437                organization: None,
438                organization_url: None,
439                timezone: None,
440            }]
441        } else {
442            Vec::new()
443        }
444    } else {
445        Vec::new()
446    }
447}
448
449/// Extracts file references from YAML.
450fn extract_file_references(yaml: &yaml_serde::Mapping) -> Vec<FileReference> {
451    let about_resource = yaml
452        .get(Value::String(FIELD_ABOUT_RESOURCE.to_string()))
453        .and_then(|v| v.as_str());
454    let license_file = yaml
455        .get(Value::String("license_file".to_string()))
456        .and_then(|v| v.as_str());
457    let notice_file = yaml
458        .get(Value::String("notice_file".to_string()))
459        .and_then(|v| v.as_str());
460
461    let mut refs = Vec::new();
462
463    if let Some(path) = about_resource {
464        refs.push(FileReference {
465            path: truncate_field(path.to_string()),
466            size: None,
467            sha1: None,
468            md5: None,
469            sha256: None,
470            sha512: None,
471            extra_data: None,
472        });
473    }
474
475    for path in [license_file, notice_file].into_iter().flatten() {
476        refs.push(FileReference {
477            path: truncate_field(path.to_string()),
478            size: None,
479            sha1: None,
480            md5: None,
481            sha256: None,
482            sha512: None,
483            extra_data: None,
484        });
485    }
486
487    refs
488}
489
490/// Returns a default (empty) PackageData structure.
491fn default_package_data() -> PackageData {
492    PackageData {
493        package_type: Some(PackageType::About),
494        datasource_id: Some(DatasourceId::AboutFile),
495        ..Default::default()
496    }
497}
498
499fn infer_about_from_download_url(
500    download_url: Option<&str>,
501    about_name: Option<&str>,
502    about_version: Option<&str>,
503) -> Option<InferredAboutIdentity> {
504    let url = Url::parse(download_url?).ok()?;
505    let host = url.host_str()?;
506
507    if matches!(host, "pypi.python.org" | "files.pythonhosted.org") {
508        let name = about_name.map(str::to_string)?;
509        let version = about_version.map(str::to_string);
510        return Some(InferredAboutIdentity {
511            package_type: PackageType::Pypi,
512            namespace: None,
513            name: Some(name),
514            version,
515        });
516    }
517
518    if matches!(host, "raw.githubusercontent.com" | "github.com") {
519        let mut segments = url.path_segments()?;
520        let owner = segments.next()?.to_string();
521        let repo = segments.next()?.to_string();
522        return Some(InferredAboutIdentity {
523            package_type: PackageType::Github,
524            namespace: Some(owner),
525            name: Some(repo),
526            version: None,
527        });
528    }
529
530    None
531}
532
533fn build_about_purl(
534    package_type: PackageType,
535    namespace: Option<&str>,
536    name: Option<&str>,
537    version: Option<&str>,
538) -> Option<String> {
539    if package_type == PackageType::About {
540        return None;
541    }
542
543    let name = name?;
544    let mut purl = PackageUrl::new(package_type.as_str(), name).ok()?;
545    if let Some(namespace) = namespace {
546        purl.with_namespace(namespace).ok()?;
547    }
548    if let Some(version) = version {
549        purl.with_version(version).ok()?;
550    }
551    Some(purl.to_string())
552}
553
554fn build_extra_data(
555    yaml: &yaml_serde::Mapping,
556) -> Option<std::collections::HashMap<String, serde_json::Value>> {
557    let mut extra_data = std::collections::HashMap::new();
558    for key in ["license_file", "notice_file", "notes"] {
559        if let Some(value) = yaml.get(Value::String(key.to_string()))
560            && let Some(value) = yaml_value_to_string(value)
561        {
562            extra_data.insert(
563                key.to_string(),
564                serde_json::Value::String(truncate_field(value)),
565            );
566        }
567    }
568    (!extra_data.is_empty()).then_some(extra_data)
569}