1use std::{
4 ffi::OsString,
5 fmt::Debug,
6 io::ErrorKind,
7 os::fd::{AsFd, AsRawFd, OwnedFd},
8 path::{Path, PathBuf},
9};
10
11use anyhow::{Context, Result};
12use clap::Parser;
13use rustix::{
14 fs::{CWD, Mode, OFlags, major, minor, mkdirat, openat, stat, symlink},
15 io::Errno,
16 mount::{
17 FsMountFlags, MountAttrFlags, OpenTreeFlags, UnmountFlags, fsconfig_create,
18 fsconfig_set_string, fsmount, open_tree, unmount,
19 },
20 path,
21};
22
23use serde::Deserialize;
24
25use composefs::{
26 fsverity::{CompareVerityError, FsVerityHashValue, MeasureVerityError, Sha512HashValue},
27 mount::{FsHandle, MountOptions},
28 mountcompat::{overlayfs_set_fd, overlayfs_set_lower_and_data_fds, prepare_mount},
29 repository::{ImageNotFound, IntegrityPolicy, Repository, VerityState},
30};
31use composefs_boot::cmdline::{ComposefsCmdline, KARG_COMPOSEFS_DIGEST, KARG_V2};
32use composefs_ctl::composefs;
33use composefs_ctl::composefs_boot;
34
35use fn_error_context::context;
36
37use linux_kernel_cmdline::utf8::Cmdline;
38
39const MOUNT_ATTR_RDONLY: u64 = 0x00000001;
41
42#[repr(C)]
43struct MountAttr {
44 attr_set: u64,
45 attr_clr: u64,
46 propagation: u64,
47 userns_fd: u64,
48}
49
50#[context("Setting mount attributes")]
52#[allow(unsafe_code)]
53fn mount_setattr(fd: impl AsFd, flags: libc::c_int, attr: &MountAttr) -> Result<()> {
54 let ret = unsafe {
55 libc::syscall(
56 libc::SYS_mount_setattr,
57 fd.as_fd().as_raw_fd(),
58 c"".as_ptr(),
59 flags,
60 attr as *const MountAttr,
61 std::mem::size_of::<MountAttr>(),
62 )
63 };
64 if ret == -1 {
65 Err(std::io::Error::last_os_error())?;
66 }
67 Ok(())
68}
69
70#[context("Setting mount readonly")]
72pub fn set_mount_readonly(fd: impl AsFd) -> Result<()> {
73 let attr = MountAttr {
74 attr_set: MOUNT_ATTR_RDONLY,
75 attr_clr: 0,
76 propagation: 0,
77 userns_fd: 0,
78 };
79 mount_setattr(fd, libc::AT_EMPTY_PATH, &attr)
80}
81
82#[context("Setting mount tree readonly")]
84pub fn set_mount_tree_readonly(fd: impl AsFd) -> Result<()> {
85 let attr = MountAttr {
86 attr_set: MOUNT_ATTR_RDONLY,
87 attr_clr: 0,
88 propagation: 0,
89 userns_fd: 0,
90 };
91 mount_setattr(fd, libc::AT_EMPTY_PATH | libc::AT_RECURSIVE, &attr)
92}
93
94#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
96#[serde(rename_all = "lowercase")]
97pub enum MountType {
98 #[serde(alias = "root")]
100 None,
101 Bind,
103 Overlay,
105 #[serde(alias = "volatile")]
107 Transient,
108}
109
110#[derive(Debug, Default, Deserialize, PartialEq)]
111struct RootConfig {
112 #[serde(default)]
113 transient: bool,
114}
115
116#[derive(Debug, Default, Deserialize, PartialEq)]
118pub struct MountConfig {
119 pub mount: Option<MountType>,
121 #[serde(default)]
122 pub transient: bool,
124}
125
126#[derive(Debug, Deserialize, Default, PartialEq)]
128pub struct Config {
129 #[serde(default)]
131 pub etc: MountConfig,
132 #[serde(default)]
134 pub var: MountConfig,
135 #[serde(default)]
136 root: RootConfig,
137}
138
139pub const SETUP_ROOT_CONF_PATH: &str = "/usr/lib/composefs/setup-root-conf.toml";
141
142pub fn config_has_transient_submounts(path: &std::path::Path) -> bool {
150 let text = match std::fs::read_to_string(path) {
151 Ok(t) => t,
152 Err(e) => {
153 tracing::debug!("Could not read {}: {e:#}", path.display());
154 return false;
155 }
156 };
157 let config: Config = match toml::from_str(&text) {
158 Ok(c) => c,
159 Err(e) => {
160 tracing::debug!("Could not parse {}: {e:#}", path.display());
161 return false;
162 }
163 };
164 let is_transient = |mc: &MountConfig| match mc.mount {
166 Some(mt) => mt == MountType::Transient,
167 None => mc.transient,
168 };
169 is_transient(&config.etc)
170}
171
172#[derive(Parser, Debug)]
174pub struct Args {
175 #[arg(help = "Execute this command (for testing)")]
176 pub cmd: Vec<OsString>,
178
179 #[arg(
180 long,
181 default_value = "/sysroot",
182 help = "sysroot directory in initramfs"
183 )]
184 pub sysroot: PathBuf,
186
187 #[arg(
188 long,
189 default_value = "/usr/lib/composefs/setup-root-conf.toml",
190 help = "Config path (for testing)"
191 )]
192 pub config: PathBuf,
194
195 #[arg(long, help = "Bind mount root-fs from (for testing)")]
197 pub root_fs: Option<PathBuf>,
199
200 #[arg(long, help = "Kernel commandline args (for testing)")]
201 pub cmdline: Option<Cmdline<'static>>,
203
204 #[arg(long, help = "Mountpoint (don't replace sysroot, for testing)")]
205 pub target: Option<PathBuf>,
207}
208
209pub fn mount_at_wrapper(
211 fs_fd: impl AsFd,
212 dirfd: impl AsFd,
213 path: impl path::Arg + Debug + Clone,
214) -> Result<()> {
215 composefs::mount::mount_at(fs_fd, dirfd, path.clone())
216 .with_context(|| format!("Mounting at path {path:?}"))
217}
218
219#[context("Opening dir {name:?}")]
221pub fn open_dir(dirfd: impl AsFd, name: impl AsRef<Path> + Debug) -> Result<OwnedFd> {
222 let res = openat(
223 dirfd,
224 name.as_ref(),
225 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
226 Mode::empty(),
227 );
228
229 Ok(res?)
230}
231
232#[context("Ensure dir")]
233fn ensure_dir(dirfd: impl AsFd, name: &str, mode: Option<rustix::fs::Mode>) -> Result<OwnedFd> {
234 match mkdirat(dirfd.as_fd(), name, mode.unwrap_or(0o700.into())) {
235 Ok(()) | Err(Errno::EXIST) => {}
236 Err(err) => Err(err).with_context(|| format!("Creating dir {name}"))?,
237 }
238
239 open_dir(dirfd, name)
240}
241
242#[context("Bind mounting to path {path}")]
243fn bind_mount(fd: impl AsFd, path: &str) -> Result<OwnedFd> {
244 let res = open_tree(
245 fd.as_fd(),
246 path,
247 OpenTreeFlags::OPEN_TREE_CLONE
248 | OpenTreeFlags::OPEN_TREE_CLOEXEC
249 | OpenTreeFlags::AT_EMPTY_PATH,
250 );
251
252 Ok(res?)
253}
254
255#[context("Mounting tmpfs for overlay")]
262fn mount_tmpfs_for_overlay() -> Result<OwnedFd> {
263 let tmpfs = FsHandle::open("tmpfs")?;
264 fsconfig_create(tmpfs.as_fd())?;
265 Ok(fsmount(
266 tmpfs.as_fd(),
267 FsMountFlags::FSMOUNT_CLOEXEC,
268 MountAttrFlags::empty(),
269 )?)
270}
271
272fn build_overlay_fd(
279 base: impl AsFd,
280 state: impl AsFd,
281 source: &str,
282 mount_attr_flags: Option<MountAttrFlags>,
283) -> Result<OwnedFd> {
284 let upper = ensure_dir(state.as_fd(), "upper", Some(0o755.into()))?;
285 let work = ensure_dir(state.as_fd(), "work", Some(0o700.into()))?;
286
287 let overlayfs = FsHandle::open("overlay")?;
288 fsconfig_set_string(overlayfs.as_fd(), "source", source)?;
289 overlayfs_set_fd(overlayfs.as_fd(), "workdir", work.as_fd())?;
290 overlayfs_set_fd(overlayfs.as_fd(), "upperdir", upper.as_fd())?;
291 overlayfs_set_lower_and_data_fds(&overlayfs, base.as_fd(), &[])?;
292 fsconfig_create(overlayfs.as_fd())?;
293 Ok(fsmount(
294 overlayfs.as_fd(),
295 FsMountFlags::FSMOUNT_CLOEXEC,
296 mount_attr_flags.unwrap_or(MountAttrFlags::empty()),
297 )?)
298}
299
300#[context("Mounting state as overlay")]
303fn overlay_state(
304 base: impl AsFd,
305 state: impl AsFd,
306 source: &str,
307 mount_attr_flags: Option<MountAttrFlags>,
308) -> Result<()> {
309 let fs = build_overlay_fd(&base, state, source, mount_attr_flags)?;
310 mount_at_wrapper(fs, base, ".").context("Moving mount")
311}
312
313#[context("Creating transient overlayfs")]
329pub fn overlay_transient(
330 base: impl AsFd,
331 source: &str,
332 mount_attr_flags: Option<MountAttrFlags>,
333) -> Result<OwnedFd> {
334 let tmpfs = mount_tmpfs_for_overlay()?;
335 let state = prepare_mount(tmpfs)?;
336 build_overlay_fd(base, state, source, mount_attr_flags)
337}
338
339#[context("Opening rootfs")]
340fn open_root_fs(path: &Path) -> Result<OwnedFd> {
341 let rootfs = open_tree(
342 CWD,
343 path,
344 OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC,
345 )?;
346
347 set_mount_readonly(&rootfs)?;
348
349 Ok(rootfs)
350}
351
352const STRICT_BOOT_WITHOUT_FSVERITY_HELP: &str = "This boot entry requires fs-verity \
355(its composefs= argument has no '?'), but the root filesystem does not support it (for \
356example ext4 created without '-O verity'). To boot, select the previous deployment in \
357the boot menu, or add '?' right after 'composefs=' (or 'composefs.digest=') in this \
358entry's kernel command line, which boots without fs-verity enforcement. To enforce \
359fs-verity, reinstall onto a filesystem with fs-verity enabled";
360
361fn is_fsverity_unsupported(error: &anyhow::Error) -> bool {
363 matches!(
364 error.downcast_ref::<CompareVerityError>(),
365 Some(CompareVerityError::Measure(
366 MeasureVerityError::FilesystemNotSupported
367 ))
368 )
369}
370
371#[context("Mounting composefs image")]
378pub fn mount_composefs_image(
379 sysroot: &OwnedFd,
380 name: &str,
381 allow_missing_fsverity: bool,
382) -> Result<OwnedFd> {
383 let digest = Sha512HashValue::from_hex(name).context("Parsing composefs digest")?;
384 let (mut repo, _upgraded) = Repository::<Sha512HashValue>::open_upgrade(sysroot, "composefs")?;
388 let verity = if allow_missing_fsverity {
393 VerityState::Disabled
396 } else {
397 VerityState::Enabled
399 };
400 repo.set_integrity_policy(IntegrityPolicy::Explicit(verity))?;
401 let rootfs = repo
403 .mount_digest(&digest, &MountOptions::default())
404 .map_err(|error| {
405 if !allow_missing_fsverity && is_fsverity_unsupported(&error) {
406 error.context(STRICT_BOOT_WITHOUT_FSVERITY_HELP)
407 } else {
408 error
409 }
410 })?;
411
412 set_mount_readonly(&rootfs)?;
413
414 Ok(rootfs)
415}
416
417fn parse_composefs_candidates(cmdline: &str) -> Result<Vec<ComposefsCmdline<Sha512HashValue>>> {
418 let mut candidates = Vec::new();
419 let mut seen_legacy = false;
420
421 for parameter in Cmdline::from(cmdline).iter() {
424 let key = parameter.key();
425 if &*key != KARG_COMPOSEFS_DIGEST && &*key != KARG_V2 {
426 continue;
427 }
428
429 let value = parameter
430 .value()
431 .ok_or_else(|| anyhow::anyhow!("{key}= composefs kernel argument has no value"))?;
432 if &*key == KARG_V2 {
433 anyhow::ensure!(
434 !seen_legacy,
435 "duplicate {KARG_V2}= composefs kernel argument"
436 );
437 seen_legacy = true;
438 }
439
440 let parameter = format!("{key}={value}");
441 if let Some(candidate) = ComposefsCmdline::<Sha512HashValue>::from_cmdline(¶meter)? {
442 candidates.push(candidate);
443 }
444 }
445 Ok(candidates)
446}
447
448fn select_composefs_candidate<T>(
449 candidates: &[ComposefsCmdline<Sha512HashValue>],
450 mut mount: impl FnMut(&ComposefsCmdline<Sha512HashValue>) -> Result<Option<T>>,
451) -> Result<(T, ComposefsCmdline<Sha512HashValue>)> {
452 for candidate in candidates {
453 if let Some(mounted) = mount(candidate)? {
454 return Ok((mounted, candidate.clone()));
455 }
456 }
457 let tried = candidates
458 .iter()
459 .map(|candidate| candidate.digest().to_hex())
460 .collect::<Vec<_>>()
461 .join(", ");
462 anyhow::bail!("no composefs image found (tried: {tried})")
463}
464
465fn mount_composefs_candidate(
466 sysroot: &OwnedFd,
467 candidate: &ComposefsCmdline<Sha512HashValue>,
468 allow_missing_fsverity: bool,
469) -> Result<Option<OwnedFd>> {
470 if candidate.is_insecure() && !allow_missing_fsverity {
472 anyhow::bail!(
473 "composefs candidate requests insecure fs-verity, but the boot policy is strict"
474 );
475 }
476
477 match mount_composefs_image(
478 sysroot,
479 &candidate.digest().to_hex(),
480 allow_missing_fsverity,
481 ) {
482 Ok(rootfs) => Ok(Some(rootfs)),
483 Err(error) if error.downcast_ref::<ImageNotFound>().is_some() => Ok(None),
484 Err(error) => Err(error),
485 }
486}
487
488#[context("Mounting subdirectory")]
490pub fn mount_subdir(
491 new_root: impl AsFd,
492 state: impl AsFd,
493 subdir: &str,
494 config: MountConfig,
495 default: MountType,
496) -> Result<()> {
497 let mount_type = match config.mount {
498 Some(mt) => mt,
499 None => match config.transient {
500 true => MountType::Transient,
501 false => default,
502 },
503 };
504
505 match mount_type {
506 MountType::None => Ok(()),
507 MountType::Bind => Ok(mount_at_wrapper(
508 bind_mount(&state, subdir)?,
509 &new_root,
510 subdir,
511 )?),
512 MountType::Overlay => overlay_state(
513 open_dir(&new_root, subdir)?,
514 open_dir(&state, subdir)?,
515 "overlay",
516 None,
517 ),
518 MountType::Transient => {
519 let subdir_fd = open_dir(&new_root, subdir)?;
522 let overlay_fd = overlay_transient(subdir_fd.as_fd(), "transient", None)?;
523 mount_at_wrapper(overlay_fd, &new_root, subdir)
524 }
525 }
526}
527
528#[context("GPT workaround")]
529pub fn gpt_workaround() -> Result<()> {
531 let rootdev = stat("/dev/gpt-auto-root");
533
534 let rootdev = match rootdev {
535 Ok(r) => r,
536 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(()),
537 Err(e) => Err(e)?,
538 };
539
540 let target = format!(
541 "/dev/block/{}:{}",
542 major(rootdev.st_rdev),
543 minor(rootdev.st_rdev)
544 );
545 match symlink(target, "/run/systemd/volatile-root") {
549 Ok(()) | Err(Errno::EXIST) => Ok(()),
550 Err(err) => Err(err).context("Creating /run/systemd/volatile-root"),
551 }
552}
553
554#[context("Setting up /sysroot")]
556pub fn setup_root(args: Args) -> Result<()> {
557 let config = match std::fs::read_to_string(args.config) {
558 Ok(text) => toml::from_str(&text)?,
559 Err(err) if err.kind() == ErrorKind::NotFound => Config::default(),
560 Err(err) => Err(err)?,
561 };
562
563 let sysroot = open_dir(CWD, &args.sysroot)
564 .with_context(|| format!("Failed to open sysroot {:?}", args.sysroot))?;
565
566 let cmdline = args
567 .cmdline
568 .unwrap_or(Cmdline::from_proc().context("Failed to read cmdline")?);
569
570 let config = {
579 let mut config = config;
580 let volatile_val = cmdline.value_of("systemd.volatile");
583 let var_volatile = matches!(volatile_val, Some("state") | Some("overlay"));
584 if var_volatile && config.var.mount.is_none() && !config.var.transient {
585 tracing::debug!(
586 "systemd.volatile={} detected; skipping /var state bind-mount",
587 volatile_val.unwrap_or("")
588 );
589 config.var.mount = Some(MountType::None);
590 }
591 config
592 };
593
594 let candidates =
595 parse_composefs_candidates(&cmdline).context("Failed to parse composefs cmdline")?;
596 if candidates.is_empty() {
597 anyhow::bail!("No composefs image in cmdline");
598 }
599 let allow_missing_fsverity = candidates[0].is_insecure();
600 if candidates
601 .iter()
602 .any(|candidate| candidate.is_insecure() != allow_missing_fsverity)
603 {
604 anyhow::bail!("composefs candidates have mixed fs-verity policy");
605 }
606
607 let (new_root, composefs_info) = match &args.root_fs {
608 Some(path) => (
609 open_root_fs(path).context("Failed to clone specified root fs")?,
610 candidates[0].clone(),
611 ),
612 None => select_composefs_candidate(&candidates, |candidate| {
613 mount_composefs_candidate(&sysroot, candidate, allow_missing_fsverity)
614 })?,
615 };
616
617 let sysroot_clone = bind_mount(&sysroot, "")?;
619
620 set_mount_readonly(&sysroot_clone)?;
621
622 let mount_target = args.target.unwrap_or(args.sysroot.clone());
623
624 if cfg!(feature = "pre-6.15") {
628 mount_at_wrapper(&new_root, CWD, &mount_target)?;
629 }
630
631 let transient_overlay_fd: Option<OwnedFd> = if config.root.transient {
642 let overlay_fd = overlay_transient(
643 &new_root,
644 &format!("transient:composefs={}", composefs_info.digest().to_hex()),
645 None,
646 )?;
647
648 if cfg!(feature = "pre-6.15") {
649 mount_at_wrapper(&overlay_fd, CWD, &mount_target)
653 .context("Moving transient overlay onto sysroot")?;
654 Some(open_dir(CWD, &mount_target).context("Opening attached overlay root")?)
655 } else {
656 Some(overlay_fd)
659 }
660 } else {
661 None
662 };
663
664 let visible_root: &dyn AsFd = transient_overlay_fd
669 .as_ref()
670 .map_or(&new_root as &dyn AsFd, |fd| fd as &dyn AsFd);
671
672 match composefs::mount::mount_at(&sysroot_clone, visible_root, "sysroot") {
675 Ok(()) | Err(Errno::NOENT) => {}
676 Err(err) => Err(err)?,
677 }
678
679 let state = open_dir(
681 open_dir(&sysroot, "state/deploy")?,
682 composefs_info.digest().to_hex(),
683 )?;
684 mount_subdir(visible_root, &state, "etc", config.etc, MountType::Bind)?;
685 mount_subdir(visible_root, &state, "var", config.var, MountType::Bind)?;
691
692 if cfg!(not(feature = "pre-6.15")) {
693 unmount(&args.sysroot, UnmountFlags::DETACH)?;
697 mount_at_wrapper(visible_root, CWD, &mount_target)?;
698 }
699
700 Ok(())
701}
702
703#[cfg(test)]
704mod tests {
705 use std::{fs, fs::File};
706
707 use composefs::{
708 fsverity::{Algorithm, CompareVerityError, MeasureVerityError, compute_verity},
709 repository::RepositoryConfig,
710 };
711
712 use super::*;
713
714 #[test]
715 fn test_strict_mount_rejects_unverified_images() -> Result<()> {
716 let tmp = tempfile::tempdir()?;
721 let sysroot: OwnedFd = File::open(tmp.path())?.into();
722 let (repo, _) = Repository::<Sha512HashValue>::init_path(
723 &sysroot,
724 "composefs",
725 RepositoryConfig::new(Algorithm::SHA512).set_insecure(),
726 )?;
727 drop(repo);
728 let images = tmp.path().join("composefs/images");
729 fs::create_dir_all(images.join("refs"))?;
730 let data = b"unverified image";
731 let digest = compute_verity::<Sha512HashValue>(data).to_hex();
732
733 fs::write(images.join("refs").join(&digest), data)?;
734 for allow_missing_fsverity in [false, true] {
735 let error =
736 mount_composefs_image(&sysroot, &digest, allow_missing_fsverity).unwrap_err();
737 assert!(error.downcast_ref::<ImageNotFound>().is_some(), "{error:#}");
738 }
739
740 fs::write(images.join(&digest), data)?;
741 let error = mount_composefs_image(&sysroot, &digest, false).unwrap_err();
742 assert!(
743 matches!(
744 error.downcast_ref::<CompareVerityError>(),
745 Some(CompareVerityError::Measure(
746 MeasureVerityError::VerityMissing | MeasureVerityError::FilesystemNotSupported
747 ))
748 ),
749 "{error:#}"
750 );
751 assert_eq!(
753 format!("{error:#}").contains(STRICT_BOOT_WITHOUT_FSVERITY_HELP),
754 is_fsverity_unsupported(&error),
755 "{error:#}"
756 );
757
758 let missing = Sha512HashValue::EMPTY.to_hex();
759 let error = mount_composefs_image(&sysroot, &missing, false).unwrap_err();
760 assert!(error.downcast_ref::<ImageNotFound>().is_some(), "{error:#}");
761 Ok(())
762 }
763
764 fn parse(toml: &str) -> Config {
765 toml::from_str(toml).expect("TOML parse failed")
766 }
767
768 #[test]
769 fn test_config_defaults() {
770 let config = parse("");
771 assert_eq!(
772 config,
773 Config {
774 etc: MountConfig {
775 mount: None,
776 transient: false
777 },
778 var: MountConfig {
779 mount: None,
780 transient: false
781 },
782 root: RootConfig { transient: false },
783 }
784 );
785 }
786
787 #[test]
788 fn test_mounttype_none() {
789 let config = parse("[etc]\nmount = \"none\"");
790 assert_eq!(config.etc.mount, Some(MountType::None));
791 }
792
793 #[test]
794 fn test_mounttype_root_alias() {
795 let config = parse("[etc]\nmount = \"root\"");
796 assert_eq!(config.etc.mount, Some(MountType::None));
797 }
798
799 #[test]
800 fn test_etc_transient_flag() {
801 let config = parse("[etc]\ntransient = true");
802 assert_eq!(config.etc.transient, true);
803 assert_eq!(config.etc.mount, None);
804 }
805
806 #[test]
807 fn test_var_none() {
808 let config = parse("[var]\nmount = \"none\"");
811 assert_eq!(config.var.mount, Some(MountType::None));
812 }
813
814 #[test]
815 fn test_root_transient() {
816 let config = parse("[root]\ntransient = true");
817 assert_eq!(config.root.transient, true);
818 }
819
820 #[test]
821 fn test_combined_config() {
822 let config = parse("[root]\ntransient = true\n[etc]\nmount = \"root\"");
823 assert_eq!(config.root.transient, true);
824 assert_eq!(config.etc.mount, Some(MountType::None));
825 }
826
827 fn v1(digest: Sha512HashValue, insecure: bool) -> String {
828 ComposefsCmdline::new_v1(digest, insecure).to_cmdline_arg()
829 }
830
831 fn v2(digest: Sha512HashValue, insecure: bool) -> String {
832 ComposefsCmdline::new_v2(digest, insecure).to_cmdline_arg()
833 }
834
835 #[test]
836 fn test_composefs_candidate_priority_and_selected_state() {
837 let v1_digest = Sha512HashValue::EMPTY;
838 let v2_digest = Sha512HashValue::from_hex("aa".repeat(64)).unwrap();
839 let cmdline = format!(
840 "quiet {} {}",
841 v1(v1_digest.clone(), false),
842 v2(v2_digest.clone(), false)
843 );
844 let candidates = parse_composefs_candidates(&cmdline).unwrap();
845 let (selected, selected_cmdline) = select_composefs_candidate(&candidates, |candidate| {
846 Ok((candidate.digest() == &v2_digest).then_some(candidate.digest().to_hex()))
847 })
848 .unwrap();
849 assert_eq!(selected, v2_digest.to_hex());
850 assert_eq!(selected_cmdline.digest(), &v2_digest);
851
852 let (selected, _) = select_composefs_candidate(&candidates, |candidate| {
853 Ok(Some(candidate.digest().to_hex()))
854 })
855 .unwrap();
856 assert_eq!(selected, v1_digest.to_hex());
857 }
858
859 #[test]
860 fn test_composefs_candidate_parse_and_policy_errors() {
861 let malformed = ["composefs=not-a-digest", "composefs.digest=v9-sha512-12:aa"];
862 for cmdline in malformed {
863 assert!(
864 parse_composefs_candidates(cmdline).is_err(),
865 "expected parse error for {cmdline:?}"
866 );
867 }
868
869 let strict = parse_composefs_candidates(&v1(Sha512HashValue::EMPTY, false)).unwrap();
870 let insecure = parse_composefs_candidates(&v2(Sha512HashValue::EMPTY, true)).unwrap();
871 assert_ne!(strict[0].is_insecure(), insecure[0].is_insecure());
872 }
873
874 #[test]
875 fn test_composefs_candidate_parser_cases() {
876 let first = Sha512HashValue::EMPTY;
877 let second = Sha512HashValue::from_hex("aa".repeat(64)).unwrap();
878 let cases = [
879 ("single v1", v1(first.clone(), false), vec![first.clone()]),
880 ("single v2", v2(first.clone(), false), vec![first.clone()]),
881 (
882 "dual preserves cmdline order",
883 format!("{} {}", v2(first.clone(), false), v1(second.clone(), false)),
884 vec![first.clone(), second.clone()],
885 ),
886 (
887 "quoted parameter",
888 format!("quiet \"{}\" rw", v1(first.clone(), false)),
889 vec![first.clone()],
890 ),
891 (
892 "skips sha256 descriptor",
893 format!(
894 "composefs.digest=v1-sha256-12:{} {} {}",
895 "aa".repeat(32),
896 v1(first.clone(), false),
897 v2(second.clone(), false)
898 ),
899 vec![first.clone(), second.clone()],
900 ),
901 ];
902
903 for (name, cmdline, expected_digests) in cases {
904 let candidates = parse_composefs_candidates(&cmdline)
905 .unwrap_or_else(|error| panic!("{name} unexpectedly failed: {error:#}"));
906 assert_eq!(
907 candidates
908 .iter()
909 .map(|candidate| candidate.digest().clone())
910 .collect::<Vec<_>>(),
911 expected_digests,
912 "case {name}"
913 );
914 }
915
916 let duplicate_cases = [
917 format!("{} {}", v2(first.clone(), false), v2(second.clone(), false)),
918 format!("{} {}", v2(first.clone(), false), v2(first.clone(), false)),
919 ];
920 for cmdline in duplicate_cases {
921 assert!(
922 parse_composefs_candidates(&cmdline).is_err(),
923 "duplicate recognized key accepted: {cmdline}"
924 );
925 }
926
927 let irrelevant = [
928 "quiet splash rw",
929 "root=UUID=abc composefs.digest=v1-sha256-12:aa",
930 ];
931 for cmdline in irrelevant {
932 assert!(
933 parse_composefs_candidates(cmdline).unwrap().is_empty(),
934 "irrelevant argument was treated as a candidate: {cmdline}"
935 );
936 }
937 }
938}