Skip to main content

provenant/parsers/
os_release.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 Linux OS release metadata files.
8//!
9//! Extracts distribution information from `/etc/os-release` and `/usr/lib/os-release`
10//! files which identify the Linux distribution and version.
11//!
12//! # Supported Formats
13//! - `/etc/os-release` (primary location)
14//! - `/usr/lib/os-release` (fallback location)
15//!
16//! # Key Features
17//! - Distribution identification (name, version, ID)
18//! - Namespace mapping (debian, fedora, etc.)
19//! - Pretty name extraction
20//! - Version ID parsing
21//!
22//! # Implementation Notes
23//! - Format: shell-compatible key=value pairs
24//! - Values may be quoted with single or double quotes
25//! - Comments start with #
26//! - Spec: https://www.freedesktop.org/software/systemd/man/os-release.html
27
28use crate::models::{DatasourceId, PackageType};
29use std::collections::HashMap;
30use std::path::Path;
31
32use crate::parser_warn as warn;
33use packageurl::PackageUrl;
34
35use crate::models::PackageData;
36
37use super::PackageParser;
38use super::metadata::ParserMetadata;
39use super::utils::{CappedIterExt, read_file_to_string, truncate_field};
40
41const PACKAGE_TYPE: PackageType = PackageType::LinuxDistro;
42
43/// Parser for Linux OS release metadata files
44pub struct OsReleaseParser;
45
46impl PackageParser for OsReleaseParser {
47    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
48
49    fn metadata() -> Vec<ParserMetadata> {
50        vec![ParserMetadata {
51            description: "Linux OS release metadata file",
52            file_patterns: &["*etc/os-release", "*usr/lib/os-release"],
53            package_type: "linux-distro",
54            primary_language: "",
55            documentation_url: Some(
56                "https://www.freedesktop.org/software/systemd/man/os-release.html",
57            ),
58        }]
59    }
60
61    fn is_match(path: &Path) -> bool {
62        path.to_str()
63            .is_some_and(|p| p.ends_with("/etc/os-release") || p.ends_with("/usr/lib/os-release"))
64    }
65
66    fn extract_packages(path: &Path) -> Vec<PackageData> {
67        let content = match read_file_to_string(path, None) {
68            Ok(c) => c,
69            Err(e) => {
70                warn!("Failed to read os-release file {:?}: {}", path, e);
71                return vec![PackageData {
72                    package_type: Some(PACKAGE_TYPE),
73                    datasource_id: Some(DatasourceId::EtcOsRelease),
74                    ..Default::default()
75                }];
76            }
77        };
78
79        vec![parse_os_release(&content)]
80    }
81}
82
83pub(crate) fn parse_os_release(content: &str) -> PackageData {
84    let fields = parse_key_value_pairs(content);
85
86    let id = fields.get("ID").map(|s| s.as_str()).unwrap_or("");
87    let id_like = fields.get("ID_LIKE").map(|s| s.as_str());
88    let pretty_name = fields
89        .get("PRETTY_NAME")
90        .map(|s| s.to_lowercase())
91        .unwrap_or_default();
92    let version_id = fields.get("VERSION_ID").cloned();
93
94    // Namespace and name mapping logic from Python reference
95    let (namespace, name) = determine_namespace_and_name(id, id_like, &pretty_name);
96
97    let homepage_url = fields.get("HOME_URL").cloned().map(truncate_field);
98    let bug_tracking_url = fields.get("BUG_REPORT_URL").cloned().map(truncate_field);
99    let code_view_url = fields.get("SUPPORT_URL").cloned().map(truncate_field);
100    let purl = build_purl(namespace, name, version_id.as_deref());
101
102    PackageData {
103        package_type: Some(PACKAGE_TYPE),
104        namespace: Some(truncate_field(namespace.to_string())),
105        name: Some(truncate_field(name.to_string())),
106        version: version_id.map(truncate_field),
107        homepage_url,
108        bug_tracking_url,
109        code_view_url,
110        datasource_id: Some(DatasourceId::EtcOsRelease),
111        purl,
112        ..Default::default()
113    }
114}
115
116fn build_purl(namespace: &str, name: &str, version: Option<&str>) -> Option<String> {
117    let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
118    purl.with_namespace(namespace).ok()?;
119    let version = version?;
120    purl.with_version(version).ok()?;
121    Some(truncate_field(purl.to_string()))
122}
123
124fn determine_namespace_and_name<'a>(
125    id: &'a str,
126    id_like: Option<&'a str>,
127    pretty_name: &'a str,
128) -> (&'a str, &'a str) {
129    match id {
130        "debian" => {
131            let name = if pretty_name.contains("distroless") {
132                "distroless"
133            } else {
134                "debian"
135            };
136            ("debian", name)
137        }
138        "ubuntu" if id_like == Some("debian") => ("debian", "ubuntu"),
139        id if id.starts_with("fedora") || id_like == Some("fedora") => {
140            let name = id_like.unwrap_or(id);
141            (id, name)
142        }
143        _ => {
144            let name = id_like.unwrap_or(id);
145            (id, name)
146        }
147    }
148}
149
150fn parse_key_value_pairs(content: &str) -> HashMap<String, String> {
151    let mut fields = HashMap::new();
152
153    for line in content.lines().capped("os-release lines") {
154        let line = line.trim();
155
156        // Skip empty lines and comments
157        if line.is_empty() || line.starts_with('#') {
158            continue;
159        }
160
161        // Parse KEY=VALUE format
162        if let Some((key, value)) = line.split_once('=') {
163            let key = key.trim().to_string();
164            let value = unquote(value.trim());
165            fields.insert(key, value);
166        }
167    }
168
169    fields
170}
171
172fn unquote(s: &str) -> String {
173    let s = s.trim();
174    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
175        s[1..s.len() - 1].to_string()
176    } else {
177        s.to_string()
178    }
179}