Skip to main content

composefs_boot/
bootloader.rs

1//! Bootloader entry parsing and manipulation.
2//!
3//! This module provides functionality to parse and manipulate Boot Loader Specification
4//! entries and Unified Kernel Images (UKIs). It supports Type 1 BLS entries with separate
5//! kernel and initrd files, Type 2 UKI files, and traditional vmlinuz/initramfs pairs
6//! from /usr/lib/modules. Key types include `BootLoaderEntryFile` for parsing BLS
7//! configuration files and `BootEntry` enum for representing different boot entry types.
8
9use 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
24/// Strips the key (if it matches) plus the following whitespace from a single line in a "Type #1
25/// Boot Loader Specification Entry" file.
26///
27/// The line needs to start with the name of the key, followed by at least one whitespace
28/// character.  The whitespace is consumed.  If the current line doesn't match the key, None is
29/// returned.
30fn 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
38// https://doc.rust-lang.org/std/primitive.str.html#method.substr_range
39fn 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/// Represents a parsed Boot Loader Specification entry file.
53///
54/// Contains the lines of a BLS .conf file and provides methods to query and modify
55/// entries like kernel paths, initrd files, and command-line options.
56#[derive(Debug)]
57pub struct BootLoaderEntryFile {
58    /// Lines from the bootloader entry configuration file
59    pub lines: Vec<String>,
60}
61
62impl BootLoaderEntryFile {
63    /// Creates a new bootloader entry file by parsing the content.
64    ///
65    /// # Arguments
66    ///
67    /// * `content` - The text content of the BLS entry file
68    ///
69    /// # Returns
70    ///
71    /// A new `BootLoaderEntryFile` with lines split on newlines
72    pub fn new(content: &str) -> Self {
73        Self {
74            lines: content.split_terminator('\n').map(String::from).collect(),
75        }
76    }
77
78    /// Returns an iterator over all values for a given key in the entry file.
79    ///
80    /// # Arguments
81    ///
82    /// * `key` - The key to search for (e.g., "initrd", "options")
83    ///
84    /// # Returns
85    ///
86    /// An iterator yielding the value portion of each matching line
87    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    /// Returns the first value for a given key in the entry file.
94    ///
95    /// # Arguments
96    ///
97    /// * `key` - The key to search for (e.g., "linux", "title")
98    ///
99    /// # Returns
100    ///
101    /// The value portion of the first matching line, or None if not found
102    pub fn get_value(&self, key: &str) -> Option<&str> {
103        self.lines.iter().find_map(|line| strip_ble_key(line, key))
104    }
105
106    /// Adds a kernel command-line argument, possibly replacing a previous value.
107    ///
108    /// arg can be something like "composefs=xyz" but it can also be something like "rw".  In
109    /// either case, if the argument already existed, it will be replaced.
110    pub fn add_cmdline(&mut self, arg: &str) {
111        let key = match arg.find('=') {
112            Some(pos) => &arg[..=pos], // include the '='
113            None => arg,
114        };
115
116        // There are three possible paths in this function:
117        //   1. options line with key= already in it (replace it)
118        //   2. options line with no key= in it (append key=value)
119        //   3. no options line (append the entire thing)
120        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                    // 1. Replace existing key
126                    let range = substr_range(line, old).unwrap();
127                    line.replace_range(range, arg);
128                } else {
129                    // 2. Append new argument
130                    line.push(' ');
131                    line.push_str(arg);
132                }
133
134                return;
135            }
136        }
137
138        // 3. Append new "options" line with our argument
139        self.lines.push(format!("options {arg}"));
140    }
141
142    /// Adjusts the kernel command-line arguments by adding a composefs karg (if provided)
143    /// and adding additional arguments.
144    ///
145    /// `karg` should be a complete kernel argument string such as
146    /// `"composefs.digest=v1-sha256-12:abc123"` or `"composefs=abc123"` as produced by
147    /// [`composefs_boot::cmdline::ComposefsCmdline::to_cmdline_arg`].
148    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/// Represents a Boot Loader Specification Type 1 entry.
160///
161/// Type 1 entries have separate kernel and initrd files referenced from a .conf file.
162/// This structure contains both the parsed configuration and the actual file objects.
163#[derive(Debug)]
164pub struct Type1Entry<ObjectID: FsVerityHashValue> {
165    /// The basename of the bootloader entry .conf file
166    pub filename: Box<OsStr>,
167    /// The parsed bootloader entry configuration
168    pub entry: BootLoaderEntryFile,
169    /// Map of file paths to their corresponding file objects (kernel, initrd, etc.)
170    pub files: HashMap<Box<str>, RegularFile<ObjectID>>,
171}
172
173impl<ObjectID: FsVerityHashValue> Type1Entry<ObjectID> {
174    /// Relocates boot resources to a new entry ID directory.
175    ///
176    /// This moves all referenced files (kernel, initrd, etc.) into a directory named after
177    /// the entry_id and updates the entry configuration to match. The entry file itself is
178    /// renamed to "{entry_id}.conf".
179    ///
180    /// # Arguments
181    ///
182    /// * `boot_subdir` - Optional subdirectory to prepend to paths in the entry file
183    /// * `entry_id` - The new entry identifier to use for the directory and filename
184    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    /// Loads a Type 1 boot entry from a BLS .conf file.
216    ///
217    /// Parses the configuration file and loads all referenced boot resources (kernel, initrd, etc.)
218    /// from the filesystem.
219    ///
220    /// # Arguments
221    ///
222    /// * `filename` - Name of the .conf file
223    /// * `file` - The configuration file object
224    /// * `root` - Root directory of the filesystem
225    /// * `repo` - The composefs repository
226    ///
227    /// # Returns
228    ///
229    /// A fully loaded Type1Entry with all referenced files
230    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    /// Loads all Type 1 boot entries from /boot/loader/entries.
254    ///
255    /// # Arguments
256    ///
257    /// * `root` - Root directory of the filesystem
258    /// * `repo` - The composefs repository
259    ///
260    /// # Returns
261    ///
262    /// A vector of all Type1Entry objects found in /boot/loader/entries
263    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
294/// File extension for EFI executables
295pub const EFI_EXT: &str = ".efi";
296/// Directory extension for UKI addon directories
297pub const EFI_ADDON_DIR_EXT: &str = ".efi.extra.d";
298/// File extension for UKI addon files
299pub const EFI_ADDON_FILE_EXT: &str = ".addon.efi";
300
301/// Type of Portable Executable (PE) file for boot.
302#[derive(Debug)]
303pub enum PEType {
304    /// A Unified Kernel Image
305    Uki,
306    /// A UKI addon extension
307    UkiAddon,
308}
309
310/// Represents a Boot Loader Specification Type 2 entry (Unified Kernel Image).
311///
312/// Type 2 entries are UKI files that bundle the kernel, initrd, and other components
313/// into a single EFI executable.
314#[derive(Debug)]
315pub struct Type2Entry<ObjectID: FsVerityHashValue> {
316    /// Kernel version string, if found in /usr/lib/modules
317    pub kver: Option<Box<OsStr>>,
318    /// Path to the file (relative to /boot/EFI/Linux)
319    pub file_path: PathBuf,
320    /// The Portable Executable binary
321    pub file: RegularFile<ObjectID>,
322    /// Type of PE file (UKI or UKI addon)
323    pub pe_type: PEType,
324}
325
326impl<ObjectID: FsVerityHashValue> Type2Entry<ObjectID> {
327    /// Renames the UKI file to a new name.
328    ///
329    /// # Arguments
330    ///
331    /// * `name` - New base name (without .efi extension)
332    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    // Find UKI components, the UKI PE binary and other UKI addons,
343    // if any, in the provided directory
344    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            // Collect all UKI extensions
354            // Usually we'll find them in the root with directories ending in `.efi.extra.d` for kernel
355            // specific addons. Global addons are found in `loader/addons`
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)?;
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    /// Loads all Type 2 boot entries from /boot/EFI/Linux and /usr/lib/modules.
395    ///
396    /// # Arguments
397    ///
398    /// * `root` - Root directory of the filesystem
399    ///
400    /// # Returns
401    ///
402    /// A vector of all Type2Entry objects found
403    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/// Represents a traditional vmlinuz/initramfs pair from /usr/lib/modules.
440///
441/// This is for kernels found in /usr/lib/modules/{kver}/ that have a vmlinuz
442/// and optionally an initramfs.img file.
443#[derive(Debug)]
444pub struct UsrLibModulesVmlinuz<ObjectID: FsVerityHashValue> {
445    /// Kernel version string (directory name in /usr/lib/modules)
446    pub kver: Box<str>,
447    /// The kernel image file
448    pub vmlinuz: RegularFile<ObjectID>,
449    /// Optional initramfs image
450    pub initramfs: Option<RegularFile<ObjectID>>,
451    /// Optional os-release file from /usr/lib/os-release
452    pub os_release: Option<RegularFile<ObjectID>>,
453}
454
455impl<ObjectID: FsVerityHashValue> UsrLibModulesVmlinuz<ObjectID> {
456    /// Converts this vmlinuz entry into a Type 1 BLS entry.
457    ///
458    /// # Arguments
459    ///
460    /// * `entry_id` - Optional entry ID to use; defaults to kernel version
461    ///
462    /// # Returns
463    ///
464    /// A Type1Entry with generated BLS configuration, or an error if initramfs is missing.
465    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    /// Loads all vmlinuz entries from /usr/lib/modules.
500    ///
501    /// # Arguments
502    ///
503    /// * `root` - Root directory of the filesystem
504    ///
505    /// # Returns
506    ///
507    /// A vector of all UsrLibModulesVmlinuz entries found
508    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                        // TODO: maybe initramfs should be mandatory: the kernel isn't useful
522                        // without it
523                        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/// Represents any type of boot entry found in the filesystem.
543///
544/// This enum unifies the three types of boot entries that can be discovered:
545/// Type 1 BLS entries, Type 2 UKIs, and traditional vmlinuz/initramfs pairs.
546#[derive(Debug)]
547pub enum BootEntry<ObjectID: FsVerityHashValue> {
548    /// Boot Loader Specification Type 1 entry
549    Type1(Type1Entry<ObjectID>),
550    /// Boot Loader Specification Type 2 entry (UKI)
551    Type2(Type2Entry<ObjectID>),
552    /// Traditional vmlinuz from /usr/lib/modules
553    UsrLibModulesVmLinuz(UsrLibModulesVmlinuz<ObjectID>),
554}
555
556/// Extracts all boot resources from a filesystem image.
557///
558/// Scans the filesystem for all types of boot entries: Type 1 BLS entries in
559/// /boot/loader/entries, Type 2 UKIs in /boot/EFI/Linux, and traditional vmlinuz
560/// files in /usr/lib/modules.
561///
562/// # Arguments
563///
564/// * `image` - The filesystem to scan
565/// * `repo` - The composefs repository
566///
567/// # Returns
568///
569/// A vector containing all boot entries found in the filesystem
570pub 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"); // Same key, should replace itself (no-op in this case)
752
753        assert_eq!(entry.lines.len(), 2);
754        assert_eq!(entry.lines[1], "options quiet rw splash");
755
756        // Test replacing with different key
757        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); // No whitespace after key
835    }
836
837    #[test]
838    fn test_substr_range_helper() {
839        let parent = "hello world test";
840        let substr = &parent[6..11]; // "world" - actual substring slice
841        let range = substr_range(parent, substr).unwrap();
842        assert_eq!(range, 6..11);
843        assert_eq!(&parent[range], "world");
844
845        // Test with different substring
846        let other_substr = &parent[0..5]; // "hello"
847        let range2 = substr_range(parent, other_substr).unwrap();
848        assert_eq!(range2, 0..5);
849        assert_eq!(&parent[range2], "hello");
850
851        // Test non-substring (separate string with same content)
852        let separate_string = String::from("world");
853        assert_eq!(substr_range(parent, &separate_string), None);
854    }
855}