diff --git a/crates/takana-cli/src/efi_boot.rs b/crates/takana-cli/src/efi_boot.rs new file mode 100644 index 00000000..6821748d --- /dev/null +++ b/crates/takana-cli/src/efi_boot.rs @@ -0,0 +1,491 @@ +//! Entradas de arranque UEFI (ADR 0018 §1/§3): construir un `EFI_LOAD_OPTION` y publicarlo en la +//! NVRAM del firmware por `efivarfs`. +//! +//! ── POR QUÉ ESTO Y NO `efibootmgr` ────────────────────────────────────────────────────────────── +//! El ADR 0018 dejaba abiertas las dos vías. Se intentó primero la ajena y **se midió el muro** +//! (2026-09-11): `efivar` 38 con musl/zig-cc choca con `secure_getenv` (no existe en musl), +//! `sys/cdefs.h` (tampoco) y `-Wl,--add-needed` (lld lo rechaza) — tres muros sólo para llegar a +//! compilar su generador de tablas, que es el patrón «pantano de parches de distro». Y arrastraría +//! además `popt` al sistema instalado. +//! +//! Lo que efibootmgr hace, en el fondo, es **escribir dos ficheros**: `Boot####` y `BootOrder`. El +//! formato es una estructura de la spec UEFI, estable desde 2.0 y de tamaño fijo. Escribirla acá es +//! menos código que mantener los parches, no agrega dependencias al sistema instalado, y el binario +//! `takana` ya está en el disco. +//! +//! ── LA ESTRUCTURA, QUE ES TODO LO QUE HAY QUE ACERTAR ─────────────────────────────────────────── +//! `EFI_LOAD_OPTION` (UEFI 2.10 §3.1.3) — todo little-endian, sin relleno: +//! +//! ```text +//! u32 Attributes LOAD_OPTION_ACTIVE = 1 +//! u16 FilePathListLength bytes del device path, NO del total +//! u16[] Description UTF-16LE terminada en NUL +//! u8[] FilePathList el device path completo, incluido su nodo final +//! u8[] OptionalData lo que el firmware pasa como LoadOptions (acá: vacío) +//! ``` +//! +//! El device path para «un fichero dentro de una partición» son tres nodos encadenados, cada uno con +//! su cabecera `(type: u8, subtype: u8, length: u16)` donde `length` **se cuenta con la cabecera**: +//! +//! ```text +//! MEDIA/HARD_DRIVE (4/1, 42 bytes): nº de partición, LBA de inicio, LBA de tamaño, +//! firma (16 B), tipo de MBR (1=MBR, 2=GPT), tipo de firma +//! MEDIA/FILE_PATH (4/4, variable): la ruta en UTF-16LE con `\` de separador, terminada en NUL +//! END (0x7F/0xFF, 4 bytes) +//! ``` +//! +//! ⚠ **Un device path mal formado no da error: el firmware ignora la entrada en silencio** y el +//! usuario ve «no arranca» sin una sola pista. Por eso `parse_load_option` existe y por eso el +//! round-trip se prueba contra bytes REALES del firmware, no sólo contra vectores escritos a mano. + +use std::io; +use std::path::{Path, PathBuf}; + +/// GUID del espacio de variables globales de EFI (`EFI_GLOBAL_VARIABLE`). +pub const GLOBAL_GUID: &str = "8be4df61-93ca-11d2-aa0d-00e098032b8c"; +/// Raíz de `efivarfs`. El firmware sólo existe detrás de esto. +pub const EFIVARS: &str = "/sys/firmware/efi/efivars"; + +/// NON_VOLATILE | BOOTSERVICE_ACCESS | RUNTIME_ACCESS. Sin las tres, la entrada no sobrevive al +/// apagado o no la ve el gestor de arranque. +pub const ATTRS: u32 = 0x0000_0007; +/// `LOAD_OPTION_ACTIVE` — sin esto la entrada existe y el firmware NO la arranca. +pub const LOAD_OPTION_ACTIVE: u32 = 0x0000_0001; + +/// La partición donde vive el `.efi`, tal como la describe el nodo HARD_DRIVE. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HdPartition { + pub number: u32, + pub start_lba: u64, + pub size_lba: u64, + pub signature: [u8; 16], + /// 1 = tabla MBR, 2 = tabla GPT. + pub mbr_type: u8, + /// 1 = firma de MBR (4 bytes útiles), 2 = GUID de partición GPT. + pub sig_type: u8, +} + +/// Una entrada `Boot####` decodificada. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadOption { + pub attributes: u32, + pub description: String, + pub file_path_list: Vec, + pub optional_data: Vec, +} + +fn utf16_nul(s: &str) -> Vec { + let mut v: Vec = s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + v.extend_from_slice(&[0, 0]); + v +} + +/// Nodo MEDIA/HARD_DRIVE: 42 bytes exactos, y el firmware los cuenta. +pub fn hd_node(p: &HdPartition) -> Vec { + let mut v = Vec::with_capacity(42); + v.extend_from_slice(&[0x04, 0x01]); + v.extend_from_slice(&42u16.to_le_bytes()); + v.extend_from_slice(&p.number.to_le_bytes()); + v.extend_from_slice(&p.start_lba.to_le_bytes()); + v.extend_from_slice(&p.size_lba.to_le_bytes()); + v.extend_from_slice(&p.signature); + v.push(p.mbr_type); + v.push(p.sig_type); + debug_assert_eq!(v.len(), 42); + v +} + +/// Nodo MEDIA/FILE_PATH. La ruta va con `\` (separador de la spec), no con `/`. +pub fn file_node(path_efi: &str) -> Vec { + let p = path_efi.replace('/', "\\"); + let s = utf16_nul(&p); + let len = (4 + s.len()) as u16; + let mut v = Vec::with_capacity(len as usize); + v.extend_from_slice(&[0x04, 0x04]); + v.extend_from_slice(&len.to_le_bytes()); + v.extend_from_slice(&s); + v +} + +/// Nodo final. Sin él, el firmware sigue leyendo memoria que no es suya. +pub fn end_node() -> Vec { + vec![0x7f, 0xff, 0x04, 0x00] +} + +pub fn device_path(part: &HdPartition, path_efi: &str) -> Vec { + let mut v = hd_node(part); + v.extend(file_node(path_efi)); + v.extend(end_node()); + v +} + +/// Serializa un `EFI_LOAD_OPTION` completo (sin los 4 bytes de atributos de efivarfs). +pub fn load_option(description: &str, device_path: &[u8], optional_data: &[u8]) -> Vec { + let desc = utf16_nul(description); + let mut v = Vec::with_capacity(4 + 2 + desc.len() + device_path.len() + optional_data.len()); + v.extend_from_slice(&LOAD_OPTION_ACTIVE.to_le_bytes()); + v.extend_from_slice(&(device_path.len() as u16).to_le_bytes()); + v.extend_from_slice(&desc); + v.extend_from_slice(device_path); + v.extend_from_slice(optional_data); + v +} + +/// Decodifica un `EFI_LOAD_OPTION`. Es la mitad que permite COMPROBAR la otra: si `parse(build(x))` +/// no devuelve `x`, el que está mal es el constructor, y eso se ve acá en vez de en el firmware. +pub fn parse_load_option(b: &[u8]) -> Result { + if b.len() < 6 { + return Err(format!("EFI_LOAD_OPTION truncado: {} bytes, mínimo 6", b.len())); + } + let attributes = u32::from_le_bytes([b[0], b[1], b[2], b[3]]); + let fpl = u16::from_le_bytes([b[4], b[5]]) as usize; + // La descripción es UTF-16 hasta el primer u16 nulo. + let mut i = 6; + let mut units: Vec = Vec::new(); + loop { + if i + 1 >= b.len() { + return Err("descripción sin terminador NUL".into()); + } + let u = u16::from_le_bytes([b[i], b[i + 1]]); + i += 2; + if u == 0 { + break; + } + units.push(u); + } + let description = String::from_utf16_lossy(&units); + if i + fpl > b.len() { + return Err(format!( + "FilePathListLength={fpl} excede el buffer ({} bytes restantes)", + b.len() - i + )); + } + let file_path_list = b[i..i + fpl].to_vec(); + let optional_data = b[i + fpl..].to_vec(); + Ok(LoadOption { attributes, description, file_path_list, optional_data }) +} + +/// Ruta de la variable `Boot####` en efivarfs. +pub fn boot_var_path(root: &Path, num: u16) -> PathBuf { + root.join(format!("Boot{num:04X}-{GLOBAL_GUID}")) +} + +/// Escribe una variable EFI: los 4 bytes de atributos van PEGADOS al dato, y todo en UNA sola +/// escritura — efivarfs rechaza la variable si llega partida en dos `write()`. +pub fn write_var(path: &Path, attrs: u32, data: &[u8]) -> io::Result<()> { + use std::io::Write; + let mut buf = Vec::with_capacity(4 + data.len()); + buf.extend_from_slice(&attrs.to_le_bytes()); + buf.extend_from_slice(data); + let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).open(path)?; + f.write_all(&buf)?; + f.flush() +} + +/// Lee una variable EFI y devuelve `(attrs, data)` ya sin la cabecera de 4 bytes. +pub fn read_var(path: &Path) -> io::Result<(u32, Vec)> { + let raw = std::fs::read(path)?; + if raw.len() < 4 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("variable EFI de {} bytes: no entra ni la cabecera de atributos", raw.len()), + )); + } + Ok((u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]), raw[4..].to_vec())) +} + +/// `BootOrder` es un array de u16 little-endian: el orden en que el firmware prueba las entradas. +pub fn parse_boot_order(data: &[u8]) -> Vec { + data.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect() +} + +pub fn serialize_boot_order(order: &[u16]) -> Vec { + order.iter().flat_map(|n| n.to_le_bytes()).collect() +} + +/// Pone `num` PRIMERO y conserva el resto sin duplicarlo. Idempotente a propósito: el reconciliador +/// del ADR 0018 §2 corre en CADA arranque, así que la operación tiene que poder repetirse sin +/// acumular basura ni alterar nada cuando ya está bien. +pub fn promote_first(order: &[u16], num: u16) -> Vec { + let mut v = vec![num]; + v.extend(order.iter().copied().filter(|&x| x != num)); + v +} + +/// Lee del DISCO los datos de la partición `number` para armar el nodo HARD_DRIVE. +/// +/// Detecta sola la tabla: si el LBA 1 empieza con `EFI PART` es GPT; si no, se trata como MBR. Los +/// dos casos hacen falta — `install-image-efi.sh` fabrica GPT y `takana-live-install.sh` instala +/// sobre **MBR** (una ESP de tipo 0xEF en tabla MBR arranca igual por UEFI). +/// +/// ⚠ Asume sectores lógicos de 512 B. En un disco 4Kn los offsets del GPT se corren y esto lee +/// basura: por eso valida la firma y el número de partición en vez de confiar, y falla ruidosamente. +pub fn read_partition(disk: &Path, number: u32) -> io::Result { + use std::io::{Read, Seek, SeekFrom}; + if number == 0 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "el nº de partición empieza en 1")); + } + let mut f = std::fs::File::open(disk)?; + + // --- ¿GPT? El header vive en el LBA 1 y empieza con la firma "EFI PART". --- + let mut hdr = [0u8; 512]; + f.seek(SeekFrom::Start(512))?; + f.read_exact(&mut hdr)?; + if &hdr[0..8] == b"EFI PART" { + let entry_lba = u64::from_le_bytes(hdr[72..80].try_into().unwrap()); + let n_entries = u32::from_le_bytes(hdr[80..84].try_into().unwrap()); + let entry_sz = u32::from_le_bytes(hdr[84..88].try_into().unwrap()) as usize; + if number > n_entries { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("la partición {number} no existe: el GPT declara {n_entries}"), + )); + } + if entry_sz < 56 || entry_sz > 4096 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("SizeOfPartitionEntry absurdo ({entry_sz}): ¿sectores de 4K?"), + )); + } + let off = entry_lba * 512 + (number as u64 - 1) * entry_sz as u64; + let mut e = vec![0u8; entry_sz]; + f.seek(SeekFrom::Start(off))?; + f.read_exact(&mut e)?; + if e[0..16] == [0u8; 16] { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("la entrada GPT {number} está VACÍA (PartitionTypeGUID todo ceros)"), + )); + } + let start = u64::from_le_bytes(e[32..40].try_into().unwrap()); + let end = u64::from_le_bytes(e[40..48].try_into().unwrap()); + let mut signature = [0u8; 16]; + // El GUID se copia CRUDO, en el mismo orden mixto que tiene en disco: es lo que el firmware + // compara byte a byte contra su propia lectura de la tabla. + signature.copy_from_slice(&e[16..32]); + return Ok(HdPartition { + number, + start_lba: start, + size_lba: end.saturating_sub(start) + 1, + signature, + mbr_type: 2, + sig_type: 2, + }); + } + + // --- MBR: la "firma" son los 4 bytes del NT disk signature en 0x1B8. --- + let mut mbr = [0u8; 512]; + f.seek(SeekFrom::Start(0))?; + f.read_exact(&mut mbr)?; + if mbr[510] != 0x55 || mbr[511] != 0xAA { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ni GPT ni MBR: falta la firma 0x55AA del sector de arranque", + )); + } + if number > 4 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("MBR sólo tiene 4 primarias; pediste la {number}"), + )); + } + let e = &mbr[446 + (number as usize - 1) * 16..][..16]; + if e[4] == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("la partición MBR {number} está vacía (tipo 0x00)"), + )); + } + let start = u32::from_le_bytes(e[8..12].try_into().unwrap()) as u64; + let size = u32::from_le_bytes(e[12..16].try_into().unwrap()) as u64; + let mut signature = [0u8; 16]; + signature[0..4].copy_from_slice(&mbr[0x1b8..0x1bc]); + Ok(HdPartition { number, start_lba: start, size_lba: size, signature, mbr_type: 1, sig_type: 1 }) +} + +/// Busca el primer `Boot####` libre. Empieza en 0 porque es lo que hace todo el mundo y así el +/// número queda chico y legible en el menú del firmware. +pub fn first_free_slot(root: &Path) -> io::Result { + for n in 0u16..=0xFFFF { + if !boot_var_path(root, n).exists() { + return Ok(n); + } + } + Err(io::Error::new(io::ErrorKind::AlreadyExists, "no queda ni un Boot#### libre (0000-FFFF)")) +} + +/// Lista las entradas `Boot####` presentes, en orden numérico, con su descripción ya decodificada. +pub fn list_entries(root: &Path) -> io::Result> { + let mut out = Vec::new(); + for ent in std::fs::read_dir(root)? { + let ent = ent?; + let name = ent.file_name().to_string_lossy().to_string(); + let Some(rest) = name.strip_prefix("Boot") else { continue }; + let Some((num, guid)) = rest.split_once('-') else { continue }; + if !guid.eq_ignore_ascii_case(GLOBAL_GUID) || num.len() != 4 { + continue; + } + let Ok(n) = u16::from_str_radix(num, 16) else { continue }; + let Ok((_, data)) = read_var(&ent.path()) else { continue }; + if let Ok(lo) = parse_load_option(&data) { + out.push((n, lo)); + } + } + out.sort_by_key(|(n, _)| *n); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parte() -> HdPartition { + HdPartition { + number: 1, + start_lba: 2048, + size_lba: 131_072, + signature: [ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, + 0xff, 0x00, + ], + mbr_type: 2, + sig_type: 2, + } + } + + #[test] + fn hd_node_mide_42_y_pone_los_campos_donde_van() { + let n = hd_node(&parte()); + assert_eq!(n.len(), 42, "el nodo HARD_DRIVE es de tamaño fijo por spec"); + assert_eq!(&n[0..2], &[0x04, 0x01], "type/subtype MEDIA/HARD_DRIVE"); + assert_eq!(u16::from_le_bytes([n[2], n[3]]), 42, "length se cuenta CON la cabecera"); + assert_eq!(u32::from_le_bytes([n[4], n[5], n[6], n[7]]), 1); + assert_eq!(n[40], 2, "GPT"); + assert_eq!(n[41], 2, "firma = GUID"); + } + + #[test] + fn file_node_usa_backslash_y_utf16() { + let n = file_node("/EFI/takana/takanax64.efi"); + assert_eq!(&n[0..2], &[0x04, 0x04]); + let len = u16::from_le_bytes([n[2], n[3]]) as usize; + assert_eq!(len, n.len(), "el length declarado es el real"); + let units: Vec = + n[4..].chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect(); + let s = String::from_utf16_lossy(&units[..units.len() - 1]); + assert_eq!(s, "\\EFI\\takana\\takanax64.efi", "la spec separa con backslash"); + } + + #[test] + fn round_trip_de_una_entrada_completa() { + let dp = device_path(&parte(), "/EFI/takana/takanax64.efi"); + let lo = load_option("takana", &dp, &[]); + let p = parse_load_option(&lo).expect("debe parsear lo que acabamos de construir"); + assert_eq!(p.description, "takana"); + assert_eq!(p.attributes, LOAD_OPTION_ACTIVE); + assert_eq!(p.file_path_list, dp, "el device path vuelve entero y sin corrimiento"); + assert!(p.optional_data.is_empty()); + } + + #[test] + fn el_device_path_termina_en_el_nodo_final() { + let dp = device_path(&parte(), "/EFI/BOOT/BOOTX64.EFI"); + assert_eq!(&dp[dp.len() - 4..], &[0x7f, 0xff, 0x04, 0x00], "sin END el firmware se va de rango"); + } + + #[test] + fn parse_rechaza_lo_truncado_en_vez_de_inventar() { + assert!(parse_load_option(&[1, 0, 0]).is_err(), "6 bytes es el mínimo"); + // FilePathListLength que miente: dice más de lo que hay. + let mut malo = load_option("x", &device_path(&parte(), "/a.efi"), &[]); + malo[4] = 0xff; + malo[5] = 0xff; + assert!(parse_load_option(&malo).is_err(), "un fpl imposible debe FALLAR, no truncar"); + } + + #[test] + fn promote_first_es_idempotente_y_no_duplica() { + let o = vec![3u16, 1, 2]; + let a = promote_first(&o, 1); + assert_eq!(a, vec![1, 3, 2]); + assert_eq!(promote_first(&a, 1), a, "repetirlo no cambia nada: corre en CADA arranque"); + assert_eq!(promote_first(&[], 5), vec![5], "BootOrder vacío también es un caso"); + } + + /// Fabrica un disco GPT sintético (sin sfdisk: bytes a mano, determinista) con UNA partición. + fn disco_gpt(tmp: &Path) -> PathBuf { + let mut d = vec![0u8; 512 * 40]; + d[510] = 0x55; + d[511] = 0xAA; // MBR protector + let h = 512; // LBA 1 = header GPT + d[h..h + 8].copy_from_slice(b"EFI PART"); + d[h + 72..h + 80].copy_from_slice(&2u64.to_le_bytes()); // PartitionEntryLBA + d[h + 80..h + 84].copy_from_slice(&128u32.to_le_bytes()); // NumberOfPartitionEntries + d[h + 84..h + 88].copy_from_slice(&128u32.to_le_bytes()); // SizeOfPartitionEntry + let e = 512 * 2; // LBA 2 = primera entrada + d[e..e + 16].copy_from_slice(&[0xAA; 16]); // PartitionTypeGUID (no nulo = ocupada) + d[e + 16..e + 32].copy_from_slice(&[0x5A; 16]); // UniquePartitionGUID + d[e + 32..e + 40].copy_from_slice(&2048u64.to_le_bytes()); // StartingLBA + d[e + 40..e + 48].copy_from_slice(&133_119u64.to_le_bytes()); // EndingLBA (inclusive) + let f = tmp.join("gpt.img"); + std::fs::write(&f, &d).unwrap(); + f + } + + #[test] + fn lee_una_particion_gpt_con_los_numeros_exactos() { + let tmp = std::env::temp_dir().join(format!("takana-efi-gpt-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let p = read_partition(&disco_gpt(&tmp), 1).expect("debe leer la partición 1"); + assert_eq!(p.number, 1); + assert_eq!(p.start_lba, 2048); + // size = end - start + 1, y el +1 importa: es un rango INCLUSIVO. + assert_eq!(p.size_lba, 131_072, "EndingLBA es inclusivo"); + assert_eq!(p.signature, [0x5A; 16], "la firma es el GUID ÚNICO, no el de tipo"); + assert_eq!((p.mbr_type, p.sig_type), (2, 2), "GPT + firma GUID"); + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn rechaza_lo_que_no_es_tabla_y_la_particion_vacia() { + let tmp = std::env::temp_dir().join(format!("takana-efi-neg-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + // Un disco sin firma 0x55AA no es ni GPT ni MBR: debe FALLAR, no devolver ceros. + let basura = tmp.join("basura.img"); + std::fs::write(&basura, vec![0u8; 512 * 8]).unwrap(); + assert!(read_partition(&basura, 1).is_err(), "sin 0x55AA no hay tabla que leer"); + // Y una entrada GPT vacía tampoco se convierte en una partición de tamaño 0. + let g = disco_gpt(&tmp); + assert!(read_partition(&g, 2).is_err(), "la entrada 2 está vacía: error, no ceros"); + assert!(read_partition(&g, 0).is_err(), "las particiones empiezan en 1"); + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn lee_una_particion_mbr() { + let tmp = std::env::temp_dir().join(format!("takana-efi-mbr-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let mut d = vec![0u8; 512 * 4]; + d[0x1b8..0x1bc].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); // NT disk signature + let e = 446; + d[e + 4] = 0xEF; // tipo ESP + d[e + 8..e + 12].copy_from_slice(&2048u32.to_le_bytes()); + d[e + 12..e + 16].copy_from_slice(&131_072u32.to_le_bytes()); + d[510] = 0x55; + d[511] = 0xAA; + let f = tmp.join("mbr.img"); + std::fs::write(&f, &d).unwrap(); + let p = read_partition(&f, 1).expect("debe leer la ESP del MBR"); + assert_eq!((p.start_lba, p.size_lba), (2048, 131_072)); + assert_eq!(&p.signature[0..4], &[0xDE, 0xAD, 0xBE, 0xEF]); + assert_eq!((p.mbr_type, p.sig_type), (1, 1), "MBR + firma de MBR"); + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn boot_order_ida_y_vuelta() { + let o = vec![0x0001u16, 0x0000, 0x2001]; + assert_eq!(parse_boot_order(&serialize_boot_order(&o)), o); + assert_eq!(parse_boot_order(&[1]), Vec::::new(), "un byte suelto se descarta, no rompe"); + } +} diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs index 10bbae3f..fa750f54 100644 --- a/crates/takana-cli/src/main.rs +++ b/crates/takana-cli/src/main.rs @@ -9,6 +9,7 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; mod alpine_import; +mod efi_boot; mod kernel_cmd; mod qorpa; mod qorpa_wl; @@ -503,6 +504,17 @@ enum BootCmd { #[arg(long, default_value = takana_upgrade::boot_graph::BOOT_SELECT_PATH)] select: PathBuf, }, + /// Manage this system's UEFI firmware boot entry (ADR 0018). + /// + /// La NVRAM del firmware es **estado mutable compartido**: otro sistema operativo instalado al + /// lado la reescribe sin coordinarse con nadie —el caso típico es Windows Update poniéndose + /// primero en `BootOrder`—. Por eso `add` es IDEMPOTENTE y pensado para correr en cada arranque: + /// reconciliar, no confiar. + Entry { + #[command(subcommand)] + sub: EntryCmd, + }, + /// [ADR 0010 · HARNESS de dev/VM] Orquesta el menú de boot completo: emite el grafo, lanza el /// compositor que lo dibuja sobre KMS, ESPERA su salida y activa lo que dejó en boot-select. /// Sirve para validar el glue en OVMF/QEMU con un compositor stub que sí termina. @@ -603,6 +615,40 @@ enum MirrorCmd { }, } +#[derive(Subcommand)] +enum EntryCmd { + /// List the `Boot####` entries the firmware currently holds, in `BootOrder` order. + List { + /// Root of efivarfs (override only for tests against a captured dump). + #[arg(long, default_value = crate::efi_boot::EFIVARS)] + efivars: PathBuf, + }, + /// Create (or refresh) takana's boot entry and put it first in `BootOrder`. + /// + /// Idempotente: si ya existe una entrada con la misma etiqueta Y el mismo device path, no + /// escribe nada y lo dice. Es lo que permite invocarlo en cada arranque como reconciliador. + Add { + /// Disk holding the ESP, e.g. `/dev/sda` or `/dev/vda`. + #[arg(long)] + disk: PathBuf, + /// 1-based partition number of the ESP. + #[arg(long, default_value_t = 1)] + partition: u32, + /// Path of the loader INSIDE the ESP. + #[arg(long, default_value = "/EFI/takana/takanax64.efi")] + loader: String, + /// Label shown in the firmware boot menu. + #[arg(long, default_value = "takana")] + label: String, + /// Root of efivarfs. + #[arg(long, default_value = crate::efi_boot::EFIVARS)] + efivars: PathBuf, + /// Print what would be written, byte count and all, and touch nothing. + #[arg(long)] + dry_run: bool, + }, +} + #[derive(Subcommand)] enum BootstrapCmd { /// [Stage 0] Ingiere un toolchain semilla pinned al store. Verifica el sha256 antes de @@ -1405,6 +1451,98 @@ fn main() -> anyhow::Result<()> { let _ = std::fs::remove_file(&select); } } + BootCmd::Entry { sub } => match sub { + EntryCmd::List { efivars } => { + let orden = efi_boot::read_var(&efivars.join(format!( + "BootOrder-{}", + efi_boot::GLOBAL_GUID + ))) + .map(|(_, d)| efi_boot::parse_boot_order(&d)) + .unwrap_or_default(); + let entradas = efi_boot::list_entries(&efivars)?; + if entradas.is_empty() { + println!("sin entradas Boot#### en {}", efivars.display()); + println!(" (si esta máquina arrancó por BIOS no hay NVRAM que mirar)"); + return Ok(()); + } + println!("BootOrder: {}", if orden.is_empty() { + "(vacío)".to_string() + } else { + orden.iter().map(|n| format!("{n:04X}")).collect::>().join(",") + }); + // Primero las que el firmware va a probar, en su orden; después las demás. + let mut vistas = Vec::new(); + for n in &orden { + if let Some((_, lo)) = entradas.iter().find(|(m, _)| m == n) { + let activa = lo.attributes & efi_boot::LOAD_OPTION_ACTIVE != 0; + println!(" Boot{n:04X}{} {}", if activa { "*" } else { " " }, lo.description); + vistas.push(*n); + } + } + for (n, lo) in &entradas { + if !vistas.contains(n) { + println!(" Boot{n:04X} {} (fuera de BootOrder)", lo.description); + } + } + } + EntryCmd::Add { disk, partition, loader, label, efivars, dry_run } => { + let parte = efi_boot::read_partition(&disk, partition)?; + let dp = efi_boot::device_path(&parte, &loader); + let lo = efi_boot::load_option(&label, &dp, &[]); + + // Idempotencia (ADR 0018 §2): si ya está exactamente esto, no se reescribe. + // Reescribir por las dudas gastaría un ciclo de la NVRAM en cada arranque, y + // la NVRAM tiene un número finito de escrituras. + let ya = efi_boot::list_entries(&efivars) + .unwrap_or_default() + .into_iter() + .find(|(_, e)| e.description == label && e.file_path_list == dp); + + println!("partición: {} p{partition} — inicio LBA {}, {} LBA, {}", + disk.display(), parte.start_lba, parte.size_lba, + if parte.mbr_type == 2 { "GPT" } else { "MBR" }); + println!("cargador: {loader}"); + println!("etiqueta: {label}"); + + if dry_run { + println!("--dry-run: NO se escribió nada"); + println!(" EFI_LOAD_OPTION: {} bytes (device path {} de ellos)", lo.len(), dp.len()); + let v = efi_boot::parse_load_option(&lo) + .map_err(|e| anyhow::anyhow!("la entrada recién construida no se puede releer: {e}"))?; + println!(" releída: «{}», {} bytes de device path ✓", v.description, v.file_path_list.len()); + return Ok(()); + } + + let num = match ya { + Some((n, _)) => { + println!("✓ la entrada Boot{n:04X} ya dice exactamente esto — no se reescribe"); + n + } + None => { + let n = efi_boot::first_free_slot(&efivars)?; + let ruta = efi_boot::boot_var_path(&efivars, n); + efi_boot::write_var(&ruta, efi_boot::ATTRS, &lo)?; + println!("✓ escrita Boot{n:04X} ({} bytes) en {}", lo.len(), ruta.display()); + n + } + }; + + // BootOrder: ponerla primera. También idempotente. + let ruta_orden = efivars.join(format!("BootOrder-{}", efi_boot::GLOBAL_GUID)); + let actual = efi_boot::read_var(&ruta_orden) + .map(|(_, d)| efi_boot::parse_boot_order(&d)) + .unwrap_or_default(); + let nuevo = efi_boot::promote_first(&actual, num); + if nuevo == actual { + println!("✓ BootOrder ya empieza por Boot{num:04X} — no se toca"); + } else { + efi_boot::write_var(&ruta_orden, efi_boot::ATTRS, &efi_boot::serialize_boot_order(&nuevo))?; + println!("✓ BootOrder: {} → {}", + actual.iter().map(|n| format!("{n:04X}")).collect::>().join(","), + nuevo.iter().map(|n| format!("{n:04X}")).collect::>().join(",")); + } + } + }, BootCmd::Menu { compositor, out, select } => { // 1) Publicar el grafo (siempre, aunque no haya compositor: otros lo leen). if let Some(parent) = out.parent() { std::fs::create_dir_all(parent).ok(); }