278 lines
9.6 KiB
Rust
278 lines
9.6 KiB
Rust
use std::fmt;
|
|
use std::{collections::BTreeMap, str::FromStr};
|
|
|
|
use anyhow::Result;
|
|
|
|
use crate::generation::Generation;
|
|
|
|
/// An os-release file represented by a BTreeMap.
|
|
///
|
|
/// This is implemented using a map, so that it can be easily extended in the future (e.g. by
|
|
/// reading the original os-release and patching it).
|
|
///
|
|
/// The BTreeMap is used over a HashMap, so that the keys are ordered. This is irrelevant for
|
|
/// systemd-boot (which does not care about order when reading the os-release file) but is useful
|
|
/// for testing. Ordered keys allow using snapshot tests.
|
|
pub struct OsRelease(pub BTreeMap<String, String>);
|
|
|
|
impl OsRelease {
|
|
pub fn from_generation(generation: &Generation) -> Result<Self> {
|
|
let mut map = BTreeMap::new();
|
|
|
|
// Because of a null pointer dereference, `bootctl` segfaults when no ID field is present
|
|
// in the .osrel section of the stub.
|
|
// Fixed in https://github.com/systemd/systemd/pull/25953
|
|
//
|
|
// Because the ID field here does not have the same meaning as in a real os-release file,
|
|
// it is fine to use a dummy value.
|
|
map.insert("ID".into(), String::from("lanza"));
|
|
|
|
// systemd-boot will only show VERSION_ID when PRETTY_NAME is not unique. This is
|
|
// confusing to users. Make sure that our PRETTY_NAME is unique, so we get a consistent
|
|
// user experience.
|
|
//
|
|
// See #220.
|
|
map.insert(
|
|
"PRETTY_NAME".into(),
|
|
format!(
|
|
"{} ({})",
|
|
generation.spec.bootspec.bootspec.label,
|
|
generation.describe()
|
|
),
|
|
);
|
|
|
|
map.insert("VERSION_ID".into(), generation.describe());
|
|
|
|
Ok(Self(map))
|
|
}
|
|
}
|
|
|
|
impl FromStr for OsRelease {
|
|
type Err = anyhow::Error;
|
|
/// Parse the string representation of a os-release file.
|
|
///
|
|
/// **Beware before reusing this function!**
|
|
///
|
|
/// This parser might not parse all valid os-release files correctly. It is only designed to
|
|
/// read the `VERSION` key from the os-release of a systemd-boot binary.
|
|
fn from_str(value: &str) -> Result<Self> {
|
|
let mut map = BTreeMap::new();
|
|
|
|
enum State {
|
|
PreKey,
|
|
Key,
|
|
PreValue,
|
|
Value,
|
|
ValueEscape,
|
|
SingleQuoteValue,
|
|
DoubleQuoteValue,
|
|
DoubleQuoteValueEscape,
|
|
Comment,
|
|
CommentEscape,
|
|
}
|
|
use State::*;
|
|
|
|
let mut state = State::PreKey;
|
|
|
|
let mut current_key = String::new();
|
|
let mut current_value = String::new();
|
|
|
|
const COMMENTS: &str = "#;";
|
|
const WHITESPACE: &str = " \t\n\r";
|
|
const NEWLINE: &str = "\r\n";
|
|
const SHELL_NEED_ESCAPE: &str = "\"\\`$";
|
|
|
|
for c in value.chars() {
|
|
match state {
|
|
PreKey => {
|
|
if COMMENTS.contains(c) {
|
|
state = Comment;
|
|
} else if !WHITESPACE.contains(c) {
|
|
state = Key;
|
|
current_key.push(c);
|
|
}
|
|
}
|
|
Key => {
|
|
if NEWLINE.contains(c) {
|
|
// keys without any '=' are simply ignored
|
|
state = PreKey;
|
|
current_key.clear();
|
|
} else if c == '=' {
|
|
state = PreValue;
|
|
} else {
|
|
current_key.push(c);
|
|
}
|
|
}
|
|
PreValue => {
|
|
if NEWLINE.contains(c) {
|
|
state = PreKey;
|
|
// strip trailing whitespace from key
|
|
let key = current_key.trim_end().to_owned();
|
|
map.insert(key, current_value.clone());
|
|
|
|
current_key.clear();
|
|
current_value.clear();
|
|
} else if c == '\'' {
|
|
state = SingleQuoteValue;
|
|
} else if c == '"' {
|
|
state = DoubleQuoteValue;
|
|
} else if c == '\\' {
|
|
state = ValueEscape;
|
|
} else if !WHITESPACE.contains(c) {
|
|
state = Value;
|
|
current_value.push(c);
|
|
}
|
|
}
|
|
Value => {
|
|
if NEWLINE.contains(c) {
|
|
state = PreKey;
|
|
// strip trailing whitespace from key
|
|
let key = current_key.trim_end().to_owned();
|
|
// strip trailing whitespace from value
|
|
let value = current_value.trim_end().to_owned();
|
|
map.insert(key, value);
|
|
|
|
current_key.clear();
|
|
current_value.clear();
|
|
} else if c == '\\' {
|
|
state = ValueEscape;
|
|
} else {
|
|
current_value.push(c);
|
|
}
|
|
}
|
|
ValueEscape => {
|
|
state = Value;
|
|
|
|
if !NEWLINE.contains(c) {
|
|
// Escaped newlines we eat up entirely
|
|
current_value.push(c);
|
|
}
|
|
}
|
|
SingleQuoteValue => {
|
|
if c == '\'' {
|
|
state = PreValue;
|
|
} else {
|
|
current_value.push(c);
|
|
}
|
|
}
|
|
DoubleQuoteValue => {
|
|
if c == '"' {
|
|
state = PreValue;
|
|
} else if c == '\\' {
|
|
state = DoubleQuoteValueEscape;
|
|
} else {
|
|
current_value.push(c);
|
|
}
|
|
}
|
|
DoubleQuoteValueEscape => {
|
|
state = DoubleQuoteValue;
|
|
|
|
if SHELL_NEED_ESCAPE.contains(c) {
|
|
// If this is a char that needs escaping, just unescape it.
|
|
current_value.push(c);
|
|
} else if c != '\n' {
|
|
// If other char than what needs escaping, keep the "\"
|
|
// in place, like the real shell does.
|
|
current_value.push('\\');
|
|
current_value.push(c);
|
|
}
|
|
// Escaped newlines (aka "continuation lines") are eaten up entirely
|
|
}
|
|
Comment => {
|
|
if c == '\\' {
|
|
state = CommentEscape;
|
|
} else if NEWLINE.contains(c) {
|
|
state = PreKey;
|
|
}
|
|
}
|
|
CommentEscape => {
|
|
log::debug!("The line which doesn't begin with \";\" or \"#\", but follows a comment line trailing with escape is now treated as a non comment line since v254.");
|
|
if NEWLINE.contains(c) {
|
|
state = PreKey;
|
|
} else {
|
|
state = Comment;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if matches!(
|
|
state,
|
|
PreValue
|
|
| Value
|
|
| ValueEscape
|
|
| SingleQuoteValue
|
|
| DoubleQuoteValue
|
|
| DoubleQuoteValueEscape
|
|
) {
|
|
// strip trailing whitespace from key
|
|
let key = current_key.trim_end().to_owned();
|
|
let value = if matches!(state, Value) {
|
|
// strip trailing whitespace from value
|
|
current_value.trim_end().to_owned()
|
|
} else {
|
|
current_value
|
|
};
|
|
map.insert(key, value);
|
|
}
|
|
|
|
Ok(Self(map))
|
|
}
|
|
}
|
|
|
|
/// Display OsRelease in the format of an os-release file.
|
|
impl fmt::Display for OsRelease {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
for (key, value) in &self.0 {
|
|
writeln!(f, "{}={}", key, value)?
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::ffi::CStr;
|
|
|
|
#[test]
|
|
fn parses_correctly_from_str() -> Result<()> {
|
|
let os_release_cstr = CStr::from_bytes_with_nul(b"ID=systemd-boot\nVERSION=\"252.1\"\n\0")?;
|
|
let os_release_str = os_release_cstr.to_str()?;
|
|
let os_release = OsRelease::from_str(os_release_str)?;
|
|
|
|
assert!(os_release.0["ID"] == "systemd-boot");
|
|
assert!(os_release.0["VERSION"] == "252.1");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn escaping_works() -> Result<()> {
|
|
let teststring = r#"
|
|
NO_QUOTES=systemd-boot
|
|
DOUBLE_QUOTES="systemd-boot"
|
|
SINGLE_QUOTES='systemd-boot'
|
|
UNESCAPED_DOLLAR=$1.2
|
|
ESCAPED_DOLLAR=\$1.2
|
|
UNESCAPED_BACKTICK=`1.2
|
|
ESCAPED_BACKTICK=\`1.2
|
|
UNESCAPED_QUOTE=""1.2"
|
|
ESCAPED_QUOTE=\"1.2
|
|
"#;
|
|
let os_release = OsRelease::from_str(teststring)?;
|
|
|
|
assert!(os_release.0["NO_QUOTES"] == "systemd-boot");
|
|
assert!(os_release.0["DOUBLE_QUOTES"] == "systemd-boot");
|
|
assert!(os_release.0["SINGLE_QUOTES"] == "systemd-boot");
|
|
assert!(os_release.0["UNESCAPED_DOLLAR"] == "$1.2");
|
|
assert!(os_release.0["ESCAPED_DOLLAR"] == "$1.2");
|
|
assert!(os_release.0["UNESCAPED_BACKTICK"] == "`1.2");
|
|
assert!(os_release.0["ESCAPED_BACKTICK"] == "`1.2");
|
|
assert!(os_release.0["UNESCAPED_QUOTE"] == "1.2\"");
|
|
assert!(os_release.0["ESCAPED_QUOTE"] == "\"1.2");
|
|
|
|
Ok(())
|
|
}
|
|
}
|