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 scoped to the found UKI
307    UkiAddon,
308    /// A global UKI addon applicable for all UKIs
309    GlobalUkiAddon,
310}
311
312/// Represents a Boot Loader Specification Type 2 entry (Unified Kernel Image).
313///
314/// Type 2 entries are UKI files that bundle the kernel, initrd, and other components
315/// into a single EFI executable.
316#[derive(Debug)]
317pub struct Type2Entry<ObjectID: FsVerityHashValue> {
318    /// Kernel version string, if found in /usr/lib/modules
319    pub kver: Option<Box<OsStr>>,
320    /// Path to the file (relative to /boot/EFI/Linux)
321    pub file_path: PathBuf,
322    /// The Portable Executable binary
323    pub file: RegularFile<ObjectID>,
324    /// Type of PE file (UKI or UKI addon)
325    pub pe_type: PEType,
326}
327
328impl<ObjectID: FsVerityHashValue> Type2Entry<ObjectID> {
329    /// Renames the UKI file to a new name.
330    ///
331    /// # Arguments
332    ///
333    /// * `name` - New base name (without .efi extension)
334    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    // Find UKI components, the UKI PE binary and other UKI addons,
345    // if any, in the provided directory
346    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                // We already filter by .efi extension
385                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    /// Loads all Type 2 boot entries from /boot/EFI/Linux and /usr/lib/modules.
402    ///
403    /// # Arguments
404    ///
405    /// * `root` - Root directory of the filesystem
406    ///
407    /// # Returns
408    ///
409    /// A vector of all Type2Entry objects found
410    pub fn load_all(fs: &FileSystem<ObjectID>) -> Result<Vec<Self>> {
411        let mut entries = vec![];
412        let root = fs.as_dir();
413
414        // Collect all UKI extensions as well
415        // Usually we'll find them in the root with directories ending in `.efi.extra.d` for kernel
416        // specific addons. Global addons are found in `loader/addons`
417        let paths = [
418            // Gather UKI and deployment specific UKI Addons
419            ("/boot/EFI/Linux", false),
420            // Gather global UKI Addons if any
421            ("/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/// Represents a traditional vmlinuz/initramfs pair from /usr/lib/modules.
464///
465/// This is for kernels found in /usr/lib/modules/{kver}/ that have a vmlinuz
466/// and optionally an initramfs.img file.
467#[derive(Debug)]
468pub struct UsrLibModulesVmlinuz<ObjectID: FsVerityHashValue> {
469    /// Kernel version string (directory name in /usr/lib/modules)
470    pub kver: Box<str>,
471    /// The kernel image file
472    pub vmlinuz: RegularFile<ObjectID>,
473    /// Optional initramfs image
474    pub initramfs: Option<RegularFile<ObjectID>>,
475    /// Optional os-release file from /usr/lib/os-release
476    pub os_release: Option<RegularFile<ObjectID>>,
477}
478
479impl<ObjectID: FsVerityHashValue> UsrLibModulesVmlinuz<ObjectID> {
480    /// Converts this vmlinuz entry into a Type 1 BLS entry.
481    ///
482    /// # Arguments
483    ///
484    /// * `entry_id` - Optional entry ID to use; defaults to kernel version
485    ///
486    /// # Returns
487    ///
488    /// A Type1Entry with generated BLS configuration, or an error if initramfs is missing.
489    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    /// Loads all vmlinuz entries from /usr/lib/modules.
524    ///
525    /// # Arguments
526    ///
527    /// * `root` - Root directory of the filesystem
528    ///
529    /// # Returns
530    ///
531    /// A vector of all UsrLibModulesVmlinuz entries found
532    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                        // TODO: maybe initramfs should be mandatory: the kernel isn't useful
546                        // without it
547                        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/// Represents any type of boot entry found in the filesystem.
567///
568/// This enum unifies the three types of boot entries that can be discovered:
569/// Type 1 BLS entries, Type 2 UKIs, and traditional vmlinuz/initramfs pairs.
570#[derive(Debug)]
571pub enum BootEntry<ObjectID: FsVerityHashValue> {
572    /// Boot Loader Specification Type 1 entry
573    Type1(Type1Entry<ObjectID>),
574    /// Boot Loader Specification Type 2 entry (UKI)
575    Type2(Type2Entry<ObjectID>),
576    /// Traditional vmlinuz from /usr/lib/modules
577    UsrLibModulesVmLinuz(UsrLibModulesVmlinuz<ObjectID>),
578}
579
580/// Extracts all boot resources from a filesystem image.
581///
582/// Scans the filesystem for all types of boot entries: Type 1 BLS entries in
583/// /boot/loader/entries, Type 2 UKIs in /boot/EFI/Linux, and traditional vmlinuz
584/// files in /usr/lib/modules.
585///
586/// # Arguments
587///
588/// * `image` - The filesystem to scan
589/// * `repo` - The composefs repository
590///
591/// # Returns
592///
593/// A vector containing all boot entries found in the filesystem
594pub 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"); // Same key, should replace itself (no-op in this case)
776
777        assert_eq!(entry.lines.len(), 2);
778        assert_eq!(entry.lines[1], "options quiet rw splash");
779
780        // Test replacing with different key
781        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); // No whitespace after key
859    }
860
861    #[test]
862    fn test_substr_range_helper() {
863        let parent = "hello world test";
864        let substr = &parent[6..11]; // "world" - actual substring slice
865        let range = substr_range(parent, substr).unwrap();
866        assert_eq!(range, 6..11);
867        assert_eq!(&parent[range], "world");
868
869        // Test with different substring
870        let other_substr = &parent[0..5]; // "hello"
871        let range2 = substr_range(parent, other_substr).unwrap();
872        assert_eq!(range2, 0..5);
873        assert_eq!(&parent[range2], "hello");
874
875        // Test non-substring (separate string with same content)
876        let separate_string = String::from("world");
877        assert_eq!(substr_range(parent, &separate_string), None);
878    }
879}