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 GlobalUkiAddon,
310}
311
312#[derive(Debug)]
317pub struct Type2Entry<ObjectID: FsVerityHashValue> {
318 pub kver: Option<Box<OsStr>>,
320 pub file_path: PathBuf,
322 pub file: RegularFile<ObjectID>,
324 pub pe_type: PEType,
326}
327
328impl<ObjectID: FsVerityHashValue> Type2Entry<ObjectID> {
329 pub fn rename(&mut self, name: &str) {
335 let new_name = format!("{name}.efi");
336
337 if let Some(parent) = self.file_path.parent() {
338 self.file_path = parent.join(new_name);
339 } else {
340 self.file_path = new_name.into();
341 }
342 }
343
344 fn find_uki_components(
347 dir: DirectoryRef<'_, ObjectID>,
348 entries: &mut Vec<Self>,
349 path: &mut PathBuf,
350 kver: &Option<Box<OsStr>>,
351 global_addons: bool,
352 ) -> Result<()> {
353 for (filename, inode) in dir.entries() {
354 path.push(filename);
355
356 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, global_addons)?;
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 let pe_type = if filename.as_bytes().ends_with(EFI_ADDON_FILE_EXT.as_bytes()) {
378 if global_addons {
379 PEType::GlobalUkiAddon
380 } else {
381 PEType::UkiAddon
382 }
383 } else {
384 PEType::Uki
386 };
387
388 entries.push(Self {
389 kver: kver.clone(),
390 file_path: path.clone(),
391 file: file.clone(),
392 pe_type,
393 });
394
395 path.pop();
396 }
397
398 Ok(())
399 }
400
401 pub fn load_all(fs: &FileSystem<ObjectID>) -> Result<Vec<Self>> {
411 let mut entries = vec![];
412 let root = fs.as_dir();
413
414 let paths = [
418 ("/boot/EFI/Linux", false),
420 ("/boot/loader/addons", true),
422 ];
423
424 for (p, global_addons) in paths {
425 match root.get_directory_ref(p.as_ref()) {
426 Ok(entries_dir) => Self::find_uki_components(
427 entries_dir,
428 &mut entries,
429 &mut PathBuf::new(),
430 &None,
431 global_addons,
432 )?,
433 Err(ImageError::NotFound(..)) => {}
434 Err(other) => Err(other)?,
435 };
436 }
437
438 match root.get_directory_ref("/usr/lib/modules".as_ref()) {
439 Ok(modules_dir) => {
440 for (kver, inode) in modules_dir.entries() {
441 let Inode::Directory(dir) = inode else {
442 continue;
443 };
444
445 let dir_ref = DirectoryRef::from_parts(dir, root.leaves());
446 Self::find_uki_components(
447 dir_ref,
448 &mut entries,
449 &mut PathBuf::new(),
450 &Some(Box::from(kver)),
451 false,
452 )?;
453 }
454 }
455 Err(ImageError::NotFound(..)) => {}
456 Err(other) => Err(other)?,
457 };
458
459 Ok(entries)
460 }
461}
462
463#[derive(Debug)]
468pub struct UsrLibModulesVmlinuz<ObjectID: FsVerityHashValue> {
469 pub kver: Box<str>,
471 pub vmlinuz: RegularFile<ObjectID>,
473 pub initramfs: Option<RegularFile<ObjectID>>,
475 pub os_release: Option<RegularFile<ObjectID>>,
477}
478
479impl<ObjectID: FsVerityHashValue> UsrLibModulesVmlinuz<ObjectID> {
480 pub fn into_type1(self, entry_id: Option<&str>) -> Result<Type1Entry<ObjectID>> {
490 let id = entry_id.unwrap_or(&self.kver);
491
492 let initramfs = self.initramfs.ok_or_else(|| {
493 anyhow::anyhow!(
494 "kernel {} has no initramfs.img in /usr/lib/modules/{}",
495 self.kver,
496 self.kver
497 )
498 })?;
499
500 let title = "todoOS";
501 let version = "0-todo";
502 let entry = BootLoaderEntryFile::new(&format!(
503 r#"# File created by composefs
504title {title}
505version {version}
506linux /{id}/vmlinuz
507initrd /{id}/initramfs.img
508"#
509 ));
510
511 let filename = Box::from(format!("{id}.conf").as_ref());
512
513 Ok(Type1Entry {
514 filename,
515 entry,
516 files: HashMap::from([
517 (Box::from(format!("/{id}/vmlinuz")), self.vmlinuz),
518 (Box::from(format!("/{id}/initramfs.img")), initramfs),
519 ]),
520 })
521 }
522
523 pub fn load_all(fs: &FileSystem<ObjectID>) -> Result<Vec<Self>> {
533 let mut entries = vec![];
534 let root = fs.as_dir();
535
536 match root.get_directory_ref("/usr/lib/modules".as_ref()) {
537 Ok(modules_dir) => {
538 for (kver, inode) in modules_dir.entries() {
539 let Inode::Directory(dir) = inode else {
540 continue;
541 };
542
543 let dir_ref = DirectoryRef::from_parts(dir, root.leaves());
544 if let Ok(vmlinuz) = dir_ref.get_file("vmlinuz".as_ref()) {
545 let initramfs = dir_ref.get_file("initramfs.img".as_ref()).ok();
548 let os_release = root.get_file("/usr/lib/os-release".as_ref()).ok();
549 entries.push(Self {
550 kver: Box::from(std::str::from_utf8(kver.as_bytes())?),
551 vmlinuz: vmlinuz.clone(),
552 initramfs: initramfs.cloned(),
553 os_release: os_release.cloned(),
554 });
555 }
556 }
557 }
558 Err(ImageError::NotFound(..)) => {}
559 Err(other) => Err(other)?,
560 };
561
562 Ok(entries)
563 }
564}
565
566#[derive(Debug)]
571pub enum BootEntry<ObjectID: FsVerityHashValue> {
572 Type1(Type1Entry<ObjectID>),
574 Type2(Type2Entry<ObjectID>),
576 UsrLibModulesVmLinuz(UsrLibModulesVmlinuz<ObjectID>),
578}
579
580pub fn get_boot_resources<ObjectID: FsVerityHashValue>(
595 image: &FileSystem<ObjectID>,
596 repo: &Repository<ObjectID>,
597) -> Result<Vec<BootEntry<ObjectID>>> {
598 let mut entries = vec![];
599
600 for e in Type1Entry::load_all(image, repo)? {
601 entries.push(BootEntry::Type1(e));
602 }
603 for e in Type2Entry::load_all(image)? {
604 entries.push(BootEntry::Type2(e));
605 }
606 for e in UsrLibModulesVmlinuz::load_all(image)? {
607 entries.push(BootEntry::UsrLibModulesVmLinuz(e));
608 }
609
610 Ok(entries)
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use composefs::{fsverity::Sha256HashValue, tree::RegularFile};
617
618 fn fake_file() -> RegularFile<Sha256HashValue> {
619 RegularFile::Inline(Default::default())
620 }
621
622 #[test]
623 fn test_into_type1_with_initramfs() {
624 let entry = UsrLibModulesVmlinuz::<Sha256HashValue> {
625 kver: "6.9.0-arch1-1".into(),
626 vmlinuz: fake_file(),
627 initramfs: Some(fake_file()),
628 os_release: None,
629 };
630 let t1 = entry
631 .into_type1(None)
632 .expect("should succeed with initramfs");
633 let keys: Vec<&str> = t1.files.keys().map(|k| k.as_ref()).collect();
634 assert!(
635 keys.contains(&"/6.9.0-arch1-1/vmlinuz"),
636 "missing vmlinuz key, got: {keys:?}"
637 );
638 assert!(
639 keys.contains(&"/6.9.0-arch1-1/initramfs.img"),
640 "missing initramfs key, got: {keys:?}"
641 );
642 }
643
644 #[test]
645 fn test_into_type1_without_initramfs_returns_error() {
646 let entry = UsrLibModulesVmlinuz::<Sha256HashValue> {
647 kver: "6.9.0-arch1-1".into(),
648 vmlinuz: fake_file(),
649 initramfs: None,
650 os_release: None,
651 };
652 let err = entry.into_type1(None).unwrap_err();
653 assert!(
654 err.to_string().contains("no initramfs"),
655 "unexpected error message: {err}"
656 );
657 }
658
659 #[test]
660 fn test_bootloader_entry_file_new() {
661 let content = "title Test Entry\nversion 1.0\nlinux /vmlinuz\ninitrd /initramfs.img\noptions quiet splash\n";
662 let entry = BootLoaderEntryFile::new(content);
663
664 assert_eq!(entry.lines.len(), 5);
665 assert_eq!(entry.lines[0], "title Test Entry");
666 assert_eq!(entry.lines[1], "version 1.0");
667 assert_eq!(entry.lines[2], "linux /vmlinuz");
668 assert_eq!(entry.lines[3], "initrd /initramfs.img");
669 assert_eq!(entry.lines[4], "options quiet splash");
670 }
671
672 #[test]
673 fn test_bootloader_entry_file_new_empty() {
674 let entry = BootLoaderEntryFile::new("");
675 assert_eq!(entry.lines.len(), 0);
676 }
677
678 #[test]
679 fn test_bootloader_entry_file_new_single_line() {
680 let entry = BootLoaderEntryFile::new("title Test");
681 assert_eq!(entry.lines.len(), 1);
682 assert_eq!(entry.lines[0], "title Test");
683 }
684
685 #[test]
686 fn test_bootloader_entry_file_new_trailing_newline() {
687 let content = "title Test\nversion 1.0\n";
688 let entry = BootLoaderEntryFile::new(content);
689 assert_eq!(entry.lines.len(), 2);
690 assert_eq!(entry.lines[0], "title Test");
691 assert_eq!(entry.lines[1], "version 1.0");
692 }
693
694 #[test]
695 fn test_get_value() {
696 let content = "title Test Entry\nversion 1.0\nlinux /vmlinuz\ninitrd /initramfs.img\noptions quiet splash\n";
697 let entry = BootLoaderEntryFile::new(content);
698
699 assert_eq!(entry.get_value("title"), Some("Test Entry"));
700 assert_eq!(entry.get_value("version"), Some("1.0"));
701 assert_eq!(entry.get_value("linux"), Some("/vmlinuz"));
702 assert_eq!(entry.get_value("initrd"), Some("/initramfs.img"));
703 assert_eq!(entry.get_value("options"), Some("quiet splash"));
704 assert_eq!(entry.get_value("nonexistent"), None);
705 }
706
707 #[test]
708 fn test_get_value_whitespace_handling() {
709 let content = "title\t\tTest Entry\nversion 1.0\nlinux\t/vmlinuz\n";
710 let entry = BootLoaderEntryFile::new(content);
711
712 assert_eq!(entry.get_value("title"), Some("Test Entry"));
713 assert_eq!(entry.get_value("version"), Some("1.0"));
714 assert_eq!(entry.get_value("linux"), Some("/vmlinuz"));
715 }
716
717 #[test]
718 fn test_get_value_no_whitespace_after_key() {
719 let content = "titleTest Entry\nversionno_space\n";
720 let entry = BootLoaderEntryFile::new(content);
721
722 assert_eq!(entry.get_value("title"), None);
723 assert_eq!(entry.get_value("version"), None);
724 }
725
726 #[test]
727 fn test_get_values_multiple() {
728 let content = "title Test Entry\ninitrd /initramfs1.img\ninitrd /initramfs2.img\noptions quiet\noptions splash\n";
729 let entry = BootLoaderEntryFile::new(content);
730
731 let initrd_values: Vec<_> = entry.get_values("initrd").collect();
732 assert_eq!(initrd_values, vec!["/initramfs1.img", "/initramfs2.img"]);
733
734 let options_values: Vec<_> = entry.get_values("options").collect();
735 assert_eq!(options_values, vec!["quiet", "splash"]);
736
737 let title_values: Vec<_> = entry.get_values("title").collect();
738 assert_eq!(title_values, vec!["Test Entry"]);
739
740 let nonexistent_values: Vec<_> = entry.get_values("nonexistent").collect();
741 assert_eq!(nonexistent_values, Vec::<&str>::new());
742 }
743
744 #[test]
745 fn test_add_cmdline_new_options_line() {
746 let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
747 entry.add_cmdline("quiet");
748
749 assert_eq!(entry.lines.len(), 3);
750 assert_eq!(entry.lines[2], "options quiet");
751 }
752
753 #[test]
754 fn test_add_cmdline_append_to_existing_options() {
755 let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions splash\n");
756 entry.add_cmdline("quiet");
757
758 assert_eq!(entry.lines.len(), 2);
759 assert_eq!(entry.lines[1], "options splash quiet");
760 }
761
762 #[test]
763 fn test_add_cmdline_replace_existing_key_value() {
764 let mut entry =
765 BootLoaderEntryFile::new("title Test Entry\noptions quiet splash root=/dev/sda1\n");
766 entry.add_cmdline("root=/dev/sda2");
767
768 assert_eq!(entry.lines.len(), 2);
769 assert_eq!(entry.lines[1], "options quiet splash root=/dev/sda2");
770 }
771
772 #[test]
773 fn test_add_cmdline_replace_existing_key_only() {
774 let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions quiet rw splash\n");
775 entry.add_cmdline("rw"); assert_eq!(entry.lines.len(), 2);
778 assert_eq!(entry.lines[1], "options quiet rw splash");
779
780 entry.add_cmdline("ro");
782 assert_eq!(entry.lines[1], "options quiet rw splash ro");
783 }
784
785 #[test]
786 fn test_add_cmdline_key_with_equals() {
787 let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions quiet\n");
788 entry.add_cmdline("composefs=abc123");
789
790 assert_eq!(entry.lines.len(), 2);
791 assert_eq!(entry.lines[1], "options quiet composefs=abc123");
792 }
793
794 #[test]
795 fn test_add_cmdline_replace_key_with_equals() {
796 let mut entry =
797 BootLoaderEntryFile::new("title Test Entry\noptions quiet composefs=old123\n");
798 entry.add_cmdline("composefs=new456");
799
800 assert_eq!(entry.lines.len(), 2);
801 assert_eq!(entry.lines[1], "options quiet composefs=new456");
802 }
803
804 #[test]
805 fn test_adjust_cmdline_with_composefs() {
806 let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
807 entry.adjust_cmdline(Some("composefs=abc123"), &["quiet", "splash"]);
808
809 assert_eq!(entry.lines.len(), 3);
810 assert_eq!(entry.lines[2], "options composefs=abc123 quiet splash");
811 }
812
813 #[test]
814 fn test_adjust_cmdline_with_composefs_insecure() {
815 let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
816 entry.adjust_cmdline(Some("composefs=?abc123"), &[]);
817
818 assert_eq!(entry.lines.len(), 3);
819 assert_eq!(entry.lines[2], "options composefs=?abc123");
820 }
821
822 #[test]
823 fn test_adjust_cmdline_no_composefs() {
824 let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
825 entry.adjust_cmdline(None, &["quiet", "splash"]);
826
827 assert_eq!(entry.lines.len(), 3);
828 assert_eq!(entry.lines[2], "options quiet splash");
829 }
830
831 #[test]
832 fn test_adjust_cmdline_existing_options() {
833 let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions root=/dev/sda1\n");
834 entry.adjust_cmdline(Some("composefs=abc123"), &["quiet"]);
835
836 assert_eq!(entry.lines.len(), 2);
837 assert!(entry.lines[1].contains("root=/dev/sda1"));
838 assert!(entry.lines[1].contains("abc123"));
839 assert!(entry.lines[1].contains("quiet"));
840 }
841
842 #[test]
843 fn test_strip_ble_key_helper() {
844 assert_eq!(
845 strip_ble_key("title Test Entry", "title"),
846 Some("Test Entry")
847 );
848 assert_eq!(
849 strip_ble_key("title\tTest Entry", "title"),
850 Some("Test Entry")
851 );
852 assert_eq!(
853 strip_ble_key("title Test Entry", "title"),
854 Some("Test Entry")
855 );
856 assert_eq!(strip_ble_key("titleTest Entry", "title"), None);
857 assert_eq!(strip_ble_key("other Test Entry", "title"), None);
858 assert_eq!(strip_ble_key("title", "title"), None); }
860
861 #[test]
862 fn test_substr_range_helper() {
863 let parent = "hello world test";
864 let substr = &parent[6..11]; let range = substr_range(parent, substr).unwrap();
866 assert_eq!(range, 6..11);
867 assert_eq!(&parent[range], "world");
868
869 let other_substr = &parent[0..5]; let range2 = substr_range(parent, other_substr).unwrap();
872 assert_eq!(range2, 0..5);
873 assert_eq!(&parent[range2], "hello");
874
875 let separate_string = String::from("world");
877 assert_eq!(substr_range(parent, &separate_string), None);
878 }
879}