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