Skip to main content

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