provenant/parsers/
os_release.rs1use crate::models::{DatasourceId, PackageType};
23use std::collections::HashMap;
24use std::path::Path;
25
26use crate::parser_warn as warn;
27
28use crate::models::PackageData;
29
30use super::PackageParser;
31use super::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
32
33const PACKAGE_TYPE: PackageType = PackageType::LinuxDistro;
34
35pub 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 read_file_to_string(path, None) {
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 let (namespace, name) = determine_namespace_and_name(id, id_like, &pretty_name);
76
77 let homepage_url = fields.get("HOME_URL").cloned().map(truncate_field);
78 let bug_tracking_url = fields.get("BUG_REPORT_URL").cloned().map(truncate_field);
79 let code_view_url = fields.get("SUPPORT_URL").cloned().map(truncate_field);
80
81 PackageData {
82 package_type: Some(PACKAGE_TYPE),
83 namespace: Some(truncate_field(namespace.to_string())),
84 name: Some(truncate_field(name.to_string())),
85 version: version_id.map(truncate_field),
86 homepage_url,
87 bug_tracking_url,
88 code_view_url,
89 datasource_id: Some(DatasourceId::EtcOsRelease),
90 ..Default::default()
91 }
92}
93
94fn determine_namespace_and_name<'a>(
95 id: &'a str,
96 id_like: Option<&'a str>,
97 pretty_name: &'a str,
98) -> (&'a str, &'a str) {
99 match id {
100 "debian" => {
101 let name = if pretty_name.contains("distroless") {
102 "distroless"
103 } else {
104 "debian"
105 };
106 ("debian", name)
107 }
108 "ubuntu" if id_like == Some("debian") => ("debian", "ubuntu"),
109 id if id.starts_with("fedora") || id_like == Some("fedora") => {
110 let name = id_like.unwrap_or(id);
111 (id, name)
112 }
113 _ => {
114 let name = id_like.unwrap_or(id);
115 (id, name)
116 }
117 }
118}
119
120fn parse_key_value_pairs(content: &str) -> HashMap<String, String> {
121 let mut fields = HashMap::new();
122
123 for line in content.lines().take(MAX_ITERATION_COUNT) {
124 let line = line.trim();
125
126 if line.is_empty() || line.starts_with('#') {
128 continue;
129 }
130
131 if let Some((key, value)) = line.split_once('=') {
133 let key = key.trim().to_string();
134 let value = unquote(value.trim());
135 fields.insert(key, value);
136 }
137 }
138
139 fields
140}
141
142fn unquote(s: &str) -> String {
143 let s = s.trim();
144 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
145 s[1..s.len() - 1].to_string()
146 } else {
147 s.to_string()
148 }
149}
150
151crate::register_parser!(
152 "Linux OS release metadata file",
153 &["*etc/os-release", "*usr/lib/os-release"],
154 "linux-distro",
155 "",
156 Some("https://www.freedesktop.org/software/systemd/man/os-release.html"),
157);