use core::ops::Range;
use std::{
collections::HashMap, ffi::OsStr, os::unix::ffi::OsStrExt, path::PathBuf, str::from_utf8,
};
use anyhow::{Result, bail};
use composefs::{
fsverity::FsVerityHashValue,
repository::Repository,
tree::{DirectoryRef, FileSystem, ImageError, Inode, LeafContent, RegularFile},
};
use crate::cmdline::split_cmdline;
fn strip_ble_key<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let rest = line.strip_prefix(key)?;
if !rest.chars().next()?.is_ascii_whitespace() {
return None;
}
Some(rest.trim_start())
}
fn substr_range(parent: &str, substr: &str) -> Option<Range<usize>> {
let parent_start = parent as *const str as *const u8 as usize;
let parent_end = parent_start + parent.len();
let substr_start = substr as *const str as *const u8 as usize;
let substr_end = substr_start + substr.len();
if parent_start <= substr_start && substr_end <= parent_end {
Some((substr_start - parent_start)..(substr_end - parent_start))
} else {
None
}
}
#[derive(Debug)]
pub struct BootLoaderEntryFile {
pub lines: Vec<String>,
}
impl BootLoaderEntryFile {
pub fn new(content: &str) -> Self {
Self {
lines: content.split_terminator('\n').map(String::from).collect(),
}
}
pub fn get_values<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'a str> + 'a {
self.lines
.iter()
.filter_map(|line| strip_ble_key(line, key))
}
pub fn get_value(&self, key: &str) -> Option<&str> {
self.lines.iter().find_map(|line| strip_ble_key(line, key))
}
pub fn add_cmdline(&mut self, arg: &str) {
let key = match arg.find('=') {
Some(pos) => &arg[..=pos], None => arg,
};
for line in &mut self.lines {
if let Some(cmdline) = strip_ble_key(line, "options") {
let segment = split_cmdline(cmdline).find(|s| s.starts_with(key));
if let Some(old) = segment {
let range = substr_range(line, old).unwrap();
line.replace_range(range, arg);
} else {
line.push(' ');
line.push_str(arg);
}
return;
}
}
self.lines.push(format!("options {arg}"));
}
pub fn adjust_cmdline(&mut self, karg: Option<&str>, extra: &[&str]) {
if let Some(k) = karg {
self.add_cmdline(k);
}
for item in extra {
self.add_cmdline(item);
}
}
}
#[derive(Debug)]
pub struct Type1Entry<ObjectID: FsVerityHashValue> {
pub filename: Box<OsStr>,
pub entry: BootLoaderEntryFile,
pub files: HashMap<Box<str>, RegularFile<ObjectID>>,
}
impl<ObjectID: FsVerityHashValue> Type1Entry<ObjectID> {
pub fn relocate(&mut self, boot_subdir: Option<&str>, entry_id: &str) {
self.filename = Box::from(format!("{entry_id}.conf").as_ref());
for line in &mut self.entry.lines {
for key in ["linux", "initrd", "efi"] {
let Some(value) = strip_ble_key(line, key) else {
continue;
};
let Some((_dir, basename)) = value.rsplit_once("/") else {
continue;
};
let file = self.files.remove(value);
let new = format!("/{entry_id}/{basename}");
let range = substr_range(line, value).unwrap();
let final_entry_path = if let Some(boot_subdir) = boot_subdir {
format!("/{boot_subdir}{new}")
} else {
new.clone()
};
line.replace_range(range, &final_entry_path);
if let Some(file) = file {
self.files.insert(new.into_boxed_str(), file);
}
}
}
}
pub fn load(
filename: &OsStr,
file: &RegularFile<ObjectID>,
root: DirectoryRef<'_, ObjectID>,
repo: &Repository<ObjectID>,
) -> Result<Self> {
let entry = BootLoaderEntryFile::new(from_utf8(&composefs::fs::read_file(file, repo)?)?);
let mut files = HashMap::new();
for key in ["linux", "initrd", "efi"] {
for pathname in entry.get_values(key) {
let (dir, filename) = root.split_ref(pathname.as_ref())?;
files.insert(Box::from(pathname), dir.get_file(filename)?.clone());
}
}
Ok(Self {
filename: Box::from(filename),
entry,
files,
})
}
pub fn load_all(fs: &FileSystem<ObjectID>, repo: &Repository<ObjectID>) -> Result<Vec<Self>> {
let mut entries = vec![];
let root = fs.as_dir();
match root.get_directory_ref("/boot/loader/entries".as_ref()) {
Ok(entries_dir) => {
for (filename, inode) in entries_dir.entries() {
if !filename.as_bytes().ends_with(b".conf") {
continue;
}
let Inode::Leaf(leaf_id, _) = inode else {
bail!("/boot/loader/entries/{filename:?} is a directory");
};
let leaf = fs.leaf(*leaf_id);
let LeafContent::Regular(file) = &leaf.content else {
bail!("/boot/loader/entries/{filename:?} is not a regular file");
};
entries.push(Self::load(filename, file, root, repo)?);
}
}
Err(ImageError::NotFound(..)) => {}
Err(other) => Err(other)?,
};
Ok(entries)
}
}
pub const EFI_EXT: &str = ".efi";
pub const EFI_ADDON_DIR_EXT: &str = ".efi.extra.d";
pub const EFI_ADDON_FILE_EXT: &str = ".addon.efi";
#[derive(Debug)]
pub enum PEType {
Uki,
UkiAddon,
}
#[derive(Debug)]
pub struct Type2Entry<ObjectID: FsVerityHashValue> {
pub kver: Option<Box<OsStr>>,
pub file_path: PathBuf,
pub file: RegularFile<ObjectID>,
pub pe_type: PEType,
}
impl<ObjectID: FsVerityHashValue> Type2Entry<ObjectID> {
pub fn rename(&mut self, name: &str) {
let new_name = format!("{name}.efi");
if let Some(parent) = self.file_path.parent() {
self.file_path = parent.join(new_name);
} else {
self.file_path = new_name.into();
}
}
fn find_uki_components(
dir: DirectoryRef<'_, ObjectID>,
entries: &mut Vec<Self>,
path: &mut PathBuf,
kver: &Option<Box<OsStr>>,
) -> Result<()> {
for (filename, inode) in dir.entries() {
path.push(filename);
if let Inode::Directory(subdir) = inode {
let subdir_ref = DirectoryRef::from_parts(subdir, dir.leaves());
Self::find_uki_components(subdir_ref, entries, path, kver)?;
path.pop();
continue;
}
if !filename.as_bytes().ends_with(EFI_EXT.as_bytes()) {
path.pop();
continue;
}
let Inode::Leaf(leaf_id, _) = inode else {
bail!("{filename:?} is a directory");
};
let leaf = dir.leaf(*leaf_id);
let LeafContent::Regular(file) = &leaf.content else {
bail!("{filename:?} is not a regular file");
};
entries.push(Self {
kver: kver.clone(),
file_path: path.clone(),
file: file.clone(),
pe_type: if path.components().count() == 1 {
PEType::Uki
} else {
PEType::UkiAddon
},
});
path.pop();
}
Ok(())
}
pub fn load_all(fs: &FileSystem<ObjectID>) -> Result<Vec<Self>> {
let mut entries = vec![];
let root = fs.as_dir();
match root.get_directory_ref("/boot/EFI/Linux".as_ref()) {
Ok(entries_dir) => {
Self::find_uki_components(entries_dir, &mut entries, &mut PathBuf::new(), &None)?
}
Err(ImageError::NotFound(..)) => {}
Err(other) => Err(other)?,
};
match root.get_directory_ref("/usr/lib/modules".as_ref()) {
Ok(modules_dir) => {
for (kver, inode) in modules_dir.entries() {
let Inode::Directory(dir) = inode else {
continue;
};
let dir_ref = DirectoryRef::from_parts(dir, root.leaves());
Self::find_uki_components(
dir_ref,
&mut entries,
&mut PathBuf::new(),
&Some(Box::from(kver)),
)?;
}
}
Err(ImageError::NotFound(..)) => {}
Err(other) => Err(other)?,
};
Ok(entries)
}
}
#[derive(Debug)]
pub struct UsrLibModulesVmlinuz<ObjectID: FsVerityHashValue> {
pub kver: Box<str>,
pub vmlinuz: RegularFile<ObjectID>,
pub initramfs: Option<RegularFile<ObjectID>>,
pub os_release: Option<RegularFile<ObjectID>>,
}
impl<ObjectID: FsVerityHashValue> UsrLibModulesVmlinuz<ObjectID> {
pub fn into_type1(self, entry_id: Option<&str>) -> Result<Type1Entry<ObjectID>> {
let id = entry_id.unwrap_or(&self.kver);
let initramfs = self.initramfs.ok_or_else(|| {
anyhow::anyhow!(
"kernel {} has no initramfs.img in /usr/lib/modules/{}",
self.kver,
self.kver
)
})?;
let title = "todoOS";
let version = "0-todo";
let entry = BootLoaderEntryFile::new(&format!(
r#"# File created by composefs
title {title}
version {version}
linux /{id}/vmlinuz
initrd /{id}/initramfs.img
"#
));
let filename = Box::from(format!("{id}.conf").as_ref());
Ok(Type1Entry {
filename,
entry,
files: HashMap::from([
(Box::from(format!("/{id}/vmlinuz")), self.vmlinuz),
(Box::from(format!("/{id}/initramfs.img")), initramfs),
]),
})
}
pub fn load_all(fs: &FileSystem<ObjectID>) -> Result<Vec<Self>> {
let mut entries = vec![];
let root = fs.as_dir();
match root.get_directory_ref("/usr/lib/modules".as_ref()) {
Ok(modules_dir) => {
for (kver, inode) in modules_dir.entries() {
let Inode::Directory(dir) = inode else {
continue;
};
let dir_ref = DirectoryRef::from_parts(dir, root.leaves());
if let Ok(vmlinuz) = dir_ref.get_file("vmlinuz".as_ref()) {
let initramfs = dir_ref.get_file("initramfs.img".as_ref()).ok();
let os_release = root.get_file("/usr/lib/os-release".as_ref()).ok();
entries.push(Self {
kver: Box::from(std::str::from_utf8(kver.as_bytes())?),
vmlinuz: vmlinuz.clone(),
initramfs: initramfs.cloned(),
os_release: os_release.cloned(),
});
}
}
}
Err(ImageError::NotFound(..)) => {}
Err(other) => Err(other)?,
};
Ok(entries)
}
}
#[derive(Debug)]
pub enum BootEntry<ObjectID: FsVerityHashValue> {
Type1(Type1Entry<ObjectID>),
Type2(Type2Entry<ObjectID>),
UsrLibModulesVmLinuz(UsrLibModulesVmlinuz<ObjectID>),
}
pub fn get_boot_resources<ObjectID: FsVerityHashValue>(
image: &FileSystem<ObjectID>,
repo: &Repository<ObjectID>,
) -> Result<Vec<BootEntry<ObjectID>>> {
let mut entries = vec![];
for e in Type1Entry::load_all(image, repo)? {
entries.push(BootEntry::Type1(e));
}
for e in Type2Entry::load_all(image)? {
entries.push(BootEntry::Type2(e));
}
for e in UsrLibModulesVmlinuz::load_all(image)? {
entries.push(BootEntry::UsrLibModulesVmLinuz(e));
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use composefs::{fsverity::Sha256HashValue, tree::RegularFile};
fn fake_file() -> RegularFile<Sha256HashValue> {
RegularFile::Inline(Default::default())
}
#[test]
fn test_into_type1_with_initramfs() {
let entry = UsrLibModulesVmlinuz::<Sha256HashValue> {
kver: "6.9.0-arch1-1".into(),
vmlinuz: fake_file(),
initramfs: Some(fake_file()),
os_release: None,
};
let t1 = entry
.into_type1(None)
.expect("should succeed with initramfs");
let keys: Vec<&str> = t1.files.keys().map(|k| k.as_ref()).collect();
assert!(
keys.contains(&"/6.9.0-arch1-1/vmlinuz"),
"missing vmlinuz key, got: {keys:?}"
);
assert!(
keys.contains(&"/6.9.0-arch1-1/initramfs.img"),
"missing initramfs key, got: {keys:?}"
);
}
#[test]
fn test_into_type1_without_initramfs_returns_error() {
let entry = UsrLibModulesVmlinuz::<Sha256HashValue> {
kver: "6.9.0-arch1-1".into(),
vmlinuz: fake_file(),
initramfs: None,
os_release: None,
};
let err = entry.into_type1(None).unwrap_err();
assert!(
err.to_string().contains("no initramfs"),
"unexpected error message: {err}"
);
}
#[test]
fn test_bootloader_entry_file_new() {
let content = "title Test Entry\nversion 1.0\nlinux /vmlinuz\ninitrd /initramfs.img\noptions quiet splash\n";
let entry = BootLoaderEntryFile::new(content);
assert_eq!(entry.lines.len(), 5);
assert_eq!(entry.lines[0], "title Test Entry");
assert_eq!(entry.lines[1], "version 1.0");
assert_eq!(entry.lines[2], "linux /vmlinuz");
assert_eq!(entry.lines[3], "initrd /initramfs.img");
assert_eq!(entry.lines[4], "options quiet splash");
}
#[test]
fn test_bootloader_entry_file_new_empty() {
let entry = BootLoaderEntryFile::new("");
assert_eq!(entry.lines.len(), 0);
}
#[test]
fn test_bootloader_entry_file_new_single_line() {
let entry = BootLoaderEntryFile::new("title Test");
assert_eq!(entry.lines.len(), 1);
assert_eq!(entry.lines[0], "title Test");
}
#[test]
fn test_bootloader_entry_file_new_trailing_newline() {
let content = "title Test\nversion 1.0\n";
let entry = BootLoaderEntryFile::new(content);
assert_eq!(entry.lines.len(), 2);
assert_eq!(entry.lines[0], "title Test");
assert_eq!(entry.lines[1], "version 1.0");
}
#[test]
fn test_get_value() {
let content = "title Test Entry\nversion 1.0\nlinux /vmlinuz\ninitrd /initramfs.img\noptions quiet splash\n";
let entry = BootLoaderEntryFile::new(content);
assert_eq!(entry.get_value("title"), Some("Test Entry"));
assert_eq!(entry.get_value("version"), Some("1.0"));
assert_eq!(entry.get_value("linux"), Some("/vmlinuz"));
assert_eq!(entry.get_value("initrd"), Some("/initramfs.img"));
assert_eq!(entry.get_value("options"), Some("quiet splash"));
assert_eq!(entry.get_value("nonexistent"), None);
}
#[test]
fn test_get_value_whitespace_handling() {
let content = "title\t\tTest Entry\nversion 1.0\nlinux\t/vmlinuz\n";
let entry = BootLoaderEntryFile::new(content);
assert_eq!(entry.get_value("title"), Some("Test Entry"));
assert_eq!(entry.get_value("version"), Some("1.0"));
assert_eq!(entry.get_value("linux"), Some("/vmlinuz"));
}
#[test]
fn test_get_value_no_whitespace_after_key() {
let content = "titleTest Entry\nversionno_space\n";
let entry = BootLoaderEntryFile::new(content);
assert_eq!(entry.get_value("title"), None);
assert_eq!(entry.get_value("version"), None);
}
#[test]
fn test_get_values_multiple() {
let content = "title Test Entry\ninitrd /initramfs1.img\ninitrd /initramfs2.img\noptions quiet\noptions splash\n";
let entry = BootLoaderEntryFile::new(content);
let initrd_values: Vec<_> = entry.get_values("initrd").collect();
assert_eq!(initrd_values, vec!["/initramfs1.img", "/initramfs2.img"]);
let options_values: Vec<_> = entry.get_values("options").collect();
assert_eq!(options_values, vec!["quiet", "splash"]);
let title_values: Vec<_> = entry.get_values("title").collect();
assert_eq!(title_values, vec!["Test Entry"]);
let nonexistent_values: Vec<_> = entry.get_values("nonexistent").collect();
assert_eq!(nonexistent_values, Vec::<&str>::new());
}
#[test]
fn test_add_cmdline_new_options_line() {
let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
entry.add_cmdline("quiet");
assert_eq!(entry.lines.len(), 3);
assert_eq!(entry.lines[2], "options quiet");
}
#[test]
fn test_add_cmdline_append_to_existing_options() {
let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions splash\n");
entry.add_cmdline("quiet");
assert_eq!(entry.lines.len(), 2);
assert_eq!(entry.lines[1], "options splash quiet");
}
#[test]
fn test_add_cmdline_replace_existing_key_value() {
let mut entry =
BootLoaderEntryFile::new("title Test Entry\noptions quiet splash root=/dev/sda1\n");
entry.add_cmdline("root=/dev/sda2");
assert_eq!(entry.lines.len(), 2);
assert_eq!(entry.lines[1], "options quiet splash root=/dev/sda2");
}
#[test]
fn test_add_cmdline_replace_existing_key_only() {
let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions quiet rw splash\n");
entry.add_cmdline("rw");
assert_eq!(entry.lines.len(), 2);
assert_eq!(entry.lines[1], "options quiet rw splash");
entry.add_cmdline("ro");
assert_eq!(entry.lines[1], "options quiet rw splash ro");
}
#[test]
fn test_add_cmdline_key_with_equals() {
let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions quiet\n");
entry.add_cmdline("composefs=abc123");
assert_eq!(entry.lines.len(), 2);
assert_eq!(entry.lines[1], "options quiet composefs=abc123");
}
#[test]
fn test_add_cmdline_replace_key_with_equals() {
let mut entry =
BootLoaderEntryFile::new("title Test Entry\noptions quiet composefs=old123\n");
entry.add_cmdline("composefs=new456");
assert_eq!(entry.lines.len(), 2);
assert_eq!(entry.lines[1], "options quiet composefs=new456");
}
#[test]
fn test_adjust_cmdline_with_composefs() {
let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
entry.adjust_cmdline(Some("composefs=abc123"), &["quiet", "splash"]);
assert_eq!(entry.lines.len(), 3);
assert_eq!(entry.lines[2], "options composefs=abc123 quiet splash");
}
#[test]
fn test_adjust_cmdline_with_composefs_insecure() {
let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
entry.adjust_cmdline(Some("composefs=?abc123"), &[]);
assert_eq!(entry.lines.len(), 3);
assert_eq!(entry.lines[2], "options composefs=?abc123");
}
#[test]
fn test_adjust_cmdline_no_composefs() {
let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
entry.adjust_cmdline(None, &["quiet", "splash"]);
assert_eq!(entry.lines.len(), 3);
assert_eq!(entry.lines[2], "options quiet splash");
}
#[test]
fn test_adjust_cmdline_existing_options() {
let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions root=/dev/sda1\n");
entry.adjust_cmdline(Some("composefs=abc123"), &["quiet"]);
assert_eq!(entry.lines.len(), 2);
assert!(entry.lines[1].contains("root=/dev/sda1"));
assert!(entry.lines[1].contains("abc123"));
assert!(entry.lines[1].contains("quiet"));
}
#[test]
fn test_strip_ble_key_helper() {
assert_eq!(
strip_ble_key("title Test Entry", "title"),
Some("Test Entry")
);
assert_eq!(
strip_ble_key("title\tTest Entry", "title"),
Some("Test Entry")
);
assert_eq!(
strip_ble_key("title Test Entry", "title"),
Some("Test Entry")
);
assert_eq!(strip_ble_key("titleTest Entry", "title"), None);
assert_eq!(strip_ble_key("other Test Entry", "title"), None);
assert_eq!(strip_ble_key("title", "title"), None); }
#[test]
fn test_substr_range_helper() {
let parent = "hello world test";
let substr = &parent[6..11]; let range = substr_range(parent, substr).unwrap();
assert_eq!(range, 6..11);
assert_eq!(&parent[range], "world");
let other_substr = &parent[0..5]; let range2 = substr_range(parent, other_substr).unwrap();
assert_eq!(range2, 0..5);
assert_eq!(&parent[range2], "hello");
let separate_string = String::from("world");
assert_eq!(substr_range(parent, &separate_string), None);
}
}