Skip to main content

provenant/parsers/
about.rs

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