Skip to main content

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 std::io::{Read, Seek, SeekFrom};
9use thiserror::Error;
10use zerocopy::{
11    FromBytes, Immutable, KnownLayout,
12    little_endian::{U16, U32},
13};
14
15use crate::os_release::OsReleaseInfo;
16
17// https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
18#[derive(Debug, FromBytes, Immutable, KnownLayout)]
19#[cfg_attr(test, derive(zerocopy::IntoBytes, Default))]
20#[repr(C)]
21struct DosStub {
22    _unused1: [u8; 0x20],
23    _unused2: [u8; 0x1c],
24    pe_offset: U32,
25}
26
27#[derive(Debug, FromBytes, Immutable, KnownLayout)]
28#[cfg_attr(test, derive(zerocopy::IntoBytes, Default))]
29#[repr(C)]
30struct CoffFileHeader {
31    machine: U16,
32    number_of_sections: U16,
33    time_date_stamp: U32,
34    pointer_to_symbol_table: U32,
35    number_of_symbols: U32,
36    size_of_optional_header: U16,
37    characteristics: U16,
38}
39
40#[derive(Debug, FromBytes, Immutable, KnownLayout)]
41#[cfg_attr(test, derive(zerocopy::IntoBytes, Default))]
42#[repr(C)]
43struct PeHeader {
44    pe_magic: [u8; 4], // P E \0 \0
45    coff_file_header: CoffFileHeader,
46}
47const PE_MAGIC: [u8; 4] = *b"PE\0\0";
48
49#[derive(Debug, FromBytes, Immutable, KnownLayout)]
50#[cfg_attr(test, derive(zerocopy::IntoBytes, Default))]
51#[repr(C)]
52struct SectionHeader {
53    name: [u8; 8],
54    virtual_size: U32,
55    virtual_address: U32,
56    size_of_raw_data: U32,
57    pointer_to_raw_data: U32,
58    pointer_to_relocations: U32,
59    pointer_to_line_numbers: U32,
60    number_of_relocations: U16,
61    number_of_line_numbers: U16,
62    characteristics: U32,
63}
64
65/// Errors that can occur when parsing UKI files.
66#[derive(Debug, Error)]
67pub enum UkiError {
68    /// IO Error while reading or seeking
69    #[error("IO Error")]
70    Io(#[from] std::io::Error),
71    /// The file is not a valid Portable Executable (PE/EFI) format
72    #[error("UKI is not valid EFI executable")]
73    PortableExecutableError,
74    /// A required PE section is missing from the UKI
75    #[error("UKI doesn't contain a '{0}' section")]
76    MissingSection(String),
77    /// A PE section contains invalid UTF-8
78    #[error("UKI section '{0}' is not UTF-8")]
79    UnicodeError(String),
80    /// The .osrel section lacks name information
81    #[error("No name information found in .osrel section")]
82    NoName,
83}
84
85/// Extracts a text section from a UKI PE file by name and validates it as UTF-8.
86///
87/// This is a convenience wrapper around [`get_section`] that additionally validates
88/// the section contents as valid UTF-8 text.
89///
90/// # Arguments
91///
92/// * `image` - The complete UKI image as a byte slice
93/// * `section_name` - Name of the PE section to extract (e.g., ".osrel", ".cmdline")
94///
95/// # Returns
96///
97/// * `Ok(&str)` - If the section is found and contains valid UTF-8
98/// * `Err(UkiError)` - If the PE is invalid, section is missing or the section contains invalid UTF-8
99pub fn get_text_section<'a>(
100    image: &'a [u8],
101    section_name: &'static str,
102) -> Result<&'a str, UkiError> {
103    let bytes = get_section(image, section_name).ok_or(UkiError::PortableExecutableError)??;
104    std::str::from_utf8(bytes).or(Err(UkiError::UnicodeError(section_name.into())))
105}
106
107/// Buffered version of [`get_text_section`].
108///
109/// See [`get_text_section`] for details. This version works with any [`Read`] + [`Seek`]
110/// source instead of requiring the entire image in memory.
111pub fn get_text_section_buffered<'a, R: Read + Seek>(
112    image: &'a mut R,
113    section_name: &'a str,
114) -> Result<String, UkiError> {
115    let bytes = get_section_buffered(image, section_name)?;
116    String::from_utf8(bytes).or(Err(UkiError::UnicodeError(section_name.into())))
117}
118
119/// Extracts a raw section from a UKI PE file by name.
120///
121/// Parses the PE file format to locate and extract the raw bytes of a named
122/// section (e.g., ".osrel", ".cmdline"). This function returns the section
123/// contents as raw bytes without any UTF-8 validation.
124///
125/// # Arguments
126///
127/// * `image` - The complete UKI image as a byte slice
128/// * `section_name` - Name of the PE section to extract (must be ≤ 8 characters)
129///
130/// # Returns
131///
132/// * `None` - If the PE format is invalid or cannot be parsed
133/// * `Some(Ok(&[u8]))` - If the section is found, containing the raw section data
134/// * `Some(Err(UkiError::MissingSection))` - If the section is not found in the PE file
135///
136/// # Implementation Notes
137// We use `None` as a way to say `Err(UkiError::PortableExecutableError)` for two reasons:
138//   - .get(..) returns Option<> and using `?` with that is extremely convenient
139//   - the error types returned from FromBytes can't be used with `?` because they try to return a
140//     reference to the data, which causes problems with lifetime rules
141//   - it saves us from having to type Err(UkiError::PortableExecutableError) everywhere
142pub fn get_section<'a>(
143    image: &'a [u8],
144    section_name: &'static str,
145) -> Option<Result<&'a [u8], UkiError>> {
146    // Turn the section_name ".osrel" into a section_key b".osrel\0\0".
147    // This will panic if section_name.len() > 8, which is what we want.
148    let mut section_key = [0u8; 8];
149    section_key[..section_name.len()].copy_from_slice(section_name.as_bytes());
150
151    // Skip the DOS stub
152    let (dos_stub, ..) = DosStub::ref_from_prefix(image).ok()?;
153    let rest = image.get(dos_stub.pe_offset.get() as usize..)?;
154
155    // Get the PE header
156    let (pe_header, rest) = PeHeader::ref_from_prefix(rest).ok()?;
157    if pe_header.pe_magic != PE_MAGIC {
158        return None;
159    }
160
161    // Skip the optional header
162    let rest = rest.get(pe_header.coff_file_header.size_of_optional_header.get() as usize..)?;
163
164    // Try to load the section headers
165    let n_sections = pe_header.coff_file_header.number_of_sections.get() as usize;
166    let (sections, ..) = <[SectionHeader]>::ref_from_prefix_with_elems(rest, n_sections).ok()?;
167
168    for section in sections {
169        if section.name == section_key {
170            let bytes = image
171                .get(section.pointer_to_raw_data.get() as usize..)?
172                .get(..section.virtual_size.get() as usize)?;
173            return Some(Ok(bytes));
174        }
175    }
176
177    Some(Err(UkiError::MissingSection(section_name.into())))
178}
179
180/// Buffered version of [`get_section`].
181///
182/// See [`get_section`] for details. This version works with any [`Read`] + [`Seek`]
183/// source and returns owned data instead of borrowed slices.
184pub fn get_section_buffered<R: Read + Seek>(
185    image: &mut R,
186    section_name: &str,
187) -> Result<Vec<u8>, UkiError> {
188    use std::io::Error as IOError;
189
190    // Turn the section_name ".osrel" into a section_key b".osrel\0\0".
191    // This will panic if section_name.len() > 8, which is what we want.
192    let mut section_key = [0u8; 8];
193    section_key[..section_name.len()].copy_from_slice(section_name.as_bytes());
194
195    // Skip the DOS stub
196    let mut buf: Vec<u8> = vec![0; std::mem::size_of::<DosStub>()];
197    image.read_exact(&mut buf)?;
198    let dos_stub =
199        DosStub::ref_from_bytes(&buf).map_err(|e| UkiError::Io(IOError::other(e.to_string())))?;
200    image.seek(SeekFrom::Start(dos_stub.pe_offset.get() as u64))?;
201
202    // Get the PE header
203    let mut buf: Vec<u8> = vec![0; std::mem::size_of::<PeHeader>()];
204    image.read_exact(&mut buf)?;
205    let pe_header =
206        PeHeader::ref_from_bytes(&buf).map_err(|e| UkiError::Io(IOError::other(e.to_string())))?;
207    if pe_header.pe_magic != PE_MAGIC {
208        return Err(UkiError::PortableExecutableError);
209    }
210
211    // Skip the optional header
212    image.seek(SeekFrom::Current(
213        pe_header.coff_file_header.size_of_optional_header.get() as i64,
214    ))?;
215
216    // Try to load the section headers
217    let n_sections = pe_header.coff_file_header.number_of_sections.get() as usize;
218    let mut sections = vec![0; std::mem::size_of::<SectionHeader>() * n_sections];
219    image.read_exact(&mut sections)?;
220    let sections = <[SectionHeader]>::ref_from_bytes_with_elems(&sections, n_sections)
221        .map_err(|e| UkiError::Io(IOError::other(e.to_string())))?;
222
223    for section in sections {
224        if section.name != section_key {
225            continue;
226        }
227
228        let mut buffer = vec![0; section.virtual_size.get() as usize];
229        image.seek(SeekFrom::Start(section.pointer_to_raw_data.get() as u64))?;
230        image.read_exact(&mut buffer)?;
231        return Ok(buffer);
232    }
233
234    Err(UkiError::MissingSection(section_name.to_string()))
235}
236
237/// Gets an appropriate label for display in the boot menu for the given UKI image, according to
238/// the "Type #2 EFI Unified Kernel Images" section in the Boot Loader Specification.  This will be
239/// based on the "PRETTY_NAME" and "VERSION_ID" fields found in the os-release file (falling back
240/// to "ID" and/or "VERSION" if they are not present).
241///
242/// For more information, see:
243///  - <https://uapi-group.org/specifications/specs/boot_loader_specification/>
244///  - <https://www.freedesktop.org/software/systemd/man/latest/os-release.html>
245///
246/// # Arguments
247///
248///  * `image`: the complete UKI image as a byte slice
249///
250/// # Return value
251///
252/// If we could successfully parse the provided UKI as a Portable Executable file and find an
253/// ".osrel" section in it, return a string to use as the boootloader entry.  If we were unable to
254/// find any meaningful content in the os-release information this will be "Unknown 0".
255///
256/// If we couldn't parse the PE file or couldn't find an ".osrel" section then an error will be
257/// returned.
258pub fn get_boot_label(image: &[u8]) -> Result<String, UkiError> {
259    let osrel = get_text_section(image, ".osrel")?;
260    OsReleaseInfo::parse(osrel)
261        .get_boot_label()
262        .ok_or(UkiError::NoName)
263}
264
265/// Buffered version of [`get_boot_label`].
266///
267/// See [`get_boot_label`] for details. This version works with any [`Read`] + [`Seek`] source.
268pub fn get_boot_label_buffered<R: Read + Seek>(image: &mut R) -> Result<String, UkiError> {
269    let osrel = get_text_section_buffered(image, ".osrel")?;
270    OsReleaseInfo::parse(&osrel)
271        .get_boot_label()
272        .ok_or(UkiError::NoName)
273}
274
275/// Gets the contents of the .cmdline section of a UKI.
276pub fn get_cmdline(image: &[u8]) -> Result<&str, UkiError> {
277    get_text_section(image, ".cmdline")
278}
279
280/// Buffered version of [`get_cmdline`]. See [`get_cmdline`] for details.
281pub fn get_cmdline_buffered<R: Read + Seek>(image: &mut R) -> Result<String, UkiError> {
282    get_text_section_buffered(image, ".cmdline")
283}
284
285#[cfg(test)]
286mod test {
287    use core::mem::size_of;
288
289    use similar_asserts::assert_eq;
290    use zerocopy::IntoBytes;
291
292    use super::*;
293
294    fn data_offset(n_sections: usize) -> usize {
295        size_of::<DosStub>() + size_of::<PeHeader>() + n_sections * size_of::<SectionHeader>()
296    }
297
298    fn peify(optional: &[u8], sections: &[SectionHeader], rest: &[&[u8]]) -> Vec<u8> {
299        let mut output = vec![];
300        output.extend_from_slice(
301            DosStub {
302                pe_offset: U32::new(size_of::<DosStub>() as u32),
303                ..Default::default()
304            }
305            .as_bytes(),
306        );
307        output.extend_from_slice(
308            PeHeader {
309                pe_magic: PE_MAGIC,
310                coff_file_header: CoffFileHeader {
311                    number_of_sections: U16::new(sections.len() as u16),
312                    size_of_optional_header: U16::new(optional.len() as u16),
313                    ..Default::default()
314                },
315            }
316            .as_bytes(),
317        );
318        output.extend_from_slice(optional);
319        for section in sections {
320            output.extend_from_slice(section.as_bytes());
321        }
322        assert_eq!(output.len(), data_offset(sections.len()));
323        for data in rest {
324            output.extend_from_slice(data);
325        }
326
327        output
328    }
329
330    fn ukify(osrel: &[u8]) -> Vec<u8> {
331        let osrel_offset = data_offset(1);
332        peify(
333            b"",
334            &[SectionHeader {
335                name: *b".osrel\0\0",
336                virtual_size: U32::new(osrel.len() as u32),
337                pointer_to_raw_data: U32::new(osrel_offset as u32),
338                ..Default::default()
339            }],
340            &[osrel],
341        )
342    }
343
344    #[test]
345    fn test_simple() {
346        let uki = ukify(
347            br#"
348PRETTY_NAME='prettyOS'
349VERSION_ID="Rocky Racoon"
350VERSION=42
351ID=pretty-os
352"#,
353        );
354
355        // Test slice-based functions
356        assert_eq!(
357            get_boot_label(uki.as_ref()).unwrap(),
358            "prettyOS Rocky Racoon"
359        );
360
361        // Test buffered functions produce same results
362        let mut cursor = std::io::Cursor::new(&uki);
363        assert_eq!(
364            get_boot_label_buffered(&mut cursor).unwrap(),
365            "prettyOS Rocky Racoon"
366        );
367    }
368
369    #[test]
370    fn test_bad_pe() {
371        fn pe_err(img: &[u8]) {
372            assert!(matches!(
373                get_boot_label(img),
374                Err(UkiError::PortableExecutableError)
375            ));
376        }
377        fn no_sec(img: &[u8]) {
378            assert!(matches!(
379                get_boot_label(img),
380                Err(UkiError::MissingSection(s)) if s == ".osrel"
381            ));
382
383            // Test buffered version
384            let mut cursor = std::io::Cursor::new(img);
385            assert!(matches!(
386                get_boot_label_buffered(&mut cursor),
387                Err(UkiError::MissingSection(s)) if s == ".osrel"
388            ));
389        }
390
391        pe_err(b"");
392        pe_err(b"This is definitely not an EFI executable, but it's big enough to pass the first step...");
393
394        pe_err(
395            DosStub {
396                pe_offset: U32::new(0),
397                ..Default::default()
398            }
399            .as_bytes(),
400        );
401
402        // no section headers
403        no_sec(&peify(b"", &[], &[]));
404        // no .osrel section
405        no_sec(&peify(
406            b"",
407            &[
408                SectionHeader {
409                    name: *b".text\0\0\0",
410                    ..Default::default()
411                },
412                SectionHeader {
413                    name: *b".rodata\0",
414                    ..Default::default()
415                },
416            ],
417            &[],
418        ));
419
420        // .osrel points to invalid offset
421        pe_err(&peify(
422            b"",
423            &[SectionHeader {
424                name: *b".osrel\0\0",
425                pointer_to_raw_data: U32::new(1234567),
426                ..Default::default()
427            }],
428            &[],
429        ));
430    }
431
432    #[test]
433    fn test_section_functions() {
434        let osrel_data = b"PRETTY_NAME='TestOS'\nVERSION_ID=1.0\n";
435        let cmdline_data = b"root=/dev/sda1 quiet";
436
437        let osrel_offset = data_offset(2);
438        let cmdline_offset = osrel_offset + osrel_data.len();
439
440        let uki = peify(
441            b"",
442            &[
443                SectionHeader {
444                    name: *b".osrel\0\0",
445                    virtual_size: U32::new(osrel_data.len() as u32),
446                    pointer_to_raw_data: U32::new(osrel_offset as u32),
447                    ..Default::default()
448                },
449                SectionHeader {
450                    name: *b".cmdline",
451                    virtual_size: U32::new(cmdline_data.len() as u32),
452                    pointer_to_raw_data: U32::new(cmdline_offset as u32),
453                    ..Default::default()
454                },
455            ],
456            &[osrel_data, cmdline_data],
457        );
458
459        // Test slice-based functions
460        let osrel_section = get_section(&uki, ".osrel").unwrap().unwrap();
461        assert_eq!(osrel_section, osrel_data);
462
463        let cmdline_section = get_section(&uki, ".cmdline").unwrap().unwrap();
464        assert_eq!(cmdline_section, cmdline_data);
465
466        let osrel_text = get_text_section(&uki, ".osrel").unwrap();
467        assert_eq!(osrel_text, "PRETTY_NAME='TestOS'\nVERSION_ID=1.0\n");
468
469        let cmdline_text = get_cmdline(&uki).unwrap();
470        assert_eq!(cmdline_text, "root=/dev/sda1 quiet");
471
472        // Test buffered functions produce same results
473        let mut cursor = std::io::Cursor::new(&uki);
474        let osrel_section_buf = get_section_buffered(&mut cursor, ".osrel").unwrap();
475        assert_eq!(osrel_section_buf, osrel_data);
476
477        cursor.set_position(0);
478        let cmdline_section_buf = get_section_buffered(&mut cursor, ".cmdline").unwrap();
479        assert_eq!(cmdline_section_buf, cmdline_data);
480
481        cursor.set_position(0);
482        let osrel_text_buf = get_text_section_buffered(&mut cursor, ".osrel").unwrap();
483        assert_eq!(osrel_text_buf, "PRETTY_NAME='TestOS'\nVERSION_ID=1.0\n");
484
485        cursor.set_position(0);
486        let cmdline_text_buf = get_cmdline_buffered(&mut cursor).unwrap();
487        assert_eq!(cmdline_text_buf, "root=/dev/sda1 quiet");
488
489        // Test missing section
490        cursor.set_position(0);
491        let missing_result = get_section_buffered(&mut cursor, ".missing");
492        assert!(matches!(missing_result, Err(UkiError::MissingSection(s)) if s == ".missing"));
493    }
494
495    #[test]
496    fn test_invalid_utf8() {
497        let invalid_utf8 = b"\xff\xfe\xfd";
498        let osrel_offset = data_offset(1);
499
500        let uki = peify(
501            b"",
502            &[SectionHeader {
503                name: *b".osrel\0\0",
504                virtual_size: U32::new(invalid_utf8.len() as u32),
505                pointer_to_raw_data: U32::new(osrel_offset as u32),
506                ..Default::default()
507            }],
508            &[invalid_utf8],
509        );
510
511        // Test slice-based function
512        let result = get_text_section(&uki, ".osrel");
513        assert!(matches!(result, Err(UkiError::UnicodeError(s)) if s == ".osrel"));
514
515        // Test buffered function gives same error
516        let mut cursor = std::io::Cursor::new(&uki);
517        let result_buf = get_text_section_buffered(&mut cursor, ".osrel");
518        assert!(matches!(result_buf, Err(UkiError::UnicodeError(s)) if s == ".osrel"));
519    }
520}