Skip to main content

provenant/parsers/
os_release.rs

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