crates/nornir-purgatory/src/kconfig/parse.rs
//! **Reading Kconfig assignments** — the one place a `.config` line becomes data.
//!
//! Deliberately a parser and nothing else. It does not know what a symbol means,
//! whether it is hardening, or whether it should be there; those are
//! [`crate::subtract`]'s questions and keeping them out of here is what lets the
//! set arithmetic in [`super::effective`] be pure.
use std::fmt;
/// What a Kconfig symbol is set to.
///
/// `No` is a first-class value rather than an absence because Kconfig spells it
/// explicitly (`# CONFIG_X is not set`) and a fragment that says so is making a
/// **claim** — the vetoes in `nuked.config` are exactly this. Treating "not set"
/// as "absent" would erase the difference between "we refused it" and "nobody
/// mentioned it", which is the difference `DROPPED` and `ADDED` are built on.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Value {
/// `CONFIG_X=y`
Yes,
/// `CONFIG_X=m`
Module,
/// `# CONFIG_X is not set`
No,
/// `CONFIG_X="…"`
Str(String),
/// `CONFIG_X=42`, `CONFIG_X=0x40`
Num(String),
}
impl Value {
/// Is the feature actually present in the kernel with this value?
///
/// `m` counts: a module is compiled, shipped and loadable, so it is surface.
#[must_use]
pub fn is_enabled(&self) -> bool {
matches!(self, Value::Yes | Value::Module)
}
}
impl fmt::Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Value::Yes => write!(f, "y"),
Value::Module => write!(f, "m"),
Value::No => write!(f, "n"),
Value::Str(s) => write!(f, "{s:?}"),
Value::Num(n) => write!(f, "{n}"),
}
}
}
/// One assignment: `CONFIG_SECCOMP=y`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Directive {
/// The symbol WITHOUT the `CONFIG_` prefix — stored bare so `CONFIG_X` and
/// `X` cannot become two entries for one symbol.
pub symbol: String,
pub value: Value,
}
impl Directive {
/// As it would appear in a `.config`.
#[must_use]
pub fn render(&self) -> String {
match &self.value {
Value::No => format!("# CONFIG_{} is not set", self.symbol),
v => format!("CONFIG_{}={v}", self.symbol),
}
}
}
impl fmt::Display for Directive {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.render())
}
}
/// Strip a leading `CONFIG_`, so one symbol has one key.
fn bare(sym: &str) -> &str {
sym.strip_prefix("CONFIG_").unwrap_or(sym)
}
/// Parse ONE line. `None` for blanks, comments that are not `is not set`, and
/// anything unrecognised.
///
/// Unrecognised is `None` rather than an error on purpose: a `.config` carries
/// banner comments and a `# CONFIG_X is not set` line differs from them by shape
/// alone. Silently skipping a *malformed assignment* would be the bad kind of
/// lenience, so the shapes accepted below are exact.
#[must_use]
pub fn parse_line(line: &str) -> Option<Directive> {
let t = line.trim();
if t.is_empty() {
return None;
}
if let Some(rest) = t.strip_prefix('#') {
// `# CONFIG_X is not set` — and nothing else beginning with `#`.
let rest = rest.trim();
let sym = rest.strip_suffix("is not set")?.trim();
let sym = sym.strip_prefix("CONFIG_")?;
if sym.is_empty() || !is_symbol(sym) {
return None;
}
return Some(Directive {
symbol: sym.to_string(),
value: Value::No,
});
}
let (lhs, rhs) = t.split_once('=')?;
let sym = bare(lhs.trim());
if sym.is_empty() || !is_symbol(sym) {
return None;
}
let rhs = rhs.trim();
let value = match rhs {
"y" => Value::Yes,
"m" => Value::Module,
"n" => Value::No,
_ if rhs.starts_with('"') => Value::Str(rhs.trim_matches('"').to_string()),
_ if !rhs.is_empty() => Value::Num(rhs.to_string()),
_ => return None,
};
Some(Directive {
symbol: sym.to_string(),
value,
})
}
/// Kconfig symbols are `[A-Z0-9_]`. Checked so a prose line that happens to
/// contain `=` cannot become a directive.
fn is_symbol(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
}
/// Every directive in a fragment or a `.config`, in file order.
#[must_use]
pub fn parse_config(text: &str) -> Vec<Directive> {
text.lines().filter_map(parse_line).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_four_shapes_a_config_actually_uses() {
assert_eq!(
parse_line("CONFIG_SECCOMP=y"),
Some(Directive {
symbol: "SECCOMP".into(),
value: Value::Yes
})
);
assert_eq!(
parse_line("CONFIG_E1000=m"),
Some(Directive {
symbol: "E1000".into(),
value: Value::Module
})
);
assert_eq!(
parse_line("# CONFIG_ATA is not set"),
Some(Directive {
symbol: "ATA".into(),
value: Value::No
})
);
assert_eq!(
parse_line("CONFIG_LSM=\"lockdown,yama\""),
Some(Directive {
symbol: "LSM".into(),
value: Value::Str("lockdown,yama".into())
})
);
assert_eq!(
parse_line("CONFIG_LOG_BUF_SHIFT=17"),
Some(Directive {
symbol: "LOG_BUF_SHIFT".into(),
value: Value::Num("17".into())
})
);
}
/// **`n` is a VALUE, not an absence.** The vetoes in `nuked.config` are
/// claims, and `DROPPED`/`ADDED` are built on the difference between "we
/// refused it" and "nobody mentioned it".
#[test]
fn not_set_is_a_value_and_is_not_enabled() {
let d = parse_line("# CONFIG_DEBUG_FS is not set").unwrap();
assert_eq!(d.value, Value::No);
assert!(!d.value.is_enabled());
assert!(Value::Yes.is_enabled());
assert!(
Value::Module.is_enabled(),
"a module is compiled, shipped and loadable — surface"
);
}
/// A banner comment must not become a directive, and neither must prose that
/// happens to contain `=`.
#[test]
fn prose_and_banners_are_not_directives() {
for line in [
"",
" ",
"#",
"# Automatically generated file; DO NOT EDIT.",
"# Linux/x86 6.12.104 Kernel Configuration",
"# CONFIG_ATA is not set for a reason",
"# this one mentions is not set but has no CONFIG_",
"lower_case=y",
"CONFIG_=y",
"no equals here",
] {
assert_eq!(parse_line(line), None, "{line:?} must not parse");
}
}
/// `CONFIG_X` and a bare `X` are ONE symbol, or the set arithmetic double-counts.
#[test]
fn the_config_prefix_is_stripped_so_one_symbol_is_one_key() {
assert_eq!(parse_line("CONFIG_SMP=y").unwrap().symbol, "SMP");
assert_eq!(parse_line("SMP=y").unwrap().symbol, "SMP");
}
#[test]
fn render_round_trips_every_value() {
for line in [
"CONFIG_SECCOMP=y",
"CONFIG_E1000=m",
"# CONFIG_ATA is not set",
"CONFIG_LOG_BUF_SHIFT=17",
] {
assert_eq!(parse_line(line).unwrap().render(), line);
}
}
}