composefs_boot/
os_release.rs

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