diff --git a/crates/boot/src/autoconfigure/bls.rs b/crates/boot/src/autoconfigure/bls.rs index f321b41..3d47431 100644 --- a/crates/boot/src/autoconfigure/bls.rs +++ b/crates/boot/src/autoconfigure/bls.rs @@ -81,6 +81,7 @@ pub fn scan( ..Default::default() }, path: format!("{}\\loader", root), + pin_names: true, }; // Generate a unique name for the BLS generator and insert the generator into the configuration. diff --git a/crates/boot/src/entries.rs b/crates/boot/src/entries.rs index 88ad5a1..b993b37 100644 --- a/crates/boot/src/entries.rs +++ b/crates/boot/src/entries.rs @@ -2,6 +2,7 @@ use crate::context::SproutContext; use alloc::rc::Rc; use alloc::string::{String, ToString}; use edera_sprout_config::entries::EntryDeclaration; +use edera_sprout_parsing::glob_match; /// Represents an entry that is stamped and ready to be booted. #[derive(Clone)] @@ -105,18 +106,29 @@ impl BootableEntry { } /// Determine if this entry matches `needle` by comparing to the name or title of the entry. - /// If `needle` ends with *, we will match a partial match. + /// The `needle` is a glob pattern, where `*` matches any sequence of characters. + /// A `needle` without any `*` must equal the name or title exactly. pub fn is_match(&self, needle: &str) -> bool { - // If the needle ends with '*', we will accept a partial match. - if needle.ends_with("*") { - // Strip off any '*' at the end. - let partial = needle.trim_end_matches("*"); - // Check if the name or title start with the partial match. - return self.name.starts_with(partial) || self.title.starts_with(partial); - } + glob_match(needle, &self.name) || glob_match(needle, &self.title) + } - // Standard quality matching rules. - self.name == needle || self.title == needle + /// Create a variant of this entry, with the name suffixed by `suffix` and using `context`. + /// The actions of the entry are stamped with `context`, the same as the list generator. + /// Variants of the same entry keep the sort key of this entry, so they stay grouped + /// together in the order they were created in. + pub fn variant(&self, suffix: &str, context: Rc) -> Self { + let mut entry = self.clone(); + entry.name.push_str(suffix); + entry.declaration.actions = context + .stamp_iter(entry.declaration.actions.iter()) + .collect(); + // Without any sort key, the name is used to sort, which differs between variants. + // Pin the sort key to the name of this entry to keep the variants together. + if entry.sort_key.is_none() && entry.declaration.sort_key.is_none() { + entry.sort_key = Some(self.name.clone()); + } + entry.context = context; + entry } /// Set the sort key of the entry. This is used to sort entries via version comparison. diff --git a/crates/boot/src/generators.rs b/crates/boot/src/generators.rs index 3578334..61b6c7c 100644 --- a/crates/boot/src/generators.rs +++ b/crates/boot/src/generators.rs @@ -1,10 +1,14 @@ use crate::context::SproutContext; use crate::entries::BootableEntry; +use alloc::collections::BTreeSet; +use alloc::format; use alloc::rc::Rc; +use alloc::string::String; use alloc::vec::Vec; use anyhow::Result; use anyhow::bail; use edera_sprout_config::generators::GeneratorDeclaration; +use edera_sprout_parsing::{build_variants, rule_matches}; /// The BLS generator. pub mod bls; @@ -15,10 +19,8 @@ pub mod list; /// The matrix generator. pub mod matrix; -/// Runs the generator specified by the `generator` option. -/// It uses the specified `context` as the parent context for -/// the generated entries, injecting more values if needed. -pub fn generate( +/// Runs the generator kind specified by the `generator` option. +fn generate_base( context: Rc, generator: &GeneratorDeclaration, ) -> Result> { @@ -32,3 +34,92 @@ pub fn generate( bail!("unknown generator configuration"); } } + +/// Multiplies `entries` by every combination of the variants in `generator`. +fn apply_variants( + entries: Vec, + generator: &GeneratorDeclaration, +) -> Result> { + // Without any variants, the entries are used as-is. + if generator.variants.is_empty() { + return Ok(entries); + } + + // Validate the axes, as an axis without choices would silently produce no entries, + // and duplicate choice names would produce entries with the same name. + let mut axes = Vec::new(); + for (axis, choices) in &generator.variants { + if choices.is_empty() { + bail!("variant axis '{}' has no choices", axis); + } + let mut seen = BTreeSet::new(); + for choice in choices { + if !seen.insert(choice.name.as_str()) { + bail!( + "variant axis '{}' has more than one choice named '{}'", + axis, + choice.name + ); + } + } + axes.push(( + axis.clone(), + choices + .iter() + .map(|choice| (choice.name.clone(), choice.values.clone())) + .collect(), + )); + } + + let combinations = build_variants(&axes); + let mut result = Vec::with_capacity(entries.len() * combinations.len()); + for entry in &entries { + for combination in &combinations { + let mut context = entry.context().fork(); + context.insert(&combination.values); + let suffix: String = combination + .names + .iter() + .map(|name| format!("-{}", name)) + .collect(); + result.push(entry.variant(&suffix, context.freeze())); + } + } + Ok(result) +} + +/// Runs the generator specified by the `generator` option, named `name`. +/// It uses the specified `context` as the parent context for +/// the generated entries, injecting more values if needed. +/// Entry names are prefixed with the generator name unless pinned, +/// then variants are applied, and then excluded entries are removed. +pub fn generate( + name: &str, + context: Rc, + generator: &GeneratorDeclaration, +) -> Result> { + let mut entries = generate_base(context, generator)?; + + // We will prefix all entries with [name]-, provided the name is not pinned. + let prefix = format!("{}-", name); + for entry in &mut entries { + if !entry.is_pin_name() { + entry.prepend_name_prefix(&prefix); + } + } + + let mut entries = apply_variants(entries, generator)?; + + // Remove any entries that match an exclusion rule. + if !generator.exclude.is_empty() { + entries.retain(|entry| { + let values = entry.context().all_values(); + !generator + .exclude + .iter() + .any(|rule| rule_matches(rule, &values)) + }); + } + + Ok(entries) +} diff --git a/crates/boot/src/generators/bls.rs b/crates/boot/src/generators/bls.rs index 03f95b7..ab11911 100644 --- a/crates/boot/src/generators/bls.rs +++ b/crates/boot/src/generators/bls.rs @@ -206,7 +206,9 @@ pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result Result<()> { for (name, generator) in config.generators { let context = context.fork().freeze(); - // We will prefix all entries with [name]-, provided the name is not pinned. - let prefix = format!("{}-", name); - // Add all the entries generated by the generator to the entry list. // The generator specifies the context associated with the entry. - for mut entry in generators::generate(context.clone(), &generator)? { - // If the entry name is not pinned, prepend the name prefix. - if !entry.is_pin_name() { - entry.prepend_name_prefix(&prefix); - } + entries.extend(generators::generate(&name, context, &generator)?); + } - entries.push(entry); + // Entry names are used to select entries, so duplicates make the selection ambiguous. + // This can happen when two generators pin names, like two BLS generators reading the + // same BLS directory. + let mut seen_names = BTreeSet::new(); + for entry in &entries { + if !seen_names.insert(entry.name()) { + warn!( + "more than one entry is named {}, selecting it by name is ambiguous", + entry.name() + ); } } diff --git a/crates/config/src/generators.rs b/crates/config/src/generators.rs index 1cb0930..551eed0 100644 --- a/crates/config/src/generators.rs +++ b/crates/config/src/generators.rs @@ -1,6 +1,10 @@ use crate::generators::bls::BlsConfiguration; use crate::generators::list::ListConfiguration; use crate::generators::matrix::MatrixConfiguration; +use crate::generators::variants::VariantChoice; +use alloc::collections::BTreeMap; +use alloc::string::String; +use alloc::vec::Vec; use serde::{Deserialize, Serialize}; /// Configuration for the BLS generator. @@ -12,6 +16,9 @@ pub mod list; /// Configuration for the matrix generator. pub mod matrix; +/// Configuration for generator variants. +pub mod variants; + /// Declares a generator configuration. /// Generators allow generating entries at runtime based on a set of data. #[derive(Serialize, Deserialize, Debug, Default, Clone)] @@ -37,4 +44,29 @@ pub struct GeneratorDeclaration { /// List generator configuration. /// Allows you to specify a list of values to generate an entry from. pub list: Option, + /// Variants to apply to every entry produced by the generator. + /// Each key is an axis, and each axis has a list of choices. Every generated entry + /// is multiplied by every combination of choices, one choice from each axis. + /// For example, + /// variants.console = [{ name = "serial" }, { name = "graphics" }] + /// variants.mode = [{ name = "normal" }, { name = "debug", values.mode-options = "loglevel=7" }] + /// would turn each generated entry into four entries: + /// console = serial, mode = normal + /// console = serial, mode = debug + /// console = graphics, mode = normal + /// console = graphics, mode = debug + /// The axis name is set to the name of the chosen choice, and then the values of the + /// chosen choice are inserted, so `$console` and `$mode-options` can be used by the entry. + /// The entry name is suffixed with the choice names, like `-graphics-debug`. + /// Axes are applied in alphabetical order. Choices are applied in the declared order. + #[serde(default)] + pub variants: BTreeMap>, + /// Rules that exclude generated entries, applied after variants. + /// Each rule is a map of value names to glob patterns, where `*` matches anything. + /// An entry is excluded if every value named in any one rule matches its pattern. + /// Values are matched before they are stamped. For example, + /// exclude = [{ version = "0-rescue-*" }, { console = "graphics", mode = "debug" }] + /// excludes BLS rescue entries and the combination of the graphics and debug variants. + #[serde(default)] + pub exclude: Vec>, } diff --git a/crates/config/src/generators/bls.rs b/crates/config/src/generators/bls.rs index a632d31..a8d3a61 100644 --- a/crates/config/src/generators/bls.rs +++ b/crates/config/src/generators/bls.rs @@ -8,13 +8,37 @@ const BLS_TEMPLATE_PATH: &str = "\\loader"; /// The configuration of the BLS generator. /// The BLS uses the Bootloader Specification to produce /// entries from an input template. -#[derive(Serialize, Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct BlsConfiguration { /// The entry to use for as a template. pub entry: EntryDeclaration, /// The path to the BLS directory. #[serde(default = "default_bls_path")] pub path: String, + /// Whether generated entries keep the name of the BLS entry file as-is. + /// When enabled, the generator name is not prepended, so the entry names match + /// the BLS entry ids used by tools like `bootctl set-default`. Variants still append + /// their choice names, so entries of a generator with variants never match the ids + /// exactly, and are best selected with a pattern like `-*`. Two BLS generators + /// that read the same BLS directory will produce entries with the same names, so + /// generators that reuse a BLS directory should disable this. + #[serde(default = "default_pin_names", rename = "pin-names")] + pub pin_names: bool, +} + +/// The default configuration, which matches an empty BLS generator section. +impl Default for BlsConfiguration { + fn default() -> Self { + Self { + entry: Default::default(), + path: default_bls_path(), + pin_names: default_pin_names(), + } + } +} + +fn default_pin_names() -> bool { + true } fn default_bls_path() -> String { diff --git a/crates/config/src/generators/variants.rs b/crates/config/src/generators/variants.rs new file mode 100644 index 0000000..4a19d91 --- /dev/null +++ b/crates/config/src/generators/variants.rs @@ -0,0 +1,15 @@ +use alloc::collections::BTreeMap; +use alloc::string::String; +use serde::{Deserialize, Serialize}; + +/// A single choice of a variant axis. +/// See [crate::generators::GeneratorDeclaration::variants] for how variants are applied. +#[derive(Serialize, Deserialize, Debug, Default, Clone)] +pub struct VariantChoice { + /// The name of the choice. This is set as the value of the axis name, + /// and is appended to the name of each entry the choice applies to. + pub name: String, + /// The values to insert into the context of each entry the choice applies to. + #[serde(default)] + pub values: BTreeMap, +} diff --git a/crates/parsing/src/lib.rs b/crates/parsing/src/lib.rs index c93380d..d43f5e3 100644 --- a/crates/parsing/src/lib.rs +++ b/crates/parsing/src/lib.rs @@ -172,6 +172,97 @@ pub fn initramfs_candidates( candidates.into_iter() } +/// Matches `text` against a glob `pattern` where `*` matches any sequence of characters, +/// including an empty one. Every other character matches itself exactly. +/// A pattern without any `*` only matches `text` when they are equal. +pub fn glob_match(pattern: &str, text: &str) -> bool { + let pattern = pattern.as_bytes(); + let text = text.as_bytes(); + + // The current position in the pattern and the text. + let (mut p, mut t) = (0, 0); + // The position of the last `*` in the pattern and the text position it was tried at. + let mut backtrack: Option<(usize, usize)> = None; + + while t < text.len() { + if p < pattern.len() && pattern[p] == b'*' { + // Try matching the `*` against nothing first, remembering where to resume. + backtrack = Some((p, t)); + p += 1; + } else if p < pattern.len() && pattern[p] == text[t] { + p += 1; + t += 1; + } else if let Some((star, star_text)) = backtrack { + // Let the last `*` consume one more character and try again. + p = star + 1; + t = star_text + 1; + backtrack = Some((star, star_text + 1)); + } else { + return false; + } + } + + // Any remaining pattern must be made of `*` only. + pattern[p..].iter().all(|item| *item == b'*') +} + +/// Determines whether every key in `rule` is present in `values` with a value +/// that matches the glob pattern from `rule`. An empty rule never matches, +/// so that an empty rule cannot accidentally match everything. +pub fn rule_matches(rule: &BTreeMap, values: &BTreeMap) -> bool { + !rule.is_empty() + && rule.iter().all(|(key, pattern)| { + values + .get(key) + .is_some_and(|value| glob_match(pattern, value)) + }) +} + +/// A single combination of variant choices produced by [build_variants]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VariantCombination { + /// The names of the chosen choices, in axis order. + pub names: Vec, + /// The values to insert into the context for this combination. + /// Each axis name is set to the name of its chosen choice, and then + /// the values of each chosen choice are inserted. + pub values: BTreeMap, +} + +/// A variant axis, which is a name and a list of choices. +/// Each choice is a name and the values it inserts. +pub type VariantAxis = (String, Vec<(String, BTreeMap)>); + +/// Builds every combination of the variant `axes`. +/// Each axis is a name and a list of choices, where each choice is a name and a set of values. +/// Combinations are produced in axis order, with the last axis varying fastest, and the +/// choices of each axis in the order they are declared. +/// No axes produce a single empty combination. An axis without any choices produces nothing. +pub fn build_variants(axes: &[VariantAxis]) -> Vec { + let mut result = alloc::vec![VariantCombination { + names: Vec::new(), + values: BTreeMap::new(), + }]; + + for (axis, choices) in axes { + let mut next = Vec::new(); + for combination in &result { + for (name, values) in choices { + let mut combination = combination.clone(); + combination.names.push(name.clone()); + combination.values.insert(axis.clone(), name.clone()); + combination + .values + .extend(values.iter().map(|(k, v)| (k.clone(), v.clone()))); + next.push(combination); + } + } + result = next; + } + + result +} + #[cfg(test)] mod tests { use super::*; @@ -484,4 +575,121 @@ mod tests { let candidates: Vec<_> = initramfs_candidates("-6.1.0", &[]).collect(); assert!(candidates.is_empty()); } + + #[test] + fn glob_without_star_is_exact() { + assert!(glob_match("abc", "abc")); + assert!(!glob_match("abc", "abcd")); + assert!(!glob_match("abc", "ab")); + assert!(glob_match("", "")); + assert!(!glob_match("", "a")); + } + + #[test] + fn glob_trailing_star_is_prefix() { + assert!(glob_match("fedora-*", "fedora-6.1")); + assert!(glob_match("fedora-*", "fedora-")); + assert!(!glob_match("fedora-*", "linux-fedora-6.1")); + } + + #[test] + fn glob_star_anywhere() { + assert!(glob_match( + "linux-*-graphics-debug", + "linux-abc-6.1-graphics-debug" + )); + assert!(!glob_match( + "linux-*-graphics-debug", + "linux-abc-6.1-serial-debug" + )); + assert!(glob_match("*rescue*", "0-rescue-abc")); + assert!(glob_match("*", "")); + assert!(glob_match("a**b", "ab")); + assert!(glob_match("*a*a*", "banana")); + assert!(!glob_match("*a*a*a*a*", "banana")); + } + + #[test] + fn rule_requires_all_keys() { + let values = map(&[("console", "graphics"), ("mode", "debug")]); + assert!(rule_matches( + &map(&[("console", "graphics"), ("mode", "deb*")]), + &values + )); + assert!(!rule_matches( + &map(&[("console", "graphics"), ("mode", "normal")]), + &values + )); + assert!(!rule_matches(&map(&[("missing", "*")]), &values)); + assert!(!rule_matches(&BTreeMap::new(), &values)); + } + + fn axis(name: &str, choices: &[(&str, &[(&str, &str)])]) -> VariantAxis { + ( + name.to_string(), + choices + .iter() + .map(|(choice, values)| (choice.to_string(), map(values))) + .collect(), + ) + } + + #[test] + fn variants_without_axes_produce_one_empty_combination() { + let result = build_variants(&[]); + assert_eq!(result.len(), 1); + assert!(result[0].names.is_empty()); + assert!(result[0].values.is_empty()); + } + + #[test] + fn variants_with_empty_axis_produce_nothing() { + let result = build_variants(&[axis("a", &[("x", &[])]), axis("b", &[])]); + assert!(result.is_empty()); + } + + #[test] + fn variants_are_ordered_and_set_values() { + let result = build_variants(&[ + axis( + "console", + &[ + ("serial", &[("console-title", "Serial")]), + ("graphics", &[("console-title", "Graphics")]), + ], + ), + axis( + "mode", + &[("normal", &[]), ("debug", &[("mode-title", " Debug")])], + ), + ]); + let names: Vec = result.iter().map(|c| c.names.join("-")).collect(); + assert_eq!( + names, + [ + "serial-normal", + "serial-debug", + "graphics-normal", + "graphics-debug" + ] + ); + assert_eq!( + result[3].values, + map(&[ + ("console", "graphics"), + ("console-title", "Graphics"), + ("mode", "debug"), + ("mode-title", " Debug") + ]) + ); + } + + #[test] + fn variant_choice_values_override_axis_name() { + let result = build_variants(&[axis("console", &[("serial", &[("console", "custom")])])]); + assert_eq!( + result[0].values.get("console").map(String::as_str), + Some("custom") + ); + } }