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