1use 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
19const OPTIONS_SOURCE_KEY_PREFIX: &str = "x-options-source-";
21
22const OSTREE_BOOTCONFIG_EXTRA_VERSION: (u32, u32) = (2026, 1);
26
27pub(crate) fn ostree_supports_bootconfig_extra() -> bool {
33 let (year, release) = OSTREE_BOOTCONFIG_EXTRA_VERSION;
34 ostree::check_version(year, release)
35}
36
37pub(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
43struct SourceName(String);
48
49impl SourceName {
50 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 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
81fn 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
105fn 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 if let Some(old_source_opts) = source_options.get(&**target_source) {
123 for param in old_source_opts.iter() {
124 merged.remove_exact(¶m);
125 }
126 }
127
128 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(¶m);
133 }
134 }
135
136 merged
137}
138
139fn 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 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 let variant = glib::Variant::from_data_with_type(&data, glib::VariantTy::VARDICT);
170 let dict = glib::VariantDict::new(Some(&variant));
171
172 if let Some(extra) = dict.lookup_value("bootconfig-extra", None) {
174 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
201fn 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 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 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#[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 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 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 let current_options = bootconfig
296 .get("options")
297 .map(|s| s.to_string())
298 .unwrap_or_default();
299
300 let source_options = if staged.is_some() {
303 let mut sources = BTreeMap::new();
317
318 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 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 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 let source_key = source.bls_key();
352 let merged = compute_merged_options(¤t_options, &source_options, &source, new_options);
353
354 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 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 let merge_bootconfig = ostree::Deployment::bootconfig(&merge_deployment)
389 .ok_or_else(|| anyhow::anyhow!("Merge deployment has no bootconfig"))?;
390
391 for name in source_options.keys() {
398 let key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{name}");
399 merge_bootconfig.set(&key, "");
400 }
401 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 if let Some(opts_str) = new_options {
410 merge_bootconfig.set(&source_key, opts_str);
411 }
412
413 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 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 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 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}