composefs_boot/
uki.rs

1//! Unified Kernel Image (UKI) parsing and metadata extraction.
2//!
3//! This module provides functionality to parse PE (Portable Executable) format UKI files
4//! and extract embedded sections like .osrel and .cmdline. It implements the Boot Loader
5//! Specification Type 2 requirements for UKI boot entries, including extraction of boot
6//! labels from os-release information embedded in the UKI binary.
7
8use thiserror::Error;
9use zerocopy::{
10    little_endian::{U16, U32},
11    FromBytes, Immutable, KnownLayout,
12};
13
14use crate::os_release::OsReleaseInfo;
15
16// https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
17#[derive(Debug, FromBytes, Immutable, KnownLayout)]
18#[cfg_attr(test, derive(zerocopy::IntoBytes, Default))]
19#[repr(C)]
20struct DosStub {
21    _unused1: [u8; 0x20],
22    _unused2: [u8; 0x1c],
23    pe_offset: U32,
24}
25
26#[derive(Debug, FromBytes, Immutable, KnownLayout)]
27#[cfg_attr(test, derive(zerocopy::IntoBytes, Default))]
28#[repr(C)]
29struct CoffFileHeader {
30    machine: U16,
31    number_of_sections: U16,
32    time_date_stamp: U32,
33    pointer_to_symbol_table: U32,
34    number_of_symbols: U32,
35    size_of_optional_header: U16,
36    characteristics: U16,
37}
38
39#[derive(Debug, FromBytes, Immutable, KnownLayout)]
40#[cfg_attr(test, derive(zerocopy::IntoBytes, Default))]
41#[repr(C)]
42struct PeHeader {
43    pe_magic: [u8; 4], // P E \0 \0
44    coff_file_header: CoffFileHeader,
45}
46const PE_MAGIC: [u8; 4] = *b"PE\0\0";
47
48#[derive(Debug, FromBytes, Immutable, KnownLayout)]
49#[cfg_attr(test, derive(zerocopy::IntoBytes, Default))]
50#[repr(C)]
51struct SectionHeader {
52    name: [u8; 8],
53    virtual_size: U32,
54    virtual_address: U32,
55    size_of_raw_data: U32,
56    pointer_to_raw_data: U32,
57    pointer_to_relocations: U32,
58    pointer_to_line_numbers: U32,
59    number_of_relocations: U16,
60    number_of_line_numbers: U16,
61    characteristics: U32,
62}
63
64/// Errors that can occur when parsing UKI files.
65#[derive(Debug, Error, PartialEq)]
66pub enum UkiError {
67    /// The file is not a valid Portable Executable (PE/EFI) format
68    #[error("UKI is not valid EFI executable")]
69    PortableExecutableError,
70    /// A required PE section is missing from the UKI
71    #[error("UKI doesn't contain a '{0}' section")]
72    MissingSection(&'static str),
73    /// A PE section contains invalid UTF-8
74    #[error("UKI section '{0}' is not UTF-8")]
75    UnicodeError(&'static str),
76    /// The .osrel section lacks name information
77    #[error("No name information found in .osrel section")]
78    NoName,
79}
80
81/// Extracts a text section from a UKI PE file by name and validates it as UTF-8.
82///
83/// This is a convenience wrapper around [`get_section`] that additionally validates
84/// the section contents as valid UTF-8 text.
85///
86/// # Arguments
87///
88/// * `image` - The complete UKI image as a byte slice
89/// * `section_name` - Name of the PE section to extract (e.g., ".osrel", ".cmdline")
90///
91/// # Returns
92///
93/// * `Ok(&str)` - If the section is found and contains valid UTF-8
94/// * `Err(UkiError)` - If the PE is invalid, section is missing or the section contains invalid UTF-8
95pub fn get_text_section<'a>(
96    image: &'a [u8],
97    section_name: &'static str,
98) -> Result<&'a str, UkiError> {
99    let bytes = get_section(image, section_name).ok_or(UkiError::PortableExecutableError)??;
100    std::str::from_utf8(bytes).or(Err(UkiError::UnicodeError(section_name)))
101}
102
103/// Extracts a raw section from a UKI PE file by name.
104///
105/// Parses the PE file format to locate and extract the raw bytes of a named
106/// section (e.g., ".osrel", ".cmdline"). This function returns the section
107/// contents as raw bytes without any UTF-8 validation.
108///
109/// # Arguments
110///
111/// * `image` - The complete UKI image as a byte slice
112/// * `section_name` - Name of the PE section to extract (must be ≤ 8 characters)
113///
114/// # Returns
115///
116/// * `None` - If the PE format is invalid or cannot be parsed
117/// * `Some(Ok(&[u8]))` - If the section is found, containing the raw section data
118/// * `Some(Err(UkiError::MissingSection))` - If the section is not found in the PE file
119///
120/// # Implementation Notes
121// We use `None` as a way to say `Err(UkiError::PortableExecutableError)` for two reasons:
122//   - .get(..) returns Option<> and using `?` with that is extremely convenient
123//   - the error types returned from FromBytes can't be used with `?` because they try to return a
124//     reference to the data, which causes problems with lifetime rules
125//   - it saves us from having to type Err(UkiError::PortableExecutableError) everywhere
126pub fn get_section<'a>(
127    image: &'a [u8],
128    section_name: &'static str,
129) -> Option<Result<&'a [u8], UkiError>> {
130    // Turn the section_name ".osrel" into a section_key b".osrel\0\0".
131    // This will panic if section_name.len() > 8, which is what we want.
132    let mut section_key = [0u8; 8];
133    section_key[..section_name.len()].copy_from_slice(section_name.as_bytes());
134
135    // Skip the DOS stub
136    let (dos_stub, ..) = DosStub::ref_from_prefix(image).ok()?;
137    let rest = image.get(dos_stub.pe_offset.get() as usize..)?;
138
139    // Get the PE header
140    let (pe_header, rest) = PeHeader::ref_from_prefix(rest).ok()?;
141    if pe_header.pe_magic != PE_MAGIC {
142        return None;
143    }
144
145    // Skip the optional header
146    let rest = rest.get(pe_header.coff_file_header.size_of_optional_header.get() as usize..)?;
147
148    // Try to load the section headers
149    let n_sections = pe_header.coff_file_header.number_of_sections.get() as usize;
150    let (sections, ..) = <[SectionHeader]>::ref_from_prefix_with_elems(rest, n_sections).ok()?;
151
152    for section in sections {
153        if section.name == section_key {
154            let bytes = image
155                .get(section.pointer_to_raw_data.get() as usize..)?
156                .get(..section.virtual_size.get() as usize)?;
157            return Some(Ok(bytes));
158        }
159    }
160
161    Some(Err(UkiError::MissingSection(section_name)))
162}
163
164/// Gets an appropriate label for display in the boot menu for the given UKI image, according to
165/// the "Type #2 EFI Unified Kernel Images" section in the Boot Loader Specification.  This will be
166/// based on the "PRETTY_NAME" and "VERSION_ID" fields found in the os-release file (falling back
167/// to "ID" and/or "VERSION" if they are not present).
168///
169/// For more information, see:
170///  - <https://uapi-group.org/specifications/specs/boot_loader_specification/>
171///  - <https://www.freedesktop.org/software/systemd/man/latest/os-release.html>
172///
173/// # Arguments
174///
175///  * `image`: the complete UKI image as a byte slice
176///
177/// # Return value
178///
179/// If we could successfully parse the provided UKI as a Portable Executable file and find an
180/// ".osrel" section in it, return a string to use as the boootloader entry.  If we were unable to
181/// find any meaningful content in the os-release information this will be "Unknown 0".
182///
183/// If we couldn't parse the PE file or couldn't find an ".osrel" section then an error will be
184/// returned.
185pub fn get_boot_label(image: &[u8]) -> Result<String, UkiError> {
186    let osrel = get_text_section(image, ".osrel")?;
187    OsReleaseInfo::parse(osrel)
188        .get_boot_label()
189        .ok_or(UkiError::NoName)
190}
191
192/// Gets the contents of the .cmdline section of a UKI.
193pub fn get_cmdline(image: &[u8]) -> Result<&str, UkiError> {
194    get_text_section(image, ".cmdline")
195}
196
197#[cfg(test)]
198mod test {
199    use core::mem::size_of;
200
201    use similar_asserts::assert_eq;
202    use zerocopy::IntoBytes;
203
204    use super::*;
205
206    fn data_offset(n_sections: usize) -> usize {
207        size_of::<DosStub>() + size_of::<PeHeader>() + n_sections * size_of::<SectionHeader>()
208    }
209
210    fn peify(optional: &[u8], sections: &[SectionHeader], rest: &[&[u8]]) -> Vec<u8> {
211        let mut output = vec![];
212        output.extend_from_slice(
213            DosStub {
214                pe_offset: U32::new(size_of::<DosStub>() as u32),
215                ..Default::default()
216            }
217            .as_bytes(),
218        );
219        output.extend_from_slice(
220            PeHeader {
221                pe_magic: PE_MAGIC,
222                coff_file_header: CoffFileHeader {
223                    number_of_sections: U16::new(sections.len() as u16),
224                    size_of_optional_header: U16::new(optional.len() as u16),
225                    ..Default::default()
226                },
227            }
228            .as_bytes(),
229        );
230        output.extend_from_slice(optional);
231        for section in sections {
232            output.extend_from_slice(section.as_bytes());
233        }
234        assert_eq!(output.len(), data_offset(sections.len()));
235        for data in rest {
236            output.extend_from_slice(data);
237        }
238
239        output
240    }
241
242    fn ukify(osrel: &[u8]) -> Vec<u8> {
243        let osrel_offset = data_offset(1);
244        peify(
245            b"",
246            &[SectionHeader {
247                name: *b".osrel\0\0",
248                virtual_size: U32::new(osrel.len() as u32),
249                pointer_to_raw_data: U32::new(osrel_offset as u32),
250                ..Default::default()
251            }],
252            &[osrel],
253        )
254    }
255
256    #[test]
257    fn test_simple() {
258        let uki = ukify(
259            br#"
260PRETTY_NAME='prettyOS'
261VERSION_ID="Rocky Racoon"
262VERSION=42
263ID=pretty-os
264"#,
265        );
266
267        assert_eq!(
268            get_boot_label(uki.as_ref()).unwrap(),
269            "prettyOS Rocky Racoon"
270        );
271    }
272
273    #[test]
274    fn test_bad_pe() {
275        fn pe_err(img: &[u8]) {
276            assert_eq!(get_boot_label(img), Err(UkiError::PortableExecutableError));
277        }
278        fn no_sec(img: &[u8]) {
279            assert_eq!(get_boot_label(img), Err(UkiError::MissingSection(".osrel")));
280        }
281
282        pe_err(b"");
283        pe_err(b"This is definitely not an EFI executable, but it's big enough to pass the first step...");
284
285        pe_err(
286            DosStub {
287                pe_offset: U32::new(0),
288                ..Default::default()
289            }
290            .as_bytes(),
291        );
292
293        // no section headers
294        no_sec(&peify(b"", &[], &[]));
295        // no .osrel section
296        no_sec(&peify(
297            b"",
298            &[
299                SectionHeader {
300                    name: *b".text\0\0\0",
301                    ..Default::default()
302                },
303                SectionHeader {
304                    name: *b".rodata\0",
305                    ..Default::default()
306                },
307            ],
308            &[],
309        ));
310
311        // .osrel points to invalid offset
312        pe_err(&peify(
313            b"",
314            &[SectionHeader {
315                name: *b".osrel\0\0",
316                pointer_to_raw_data: U32::new(1234567),
317                ..Default::default()
318            }],
319            &[],
320        ));
321    }
322}