Skip to main content

bootc_lib/
kernel.rs

1//! Kernel detection for container images.
2//!
3//! This module provides functionality to detect kernel information in container
4//! images, supporting both traditional kernels (with separate vmlinuz/initrd) and
5//! Unified Kernel Images (UKI), and aboot payloads.
6
7use std::io::{Read, Seek, SeekFrom};
8use std::path::Path;
9
10use anyhow::{Context, Result};
11use camino::Utf8PathBuf;
12use cap_std_ext::cap_std::fs::Dir;
13use cap_std_ext::dirext::CapStdExtDirExt;
14use composefs_boot::{
15    android_boot::{AndroidBootImage, Component},
16    bootloader::{AbootEncoding, EFI_ADDON_FILE_EXT},
17    cmdline::{KARG_COMPOSEFS_DIGEST, KARG_V2},
18};
19use composefs_ctl::composefs_boot;
20use linux_kernel_cmdline::utf8::Cmdline;
21use ostree_ext::composefs_boot::bootloader::EFI_ADDON_DIR_EXT;
22use serde::Serialize;
23
24use crate::bootc_composefs::boot::{EFI_LINUX, GLOBAL_UKI_ADDONS_DIR};
25use crate::install::UkiAddonOpts;
26
27/// Information about the kernel in a container image.
28#[derive(Debug, Serialize)]
29#[serde(rename_all = "kebab-case")]
30pub(crate) struct Kernel {
31    /// The kernel version identifier. For traditional kernels, this is derived from the
32    /// `/usr/lib/modules/<version>` directory name. For UKI images, this is the UKI filename
33    /// (without the .efi extension).
34    pub(crate) version: String,
35    /// Whether the kernel and initramfs are packaged together, as a UKI or aboot image.
36    pub(crate) unified: bool,
37}
38
39/// Path to kernel component(s)
40///
41/// UKI and aboot kernels have a single boot image, whereas
42/// traditional "vmlinuz" kernels have distinct kernel and
43/// initramfs.
44pub(crate) enum KernelType {
45    Aboot {
46        path: Utf8PathBuf,
47        encoding: AbootEncoding,
48        cmdline: Cmdline<'static>,
49    },
50    Uki {
51        path: Utf8PathBuf,
52        /// The commandline we found in the UKI
53        /// Again due to UKI Addons, we may or may not have it in the UKI itself
54        cmdline: Option<Cmdline<'static>>,
55    },
56    Vmlinuz {
57        path: Utf8PathBuf,
58        initramfs: Utf8PathBuf,
59    },
60}
61
62/// Internal-only kernel wrapper with extra path information that are
63/// useful but we don't want to leak out via serialization to
64/// inspection.
65///
66/// `Kernel` implements `From<KernelInternal>` so we can just `.into()`
67/// to get the "public" form where needed.
68pub(crate) struct KernelInternal {
69    pub(crate) kernel: Kernel,
70    pub(crate) k_type: KernelType,
71}
72
73impl From<KernelInternal> for Kernel {
74    fn from(kernel_internal: KernelInternal) -> Self {
75        kernel_internal.kernel
76    }
77}
78
79/// Find the kernel in a container image root directory.
80///
81/// This function first attempts to find a UKI in `/boot/EFI/Linux/*.efi`.
82/// If that doesn't exist, it falls back to looking for a traditional kernel
83/// layout with `/usr/lib/modules/<version>/vmlinuz`. Modern aboot payloads
84/// are detected in `/boot/aboot-<version>.img` and cannot coexist with either.
85///
86/// Returns `None` if no kernel is found.
87pub(crate) fn find_kernel(
88    root: &Dir,
89    uki_addon_opts: Option<&UkiAddonOpts>,
90) -> Result<Option<KernelInternal>> {
91    if let Some(aboot) = find_aboot_kernel(root)? {
92        anyhow::ensure!(
93            find_uki_path(root)?.is_none()
94                && ostree_ext::bootabletree::find_kernel_dir_fs(root)?.is_none()
95                && !has_type1_entries(root)?,
96            "aboot payload cannot be combined with other boot artifacts"
97        );
98        return Ok(Some(aboot));
99    }
100
101    // First, try to find a UKI
102    if let Some(uki_path) = find_uki_path(root)? {
103        let version = uki_path.file_stem().unwrap_or(uki_path.as_str()).to_owned();
104
105        let mut uki = root.open(&uki_path).context("Opening UKI")?;
106
107        // Best effort to check for composefs=?verity in the UKI cmdline
108        let cmdline = composefs_boot::uki::get_section_buffered(&mut uki, ".cmdline");
109
110        let mut cmdline = match cmdline {
111            Ok(cmdline) => {
112                let cmdline_str = std::str::from_utf8(&cmdline)?;
113                Cmdline::from(cmdline_str.to_owned())
114            }
115
116            Err(uki_error) => match uki_error {
117                composefs_boot::uki::UkiError::MissingSection(_) => Cmdline::new(),
118                e => anyhow::bail!("Failed to read UKI cmdline: {e:?}"),
119            },
120        };
121
122        if let Some(uki_addon_opts) = uki_addon_opts {
123            // This has to exist since we found a UKI.
124            let boot = root.open_dir(crate::install::BOOT)?;
125            validate_uki_addon_cmdlines(&boot, uki_addon_opts, &mut cmdline)?;
126        };
127
128        return Ok(Some(KernelInternal {
129            kernel: Kernel {
130                version,
131                unified: true,
132            },
133            k_type: KernelType::Uki {
134                path: uki_path,
135                cmdline: Some(cmdline),
136            },
137        }));
138    }
139
140    // Fall back to checking for a traditional kernel via ostree_ext
141    if let Some(modules_dir) = ostree_ext::bootabletree::find_kernel_dir_fs(root)? {
142        let version = modules_dir
143            .file_name()
144            .ok_or_else(|| anyhow::anyhow!("kernel dir should have a file name: {modules_dir}"))?
145            .to_owned();
146        let vmlinuz = modules_dir.join("vmlinuz");
147        let initramfs = modules_dir.join("initramfs.img");
148        return Ok(Some(KernelInternal {
149            kernel: Kernel {
150                version,
151                unified: false,
152            },
153            k_type: KernelType::Vmlinuz {
154                path: vmlinuz,
155                initramfs,
156            },
157        }));
158    }
159
160    Ok(None)
161}
162
163pub(crate) fn read_aboot_metadata<R: Read + Seek>(
164    image: &mut R,
165) -> Result<(AbootEncoding, Cmdline<'static>)> {
166    let mut magic = [0; 8];
167    image
168        .read_exact(&mut magic)
169        .context("Reading aboot payload magic")?;
170    image.seek(SeekFrom::Start(0))?;
171    let (encoding, cmdline) = if magic == *b"ANDROID!" {
172        let header = AndroidBootImage::parse(image).context("Parsing Android boot image")?;
173        header.component(image, Component::Kernel)?;
174        header.component(image, Component::Ramdisk)?;
175        (AbootEncoding::AndroidV2, header.cmdline()?.to_owned())
176    } else {
177        composefs_boot::uki::get_section_buffered(image, ".linux")?;
178        image.seek(SeekFrom::Start(0))?;
179        composefs_boot::uki::get_section_buffered(image, ".initrd")?;
180        image.seek(SeekFrom::Start(0))?;
181        (
182            AbootEncoding::Uki,
183            composefs_boot::uki::get_cmdline_buffered(image)?,
184        )
185    };
186    Ok((encoding, Cmdline::from(cmdline)))
187}
188
189fn find_aboot_kernel(root: &Dir) -> Result<Option<KernelInternal>> {
190    let Some(boot) = root.open_dir_optional("boot")? else {
191        return Ok(None);
192    };
193    let mut payload = None;
194    let mut vbmeta_versions = Vec::new();
195    for entry in boot.entries()? {
196        let entry = entry?;
197        let name = entry.file_name();
198        let Some(name) = name.to_str() else { continue };
199        if let Some(version) = name
200            .strip_prefix("vbmeta-")
201            .and_then(|s| s.strip_suffix(".img"))
202        {
203            anyhow::ensure!(entry.file_type()?.is_file(), "{name} is not a regular file");
204            vbmeta_versions.push(version.to_owned());
205        }
206        let Some(version) = name
207            .strip_prefix("aboot-")
208            .and_then(|s| s.strip_suffix(".img"))
209        else {
210            continue;
211        };
212        anyhow::ensure!(!version.is_empty(), "aboot payload has no kernel version");
213        anyhow::ensure!(entry.file_type()?.is_file(), "{name} is not a regular file");
214        anyhow::ensure!(payload.is_none(), "multiple aboot payloads found");
215        payload = Some((version.to_owned(), name.to_owned()));
216    }
217    for version in &vbmeta_versions {
218        anyhow::ensure!(
219            payload.as_ref().is_some_and(|(v, _)| v == version),
220            "vbmeta image has no matching aboot payload"
221        );
222    }
223    let Some((version, name)) = payload else {
224        return Ok(None);
225    };
226    let path = Utf8PathBuf::from("boot").join(name);
227    let mut file = root
228        .open(&path)
229        .with_context(|| format!("Opening {path}"))?;
230    let (encoding, cmdline) =
231        read_aboot_metadata(&mut file).with_context(|| format!("Parsing {path}"))?;
232    anyhow::ensure!(
233        encoding != AbootEncoding::Uki || vbmeta_versions.is_empty(),
234        "ukiboot payload must not have a vbmeta image"
235    );
236    Ok(Some(KernelInternal {
237        kernel: Kernel {
238            version,
239            unified: true,
240        },
241        k_type: KernelType::Aboot {
242            path,
243            encoding,
244            cmdline,
245        },
246    }))
247}
248
249/// The boot artifact type reported by `bootc container inspect`.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
251#[serde(rename_all = "kebab-case")]
252pub(crate) enum ContainerImageType {
253    Aboot,
254    AbootEfi,
255    Uki,
256    Vmlinuz,
257}
258
259impl ContainerImageType {
260    pub(crate) fn as_str(self) -> &'static str {
261        match self {
262            Self::Aboot => "aboot",
263            Self::AbootEfi => "aboot-efi",
264            Self::Uki => "UKI",
265            Self::Vmlinuz => "vmlinuz",
266        }
267    }
268}
269
270impl KernelType {
271    pub(crate) fn cmdline(&self) -> Option<&Cmdline<'static>> {
272        match self {
273            Self::Aboot { cmdline, .. } => Some(cmdline),
274            Self::Uki { cmdline, .. } => cmdline.as_ref(),
275            Self::Vmlinuz { .. } => None,
276        }
277    }
278
279    pub(crate) fn is_unified(&self) -> bool {
280        matches!(self, Self::Uki { .. } | Self::Aboot { .. })
281    }
282
283    pub(crate) fn image_type(&self) -> ContainerImageType {
284        match self {
285            Self::Aboot {
286                encoding: AbootEncoding::AndroidV2,
287                ..
288            } => ContainerImageType::Aboot,
289            Self::Aboot {
290                encoding: AbootEncoding::Uki,
291                ..
292            } => ContainerImageType::AbootEfi,
293            Self::Uki { .. } => ContainerImageType::Uki,
294            Self::Vmlinuz { .. } => ContainerImageType::Vmlinuz,
295        }
296    }
297}
298
299/// Files that distributions ship next to `vmlinuz` in `/usr/lib/modules/<kver>/`
300/// which belong to that kernel binary, and so must travel with it.
301///
302/// - `.vmlinuz.hmac`: the kernel's HMAC, which dracut's `fips` module checks
303///   at boot in FIPS mode. Shipped by Fedora and RHEL derivatives' kernel
304///   packages; ostree and Fedora's grub2 kernel-install plugin install it next
305///   to the kernel as `.vmlinuz-<kver>.hmac`.
306///
307/// This is deliberately limited to companions of the kernel binary. Userspace
308/// metadata such as `config`, `System.map` or `symvers.xz` stays in the rootfs,
309/// where tools look for it. ostree also installs a `devicetree` file or `dtb/`
310/// directory and an `aboot.img` from this directory, but those are separate
311/// boot inputs rather than companions of `vmlinuz`, so they are left alone.
312pub(crate) const KERNEL_COMPANION_FILES: &[&str] = &[".vmlinuz.hmac"];
313
314/// Move the kernel out of `root` into `output/<kver>/`.
315///
316/// The kernel is written as `vmlinuz` and the initramfs as `initramfs.img`,
317/// along with any [`KERNEL_COMPANION_FILES`] present, under the same names.
318/// UKIs are not supported. Returns the kernel version.
319pub(crate) fn split_kernel(root: &Dir, output: &Dir) -> Result<String> {
320    let kernel =
321        find_kernel(root, None)?.ok_or_else(|| anyhow::anyhow!("No kernel found in rootfs"))?;
322    let KernelType::Vmlinuz { path, initramfs } = &kernel.k_type else {
323        anyhow::bail!("Only traditional kernels can be split");
324    };
325    let kver = kernel.kernel.version;
326
327    output
328        .create_dir_all(&kver)
329        .with_context(|| format!("Creating {kver} in output directory"))?;
330    let dest = output.open_dir(&kver)?;
331
332    root.rename(path, &dest, "vmlinuz")
333        .with_context(|| format!("Moving {path}"))?;
334    root.rename(initramfs, &dest, "initramfs.img")
335        .with_context(|| format!("Moving {initramfs}"))?;
336
337    for &name in KERNEL_COMPANION_FILES {
338        let src = path.with_file_name(name);
339        match root.rename(&src, &dest, name) {
340            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
341            r => r.with_context(|| format!("Moving {src}"))?,
342        }
343    }
344
345    Ok(kver)
346}
347
348fn has_type1_entries(root: &Dir) -> Result<bool> {
349    let Some(entries) = root.open_dir_optional("boot/loader/entries")? else {
350        return Ok(false);
351    };
352    for entry in entries.entries()? {
353        if Path::new(&entry?.file_name())
354            .extension()
355            .is_some_and(|s| s == "conf")
356        {
357            return Ok(true);
358        }
359    }
360    Ok(false)
361}
362
363/// Returns the path to the first UKI found in the container root, if any.
364///
365/// Looks in `/boot/EFI/Linux/*.efi`, excluding addons. If multiple UKIs are present, returns
366/// the first one in sorted order for determinism.
367fn find_uki_path(root: &Dir) -> Result<Option<Utf8PathBuf>> {
368    let Some(boot) = root.open_dir_optional(crate::install::BOOT)? else {
369        return Ok(None);
370    };
371    let Some(efi_linux) = boot.open_dir_optional(EFI_LINUX)? else {
372        return Ok(None);
373    };
374
375    let mut uki_files = Vec::new();
376    for entry in efi_linux.entries()? {
377        let entry = entry?;
378        let name = entry.file_name();
379        let name_path = Path::new(&name);
380        let extension = name_path.extension().and_then(|v| v.to_str());
381        if extension == Some("efi") && !name.to_string_lossy().ends_with(".addon.efi") {
382            if let Some(name_str) = name.to_str() {
383                uki_files.push(name_str.to_owned());
384            }
385        }
386    }
387
388    // Sort for deterministic behavior when multiple UKIs are present
389    uki_files.sort();
390    Ok(uki_files
391        .into_iter()
392        .next()
393        .map(|filename| Utf8PathBuf::from(format!("boot/{EFI_LINUX}/{filename}"))))
394}
395
396fn cmdline_has_composefs(cmdline: &str) -> bool {
397    Cmdline::from(cmdline).iter().any(|parameter| {
398        let key = &*parameter.key();
399        key == KARG_V2 || key == KARG_COMPOSEFS_DIGEST
400    })
401}
402
403#[fn_error_context::context("Getting cmdline from addon {name}")]
404fn get_cmdline_in_addon<'a>(dir: &'a Dir, name: &'a str) -> Result<Option<Cmdline<'static>>> {
405    let mut file = dir.open(name).with_context(|| format!("Opening {name}"))?;
406
407    match composefs_boot::uki::get_cmdline_buffered(&mut file) {
408        Ok(cmdline) => Ok(Some(Cmdline::from(cmdline))),
409        Err(composefs_boot::uki::UkiError::MissingSection(_)) => Ok(None),
410        Err(e) => Err(e).with_context(|| format!("Reading cmdline from {name}")),
411    }
412}
413
414/// Make sure of the following
415/// - We don't see a composefs cmdline in a global addon
416/// - We don't see multiple composefs comdlines across UKIs and UKI Addons
417#[fn_error_context::context("Validating UKI Addon Cmdlines")]
418fn validate_uki_addon_cmdlines(
419    boot: &Dir,
420    uki_addon_opts: &UkiAddonOpts,
421    final_cmdline: &mut Cmdline,
422) -> Result<()> {
423    let selected_global = uki_addon_opts.global.as_deref().unwrap_or(&[]);
424    let selected_scoped = uki_addon_opts.scoped.as_deref().unwrap_or(&[]);
425
426    if let Some(addons_dir) = boot.open_dir_optional(GLOBAL_UKI_ADDONS_DIR)? {
427        for ent in addons_dir.entries_utf8()? {
428            let filename = ent?.file_name()?;
429
430            let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) else {
431                continue;
432            };
433
434            if !selected_global.iter().any(|n| n == name) {
435                // This addon isn't going to be included
436                continue;
437            }
438
439            if let Some(addon_cmdline) = get_cmdline_in_addon(&addons_dir, &filename)? {
440                if cmdline_has_composefs(&addon_cmdline) {
441                    anyhow::bail!("composefs cmdline found in global UKI addon {name}");
442                }
443
444                final_cmdline.extend(&addon_cmdline);
445            }
446        }
447    };
448
449    let efi_linux = boot
450        .open_dir(EFI_LINUX)
451        .with_context(|| format!("Opening {EFI_LINUX}"))?;
452
453    for ent in efi_linux.entries_utf8()? {
454        let ent = ent.context("Reading directory entry")?;
455        let filename = ent.file_name().context("Reading filename")?;
456        let file_type = ent
457            .file_type()
458            .with_context(|| format!("Getting file type for {filename}"))?;
459
460        if !(filename.ends_with(EFI_ADDON_DIR_EXT) && file_type.is_dir()) {
461            continue;
462        }
463
464        let addon_dir = efi_linux
465            .open_dir(&filename)
466            .with_context(|| format!("Opening directory {filename}"))?;
467
468        for addon_dir_ent in addon_dir
469            .entries_utf8()
470            .with_context(|| format!("Reading dirents {filename}"))?
471        {
472            let addon_entry = addon_dir_ent?;
473            let addon_name = addon_entry.file_name().context("Reading UKI Addon name")?;
474
475            let Some(addon_name_wo_suffix) = addon_name.strip_suffix(EFI_ADDON_FILE_EXT) else {
476                continue;
477            };
478
479            if !selected_scoped.iter().any(|n| n == addon_name_wo_suffix) {
480                continue;
481            }
482
483            let final_cmdline_has_cfs = cmdline_has_composefs(final_cmdline);
484            let addon_cmdline = get_cmdline_in_addon(&addon_dir, &addon_name)?;
485
486            if let Some(addon_cmdline) = addon_cmdline {
487                let cfs_in_addon_cmdline = cmdline_has_composefs(&addon_cmdline);
488
489                if final_cmdline_has_cfs && cfs_in_addon_cmdline {
490                    anyhow::bail!(
491                        "Multiple composefs cmdlines found across the UKI and its addons (duplicate in scoped addon {addon_name})"
492                    );
493                }
494
495                final_cmdline.extend(&addon_cmdline);
496            };
497        }
498    }
499
500    Ok(())
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use bootc_utils::create_minimal_pe;
507    use cap_std_ext::{cap_std, cap_tempfile, dirext::CapStdExtDirExt};
508
509    fn android_image() -> Vec<u8> {
510        let mut image = vec![0; 4097];
511        image[..8].copy_from_slice(b"ANDROID!");
512        for (offset, value) in [(8, 1u32), (16, 1), (36, 2048), (40, 2), (1644, 1660)] {
513            image[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
514        }
515        image[64..69].copy_from_slice(b"quiet");
516        image[2048] = 1;
517        image[4096] = 2;
518        image
519    }
520
521    fn aboot_uki() -> Vec<u8> {
522        let mut image = create_minimal_pe();
523        image[0x86..0x88].copy_from_slice(&3u16.to_le_bytes());
524        let headers = 0x188;
525        for (i, name) in [b".linux\0\0", b".initrd\0"].iter().enumerate() {
526            let offset = headers + 40 * (i + 1);
527            image[offset..offset + 8].copy_from_slice(*name);
528            image[offset + 8..offset + 12].copy_from_slice(&1u32.to_le_bytes());
529            image[offset + 16..offset + 20].copy_from_slice(&1u32.to_le_bytes());
530            image[offset + 20..offset + 24].copy_from_slice(&0x200u32.to_le_bytes());
531        }
532        image
533    }
534
535    #[test]
536    fn test_aboot_kernel_detection() -> Result<()> {
537        for (payload, encoding, image_type) in [
538            (
539                android_image(),
540                AbootEncoding::AndroidV2,
541                ContainerImageType::Aboot,
542            ),
543            (
544                aboot_uki(),
545                AbootEncoding::Uki,
546                ContainerImageType::AbootEfi,
547            ),
548        ] {
549            let root = cap_tempfile::tempdir(cap_std::ambient_authority())?;
550            root.create_dir("boot")?;
551            root.write("boot/aboot-6.12.img", payload)?;
552            let kernel = find_kernel(&root, None)?.unwrap();
553            assert_eq!(kernel.kernel.version, "6.12");
554            assert_eq!(kernel.k_type.image_type(), image_type);
555            assert!(kernel.kernel.unified);
556            assert!(kernel.k_type.is_unified());
557            assert!(
558                kernel
559                    .k_type
560                    .cmdline()
561                    .unwrap()
562                    .to_string()
563                    .contains("quiet")
564            );
565            let KernelType::Aboot {
566                path,
567                encoding: actual,
568                ..
569            } = kernel.k_type
570            else {
571                panic!()
572            };
573            assert_eq!(path, "boot/aboot-6.12.img");
574            assert_eq!(actual, encoding);
575            root.write("boot/aboot-6.13.img", android_image())?;
576            assert!(find_kernel(&root, None).is_err());
577        }
578        Ok(())
579    }
580
581    #[test]
582    fn test_aboot_detection_validation() -> Result<()> {
583        for (files, valid) in [
584            (vec![("boot/aboot-6.12.img", android_image())], true),
585            (
586                vec![
587                    ("boot/aboot-6.12.img", android_image()),
588                    ("boot/EFI/Linux/slot.addon.efi", create_minimal_pe()),
589                ],
590                true,
591            ),
592            (
593                vec![
594                    ("boot/aboot-6.12.img", android_image()),
595                    ("boot/loader/entries/example.conf", vec![0]),
596                ],
597                false,
598            ),
599            (vec![("boot/aboot-6.12.img", b"ANDROID!".to_vec())], false),
600            (vec![("boot/aboot-.img", android_image())], false),
601            (vec![("boot/vbmeta-6.12.img", vec![0])], false),
602            (
603                vec![
604                    ("boot/aboot-6.12.img", aboot_uki()),
605                    ("boot/vbmeta-6.12.img", vec![0]),
606                ],
607                false,
608            ),
609            (
610                vec![
611                    ("boot/aboot-6.12.img", android_image()),
612                    ("usr/lib/modules/6.12/vmlinuz", vec![0]),
613                ],
614                false,
615            ),
616        ] {
617            let root = cap_tempfile::tempdir(cap_std::ambient_authority())?;
618            for (path, contents) in files {
619                let path = std::path::Path::new(path);
620                root.create_dir_all(path.parent().unwrap())?;
621                root.write(path, contents)?;
622            }
623            assert_eq!(find_kernel(&root, None).is_ok(), valid);
624        }
625        let root = cap_tempfile::tempdir(cap_std::ambient_authority())?;
626        root.create_dir_all("usr/lib/modules/6.12")?;
627        root.write("usr/lib/modules/6.12/vmlinuz", b"kernel")?;
628        root.write("usr/lib/modules/6.12/aboot.img", b"legacy")?;
629        assert!(matches!(
630            find_kernel(&root, None)?.unwrap().k_type,
631            KernelType::Vmlinuz { .. }
632        ));
633        Ok(())
634    }
635
636    #[test]
637    fn test_find_kernel_none() -> Result<()> {
638        let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?;
639        assert!(find_kernel(&tempdir, None)?.is_none());
640        Ok(())
641    }
642
643    #[test]
644    fn test_find_kernel_traditional() -> Result<()> {
645        let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?;
646        tempdir.create_dir_all("usr/lib/modules/6.12.0-100.fc41.x86_64")?;
647        tempdir.atomic_write(
648            "usr/lib/modules/6.12.0-100.fc41.x86_64/vmlinuz",
649            b"fake kernel",
650        )?;
651
652        let kernel_internal = find_kernel(&tempdir, None)?.expect("should find kernel");
653        assert_eq!(kernel_internal.kernel.version, "6.12.0-100.fc41.x86_64");
654        assert!(!kernel_internal.kernel.unified);
655        assert!(!kernel_internal.k_type.is_unified());
656        assert!(kernel_internal.k_type.cmdline().is_none());
657        match &kernel_internal.k_type {
658            KernelType::Vmlinuz { path, initramfs } => {
659                assert_eq!(
660                    path.as_str(),
661                    "usr/lib/modules/6.12.0-100.fc41.x86_64/vmlinuz"
662                );
663                assert_eq!(
664                    initramfs.as_str(),
665                    "usr/lib/modules/6.12.0-100.fc41.x86_64/initramfs.img"
666                );
667            }
668            _ => panic!("Expected Vmlinuz"),
669        }
670        Ok(())
671    }
672
673    #[test]
674    fn test_find_kernel_uki() -> Result<()> {
675        let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?;
676        tempdir.create_dir_all("boot/EFI/Linux")?;
677        tempdir.atomic_write("boot/EFI/Linux/fedora-6.12.0.efi", &create_minimal_pe())?;
678
679        let kernel_internal = find_kernel(&tempdir, None)?.expect("should find kernel");
680        assert_eq!(kernel_internal.kernel.version, "fedora-6.12.0");
681        assert!(kernel_internal.kernel.unified);
682        assert!(kernel_internal.k_type.is_unified());
683        match &kernel_internal.k_type {
684            KernelType::Uki { path, .. } => {
685                assert_eq!(path.as_str(), "boot/EFI/Linux/fedora-6.12.0.efi");
686            }
687            _ => panic!("Expected Uki"),
688        }
689        Ok(())
690    }
691
692    #[test]
693    fn test_find_kernel_uki_takes_precedence() -> Result<()> {
694        let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?;
695        // Both traditional and UKI exist
696        tempdir.create_dir_all("usr/lib/modules/6.12.0-100.fc41.x86_64")?;
697        tempdir.atomic_write(
698            "usr/lib/modules/6.12.0-100.fc41.x86_64/vmlinuz",
699            b"fake kernel",
700        )?;
701        tempdir.create_dir_all("boot/EFI/Linux")?;
702        tempdir.atomic_write("boot/EFI/Linux/fedora-6.12.0.efi", &create_minimal_pe())?;
703
704        let kernel_internal = find_kernel(&tempdir, None)?.expect("should find kernel");
705        // UKI should take precedence
706        assert_eq!(kernel_internal.kernel.version, "fedora-6.12.0");
707        assert!(kernel_internal.kernel.unified);
708        Ok(())
709    }
710
711    #[test]
712    fn test_split_kernel() -> Result<()> {
713        const KVER: &str = "6.12.0-100.fc41.x86_64";
714        let moddir = format!("usr/lib/modules/{KVER}");
715        // Userspace metadata that distributions also put here; it must stay.
716        const STAYS: &[&str] = &["modules.dep", "config", "System.map"];
717        // Companion files are optional: try none, each one alone, and all of them.
718        let cases = std::iter::once(&[][..])
719            .chain(KERNEL_COMPANION_FILES.chunks(1))
720            .chain(std::iter::once(KERNEL_COMPANION_FILES));
721        for present in cases {
722            let root = cap_tempfile::tempdir(cap_std::ambient_authority())?;
723            let output = cap_tempfile::tempdir(cap_std::ambient_authority())?;
724            root.create_dir_all(&moddir)?;
725            root.atomic_write(format!("{moddir}/vmlinuz"), b"kernel")?;
726            root.atomic_write(format!("{moddir}/initramfs.img"), b"initramfs")?;
727            for name in STAYS.iter().chain(present) {
728                root.atomic_write(format!("{moddir}/{name}"), name)?;
729            }
730
731            assert_eq!(split_kernel(&root, &output)?, KVER);
732
733            let mut remaining: Vec<_> = root
734                .read_dir(&moddir)?
735                .map(|e| e.map(|e| e.file_name()))
736                .collect::<std::io::Result<_>>()?;
737            remaining.sort();
738            let mut expected = STAYS.to_vec();
739            expected.sort();
740            assert_eq!(remaining, expected, "{present:?}");
741
742            let dest = output.open_dir(KVER)?;
743            assert_eq!(dest.read("vmlinuz")?, b"kernel");
744            assert_eq!(dest.read("initramfs.img")?, b"initramfs");
745            for &name in KERNEL_COMPANION_FILES {
746                if present.contains(&name) {
747                    assert_eq!(dest.read(name)?, name.as_bytes(), "{name}");
748                } else {
749                    assert!(!dest.try_exists(name)?, "{name}");
750                }
751            }
752        }
753        Ok(())
754    }
755
756    #[test]
757    fn test_split_kernel_uki() -> Result<()> {
758        let root = cap_tempfile::tempdir(cap_std::ambient_authority())?;
759        let output = cap_tempfile::tempdir(cap_std::ambient_authority())?;
760        root.create_dir_all("boot/EFI/Linux")?;
761        root.atomic_write("boot/EFI/Linux/fedora-6.12.0.efi", &create_minimal_pe())?;
762        assert!(split_kernel(&root, &output).is_err());
763        // An empty rootfs has no kernel at all
764        assert!(split_kernel(&output, &root).is_err());
765        Ok(())
766    }
767
768    #[test]
769    fn test_find_uki_path_sorted() -> Result<()> {
770        let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?;
771        tempdir.create_dir_all("boot/EFI/Linux")?;
772        tempdir.atomic_write("boot/EFI/Linux/zzz.efi", &create_minimal_pe())?;
773        tempdir.atomic_write("boot/EFI/Linux/aaa.efi", &create_minimal_pe())?;
774        tempdir.atomic_write("boot/EFI/Linux/mmm.efi", &create_minimal_pe())?;
775
776        // Should return first in sorted order
777        let path = find_uki_path(&tempdir)?.expect("should find uki");
778        assert_eq!(path.as_str(), "boot/EFI/Linux/aaa.efi");
779        Ok(())
780    }
781
782    #[test]
783    fn test_find_kernel_with_addons() -> Result<()> {
784        for (has_uki, has_vmlinuz, has_cmdline, expected_unified) in [
785            (true, false, true, Some(true)),
786            (false, false, false, None),
787            (false, true, false, Some(false)),
788            (true, false, false, Some(true)),
789        ] {
790            let root = cap_tempfile::tempdir(cap_std::ambient_authority())?;
791            root.create_dir_all("boot/EFI/Linux")?;
792            root.write("boot/EFI/Linux/000.addon.efi", create_minimal_pe())?;
793            if has_uki {
794                let mut uki = create_minimal_pe();
795                if !has_cmdline {
796                    const SECTION_HEADER_OFFSET: usize = 0x188;
797                    uki[SECTION_HEADER_OFFSET..SECTION_HEADER_OFFSET + 8]
798                        .copy_from_slice(b".linux\0\0");
799                }
800                root.write("boot/EFI/Linux/kernel.efi", uki)?;
801            }
802            if has_vmlinuz {
803                root.create_dir_all("usr/lib/modules/6.12")?;
804                root.write("usr/lib/modules/6.12/vmlinuz", b"kernel")?;
805            }
806
807            let kernel = find_kernel(&root, None)?;
808            assert_eq!(kernel.as_ref().map(|k| k.kernel.unified), expected_unified);
809            if let Some(kernel) = kernel {
810                assert_eq!(
811                    kernel.kernel.version,
812                    if has_uki { "kernel" } else { "6.12" }
813                );
814                if let KernelType::Uki { path, cmdline } = kernel.k_type {
815                    assert_eq!(path, "boot/EFI/Linux/kernel.efi");
816                    assert_eq!(
817                        cmdline.as_ref().map(ToString::to_string).as_deref(),
818                        has_cmdline.then_some("quiet splash").or_else(|| Some(""))
819                    );
820                } else {
821                    let KernelType::Vmlinuz { path, .. } = kernel.k_type else {
822                        panic!("Expected vmlinuz");
823                    };
824                    assert_eq!(path, "usr/lib/modules/6.12/vmlinuz");
825                }
826            }
827        }
828        Ok(())
829    }
830}