Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-build-thing/src/bios_patch.rs

//! **The BIOS-stage patch table** — the contract between a legacy-BIOS
//! El-Torito boot image (`korp-boot-bios`, the 16-bit real-mode bzImage
//! loader) and the ISO writer that masters the medium it boots from.
//!
//! # Why a patch table
//!
//! A 16-bit loader that walked ISO-9660 (path tables, directory records,
//! both-endian fields) would be several hundred instructions of hand-emitted
//! real-mode code doing, badly, what [`crate::iso_writer::IsoWriter`] already
//! knows with certainty: **where every file landed**. So the loader does no
//! filesystem parsing at all. It carries a small table at a fixed offset —
//! the 2048-byte LBAs and byte lengths of the kernel and the initramfs on the
//! volume, plus the kernel command line — and the writer FILLS IT IN at
//! mastering time, after placement, before the file data is serialized. This
//! is the same trick isolinux's boot-info table plays, with the difference
//! that the whole payload list is patched, not just the loader's own LBA.
//!
//! The format lives HERE (the writer's crate) rather than in the loader's,
//! because the writer must be able to fill a table without linking an x86
//! assembler; the loader crate depends on this one for the constants, so the
//! two halves cannot drift.
//!
//! # Layout (little-endian, `TABLE_LEN` bytes)
//!
//! ```text
//!   +0x00  MAGIC            8 B   "KORPBIOS" — how the writer FINDS the table
//!   +0x08  kernel_lba       u32   2048-byte LBA of the bzImage on the volume
//!   +0x0C  kernel_len       u32   its byte length
//!   +0x10  initrd_lba       u32   2048-byte LBA of the initramfs (0 = none)
//!   +0x14  initrd_len       u32   its byte length (0 = none)
//!   +0x18  cmdline_cap      u16   capacity of the cmdline field, NUL included
//!   +0x1A  sector_shift     u8    log2(sector bytes): 11 = 2048 (CD), 9 = 512 (disk)
//!   +0x1B  reserved         5 B   zero
//!   +0x20  cmdline          cmdline_cap B, NUL-terminated ASCII
//! ```
//!
//! The emitter lays the MAGIC, the capacity and zeros; a table whose
//! `kernel_lba` is still 0 is UNPATCHED and the loader refuses to run it (an
//! unpatched stage on a medium is a mastering bug, and it must say so on the
//! console rather than read LBA 0).

use anyhow::{Result, bail};

/// The eight bytes the writer searches a staged BIOS stage for.
pub const MAGIC: [u8; 8] = *b"KORPBIOS";
/// Capacity of the command-line field, NUL terminator included. Linux 6.x
/// accepts 2047 bytes; a composed installer line is ~450 B; 1 KiB leaves
/// headroom without bloating the stage past a handful of 512-byte sectors.
pub const CMDLINE_CAP: usize = 1024;
/// Byte offset of `kernel_lba` inside the table.
pub const OFF_KERNEL_LBA: usize = 0x08;
/// Byte offset of `kernel_len` inside the table.
pub const OFF_KERNEL_LEN: usize = 0x0C;
/// Byte offset of `initrd_lba` inside the table.
pub const OFF_INITRD_LBA: usize = 0x10;
/// Byte offset of `initrd_len` inside the table.
pub const OFF_INITRD_LEN: usize = 0x14;
/// Byte offset of `cmdline_cap` (u16) inside the table.
pub const OFF_CMDLINE_CAP: usize = 0x18;
/// Byte offset of `sector_shift` (u8) inside the table: log2 of the byte size
/// of the sectors the LBAs count and INT 13h reads — [`SECTOR_SHIFT_CD`] on an
/// El-Torito medium, [`SECTOR_SHIFT_DISK`] on an installed hard disk. The
/// same stage serves both; only this byte and the LBAs differ.
pub const OFF_SECTOR_SHIFT: usize = 0x1A;
/// 2048-byte sectors: optical media, the El-Torito no-emulation boot drive.
pub const SECTOR_SHIFT_CD: u8 = 11;
/// 512-byte sectors: a hard disk the MBR chainloaded the stage from.
pub const SECTOR_SHIFT_DISK: u8 = 9;
/// Byte offset of the command line inside the table.
pub const OFF_CMDLINE: usize = 0x20;
/// **Bytes of boot code an MBR has room for** — 0..440, stopping before the
/// disk signature (440..444), the reserved word (444..446) and the partition
/// table (446..510). Spelled HERE because both halves need it and neither may
/// respell it: `korp_boot_bios::emit_mbr` fills the array, and
/// [`crate::iso_writer::IsoWriter::set_hybrid_mbr`] lays it at LBA 0 of a
/// hybrid medium.
pub const MBR_CODE_LEN: usize = 440;
/// Total table length in bytes.
pub const TABLE_LEN: usize = OFF_CMDLINE + CMDLINE_CAP;

/// What the writer fills in: where the payloads landed, and the line the
/// kernel boots with.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BiosPatchTable {
    /// LBA of the bzImage, in sectors of `1 << sector_shift` bytes.
    pub kernel_lba: u32,
    /// Byte length of the bzImage.
    pub kernel_len: u32,
    /// LBA of the initramfs (0 when there is none), same sector size.
    pub initrd_lba: u32,
    /// Byte length of the initramfs (0 when there is none).
    pub initrd_len: u32,
    /// The kernel command line (ASCII, no NUL; at most `CMDLINE_CAP - 1` bytes).
    pub cmdline: String,
    /// [`SECTOR_SHIFT_CD`] (11) or [`SECTOR_SHIFT_DISK`] (9).
    pub sector_shift: u8,
}

impl Default for BiosPatchTable {
    /// The medium shape: 2048-byte sectors — what every writer before the disk
    /// road assumed, so an unspecified table keeps meaning what it meant.
    fn default() -> Self {
        BiosPatchTable {
            kernel_lba: 0,
            kernel_len: 0,
            initrd_lba: 0,
            initrd_len: 0,
            cmdline: String::new(),
            sector_shift: SECTOR_SHIFT_CD,
        }
    }
}

/// The bytes an EMITTER lays at the table's offset: MAGIC, the capacity, the
/// CD sector shift, and zeros everywhere else — the unpatched shape.
pub fn blank_table() -> [u8; TABLE_LEN] {
    let mut t = [0u8; TABLE_LEN];
    t[..8].copy_from_slice(&MAGIC);
    t[OFF_CMDLINE_CAP..OFF_CMDLINE_CAP + 2].copy_from_slice(&(CMDLINE_CAP as u16).to_le_bytes());
    t[OFF_SECTOR_SHIFT] = SECTOR_SHIFT_CD;
    t
}

/// Locate the table in a stage image by its MAGIC. Exactly one occurrence is
/// required: none means the image is not a patchable stage, two means the
/// magic leaked into code or a string and the writer cannot tell which is
/// the table.
pub fn find_table(image: &[u8]) -> Result<usize> {
    let hits: Vec<usize> = image
        .windows(MAGIC.len())
        .enumerate()
        .filter(|(_, w)| *w == MAGIC)
        .map(|(i, _)| i)
        .collect();
    match hits.as_slice() {
        [off] => {
            if off + TABLE_LEN > image.len() {
                bail!(
                    "bios stage: patch table at offset {off} needs {TABLE_LEN} bytes but the image is {} bytes",
                    image.len()
                );
            }
            Ok(*off)
        }
        [] => bail!(
            "bios stage: no patch table (MAGIC {:?} absent) — the image is not a patchable BIOS stage",
            std::str::from_utf8(&MAGIC).unwrap_or("?")
        ),
        many => bail!("bios stage: patch-table MAGIC occurs {} times (at {many:?}); refusing to guess", many.len()),
    }
}

impl BiosPatchTable {
    /// Write this table over the blank one at `off` in `image`. Refuses a
    /// command line that does not fit the stage's declared capacity, and an
    /// image whose table slot does not carry the MAGIC.
    pub fn write_into(&self, image: &mut [u8], off: usize) -> Result<()> {
        if image.len() < off + TABLE_LEN {
            bail!("bios stage: image too short for a patch table at {off}");
        }
        let t = &mut image[off..off + TABLE_LEN];
        if t[..8] != MAGIC {
            bail!("bios stage: no MAGIC at offset {off}");
        }
        let cap = u16::from_le_bytes([t[OFF_CMDLINE_CAP], t[OFF_CMDLINE_CAP + 1]]) as usize;
        if cap == 0 || cap > CMDLINE_CAP {
            bail!("bios stage: table declares cmdline capacity {cap}, this writer knows {CMDLINE_CAP}");
        }
        if !self.cmdline.is_ascii() || self.cmdline.bytes().any(|b| b == 0) {
            bail!("bios stage: the kernel command line must be NUL-free ASCII");
        }
        if self.cmdline.len() + 1 > cap {
            bail!(
                "bios stage: kernel command line is {} bytes, the stage holds {} (NUL included)",
                self.cmdline.len(),
                cap
            );
        }
        if self.kernel_lba == 0 || self.kernel_len == 0 {
            bail!("bios stage: a kernel at LBA 0 / length 0 is not a kernel");
        }
        if self.sector_shift != SECTOR_SHIFT_CD && self.sector_shift != SECTOR_SHIFT_DISK {
            bail!(
                "bios stage: sector_shift {} is neither {SECTOR_SHIFT_CD} (2048-byte CD sectors) nor \
                 {SECTOR_SHIFT_DISK} (512-byte disk sectors)",
                self.sector_shift
            );
        }
        t[OFF_SECTOR_SHIFT] = self.sector_shift;
        t[OFF_KERNEL_LBA..OFF_KERNEL_LBA + 4].copy_from_slice(&self.kernel_lba.to_le_bytes());
        t[OFF_KERNEL_LEN..OFF_KERNEL_LEN + 4].copy_from_slice(&self.kernel_len.to_le_bytes());
        t[OFF_INITRD_LBA..OFF_INITRD_LBA + 4].copy_from_slice(&self.initrd_lba.to_le_bytes());
        t[OFF_INITRD_LEN..OFF_INITRD_LEN + 4].copy_from_slice(&self.initrd_len.to_le_bytes());
        for b in &mut t[OFF_CMDLINE..OFF_CMDLINE + cap] {
            *b = 0;
        }
        t[OFF_CMDLINE..OFF_CMDLINE + self.cmdline.len()].copy_from_slice(self.cmdline.as_bytes());
        Ok(())
    }

    /// Read a table back from `image` at `off` (the inverse of
    /// [`write_into`](Self::write_into); what a test round-trips and what a
    /// medium inspector prints).
    pub fn read_from(image: &[u8], off: usize) -> Result<Self> {
        if image.len() < off + TABLE_LEN {
            bail!("bios stage: image too short for a patch table at {off}");
        }
        let t = &image[off..off + TABLE_LEN];
        if t[..8] != MAGIC {
            bail!("bios stage: no MAGIC at offset {off}");
        }
        let u32_at = |o: usize| u32::from_le_bytes([t[o], t[o + 1], t[o + 2], t[o + 3]]);
        let cap = (u16::from_le_bytes([t[OFF_CMDLINE_CAP], t[OFF_CMDLINE_CAP + 1]]) as usize).min(CMDLINE_CAP);
        let raw = &t[OFF_CMDLINE..OFF_CMDLINE + cap];
        let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
        Ok(BiosPatchTable {
            kernel_lba: u32_at(OFF_KERNEL_LBA),
            kernel_len: u32_at(OFF_KERNEL_LEN),
            initrd_lba: u32_at(OFF_INITRD_LBA),
            initrd_len: u32_at(OFF_INITRD_LEN),
            cmdline: String::from_utf8_lossy(&raw[..end]).into_owned(),
            // A table written before the field existed holds 0 here; it was a
            // CD table, so read it as one.
            sector_shift: if t[OFF_SECTOR_SHIFT] == 0 { SECTOR_SHIFT_CD } else { t[OFF_SECTOR_SHIFT] },
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_blank_table_is_found_written_and_read_back() {
        let mut img = vec![0x90u8; 0x400];
        img.extend_from_slice(&blank_table());
        img.extend_from_slice(b"trailing strings");
        let off = find_table(&img).unwrap();
        assert_eq!(off, 0x400);
        let t = BiosPatchTable {
            kernel_lba: 77,
            kernel_len: 1_868_800,
            initrd_lba: 1200,
            initrd_len: 1_783_296,
            cmdline: "console=ttyS0,115200 korp.entry=gunnar".into(),
            sector_shift: SECTOR_SHIFT_CD,
        };
        t.write_into(&mut img, off).unwrap();
        assert_eq!(BiosPatchTable::read_from(&img, off).unwrap(), t);
        // Re-patching with a SHORTER line leaves no tail of the old one.
        let t2 = BiosPatchTable { cmdline: "x=1".into(), ..t.clone() };
        t2.write_into(&mut img, off).unwrap();
        assert_eq!(BiosPatchTable::read_from(&img, off).unwrap(), t2);
    }

    #[test]
    fn an_unpatched_table_reads_as_lba_zero() {
        let img = blank_table();
        let t = BiosPatchTable::read_from(&img, 0).unwrap();
        assert_eq!(t.kernel_lba, 0);
        assert_eq!(t.cmdline, "");
    }

    #[test]
    fn refusals_name_the_defect() {
        assert!(find_table(&[0u8; 64]).unwrap_err().to_string().contains("no patch table"));
        let mut two = blank_table().to_vec();
        two.extend_from_slice(&blank_table());
        assert!(find_table(&two).unwrap_err().to_string().contains("2 times"));
        let mut img = blank_table();
        let too_long = BiosPatchTable {
            kernel_lba: 1,
            kernel_len: 1,
            cmdline: "a".repeat(CMDLINE_CAP),
            ..Default::default()
        };
        assert!(too_long.write_into(&mut img, 0).unwrap_err().to_string().contains("holds"));
        let no_kernel = BiosPatchTable { cmdline: "ok".into(), ..Default::default() };
        assert!(no_kernel.write_into(&mut img, 0).unwrap_err().to_string().contains("not a kernel"));
    }
}