Skip to content

Commit d417205

Browse files
uki: split out os-release code to separate file
We'll want to use this for non-UKI cases soon, as well. Signed-off-by: Allison Karlitskaya <[email protected]>
1 parent dd44531 commit d417205

File tree

3 files changed

+239
-189
lines changed

3 files changed

+239
-189
lines changed

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod fsverity;
99
pub mod mount;
1010
pub mod mountcompat;
1111
pub mod oci;
12+
pub mod os_release;
1213
pub mod repository;
1314
pub mod selabel;
1415
pub mod splitstream;

src/os_release.rs

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
use std::collections::HashMap;
2+
3+
// We could be using 'shlex' for this but we really only need to parse a subset of the spec and
4+
// it's easy enough to do for ourselves. Also note that the spec itself suggests using
5+
// `ast.literal_eval()` in Python which is substantially different from a proper shlex,
6+
// particularly in terms of treatment of escape sequences.
7+
fn dequote(value: &str) -> Option<String> {
8+
// https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html
9+
let mut result = String::new();
10+
let mut iter = value.trim().chars();
11+
12+
// os-release spec says we don't have to support concatenation of independently-quoted
13+
// substrings, but honestly, it's easier if we do...
14+
while let Some(c) = iter.next() {
15+
match c {
16+
'"' => loop {
17+
result.push(match iter.next()? {
18+
// Strictly speaking, we should only handle \" \$ \` and \\...
19+
'\\' => iter.next()?,
20+
'"' => break,
21+
other => other,
22+
});
23+
},
24+
25+
'\'' => loop {
26+
result.push(match iter.next()? {
27+
'\'' => break,
28+
other => other,
29+
});
30+
},
31+
32+
// Per POSIX we should handle '\\' sequences here, but os-release spec says we'll only
33+
// encounter A-Za-z0-9 outside of quotes, so let's not bother with that for now...
34+
other => result.push(other),
35+
}
36+
}
37+
38+
Some(result)
39+
}
40+
41+
pub(crate) struct OsReleaseInfo<'a> {
42+
map: HashMap<&'a str, &'a str>,
43+
}
44+
45+
impl<'a> OsReleaseInfo<'a> {
46+
/// Parses an /etc/os-release file
47+
pub(crate) fn parse(content: &'a str) -> Self {
48+
let map = HashMap::from_iter(
49+
content
50+
.lines()
51+
.filter(|line| !line.trim().starts_with('#'))
52+
.filter_map(|line| line.split_once('=')),
53+
);
54+
Self { map }
55+
}
56+
57+
/// Looks up a key (like "PRETTY_NAME") in the os-release file and returns the properly
58+
/// dequoted and unescaped value, if one exists.
59+
pub(crate) fn get_value(&self, keys: &[&str]) -> Option<String> {
60+
keys.iter()
61+
.find_map(|key| self.map.get(key).and_then(|v| dequote(v)))
62+
}
63+
64+
/// Returns the value of the PRETTY_NAME, NAME, or ID field, whichever is found first.
65+
pub(crate) fn get_pretty_name(&self) -> Option<String> {
66+
self.get_value(&["PRETTY_NAME", "NAME", "ID"])
67+
}
68+
69+
/// Returns the value of the VERSION_ID or VERSION field, whichever is found first.
70+
pub(crate) fn get_version(&self) -> Option<String> {
71+
self.get_value(&["VERSION_ID", "VERSION"])
72+
}
73+
74+
/// Combines get_pretty_name() with get_version() as specified in the Boot Loader
75+
/// Specification to produce a boot label. This will return None if we can't find a name, but
76+
/// failing to find a version isn't fatal.
77+
pub(crate) fn get_boot_label(&self) -> Option<String> {
78+
let mut result = self.get_pretty_name()?;
79+
if let Some(version) = self.get_version() {
80+
result.push_str(&format!(" {version}"));
81+
}
82+
Some(result)
83+
}
84+
}
85+
86+
#[cfg(test)]
87+
mod test {
88+
use similar_asserts::assert_eq;
89+
90+
use super::*;
91+
92+
#[test]
93+
fn test_dequote() {
94+
let cases = r##"
95+
96+
We encode the testcases inside of a custom string format to give
97+
us more flexibility and less visual noise. Lines with 4 pipes
98+
are successful testcases (left is quoted, right is unquoted):
99+
100+
|"example"| |example|
101+
102+
and lines with 2 pipes are failing testcases:
103+
104+
|"broken example|
105+
106+
Lines with no pipes are ignored as comments. Now, the cases:
107+
108+
|| || Empty is empty...
109+
|""| ||
110+
|''| ||
111+
|""''""| ||
112+
113+
Unquoted stuff
114+
115+
|hello| |hello|
116+
|1234| |1234|
117+
|\\\\| |\\\\| ...this is non-POSIX...
118+
|\$\`\\| |\$\`\\| ...this too...
119+
120+
Double quotes
121+
122+
|"closed"| |closed|
123+
|"closed\\"| |closed\|
124+
|"a"| |a|
125+
|" "| | |
126+
|"\""| |"|
127+
|"\\"| |\|
128+
|"\$5"| |$5|
129+
|"$5"| |$5| non-POSIX
130+
|"\`tick\`"| |`tick`|
131+
|"`tick`"| |`tick`| non-POSIX
132+
133+
|"\'"| |'| non-POSIX
134+
|"\'"| |'| non-POSIX
135+
136+
...failures...
137+
|"not closed|
138+
|"not closed\"|
139+
|"|
140+
|"\\|
141+
|"\"|
142+
143+
Single quotes
144+
145+
|'a'| |a|
146+
|' '| | |
147+
|'\'| |\|
148+
|'\$'| |\$|
149+
|'closed\'| |closed\|
150+
151+
...failures...
152+
|'| not closed
153+
|'not closed|
154+
|'\''| this is '\' + a second unclosed quote '
155+
156+
"##;
157+
158+
for case in cases.lines() {
159+
match case.split('|').collect::<Vec<&str>>()[..] {
160+
[_comment] => {}
161+
[_, quoted, _, result, _] => assert_eq!(dequote(quoted).as_deref(), Some(result)),
162+
[_, quoted, _] => assert_eq!(dequote(quoted), None),
163+
_ => unreachable!("Invalid test line {case:?}"),
164+
}
165+
}
166+
}
167+
168+
#[test]
169+
fn test_fallbacks() {
170+
let cases = [
171+
(
172+
r#"
173+
PRETTY_NAME='prettyOS'
174+
VERSION_ID="Rocky Racoon"
175+
VERSION=42
176+
ID=pretty-os
177+
"#,
178+
"prettyOS Rocky Racoon",
179+
),
180+
(
181+
r#"
182+
PRETTY_NAME='prettyOS
183+
VERSION_ID="Rocky Racoon"
184+
VERSION=42
185+
ID=pretty-os
186+
"#,
187+
"pretty-os Rocky Racoon",
188+
),
189+
(
190+
r#"
191+
PRETTY_NAME='prettyOS
192+
VERSION=42
193+
ID=pretty-os
194+
"#,
195+
"pretty-os 42",
196+
),
197+
(
198+
r#"
199+
PRETTY_NAME='prettyOS
200+
VERSION=42
201+
ID=pretty-os
202+
"#,
203+
"pretty-os 42",
204+
),
205+
(
206+
r#"
207+
PRETTY_NAME='prettyOS'
208+
ID=pretty-os
209+
"#,
210+
"prettyOS",
211+
),
212+
(
213+
r#"
214+
ID=pretty-os
215+
"#,
216+
"pretty-os",
217+
),
218+
];
219+
220+
for (osrel, label) in cases {
221+
let info = OsReleaseInfo::parse(osrel);
222+
assert_eq!(info.get_boot_label().unwrap(), label);
223+
}
224+
}
225+
}

0 commit comments

Comments
 (0)