1use 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
19const OPTIONS_SOURCE_KEY_PREFIX: &str = "x-options-source-";
21
22struct SourceName(String);
27
28impl SourceName {
29 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 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
60fn 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
84fn 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 if let Some(old_source_opts) = source_options.get(&**target_source) {
102 for param in old_source_opts.iter() {
103 merged.remove_exact(¶m);
104 }
105 }
106
107 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(¶m);
112 }
113 }
114
115 merged
116}
117
118fn 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 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 let variant = glib::Variant::from_data_with_type(&data, glib::VariantTy::VARDICT);
149 let dict = glib::VariantDict::new(Some(&variant));
150
151 if let Some(extra) = dict.lookup_value("bootconfig-extra", None) {
153 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
180fn 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 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 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#[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 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 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 let current_options = bootconfig
276 .get("options")
277 .map(|s| s.to_string())
278 .unwrap_or_default();
279
280 let source_options = if staged.is_some() {
283 let mut sources = BTreeMap::new();
297
298 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 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 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 let source_key = source.bls_key();
332 let merged = compute_merged_options(¤t_options, &source_options, &source, new_options);
333
334 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 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 let merge_bootconfig = ostree::Deployment::bootconfig(&merge_deployment)
369 .ok_or_else(|| anyhow::anyhow!("Merge deployment has no bootconfig"))?;
370
371 for name in source_options.keys() {
378 let key = format!("{OPTIONS_SOURCE_KEY_PREFIX}{name}");
379 merge_bootconfig.set(&key, "");
380 }
381 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 if let Some(opts_str) = new_options {
390 merge_bootconfig.set(&source_key, opts_str);
391 }
392
393 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 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 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 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}