tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

takana boot entry: la entrada NVRAM, sin efibootmgr — con el muro medido

ADR 0018 §1. El ADR dejaba dos vías y se intentó primero la ajena:
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 compilar su generador de tablas, y arrastraría popt al sistema
instalado. Es el patrón "pantano de parches de distro".

Lo que efibootmgr hace en el fondo es escribir DOS ficheros, Boot#### y
BootOrder, con una estructura de la spec UEFI estable desde 2.0. Hacerlo
acá es menos código que mantener los parches, no agrega dependencias al
sistema instalado y el binario takana ya está en el disco.

crates/takana-cli/src/efi_boot.rs — EFI_LOAD_OPTION + device path
(HARD_DRIVE/FILE_PATH/END), lectura y escritura de efivarfs, y un lector
de tabla de particiones que detecta GPT o MBR solo. Los dos casos hacen
falta: install-image-efi.sh fabrica GPT y takana-live-install.sh instala
sobre MBR.

`takana boot entry {list,add}` (CLI en inglés, regla 4). `add` es
IDEMPOTENTE a propósito: está pensado para correr en CADA arranque como
reconciliador (§2), y la NVRAM tiene un número finito de escrituras, así
que si ya dice exactamente eso no se reescribe.

parse_load_option existe para poder COMPROBAR al constructor: 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.

10 tests, con los negativos que son los que valen: un disco sin 0x55AA
falla en vez de devolver ceros, una entrada GPT vacía no se convierte en
partición de tamaño 0, y un FilePathListLength que miente es error y no
un truncado silencioso. 69/69 del CLI en verde.

Verificado además contra una tabla GPT REAL de sfdisk: inicio LBA 2048 y
131072 sectores, los mismos números que reporta sfdisk -l.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj

ba5754551f68678ab8841c4fcc4b4edfec97bc56 · por Sergio · (hace 1 mes) · padre 2ecce583e · ver el árbol en este commit

Delta semántico · 39

crates/takana-cli/src/efi_boot.rs

crates/takana-cli/src/main.rs

2 archivos · +629 −0

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<u8>,
+    pub optional_data: Vec<u8>,
+}
+
+fn utf16_nul(s: &str) -> Vec<u8> {
+    let mut v: Vec<u8> = 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<u8> {
+    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<u8> {
+    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<u8> {
+    vec![0x7f, 0xff, 0x04, 0x00]
+}
+
+pub fn device_path(part: &HdPartition, path_efi: &str) -> Vec<u8> {
+    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<u8> {
+    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<LoadOption, String> {
+    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<u16> = 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<u8>)> {
+    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<u16> {
+    data.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
+}
+
+pub fn serialize_boot_order(order: &[u16]) -> Vec<u8> {
+    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<u16> {
+    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<HdPartition> {
+    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<u16> {
+    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<Vec<(u16, LoadOption)>> {
+    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<u16> =
+            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::<u16>::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::<Vec<_>>().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::<Vec<_>>().join(","),
+                                nuevo.iter().map(|n| format!("{n:04X}")).collect::<Vec<_>>().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(); }