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"));
}
}