provenant/parsers/
os_release.rs1use crate::models::{DatasourceId, PackageType};
26use std::collections::HashMap;
27use std::path::Path;
28
29use crate::parser_warn as warn;
30
31use crate::models::PackageData;
32
33use super::PackageParser;
34use super::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
35
36const PACKAGE_TYPE: PackageType = PackageType::LinuxDistro;
37
38pub struct OsReleaseParser;
40
41impl PackageParser for OsReleaseParser {
42 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
43
44 fn is_match(path: &Path) -> bool {
45 path.to_str()
46 .is_some_and(|p| p.ends_with("/etc/os-release") || p.ends_with("/usr/lib/os-release"))
47 }
48
49 fn extract_packages(path: &Path) -> Vec<PackageData> {
50 let content = match read_file_to_string(path, None) {
51 Ok(c) => c,
52 Err(e) => {
53 warn!("Failed to read os-release file {:?}: {}", path, e);
54 return vec![PackageData {
55 package_type: Some(PACKAGE_TYPE),
56 datasource_id: Some(DatasourceId::EtcOsRelease),
57 ..Default::default()
58 }];
59 }
60 };
61
62 vec![parse_os_release(&content)]
63 }
64}
65
66pub(crate) fn parse_os_release(content: &str) -> PackageData {
67 let fields = parse_key_value_pairs(content);
68
69 let id = fields.get("ID").map(|s| s.as_str()).unwrap_or("");
70 let id_like = fields.get("ID_LIKE").map(|s| s.as_str());
71 let pretty_name = fields
72 .get("PRETTY_NAME")
73 .map(|s| s.to_lowercase())
74 .unwrap_or_default();
75 let version_id = fields.get("VERSION_ID").cloned();
76
77 let (namespace, name) = determine_namespace_and_name(id, id_like, &pretty_name);
79
80 let homepage_url = fields.get("HOME_URL").cloned().map(truncate_field);
81 let bug_tracking_url = fields.get("BUG_REPORT_URL").cloned().map(truncate_field);
82 let code_view_url = fields.get("SUPPORT_URL").cloned().map(truncate_field);
83
84 PackageData {
85 package_type: Some(PACKAGE_TYPE),
86 namespace: Some(truncate_field(namespace.to_string())),
87 name: Some(truncate_field(name.to_string())),
88 version: version_id.map(truncate_field),
89 homepage_url,
90 bug_tracking_url,
91 code_view_url,
92 datasource_id: Some(DatasourceId::EtcOsRelease),
93 ..Default::default()
94 }
95}
96
97fn determine_namespace_and_name<'a>(
98 id: &'a str,
99 id_like: Option<&'a str>,
100 pretty_name: &'a str,
101) -> (&'a str, &'a str) {
102 match id {
103 "debian" => {
104 let name = if pretty_name.contains("distroless") {
105 "distroless"
106 } else {
107 "debian"
108 };
109 ("debian", name)
110 }
111 "ubuntu" if id_like == Some("debian") => ("debian", "ubuntu"),
112 id if id.starts_with("fedora") || id_like == Some("fedora") => {
113 let name = id_like.unwrap_or(id);
114 (id, name)
115 }
116 _ => {
117 let name = id_like.unwrap_or(id);
118 (id, name)
119 }
120 }
121}
122
123fn parse_key_value_pairs(content: &str) -> HashMap<String, String> {
124 let mut fields = HashMap::new();
125
126 for line in content.lines().take(MAX_ITERATION_COUNT) {
127 let line = line.trim();
128
129 if line.is_empty() || line.starts_with('#') {
131 continue;
132 }
133
134 if let Some((key, value)) = line.split_once('=') {
136 let key = key.trim().to_string();
137 let value = unquote(value.trim());
138 fields.insert(key, value);
139 }
140 }
141
142 fields
143}
144
145fn unquote(s: &str) -> String {
146 let s = s.trim();
147 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
148 s[1..s.len() - 1].to_string()
149 } else {
150 s.to_string()
151 }
152}
153
154crate::register_parser!(
155 "Linux OS release metadata file",
156 &["*etc/os-release", "*usr/lib/os-release"],
157 "linux-distro",
158 "",
159 Some("https://www.freedesktop.org/software/systemd/man/os-release.html"),
160);