Skip to main content

bootc_lib/
loader_entries.rs

1//! # Boot Loader Specification entry management
2//!
3//! This module implements support for merging disparate kernel argument sources
4//! into the single BLS entry `options` field. Each source (e.g., TuneD, admin,
5//! bootc kargs.d) can independently manage its own set of kernel arguments,
6//! which are tracked via `x-options-source-<name>` extension keys in BLS config
7//! files.
8//!
9//! See <https://github.com/ostreedev/ostree/pull/3570>
10//! See <https://github.com/bootc-dev/bootc/issues/899>
11
12use anyhow::{Context, Result, ensure};
13use fn_error_context::context;
14use linux_kernel_cmdline::utf8::{Cmdline, CmdlineOwned};
15use ostree::{gio, glib};
16use ostree_ext::ostree;
17use std::collections::BTreeMap;
18
19/// The BLS extension key prefix for source-tracked options.
20const OPTIONS_SOURCE_KEY_PREFIX: &str = "x-options-source-";
21
22/// The ostree release (year, release) that introduced `bootconfig-extra`
23/// serialization, which preserves `x-` prefixed BLS keys across staged
24/// deployment roundtrips. See <https://github.com/ostreedev/ostree/pull/3570>.
25const OSTREE_BOOTCONFIG_EXTRA_VERSION: (u32, u32) = (2026, 1);
26
27/// Whether the libostree loaded at runtime supports `bootconfig-extra`.
28///
29/// Without it, the `x-options-source-*` keys set by this module are
30/// silently dropped during finalization at shutdown, so the feature
31/// cannot work at all.
32pub(crate) fn ostree_supports_bootconfig_extra() -> bool {
33    let (year, release) = OSTREE_BOOTCONFIG_EXTRA_VERSION;
34    ostree::check_version(year, release)
35}
36
37/// The error reported when the host ostree lacks `bootconfig-extra` support.
38pub(crate) fn unsupported_ostree_error() -> anyhow::Error {
39    let (year, release) = OSTREE_BOOTCONFIG_EXTRA_VERSION;
40    anyhow::anyhow!("This feature requires ostree >= {year}.{release} for bootconfig-extra support")
41}
42
43/// A validated source name (alphanumeric + hyphens + underscores, non-empty).
44///
45/// This is a newtype wrapper around `String` that enforces validation at
46/// construction time. See <https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/>.
47struct SourceName(String);
48
49impl SourceName {
50    /// Parse and validate a source name.
51    fn parse(source: &str) -> Result<Self> {
52        ensure!(!source.is_empty(), "Source name must not be empty");
53        ensure!(
54            source
55                .chars()
56                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
57            "Source name must contain only alphanumeric characters, hyphens, or underscores"
58        );
59        Ok(Self(source.to_owned()))
60    }
61
62    /// The BLS key for this source (e.g., `x-options-source-tuned`).
63    fn bls_key(&self) -> String {
64        format!("{OPTIONS_SOURCE_KEY_PREFIX}{}", self.0)
65    }
66}
67
68impl std::ops::Deref for SourceName {
69    type Target = str;
70    fn deref(&self) -> &str {
71        &self.0
72    }
73}
74
75impl std::fmt::Display for SourceName {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.write_str(&self.0)
78    }
79}
80
81/// Extract source options from BLS entry content. Parses `x-options-source-*` keys
82/// from the raw BLS text since the ostree BootconfigParser doesn't expose key iteration.
83fn extract_source_options_from_bls(content: &str) -> BTreeMap<String, CmdlineOwned> {
84    let mut sources = BTreeMap::new();
85    for line in content.lines() {
86        let line = line.trim();
87        let Some(rest) = line.strip_prefix(OPTIONS_SOURCE_KEY_PREFIX) else {
88            continue;
89        };
90        let Some((source_name, value)) = rest.split_once(|c: char| c.is_ascii_whitespace()) else {
91            continue;
92        };
93        let value = value.trim();
94        if source_name.is_empty() || value.is_empty() {
95            continue;
96        }
97        sources.insert(
98            source_name.to_string(),
99            CmdlineOwned::from(value.to_string()),
100        );
101    }
102    sources
103}
104
105/// Compute the merged `options` line from all sources.
106///
107/// The algorithm:
108/// 1. Start with the current options line
109/// 2. Remove all options that belong to the old value of the specified source
110/// 3. Add the new options for the specified source
111///
112/// Options not tracked by any source are preserved as-is.
113fn compute_merged_options(
114    current_options: &str,
115    source_options: &BTreeMap<String, CmdlineOwned>,
116    target_source: &SourceName,
117    new_options: Option<&str>,
118) -> CmdlineOwned {
119    let mut merged = CmdlineOwned::from(current_options.to_owned());
120
121    // Remove old options from the target source (if it was previously tracked)
122    if let Some(old_source_opts) = source_options.get(&**target_source) {
123        for param in old_source_opts.iter() {
124            merged.remove_exact(&param);
125        }
126    }
127
128    // Add new options for the target source
129    if let Some(new_opts) = new_options.filter(|v| !v.is_empty()) {
130        let new_cmdline = Cmdline::from(new_opts);
131        for param in new_cmdline.iter() {
132            merged.add(&param);
133        }
134    }
135
136    merged
137}
138
139/// Read x-options-source-* keys from the staged deployment data file.
140///
141/// When a deployment is staged, ostree serializes any extension BLS keys into
142/// the "bootconfig-extra" field of the staged deployment GVariant at
143/// /run/ostree/staged-deployment. This function reads that file, extracts the
144/// bootconfig-extra dict, and returns all x-options-source-* entries.
145///
146/// This is needed to discover sources set by previous calls to
147/// set-options-for-source in the same boot cycle, since the staged BLS entry
148/// doesn't exist on disk yet (finalization writes it at shutdown).
149fn read_staged_bootconfig_extra_sources(
150    sysroot: &ostree::Sysroot,
151) -> Result<BTreeMap<String, CmdlineOwned>> {
152    let mut sources = BTreeMap::new();
153    let sysroot_dir = crate::utils::sysroot_dir(sysroot)?;
154
155    // The staged deployment data file is written by ostree during
156    // stage_tree_with_options() and lives under /run/ostree/.
157    let data = match sysroot_dir.open("run/ostree/staged-deployment") {
158        Ok(mut f) => {
159            let mut buf = Vec::new();
160            std::io::Read::read_to_end(&mut f, &mut buf)
161                .context("Reading staged deployment data")?;
162            buf
163        }
164        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(sources),
165        Err(e) => return Err(anyhow::Error::new(e).context("Opening staged deployment data")),
166    };
167
168    // The staged deployment file is a GVariant of type a{sv}.
169    let variant = glib::Variant::from_data_with_type(&data, glib::VariantTy::VARDICT);
170    let dict = glib::VariantDict::new(Some(&variant));
171
172    // Look up "bootconfig-extra" which is stored as a{ss} inside the a{sv} dict.
173    if let Some(extra) = dict.lookup_value("bootconfig-extra", None) {
174        // Handle both direct a{ss} and variant-wrapped a{ss}
175        let inner = if extra.type_().as_str() == "v" {
176            extra.child_value(0)
177        } else {
178            extra
179        };
180        if inner.type_().as_str() == "a{ss}" {
181            for i in 0..inner.n_children() {
182                let entry = inner.child_value(i);
183                let key: String = entry.child_value(0).get().ok_or_else(|| {
184                    anyhow::anyhow!("Unexpected type for key in bootconfig-extra entry")
185                })?;
186                let value: String = entry.child_value(1).get().ok_or_else(|| {
187                    anyhow::anyhow!("Unexpected type for value in bootconfig-extra entry")
188                })?;
189                if let Some(name) = key.strip_prefix(OPTIONS_SOURCE_KEY_PREFIX) {
190                    if !value.is_empty() {
191                        sources.insert(name.to_string(), CmdlineOwned::from(value));
192                    }
193                }
194            }
195        }
196    }
197
198    Ok(sources)
199}
200
201/// Read the BLS entry file content for a deployment from /boot/loader/entries/.
202///
203/// Returns `Ok(Some(content))` if the entry is found, `Ok(None)` if no matching
204/// entry exists, or `Err` if there's an I/O error.
205///
206/// We match by checking the `options` line for the deployment's ostree path
207/// (which includes the stateroot, bootcsum, and bootserial).
208fn read_bls_entry_for_deployment(
209    sysroot: &ostree::Sysroot,
210    deployment: &ostree::Deployment,
211) -> Result<Option<String>> {
212    let sysroot_dir = crate::utils::sysroot_dir(sysroot)?;
213    let entries_dir = sysroot_dir
214        .open_dir("boot/loader/entries")
215        .context("Opening boot/loader/entries")?;
216
217    // Build the expected ostree= value from the deployment to match against.
218    // The ostree= karg format is: /ostree/boot.N/$stateroot/$bootcsum/$bootserial
219    // where bootcsum is the boot checksum and bootserial is the serial among
220    // deployments sharing the same bootcsum (NOT the deployserial).
221    let stateroot = deployment.stateroot();
222    let bootserial = deployment.bootserial();
223    let bootcsum = deployment.bootcsum();
224    let ostree_match = format!("/{stateroot}/{bootcsum}/{bootserial}");
225
226    for entry in entries_dir.entries_utf8()? {
227        let entry = entry?;
228        let file_name = entry.file_name()?;
229
230        if !file_name.starts_with("ostree-") || !file_name.ends_with(".conf") {
231            continue;
232        }
233        let content = entries_dir
234            .read_to_string(&file_name)
235            .with_context(|| format!("Reading BLS entry {file_name}"))?;
236        // Match by parsing the ostree= karg from the options line and checking
237        // that its path ends with our deployment's stateroot/bootcsum/bootserial.
238        // A simple `contains` would be fragile (e.g., serial 0 vs 01).
239        if content.lines().any(|line| {
240            line.starts_with("options ")
241                && line.split_ascii_whitespace().any(|arg| {
242                    arg.strip_prefix("ostree=")
243                        .is_some_and(|path| path.ends_with(&ostree_match))
244                })
245        }) {
246            return Ok(Some(content));
247        }
248    }
249
250    Ok(None)
251}
252
253/// Set the kernel arguments for a specific source via ostree staged deployment.
254///
255/// If no staged deployment exists, this stages a new deployment based on
256/// the booted deployment's commit with the updated kargs. If a staged
257/// deployment already exists (e.g. from `bootc upgrade`), it is replaced
258/// with a new one using the staged commit and origin, preserving any
259/// pending upgrade while layering the source kargs change on top.
260///
261/// The `x-options-source-*` keys survive the staging roundtrip via the
262/// ostree `bootconfig-extra` serialization: source keys are set on the
263/// merge deployment's in-memory bootconfig before staging, ostree inherits
264/// them during `stage_tree_with_options()`, serializes them into the staged
265/// GVariant, and restores them at shutdown during finalization.
266#[context("Setting options for source '{source}' (staged)")]
267pub(crate) fn set_options_for_source_staged(
268    sysroot: &ostree_ext::sysroot::SysrootLock,
269    source: &str,
270    new_options: Option<&str>,
271) -> Result<()> {
272    let source = SourceName::parse(source)?;
273
274    // The CLI refuses to dispatch here on unsupported hosts, but keep the
275    // check so the library API can't be misused either.
276    if !ostree_supports_bootconfig_extra() {
277        return Err(unsupported_ostree_error());
278    }
279
280    let booted = sysroot
281        .booted_deployment()
282        .ok_or_else(|| anyhow::anyhow!("Not booted into an ostree deployment"))?;
283
284    // Determine the "base" deployment whose kargs and source keys we start from.
285    // If there's already a staged deployment (e.g. from `bootc upgrade`), we use
286    // its commit, origin, and kargs so we don't discard a pending upgrade. If no
287    // staged deployment exists, we use the booted deployment.
288    let staged = sysroot.staged_deployment();
289    let base_deployment = staged.as_ref().unwrap_or(&booted);
290
291    let bootconfig = ostree::Deployment::bootconfig(base_deployment)
292        .ok_or_else(|| anyhow::anyhow!("Base deployment has no bootconfig"))?;
293
294    // Read current options from the base deployment's bootconfig.
295    let current_options = bootconfig
296        .get("options")
297        .map(|s| s.to_string())
298        .unwrap_or_default();
299
300    // Read existing x-options-source-* keys.
301    //
302    let source_options = if staged.is_some() {
303        // For staged deployments, extract source keys from the in-memory bootconfig.
304        // We can't read a BLS file because it hasn't been written yet (finalization
305        // happens at shutdown).
306        //
307        // We discover sources from two places:
308        // 1. The booted BLS entry (sources that have been finalized in previous boots)
309        // 2. The staged bootconfig (sources set since last boot via prior calls to
310        //    set-options-for-source that haven't been finalized yet)
311        //
312        // For (2), the staged bootconfig's extra keys are restored from the
313        // "bootconfig-extra" GVariant by ostree's _ostree_sysroot_reload_staged()
314        // during sysroot.load(). We probe the bootconfig for all source keys we
315        // can discover.
316        let mut sources = BTreeMap::new();
317
318        // First: discover from the booted BLS entry (already-finalized sources)
319        if let Some(bls_content) =
320            read_bls_entry_for_deployment(sysroot, &booted).context("Reading booted BLS entry")?
321        {
322            let booted_sources = extract_source_options_from_bls(&bls_content);
323            for name in booted_sources.keys() {
324                let key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{name}");
325                if let Some(val) = bootconfig.get(&key) {
326                    sources.insert(name.clone(), CmdlineOwned::from(val.to_string()));
327                }
328            }
329        }
330
331        // Second: discover from the staged bootconfig's extra keys.
332        // These are sources set by prior calls to set-options-for-source
333        // in this boot cycle (before any reboot). We read them from the
334        // staged deployment data file which contains the serialized
335        // bootconfig-extra GVariant.
336        let staged_sources = read_staged_bootconfig_extra_sources(sysroot)?;
337        for (name, value) in staged_sources {
338            sources.entry(name).or_insert(value);
339        }
340
341        sources
342    } else {
343        // For booted deployments, parse the BLS file directly
344        let bls_content = read_bls_entry_for_deployment(sysroot, &booted)
345            .context("Reading booted BLS entry")?
346            .ok_or_else(|| anyhow::anyhow!("No BLS entry found for booted deployment"))?;
347        extract_source_options_from_bls(&bls_content)
348    };
349
350    // Compute merged options
351    let source_key = source.bls_key();
352    let merged = compute_merged_options(&current_options, &source_options, &source, new_options);
353
354    // Check for idempotency: if nothing changed, skip staging.
355    // Compare the merged cmdline against the current one, and the source value.
356    let merged_str = merged.to_string();
357    let is_options_unchanged = merged_str == current_options;
358    let is_source_unchanged = match (source_options.get(&*source), new_options) {
359        (Some(old), Some(new)) => &**old == new,
360        (None, None) | (None, Some("")) => true,
361        _ => false,
362    };
363
364    if is_options_unchanged && is_source_unchanged {
365        tracing::info!("No changes needed for source '{source}'");
366        return Ok(());
367    }
368
369    // Use the base deployment's commit and origin so we don't discard a
370    // pending upgrade. The merge deployment is always the booted one (for
371    // /etc merge), but the commit/origin come from whichever deployment
372    // we're building on top of.
373    let stateroot = booted.stateroot();
374    let merge_deployment = sysroot
375        .merge_deployment(Some(stateroot.as_str()))
376        .unwrap_or_else(|| booted.clone());
377
378    let origin = ostree::Deployment::origin(base_deployment)
379        .ok_or_else(|| anyhow::anyhow!("Base deployment has no origin"))?;
380
381    let ostree_commit = base_deployment.csum();
382
383    // Update the source keys on the merge deployment's bootconfig BEFORE staging.
384    // The ostree patch (bootconfig-extra) inherits x-prefixed keys from the merge
385    // deployment's bootconfig during stage_tree_with_options(). By updating the
386    // merge deployment's in-memory bootconfig here, the updated source keys will
387    // be serialized into the staged GVariant and survive finalization at shutdown.
388    let merge_bootconfig = ostree::Deployment::bootconfig(&merge_deployment)
389        .ok_or_else(|| anyhow::anyhow!("Merge deployment has no bootconfig"))?;
390
391    // Set all desired source keys on the merge bootconfig.
392    // First, clear any existing source keys that we know about by setting
393    // them to empty string. BootconfigParser has no remove() API, so ""
394    // acts as a tombstone. An empty x-options-source-* key is harmless:
395    // extract_source_options_from_bls will parse it as an empty value,
396    // and the idempotency check skips empty values (!val.is_empty()).
397    for name in source_options.keys() {
398        let key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{name}");
399        merge_bootconfig.set(&key, "");
400    }
401    // Re-set the keys we want to keep (all except the one being removed)
402    for (name, value) in &source_options {
403        if name != &*source {
404            let key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{name}");
405            merge_bootconfig.set(&key, value);
406        }
407    }
408    // Set the new/updated source key (if not removing)
409    if let Some(opts_str) = new_options {
410        merge_bootconfig.set(&source_key, opts_str);
411    }
412
413    // Build kargs as string slices for the ostree API
414    let kargs_strs: Vec<String> = merged.iter_str().map(|s| s.to_string()).collect();
415    let kargs_refs: Vec<&str> = kargs_strs.iter().map(|s| s.as_str()).collect();
416
417    let opts = ostree::SysrootDeployTreeOpts {
418        override_kernel_argv: Some(&kargs_refs),
419        ..Default::default()
420    };
421
422    sysroot.stage_tree_with_options(
423        Some(stateroot.as_str()),
424        &ostree_commit,
425        Some(&origin),
426        Some(&merge_deployment),
427        &opts,
428        gio::Cancellable::NONE,
429    )?;
430
431    tracing::info!("Staged deployment with updated kargs for source '{source}'");
432
433    Ok(())
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn test_source_name_validation() {
442        // (input, should_succeed)
443        let cases = [
444            ("tuned", true),
445            ("bootc-kargs-d", true),
446            ("my_source_123", true),
447            ("", false),
448            ("bad name", false),
449            ("bad/name", false),
450            ("bad.name", false),
451            ("foo@bar", false),
452        ];
453        for (input, expect_ok) in cases {
454            let result = SourceName::parse(input);
455            assert_eq!(
456                result.is_ok(),
457                expect_ok,
458                "SourceName::parse({input:?}) should {}",
459                if expect_ok { "succeed" } else { "fail" }
460            );
461        }
462    }
463
464    #[test]
465    fn test_source_name_bls_key() {
466        let name = SourceName::parse("tuned").unwrap();
467        assert_eq!(name.bls_key(), "x-options-source-tuned");
468    }
469
470    #[test]
471    fn test_extract_source_options_from_bls() {
472        let bls = "\
473title Fedora Linux 43
474version 6.8.0-300.fc40.x86_64
475linux /vmlinuz-6.8.0
476initrd /initramfs-6.8.0.img
477options root=UUID=abc rw nohz=full isolcpus=1-3 rd.driver.pre=vfio-pci
478x-options-source-tuned nohz=full isolcpus=1-3
479x-options-source-dracut rd.driver.pre=vfio-pci
480";
481
482        let sources = extract_source_options_from_bls(bls);
483        assert_eq!(sources.len(), 2);
484        assert_eq!(&*sources["tuned"], "nohz=full isolcpus=1-3");
485        assert_eq!(&*sources["dracut"], "rd.driver.pre=vfio-pci");
486    }
487
488    #[test]
489    fn test_extract_source_options_ignores_non_source_keys() {
490        let bls = "\
491title Test
492version 1
493linux /vmlinuz
494options root=UUID=abc
495x-unrelated-key some-value
496custom-key data
497";
498
499        let sources = extract_source_options_from_bls(bls);
500        assert!(sources.is_empty());
501    }
502
503    #[test]
504    fn test_extract_source_options_ignores_empty_values() {
505        // Empty value (tombstone) should be filtered out
506        let bls = "\
507options root=UUID=abc
508x-options-source-tuned
509x-options-source-dracut   
510x-options-source-admin nohz=full
511";
512
513        let sources = extract_source_options_from_bls(bls);
514        assert_eq!(sources.len(), 1);
515        assert_eq!(&*sources["admin"], "nohz=full");
516    }
517
518    #[test]
519    fn test_compute_merged_options() {
520        // Each case: (description, current_options, source_map, target_source, new_options, expected)
521        let cases: &[(&str, &str, &[(&str, &str)], &str, Option<&str>, &str)] = &[
522            (
523                "add new source",
524                "root=UUID=abc123 rw composefs=digest123",
525                &[],
526                "tuned",
527                Some("isolcpus=1-3 nohz_full=1-3"),
528                "root=UUID=abc123 rw composefs=digest123 isolcpus=1-3 nohz_full=1-3",
529            ),
530            (
531                "update existing source",
532                "root=UUID=abc123 rw isolcpus=1-3 nohz_full=1-3",
533                &[("tuned", "isolcpus=1-3 nohz_full=1-3")],
534                "tuned",
535                Some("isolcpus=0-7"),
536                "root=UUID=abc123 rw isolcpus=0-7",
537            ),
538            (
539                "remove source (None)",
540                "root=UUID=abc123 rw isolcpus=1-3 nohz_full=1-3",
541                &[("tuned", "isolcpus=1-3 nohz_full=1-3")],
542                "tuned",
543                None,
544                "root=UUID=abc123 rw",
545            ),
546            (
547                "empty initial options",
548                "",
549                &[],
550                "tuned",
551                Some("isolcpus=1-3"),
552                "isolcpus=1-3",
553            ),
554            (
555                "clear source with empty string",
556                "root=UUID=abc123 rw isolcpus=1-3",
557                &[("tuned", "isolcpus=1-3")],
558                "tuned",
559                Some(""),
560                "root=UUID=abc123 rw",
561            ),
562            (
563                "preserves untracked options",
564                "root=UUID=abc123 rw quiet isolcpus=1-3",
565                &[("tuned", "isolcpus=1-3")],
566                "tuned",
567                Some("nohz=full"),
568                "root=UUID=abc123 rw quiet nohz=full",
569            ),
570            (
571                "multiple sources, update one preserves others",
572                "root=UUID=abc rw isolcpus=1-3 rd.driver.pre=vfio-pci",
573                &[
574                    ("tuned", "isolcpus=1-3"),
575                    ("dracut", "rd.driver.pre=vfio-pci"),
576                ],
577                "tuned",
578                Some("nohz=full"),
579                "root=UUID=abc rw rd.driver.pre=vfio-pci nohz=full",
580            ),
581        ];
582
583        for (desc, current, source_entries, target, new_opts, expected) in cases {
584            let mut sources = BTreeMap::new();
585            for (name, value) in *source_entries {
586                sources.insert(name.to_string(), CmdlineOwned::from(value.to_string()));
587            }
588            let source = SourceName::parse(target).unwrap();
589            let result = compute_merged_options(current, &sources, &source, *new_opts);
590            assert_eq!(&*result, *expected, "case: {desc}");
591        }
592    }
593}