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::{bail, Result};
15
16use composefs::{
17    fsverity::FsVerityHashValue,
18    repository::Repository,
19    tree::{Directory, FileSystem, ImageError, Inode, LeafContent, RegularFile},
20};
21
22use crate::cmdline::{make_cmdline_composefs, 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= parameter (if appropriate)
143    /// and adding additional arguments, as requested.
144    pub fn adjust_cmdline(&mut self, composefs: Option<&str>, insecure: bool, extra: &[&str]) {
145        if let Some(id) = composefs {
146            self.add_cmdline(&make_cmdline_composefs(id, insecure));
147        }
148
149        for item in extra {
150            self.add_cmdline(item);
151        }
152    }
153}
154
155/// Represents a Boot Loader Specification Type 1 entry.
156///
157/// Type 1 entries have separate kernel and initrd files referenced from a .conf file.
158/// This structure contains both the parsed configuration and the actual file objects.
159#[derive(Debug)]
160pub struct Type1Entry<ObjectID: FsVerityHashValue> {
161    /// The basename of the bootloader entry .conf file
162    pub filename: Box<OsStr>,
163    /// The parsed bootloader entry configuration
164    pub entry: BootLoaderEntryFile,
165    /// Map of file paths to their corresponding file objects (kernel, initrd, etc.)
166    pub files: HashMap<Box<str>, RegularFile<ObjectID>>,
167}
168
169impl<ObjectID: FsVerityHashValue> Type1Entry<ObjectID> {
170    /// Relocates boot resources to a new entry ID directory.
171    ///
172    /// This moves all referenced files (kernel, initrd, etc.) into a directory named after
173    /// the entry_id and updates the entry configuration to match. The entry file itself is
174    /// renamed to "{entry_id}.conf".
175    ///
176    /// # Arguments
177    ///
178    /// * `boot_subdir` - Optional subdirectory to prepend to paths in the entry file
179    /// * `entry_id` - The new entry identifier to use for the directory and filename
180    pub fn relocate(&mut self, boot_subdir: Option<&str>, entry_id: &str) {
181        self.filename = Box::from(format!("{entry_id}.conf").as_ref());
182        for line in &mut self.entry.lines {
183            for key in ["linux", "initrd", "efi"] {
184                let Some(value) = strip_ble_key(line, key) else {
185                    continue;
186                };
187                let Some((_dir, basename)) = value.rsplit_once("/") else {
188                    continue;
189                };
190
191                let file = self.files.remove(value);
192
193                let new = format!("/{entry_id}/{basename}");
194                let range = substr_range(line, value).unwrap();
195
196                let final_entry_path = if let Some(boot_subdir) = boot_subdir {
197                    format!("/{boot_subdir}{new}")
198                } else {
199                    new.clone()
200                };
201
202                line.replace_range(range, &final_entry_path);
203
204                if let Some(file) = file {
205                    self.files.insert(new.into_boxed_str(), file);
206                }
207            }
208        }
209    }
210
211    /// Loads a Type 1 boot entry from a BLS .conf file.
212    ///
213    /// Parses the configuration file and loads all referenced boot resources (kernel, initrd, etc.)
214    /// from the filesystem.
215    ///
216    /// # Arguments
217    ///
218    /// * `filename` - Name of the .conf file
219    /// * `file` - The configuration file object
220    /// * `root` - Root directory of the filesystem
221    /// * `repo` - The composefs repository
222    ///
223    /// # Returns
224    ///
225    /// A fully loaded Type1Entry with all referenced files
226    pub fn load(
227        filename: &OsStr,
228        file: &RegularFile<ObjectID>,
229        root: &Directory<ObjectID>,
230        repo: &Repository<ObjectID>,
231    ) -> Result<Self> {
232        let entry = BootLoaderEntryFile::new(from_utf8(&composefs::fs::read_file(file, repo)?)?);
233
234        let mut files = HashMap::new();
235        for key in ["linux", "initrd", "efi"] {
236            for pathname in entry.get_values(key) {
237                let (dir, filename) = root.split(pathname.as_ref())?;
238                files.insert(Box::from(pathname), dir.get_file(filename)?.clone());
239            }
240        }
241
242        Ok(Self {
243            filename: Box::from(filename),
244            entry,
245            files,
246        })
247    }
248
249    /// Loads all Type 1 boot entries from /boot/loader/entries.
250    ///
251    /// # Arguments
252    ///
253    /// * `root` - Root directory of the filesystem
254    /// * `repo` - The composefs repository
255    ///
256    /// # Returns
257    ///
258    /// A vector of all Type1Entry objects found in /boot/loader/entries
259    pub fn load_all(root: &Directory<ObjectID>, repo: &Repository<ObjectID>) -> Result<Vec<Self>> {
260        let mut entries = vec![];
261
262        match root.get_directory("/boot/loader/entries".as_ref()) {
263            Ok(entries_dir) => {
264                for (filename, inode) in entries_dir.entries() {
265                    if !filename.as_bytes().ends_with(b".conf") {
266                        continue;
267                    }
268
269                    let Inode::Leaf(leaf) = inode else {
270                        bail!("/boot/loader/entries/{filename:?} is a directory");
271                    };
272
273                    let LeafContent::Regular(file) = &leaf.content else {
274                        bail!("/boot/loader/entries/{filename:?} is not a regular file");
275                    };
276
277                    entries.push(Self::load(filename, file, root, repo)?);
278                }
279            }
280            Err(ImageError::NotFound(..)) => {}
281            Err(other) => Err(other)?,
282        };
283
284        Ok(entries)
285    }
286}
287
288/// File extension for EFI executables
289pub const EFI_EXT: &str = ".efi";
290/// Directory extension for UKI addon directories
291pub const EFI_ADDON_DIR_EXT: &str = ".efi.extra.d";
292/// File extension for UKI addon files
293pub const EFI_ADDON_FILE_EXT: &str = ".addon.efi";
294
295/// Type of Portable Executable (PE) file for boot.
296#[derive(Debug)]
297pub enum PEType {
298    /// A Unified Kernel Image
299    Uki,
300    /// A UKI addon extension
301    UkiAddon,
302}
303
304/// Represents a Boot Loader Specification Type 2 entry (Unified Kernel Image).
305///
306/// Type 2 entries are UKI files that bundle the kernel, initrd, and other components
307/// into a single EFI executable.
308#[derive(Debug)]
309pub struct Type2Entry<ObjectID: FsVerityHashValue> {
310    /// Kernel version string, if found in /usr/lib/modules
311    pub kver: Option<Box<OsStr>>,
312    /// Path to the file (relative to /boot/EFI/Linux)
313    pub file_path: PathBuf,
314    /// The Portable Executable binary
315    pub file: RegularFile<ObjectID>,
316    /// Type of PE file (UKI or UKI addon)
317    pub pe_type: PEType,
318}
319
320impl<ObjectID: FsVerityHashValue> Type2Entry<ObjectID> {
321    /// Renames the UKI file to a new name.
322    ///
323    /// # Arguments
324    ///
325    /// * `name` - New base name (without .efi extension)
326    pub fn rename(&mut self, name: &str) {
327        let new_name = format!("{name}.efi");
328
329        if let Some(parent) = self.file_path.parent() {
330            self.file_path = parent.join(new_name);
331        } else {
332            self.file_path = new_name.into();
333        }
334    }
335
336    // Find UKI components, the UKI PE binary and other UKI addons,
337    // if any, in the provided directory
338    fn find_uki_components(
339        dir: &Directory<ObjectID>,
340        entries: &mut Vec<Self>,
341        path: &mut PathBuf,
342        kver: &Option<Box<OsStr>>,
343    ) -> Result<()> {
344        for (filename, inode) in dir.entries() {
345            path.push(filename);
346
347            // Collect all UKI extensions
348            // Usually we'll find them in the root with directories ending in `.efi.extra.d` for kernel
349            // specific addons. Global addons are found in `loader/addons`
350            if let Inode::Directory(dir) = inode {
351                Self::find_uki_components(dir, entries, path, kver)?;
352                path.pop();
353                continue;
354            }
355
356            if !filename.as_bytes().ends_with(EFI_EXT.as_bytes()) {
357                path.pop();
358                continue;
359            }
360
361            let Inode::Leaf(leaf) = inode else {
362                bail!("{filename:?} is a directory");
363            };
364
365            let LeafContent::Regular(file) = &leaf.content else {
366                bail!("{filename:?} is not a regular file");
367            };
368
369            entries.push(Self {
370                kver: kver.clone(),
371                file_path: path.clone(),
372                file: file.clone(),
373                pe_type: if path.components().count() == 1 {
374                    PEType::Uki
375                } else {
376                    PEType::UkiAddon
377                },
378            });
379
380            path.pop();
381        }
382
383        Ok(())
384    }
385
386    /// Loads all Type 2 boot entries from /boot/EFI/Linux and /usr/lib/modules.
387    ///
388    /// # Arguments
389    ///
390    /// * `root` - Root directory of the filesystem
391    ///
392    /// # Returns
393    ///
394    /// A vector of all Type2Entry objects found
395    pub fn load_all(root: &Directory<ObjectID>) -> Result<Vec<Self>> {
396        let mut entries = vec![];
397
398        match root.get_directory("/boot/EFI/Linux".as_ref()) {
399            Ok(entries_dir) => {
400                Self::find_uki_components(entries_dir, &mut entries, &mut PathBuf::new(), &None)?
401            }
402            Err(ImageError::NotFound(..)) => {}
403            Err(other) => Err(other)?,
404        };
405
406        match root.get_directory("/usr/lib/modules".as_ref()) {
407            Ok(modules_dir) => {
408                for (kver, inode) in modules_dir.entries() {
409                    let Inode::Directory(dir) = inode else {
410                        continue;
411                    };
412
413                    Self::find_uki_components(
414                        dir,
415                        &mut entries,
416                        &mut PathBuf::new(),
417                        &Some(Box::from(kver)),
418                    )?;
419                }
420            }
421            Err(ImageError::NotFound(..)) => {}
422            Err(other) => Err(other)?,
423        };
424
425        Ok(entries)
426    }
427}
428
429/// Represents a traditional vmlinuz/initramfs pair from /usr/lib/modules.
430///
431/// This is for kernels found in /usr/lib/modules/{kver}/ that have a vmlinuz
432/// and optionally an initramfs.img file.
433#[derive(Debug)]
434pub struct UsrLibModulesVmlinuz<ObjectID: FsVerityHashValue> {
435    /// Kernel version string (directory name in /usr/lib/modules)
436    pub kver: Box<str>,
437    /// The kernel image file
438    pub vmlinuz: RegularFile<ObjectID>,
439    /// Optional initramfs image
440    pub initramfs: Option<RegularFile<ObjectID>>,
441    /// Optional os-release file from /usr/lib/os-release
442    pub os_release: Option<RegularFile<ObjectID>>,
443}
444
445impl<ObjectID: FsVerityHashValue> UsrLibModulesVmlinuz<ObjectID> {
446    /// Converts this vmlinuz entry into a Type 1 BLS entry.
447    ///
448    /// # Arguments
449    ///
450    /// * `entry_id` - Optional entry ID to use; defaults to kernel version
451    ///
452    /// # Returns
453    ///
454    /// A Type1Entry with generated BLS configuration
455    pub fn into_type1(self, entry_id: Option<&str>) -> Type1Entry<ObjectID> {
456        let id = entry_id.unwrap_or(&self.kver);
457
458        let title = "todoOS";
459        let version = "0-todo";
460        let entry = BootLoaderEntryFile::new(&format!(
461            r#"# File created by composefs
462title {title}
463version {version}
464linux /{id}/vmlinuz
465initrd /{id}/initramfs.img
466"#
467        ));
468
469        let filename = Box::from(format!("{id}.conf").as_ref());
470
471        Type1Entry {
472            filename,
473            entry,
474            files: HashMap::from([
475                (Box::from(format!("/{id}/vmlinuz")), self.vmlinuz),
476                (
477                    Box::from(format!("/{id}/initramfs.img")),
478                    self.initramfs.unwrap(),
479                ),
480            ]),
481        }
482    }
483
484    /// Loads all vmlinuz entries from /usr/lib/modules.
485    ///
486    /// # Arguments
487    ///
488    /// * `root` - Root directory of the filesystem
489    ///
490    /// # Returns
491    ///
492    /// A vector of all UsrLibModulesVmlinuz entries found
493    pub fn load_all(root: &Directory<ObjectID>) -> Result<Vec<Self>> {
494        let mut entries = vec![];
495
496        match root.get_directory("/usr/lib/modules".as_ref()) {
497            Ok(modules_dir) => {
498                for (kver, inode) in modules_dir.entries() {
499                    let Inode::Directory(dir) = inode else {
500                        continue;
501                    };
502
503                    if let Ok(vmlinuz) = dir.get_file("vmlinuz".as_ref()) {
504                        // TODO: maybe initramfs should be mandatory: the kernel isn't useful
505                        // without it
506                        let initramfs = dir.get_file("initramfs.img".as_ref()).ok();
507                        let os_release = root.get_file("/usr/lib/os-release".as_ref()).ok();
508                        entries.push(Self {
509                            kver: Box::from(std::str::from_utf8(kver.as_bytes())?),
510                            vmlinuz: vmlinuz.clone(),
511                            initramfs: initramfs.cloned(),
512                            os_release: os_release.cloned(),
513                        });
514                    }
515                }
516            }
517            Err(ImageError::NotFound(..)) => {}
518            Err(other) => Err(other)?,
519        };
520
521        Ok(entries)
522    }
523}
524
525/// Represents any type of boot entry found in the filesystem.
526///
527/// This enum unifies the three types of boot entries that can be discovered:
528/// Type 1 BLS entries, Type 2 UKIs, and traditional vmlinuz/initramfs pairs.
529#[derive(Debug)]
530pub enum BootEntry<ObjectID: FsVerityHashValue> {
531    /// Boot Loader Specification Type 1 entry
532    Type1(Type1Entry<ObjectID>),
533    /// Boot Loader Specification Type 2 entry (UKI)
534    Type2(Type2Entry<ObjectID>),
535    /// Traditional vmlinuz from /usr/lib/modules
536    UsrLibModulesVmLinuz(UsrLibModulesVmlinuz<ObjectID>),
537}
538
539/// Extracts all boot resources from a filesystem image.
540///
541/// Scans the filesystem for all types of boot entries: Type 1 BLS entries in
542/// /boot/loader/entries, Type 2 UKIs in /boot/EFI/Linux, and traditional vmlinuz
543/// files in /usr/lib/modules.
544///
545/// # Arguments
546///
547/// * `image` - The filesystem to scan
548/// * `repo` - The composefs repository
549///
550/// # Returns
551///
552/// A vector containing all boot entries found in the filesystem
553pub fn get_boot_resources<ObjectID: FsVerityHashValue>(
554    image: &FileSystem<ObjectID>,
555    repo: &Repository<ObjectID>,
556) -> Result<Vec<BootEntry<ObjectID>>> {
557    let mut entries = vec![];
558
559    for e in Type1Entry::load_all(&image.root, repo)? {
560        entries.push(BootEntry::Type1(e));
561    }
562    for e in Type2Entry::load_all(&image.root)? {
563        entries.push(BootEntry::Type2(e));
564    }
565    for e in UsrLibModulesVmlinuz::load_all(&image.root)? {
566        entries.push(BootEntry::UsrLibModulesVmLinuz(e));
567    }
568
569    Ok(entries)
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    #[test]
577    fn test_bootloader_entry_file_new() {
578        let content = "title Test Entry\nversion 1.0\nlinux /vmlinuz\ninitrd /initramfs.img\noptions quiet splash\n";
579        let entry = BootLoaderEntryFile::new(content);
580
581        assert_eq!(entry.lines.len(), 5);
582        assert_eq!(entry.lines[0], "title Test Entry");
583        assert_eq!(entry.lines[1], "version 1.0");
584        assert_eq!(entry.lines[2], "linux /vmlinuz");
585        assert_eq!(entry.lines[3], "initrd /initramfs.img");
586        assert_eq!(entry.lines[4], "options quiet splash");
587    }
588
589    #[test]
590    fn test_bootloader_entry_file_new_empty() {
591        let entry = BootLoaderEntryFile::new("");
592        assert_eq!(entry.lines.len(), 0);
593    }
594
595    #[test]
596    fn test_bootloader_entry_file_new_single_line() {
597        let entry = BootLoaderEntryFile::new("title Test");
598        assert_eq!(entry.lines.len(), 1);
599        assert_eq!(entry.lines[0], "title Test");
600    }
601
602    #[test]
603    fn test_bootloader_entry_file_new_trailing_newline() {
604        let content = "title Test\nversion 1.0\n";
605        let entry = BootLoaderEntryFile::new(content);
606        assert_eq!(entry.lines.len(), 2);
607        assert_eq!(entry.lines[0], "title Test");
608        assert_eq!(entry.lines[1], "version 1.0");
609    }
610
611    #[test]
612    fn test_get_value() {
613        let content = "title Test Entry\nversion 1.0\nlinux /vmlinuz\ninitrd /initramfs.img\noptions quiet splash\n";
614        let entry = BootLoaderEntryFile::new(content);
615
616        assert_eq!(entry.get_value("title"), Some("Test Entry"));
617        assert_eq!(entry.get_value("version"), Some("1.0"));
618        assert_eq!(entry.get_value("linux"), Some("/vmlinuz"));
619        assert_eq!(entry.get_value("initrd"), Some("/initramfs.img"));
620        assert_eq!(entry.get_value("options"), Some("quiet splash"));
621        assert_eq!(entry.get_value("nonexistent"), None);
622    }
623
624    #[test]
625    fn test_get_value_whitespace_handling() {
626        let content = "title\t\tTest Entry\nversion   1.0\nlinux\t/vmlinuz\n";
627        let entry = BootLoaderEntryFile::new(content);
628
629        assert_eq!(entry.get_value("title"), Some("Test Entry"));
630        assert_eq!(entry.get_value("version"), Some("1.0"));
631        assert_eq!(entry.get_value("linux"), Some("/vmlinuz"));
632    }
633
634    #[test]
635    fn test_get_value_no_whitespace_after_key() {
636        let content = "titleTest Entry\nversionno_space\n";
637        let entry = BootLoaderEntryFile::new(content);
638
639        assert_eq!(entry.get_value("title"), None);
640        assert_eq!(entry.get_value("version"), None);
641    }
642
643    #[test]
644    fn test_get_values_multiple() {
645        let content = "title Test Entry\ninitrd /initramfs1.img\ninitrd /initramfs2.img\noptions quiet\noptions splash\n";
646        let entry = BootLoaderEntryFile::new(content);
647
648        let initrd_values: Vec<_> = entry.get_values("initrd").collect();
649        assert_eq!(initrd_values, vec!["/initramfs1.img", "/initramfs2.img"]);
650
651        let options_values: Vec<_> = entry.get_values("options").collect();
652        assert_eq!(options_values, vec!["quiet", "splash"]);
653
654        let title_values: Vec<_> = entry.get_values("title").collect();
655        assert_eq!(title_values, vec!["Test Entry"]);
656
657        let nonexistent_values: Vec<_> = entry.get_values("nonexistent").collect();
658        assert_eq!(nonexistent_values, Vec::<&str>::new());
659    }
660
661    #[test]
662    fn test_add_cmdline_new_options_line() {
663        let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
664        entry.add_cmdline("quiet");
665
666        assert_eq!(entry.lines.len(), 3);
667        assert_eq!(entry.lines[2], "options quiet");
668    }
669
670    #[test]
671    fn test_add_cmdline_append_to_existing_options() {
672        let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions splash\n");
673        entry.add_cmdline("quiet");
674
675        assert_eq!(entry.lines.len(), 2);
676        assert_eq!(entry.lines[1], "options splash quiet");
677    }
678
679    #[test]
680    fn test_add_cmdline_replace_existing_key_value() {
681        let mut entry =
682            BootLoaderEntryFile::new("title Test Entry\noptions quiet splash root=/dev/sda1\n");
683        entry.add_cmdline("root=/dev/sda2");
684
685        assert_eq!(entry.lines.len(), 2);
686        assert_eq!(entry.lines[1], "options quiet splash root=/dev/sda2");
687    }
688
689    #[test]
690    fn test_add_cmdline_replace_existing_key_only() {
691        let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions quiet rw splash\n");
692        entry.add_cmdline("rw"); // Same key, should replace itself (no-op in this case)
693
694        assert_eq!(entry.lines.len(), 2);
695        assert_eq!(entry.lines[1], "options quiet rw splash");
696
697        // Test replacing with different key
698        entry.add_cmdline("ro");
699        assert_eq!(entry.lines[1], "options quiet rw splash ro");
700    }
701
702    #[test]
703    fn test_add_cmdline_key_with_equals() {
704        let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions quiet\n");
705        entry.add_cmdline("composefs=abc123");
706
707        assert_eq!(entry.lines.len(), 2);
708        assert_eq!(entry.lines[1], "options quiet composefs=abc123");
709    }
710
711    #[test]
712    fn test_add_cmdline_replace_key_with_equals() {
713        let mut entry =
714            BootLoaderEntryFile::new("title Test Entry\noptions quiet composefs=old123\n");
715        entry.add_cmdline("composefs=new456");
716
717        assert_eq!(entry.lines.len(), 2);
718        assert_eq!(entry.lines[1], "options quiet composefs=new456");
719    }
720
721    #[test]
722    fn test_adjust_cmdline_with_composefs() {
723        let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
724        entry.adjust_cmdline(Some("abc123"), false, &["quiet", "splash"]);
725
726        assert_eq!(entry.lines.len(), 3);
727        assert_eq!(entry.lines[2], "options composefs=abc123 quiet splash");
728    }
729
730    #[test]
731    fn test_adjust_cmdline_with_composefs_insecure() {
732        let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
733        entry.adjust_cmdline(Some("abc123"), true, &[]);
734
735        assert_eq!(entry.lines.len(), 3);
736        // Assuming make_cmdline_composefs adds digest=off for insecure mode
737        assert!(entry.lines[2].contains("abc123"));
738    }
739
740    #[test]
741    fn test_adjust_cmdline_no_composefs() {
742        let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n");
743        entry.adjust_cmdline(None, false, &["quiet", "splash"]);
744
745        assert_eq!(entry.lines.len(), 3);
746        assert_eq!(entry.lines[2], "options quiet splash");
747    }
748
749    #[test]
750    fn test_adjust_cmdline_existing_options() {
751        let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions root=/dev/sda1\n");
752        entry.adjust_cmdline(Some("abc123"), false, &["quiet"]);
753
754        assert_eq!(entry.lines.len(), 2);
755        assert!(entry.lines[1].contains("root=/dev/sda1"));
756        assert!(entry.lines[1].contains("abc123"));
757        assert!(entry.lines[1].contains("quiet"));
758    }
759
760    #[test]
761    fn test_strip_ble_key_helper() {
762        assert_eq!(
763            strip_ble_key("title Test Entry", "title"),
764            Some("Test Entry")
765        );
766        assert_eq!(
767            strip_ble_key("title\tTest Entry", "title"),
768            Some("Test Entry")
769        );
770        assert_eq!(
771            strip_ble_key("title  Test Entry", "title"),
772            Some("Test Entry")
773        );
774        assert_eq!(strip_ble_key("titleTest Entry", "title"), None);
775        assert_eq!(strip_ble_key("other Test Entry", "title"), None);
776        assert_eq!(strip_ble_key("title", "title"), None); // No whitespace after key
777    }
778
779    #[test]
780    fn test_substr_range_helper() {
781        let parent = "hello world test";
782        let substr = &parent[6..11]; // "world" - actual substring slice
783        let range = substr_range(parent, substr).unwrap();
784        assert_eq!(range, 6..11);
785        assert_eq!(&parent[range], "world");
786
787        // Test with different substring
788        let other_substr = &parent[0..5]; // "hello"
789        let range2 = substr_range(parent, other_substr).unwrap();
790        assert_eq!(range2, 0..5);
791        assert_eq!(&parent[range2], "hello");
792
793        // Test non-substring (separate string with same content)
794        let separate_string = String::from("world");
795        assert_eq!(substr_range(parent, &separate_string), None);
796    }
797}