provenant/parsers/
os_release.rs1use 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
40pub 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 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 if line.is_empty() || line.starts_with('#') {
155 continue;
156 }
157
158 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}