1use core::ops::Range;
10use std::{
11 collections::HashMap, ffi::OsStr, os::unix::ffi::OsStrExt, path::PathBuf, str::from_utf8,
12};
13
14use anyhow::{Result, bail};
15
16use composefs::{
17 fsverity::FsVerityHashValue,
18 repository::Repository,
19 tree::{DirectoryRef, FileSystem, ImageError, Inode, LeafContent, RegularFile},
20};
21
22use crate::cmdline::split_cmdline;
23
24fn strip_ble_key<'a>(line: &'a str, key: &str) -> Option<&'a str> {
31 let rest = line.strip_prefix(key)?;
32 if !rest.chars().next()?.is_ascii_whitespace() {
33 return None;
34 }
35 Some(rest.trim_start())
36}
37
38fn substr_range(parent: &str, substr: &str) -> Option<Range<usize>> {
40 let parent_start = parent as *const str as *const u8 as usize;
41 let parent_end = parent_start + parent.len();
42 let substr_start = substr as *const str as *const u8 as usize;
43 let substr_end = substr_start + substr.len();
44
45 if parent_start <= substr_start && substr_end <= parent_end {
46 Some((substr_start - parent_start)..(substr_end - parent_start))
47 } else {
48 None
49 }
50}
51
52#[derive(Debug)]
57pub struct BootLoaderEntryFile {
58 pub lines: Vec<String>,
60}
61
62impl BootLoaderEntryFile {
63 pub fn new(content: &str) -> Self {
73 Self {
74 lines: content.split_terminator('\n').map(String::from).collect(),
75 }
76 }
77
78 pub fn get_values<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'a str> + 'a {
88 self.lines
89 .iter()
90 .filter_map(|line| strip_ble_key(line, key))
91 }
92
93 pub fn get_value(&self, key: &str) -> Option<&str> {
103 self.lines.iter().find_map(|line| strip_ble_key(line, key))
104 }
105
106 pub fn add_cmdline(&mut self, arg: &str) {
111 let key = match arg.find('=') {
112 Some(pos) => &arg[..=pos], None => arg,
114 };
115
116 for line in &mut self.lines {
121 if let Some(cmdline) = strip_ble_key(line, "options") {
122 let segment = split_cmdline(cmdline).find(|s| s.starts_with(key));
123
124 if let Some(old) = segment {
125 let range = substr_range(line, old).unwrap();
127 line.replace_range(range, arg);
128 } else {
129 line.push(' ');
131 line.push_str(arg);
132 }
133
134 return;
135 }
136 }
137
138 self.lines.push(format!("options {arg}"));
140 }
141
142 pub fn adjust_cmdline(&mut self, karg: Option<&str>, extra: &[&str]) {
149 if let Some(k) = karg {
150 self.add_cmdline(k);
151 }
152
153 for item in extra {
154 self.add_cmdline(item);
155 }
156 }
157}
158
159#[derive(Debug)]
164pub struct Type1Entry<ObjectID: FsVerityHashValue> {
165 pub filename: Box<OsStr>,
167 pub entry: BootLoaderEntryFile,
169 pub files: HashMap<Box<str>, RegularFile<ObjectID>>,
171}
172
173impl<ObjectID: FsVerityHashValue> Type1Entry<ObjectID> {
174 pub fn relocate(&mut self, boot_subdir: Option<&str>, entry_id: &str) {
185 self.filename = Box::from(format!("{entry_id}.conf").as_ref());
186 for line in &mut self.entry.lines {
187 for key in ["linux", "initrd", "efi"] {
188 let Some(value) = strip_ble_key(line, key) else {
189 continue;
190 };
191 let Some((_dir, basename)) = value.rsplit_once("/") else {
192 continue;
193 };
194
195 let file = self.files.remove(value);
196
197 let new = format!("/{entry_id}/{basename}");
198 let range = substr_range(line, value).unwrap();
199
200 let final_entry_path = if let Some(boot_subdir) = boot_subdir {
201 format!("/{boot_subdir}{new}")
202 } else {
203 new.clone()
204 };
205
206 line.replace_range(range, &final_entry_path);
207
208 if let Some(file) = file {
209 self.files.insert(new.into_boxed_str(), file);
210 }
211 }
212 }
213 }
214
215 pub fn load(
231 filename: &OsStr,
232 file: &RegularFile<ObjectID>,
233 root: DirectoryRef<'_, ObjectID>,
234 repo: &Repository<ObjectID>,
235 ) -> Result<Self> {
236 let entry = BootLoaderEntryFile::new(from_utf8(&composefs::fs::read_file(file, repo)?)?);
237
238 let mut files = HashMap::new();
239 for key in ["linux", "initrd", "efi"] {
240 for pathname in entry.get_values(key) {
241 let (dir, filename) = root.split_ref(pathname.as_ref())?;
242 files.insert(Box::from(pathname), dir.get_file(filename)?.clone());
243 }
244 }
245
246 Ok(Self {
247 filename: Box::from(filename),
248 entry,
249 files,
250 })
251 }
252
253 pub fn load_all(fs: &FileSystem<ObjectID>, repo: &Repository<ObjectID>) -> Result<Vec<Self>> {
264 let mut entries = vec![];
265 let root = fs.as_dir();
266
267 match root.get_directory_ref("/boot/loader/entries".as_ref()) {
268 Ok(entries_dir) => {
269 for (filename, inode) in entries_dir.entries() {
270 if !filename.as_bytes().ends_with(b".conf") {
271 continue;
272 }
273
274 let Inode::Leaf(leaf_id, _) = inode else {
275 bail!("/boot/loader/entries/{filename:?} is a directory");
276 };
277
278 let leaf = fs.leaf(*leaf_id);
279 let LeafContent::Regular(file) = &leaf.content else {
280 bail!("/boot/loader/entries/{filename:?} is not a regular file");
281 };
282
283 entries.push(Self::load(filename, file, root, repo)?);
284 }
285 }
286 Err(ImageError::NotFound(..)) => {}
287 Err(other) => Err(other)?,
288 };
289
290 Ok(entries)
291 }
292}
293
294pub const EFI_EXT: &str = ".efi";
296pub const EFI_ADDON_DIR_EXT: &str = ".efi.extra.d";
298pub const EFI_ADDON_FILE_EXT: &str = ".addon.efi";
300
301#[derive(Debug)]
303pub enum PEType {
304 Uki,
306 UkiAddon,
308}
309
310#[derive(Debug)]
315pub struct Type2Entry<ObjectID: FsVerityHashValue> {
316 pub kver: Option<Box<OsStr>>,
318 pub file_path: PathBuf,
320 pub file: RegularFile<ObjectID>,
322 pub pe_type: PEType,
324}
325
326impl<ObjectID: FsVerityHashValue> Type2Entry<ObjectID> {
327 pub fn rename(&mut self, name: &str) {
333 let new_name = format!("{name}.efi");
334
335 if let Some(parent) = self.file_path.parent() {
336 self.file_path = parent.join(new_name);
337 } else {
338 self.file_path = new_name.into();
339 }
340 }
341
342 fn find_uki_components(
345 dir: DirectoryRef<'_, ObjectID>,
346 entries: &mut Vec<Self>,
347 path: &mut PathBuf,
348 kver: &Option<Box<OsStr>>,
349 ) -> Result<()> {
350 for (filename, inode) in dir.entries() {
351 path.push(filename);
352
353 if let Inode::Directory(subdir) = inode {
357 let subdir_ref = DirectoryRef::from_parts(subdir, dir.leaves());
358 Self::find_uki_components(subdir_ref, entries, path, kver)?;
359 path.pop();
360 continue;
361 }
362
363 if !filename.as_bytes().ends_with(EFI_EXT.as_bytes()) {
364 path.pop();
365 continue;
366 }
367
368 let Inode::Leaf(leaf_id, _) = inode else {
369 bail!("{filename:?} is a directory");
370 };
371
372 let leaf = dir.leaf(*leaf_id);
373 let LeafContent::Regular(file) = &leaf.content else {
374 bail!("{filename:?} is not a regular file");
375 };
376
377 entries.push(Self {
378 kver: kver.clone(),
379 file_path: path.clone(),
380 file: file.clone(),
381 pe_type: if path.components().count() == 1 {
382 PEType::Uki
383 } else {
384 PEType::UkiAddon
385 },
386 });
387
388 path.pop();
389 }
390
391 Ok(())
392 }
393
394 pub fn load_all(fs: &FileSystem<ObjectID>) -> Result<Vec<Self>> {
404 let mut entries = vec![];
405 let root = fs.as_dir();
406
407 match root.get_directory_ref("/boot/EFI/Linux".as_ref()) {
408 Ok(entries_dir) => {
409 Self::find_uki_components(entries_dir, &mut entries, &mut PathBuf::new(), &None)?
410 }
411 Err(ImageError::NotFound(..)) => {}
412 Err(other) => Err(other)?,
413 };
414
415 match root.get_directory_ref("/usr/lib/modules".as_ref()) {
416 Ok(modules_dir) => {
417 for (kver, inode) in modules_dir.entries() {
418 let Inode::Directory(dir) = inode else {
419 continue;
420 };
421
422 let dir_ref = DirectoryRef::from_parts(dir, root.leaves());
423 Self::find_uki_components(
424 dir_ref,
425 &mut entries,
426 &mut PathBuf::new(),
427 &Some(Box::from(kver)),
428 )?;
429 }
430 }
431 Err(ImageError::NotFound(..)) => {}
432 Err(other) => Err(other)?,
433 };
434
435 Ok(entries)
436 }
437}
438
439#[derive(Debug)]
444pub struct UsrLibModulesVmlinuz<ObjectID: FsVerityHashValue> {
445 pub kver: Box<str>,
447 pub vmlinuz: RegularFile<ObjectID>,
449 pub initramfs: Option<RegularFile<ObjectID>>,
451 pub os_release: Option<RegularFile<ObjectID>>,
453}
454
455impl<ObjectID: FsVerityHashValue> UsrLibModulesVmlinuz<ObjectID> {
456 pub fn into_type1(self, entry_id: Option<&str>) -> Result<Type1Entry<ObjectID>> {
466 let id = entry_id.unwrap_or(&self.kver);
467
468 let initramfs = self.initramfs.ok_or_else(|| {
469 anyhow::anyhow!(
470 "kernel {} has no initramfs.img in /usr/lib/modules/{}",
471 self.kver,
472 self.kver
473 )
474 })?;
475
476 let title = "todoOS";
477 let version = "0-todo";
478 let entry = BootLoaderEntryFile::new(&format!(
479 r#"# File created by composefs
480title {title}
481version {version}
482linux /{id}/vmlinuz
483initrd /{id}/initramfs.img
484"#
485 ));
486
487 let filename = Box::from(format!("{id}.conf").as_ref());
488
489 Ok(Type1Entry {
490 filename,
491 entry,
492 files: HashMap::from([
493 (Box::from(format!("/{id}/vmlinuz")), self.vmlinuz),
494 (Box::from(format!("/{id}/initramfs.img")), initramfs),
495 ]),
496 })
497 }
498
499 pub fn load_all(fs: &FileSystem<ObjectID>) -> Result<Vec<Self>> {
509 let mut entries = vec![];
510 let root = fs.as_dir();
511
512 match root.get_directory_ref("/usr/lib/modules".as_ref()) {
513 Ok(modules_dir) => {
514 for (kver, inode) in modules_dir.entries() {
515 let Inode::Directory(dir) = inode else {
516 continue;
517 };
518
519 let dir_ref = DirectoryRef::from_parts(dir, root.leaves());
520 if let Ok(vmlinuz) = dir_ref.get_file("vmlinuz".as_ref()) {
521 let initramfs = dir_ref.get_file("initramfs.img".as_ref()).ok();
524 let os_release = root.get_file("/usr/lib/os-release".as_ref()).ok();
525 entries.push(Self {
526 kver: Box::from(std::str::from_utf8(kver.as_bytes())?),
527 vmlinuz: vmlinuz.clone(),
528 initramfs: initramfs.cloned(),
529 os_release: os_release.cloned(),
530 });
531 }
532 }
533 }
534 Err(ImageError::NotFound(..)) => {}
535 Err(other) => Err(other)?,
536 };
537
538 Ok(entries)
539 }
540}
541
542#[derive(Debug)]
547pub enum BootEntry<ObjectID: FsVerityHashValue> {
548 Type1(Type1Entry<ObjectID>),
550 Type2(Type2Entry<ObjectID>),
552 UsrLibModulesVmLinuz(UsrLibModulesVmlinuz<ObjectID>),
554}
555
556pub fn get_boot_resources<ObjectID: FsVerityHashValue>(
571 image: &FileSystem<ObjectID>,
572 repo: &Repository<ObjectID>,
573) -> Result<Vec<BootEntry<ObjectID>>> {
574 let mut entries = vec![];
575
576 for e in Type1Entry::load_all(image, repo)? {
577 entries.push(BootEntry::Type1(e));
578 }
579 for e in Type2Entry::load_all(image)? {
580 entries.push(BootEntry::Type2(e));
581 }
582 for e in UsrLibModulesVmlinuz::load_all(image)? {
583 entries.push(BootEntry::UsrLibModulesVmLinuz(e));
584 }
585
586 Ok(entries)
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use composefs::{fsverity::Sha256HashValue, tree::RegularFile};
593
594 fn fake_file() -> RegularFile<Sha256HashValue> {
595 RegularFile::Inline(Default::default())
596 }
597
598 #[test]
599 fn test_into_type1_with_initramfs() {
600 let entry = UsrLibModulesVmlinuz::<Sha256HashValue> {
601 kver: "6.9.0-arch1-1".into(),
602 vmlinuz: fake_file(),
603 initramfs: Some(fake_file()),
604 os_release: None,
605 };
606 let t1 = entry
607 .into_type1(None)
608 .expect("should succeed with initramfs");
609 let keys: Vec<&str> = t1.files.keys().map(|k| k.as_ref()).collect();
610 assert!(
611 keys.contains(&"/6.9.0-arch1-1/vmlinuz"),
612 "missing vmlinuz key, got: {keys:?}"
613 );
614 assert!(
615 keys.contains(&"/6.9.0-arch1-1/initramfs.img"),
616 "missing initramfs key, got: {keys:?}"
617 );
618 }
619
620 #[test]
621 fn test_into_type1_without_initramfs_returns_error() {
622 let entry = UsrLibModulesVmlinuz::<Sha256HashValue> {
623 kver: "6.9.0-arch1-1".into(),
624 vmlinuz: fake_file(),
625 initramfs: None,
626 os_release: None,
627 };
628 let err = entry.into_type1(None).unwrap_err();
629 assert!(
630 err.to_string().contains("no initramfs"),
631 "unexpected error message: {err}"
632 );
633 }
634
635 #[test]
636 fn test_bootloader_entry_file_new() {
637 let content = "title Test Entry\nversion 1.0\nlinux /vmlinuz\ninitrd /initramfs.img\noptions quiet splash\n";
638 let entry = BootLoaderEntryFile::new(content);
639
640 assert_eq!(entry.lines.len(), 5);
641 assert_eq!(entry.lines[0], "title Test Entry");
642 assert_eq!(entry.lines[1], "version 1.0");
643 assert_eq!(entry.lines[2], "linux /vmlinuz");
644 assert_eq!(entry.lines[3], "initrd /initramfs.img");
645 assert_eq!(entry.lines[4], "options quiet splash");
646 }
647
648 #[test]
649 fn test_bootloader_entry_file_new_empty() {
650 let entry = BootLoaderEntryFile::new("");
651 assert_eq!(entry.lines.len(), 0);
652 }
653
654 #[test]
655 fn test_bootloader_entry_file_new_single_line() {
656 let entry = BootLoaderEntryFile::new("title Test");
657 assert_eq!(entry.lines.len(), 1);
658 assert_eq!(entry.lines[0], "title Test");
659 }
660
661 #[test]
662 fn test_bootloader_entry_file_new_trailing_newline() {
663 let content = "title Test\nversion 1.0\n";
664 let entry = BootLoaderEntryFile::new(content);
665 assert_eq!(entry.lines.len(), 2);
666 assert_eq!(entry.lines[0], "title Test");
667 assert_eq!(entry.lines[1], "version 1.0");
668 }
669
670 #[test]
671 fn test_get_value() {
672 let content = "title Test Entry\nversion 1.0\nlinux /vmlinuz\ninitrd /initramfs.img\noptions quiet splash\n";
673 let entry = BootLoaderEntryFile::new(content);
674
675 assert_eq!(entry.get_value("title"), Some("Test Entry"));
676 assert_eq!(entry.get_value("version"), Some("1.0"));
677 assert_eq!(entry.get_value("linux"), Some("/vmlinuz"));
678 assert_eq!(entry.get_value("initrd"), Some("/initramfs.img"));
679 assert_eq!(entry.get_value("options"), Some("quiet splash"));
680 assert_eq!(entry.get_value("nonexistent"), None);
681 }
682
683 #[test]
684 fn test_get_value_whitespace_handling() {
685 let content = "title\t\tTest Entry\nversion 1.0\nlinux\t/vmlinuz\n";
686 let entry = BootLoaderEntryFile::new(content);
687
688 assert_eq!(entry.get_value("title"), Some("Test Entry"));
689 assert_eq!(entry.get_value("version"), Some("1.0"));
690 assert_eq!(entry.get_value("linux"), Some("/vmlinuz"));
691 }
692
693 #[test]
694 fn test_get_value_no_whitespace_after_key() {
695 let content = "titleTest Entry\nversionno_space\n";
696 let entry = BootLoaderEntryFile::new(content);
697
698 assert_eq!(entry.get_value("title"), None);
699 assert_eq!(entry.get_value("version"), None);
700 }
701
702 #[test]
703 fn test_get_values_multiple() {
704 let content = "title Test Entry\ninitrd /initramfs1.img\ninitrd /initramfs2.img\noptions quiet\noptions splash\n";
705 let entry = BootLoaderEntryFile::new(content);
706
707 let initrd_values: Vec<_> = entry.get_values("initrd").collect();
708 assert_eq!(initrd_values, vec!["/initramfs1.img", "/initramfs2.img"]);
709
710 let options_values: Vec<_> = entry.get_values("options").collect();
711 assert_eq!(options_values, vec!["quiet", "splash"]);
712
713 let title_values: Vec<_> = entry.get_values("title").collect();
714 assert_eq!(title_values, vec!["Test Entry"]);
715
716 let nonexistent_values: Vec<_> = entry.get_values("nonexistent").collect();
717 assert_eq!(nonexistent_values, Vec::<&str>::new());
718 }
719
720 #[test]
721 fn test_add_cmdline_new_options_line() {
722 let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
723 entry.add_cmdline("quiet");
724
725 assert_eq!(entry.lines.len(), 3);
726 assert_eq!(entry.lines[2], "options quiet");
727 }
728
729 #[test]
730 fn test_add_cmdline_append_to_existing_options() {
731 let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions splash\n");
732 entry.add_cmdline("quiet");
733
734 assert_eq!(entry.lines.len(), 2);
735 assert_eq!(entry.lines[1], "options splash quiet");
736 }
737
738 #[test]
739 fn test_add_cmdline_replace_existing_key_value() {
740 let mut entry =
741 BootLoaderEntryFile::new("title Test Entry\noptions quiet splash root=/dev/sda1\n");
742 entry.add_cmdline("root=/dev/sda2");
743
744 assert_eq!(entry.lines.len(), 2);
745 assert_eq!(entry.lines[1], "options quiet splash root=/dev/sda2");
746 }
747
748 #[test]
749 fn test_add_cmdline_replace_existing_key_only() {
750 let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions quiet rw splash\n");
751 entry.add_cmdline("rw"); assert_eq!(entry.lines.len(), 2);
754 assert_eq!(entry.lines[1], "options quiet rw splash");
755
756 entry.add_cmdline("ro");
758 assert_eq!(entry.lines[1], "options quiet rw splash ro");
759 }
760
761 #[test]
762 fn test_add_cmdline_key_with_equals() {
763 let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions quiet\n");
764 entry.add_cmdline("composefs=abc123");
765
766 assert_eq!(entry.lines.len(), 2);
767 assert_eq!(entry.lines[1], "options quiet composefs=abc123");
768 }
769
770 #[test]
771 fn test_add_cmdline_replace_key_with_equals() {
772 let mut entry =
773 BootLoaderEntryFile::new("title Test Entry\noptions quiet composefs=old123\n");
774 entry.add_cmdline("composefs=new456");
775
776 assert_eq!(entry.lines.len(), 2);
777 assert_eq!(entry.lines[1], "options quiet composefs=new456");
778 }
779
780 #[test]
781 fn test_adjust_cmdline_with_composefs() {
782 let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
783 entry.adjust_cmdline(Some("composefs=abc123"), &["quiet", "splash"]);
784
785 assert_eq!(entry.lines.len(), 3);
786 assert_eq!(entry.lines[2], "options composefs=abc123 quiet splash");
787 }
788
789 #[test]
790 fn test_adjust_cmdline_with_composefs_insecure() {
791 let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
792 entry.adjust_cmdline(Some("composefs=?abc123"), &[]);
793
794 assert_eq!(entry.lines.len(), 3);
795 assert_eq!(entry.lines[2], "options composefs=?abc123");
796 }
797
798 #[test]
799 fn test_adjust_cmdline_no_composefs() {
800 let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
801 entry.adjust_cmdline(None, &["quiet", "splash"]);
802
803 assert_eq!(entry.lines.len(), 3);
804 assert_eq!(entry.lines[2], "options quiet splash");
805 }
806
807 #[test]
808 fn test_adjust_cmdline_existing_options() {
809 let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions root=/dev/sda1\n");
810 entry.adjust_cmdline(Some("composefs=abc123"), &["quiet"]);
811
812 assert_eq!(entry.lines.len(), 2);
813 assert!(entry.lines[1].contains("root=/dev/sda1"));
814 assert!(entry.lines[1].contains("abc123"));
815 assert!(entry.lines[1].contains("quiet"));
816 }
817
818 #[test]
819 fn test_strip_ble_key_helper() {
820 assert_eq!(
821 strip_ble_key("title Test Entry", "title"),
822 Some("Test Entry")
823 );
824 assert_eq!(
825 strip_ble_key("title\tTest Entry", "title"),
826 Some("Test Entry")
827 );
828 assert_eq!(
829 strip_ble_key("title Test Entry", "title"),
830 Some("Test Entry")
831 );
832 assert_eq!(strip_ble_key("titleTest Entry", "title"), None);
833 assert_eq!(strip_ble_key("other Test Entry", "title"), None);
834 assert_eq!(strip_ble_key("title", "title"), None); }
836
837 #[test]
838 fn test_substr_range_helper() {
839 let parent = "hello world test";
840 let substr = &parent[6..11]; let range = substr_range(parent, substr).unwrap();
842 assert_eq!(range, 6..11);
843 assert_eq!(&parent[range], "world");
844
845 let other_substr = &parent[0..5]; let range2 = substr_range(parent, other_substr).unwrap();
848 assert_eq!(range2, 0..5);
849 assert_eq!(&parent[range2], "hello");
850
851 let separate_string = String::from("world");
853 assert_eq!(substr_range(parent, &separate_string), None);
854 }
855}