Skip to main content

provenant/parsers/
os_release.rs

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