gioser

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 815Rust 56Shell 37Markdown 291229 Definiciones

⎇ main crates/takana-cli/src/efi_boot.rs Rust · 782 líneas · 34.2 KB Historia Quién tocó qué crudo

respaldo del arranque: lista blanca, porque BootCurrent rompía la idempotencia 2b87ecb50 · Sergio ·

1//! Entradas de arranque UEFI (ADR 0018 §1/§3): construir un `EFI_LOAD_OPTION` y publicarlo en la
2//! NVRAM del firmware por `efivarfs`.
3//!
4//! ── POR QUÉ ESTO Y NO `efibootmgr` ──────────────────────────────────────────────────────────────
5//! El ADR 0018 dejaba abiertas las dos vías. Se intentó primero la ajena y **se midió el muro**
6//! (2026-09-11): `efivar` 38 con musl/zig-cc choca con `secure_getenv` (no existe en musl),
7//! `sys/cdefs.h` (tampoco) y `-Wl,--add-needed` (lld lo rechaza) — tres muros sólo para llegar a
8//! compilar su generador de tablas, que es el patrón «pantano de parches de distro». Y arrastraría
9//! además `popt` al sistema instalado.
10//!
11//! Lo que efibootmgr hace, en el fondo, es **escribir dos ficheros**: `Boot####` y `BootOrder`. El
12//! formato es una estructura de la spec UEFI, estable desde 2.0 y de tamaño fijo. Escribirla acá es
13//! menos código que mantener los parches, no agrega dependencias al sistema instalado, y el binario
14//! `takana` ya está en el disco.
15//!
16//! ── LA ESTRUCTURA, QUE ES TODO LO QUE HAY QUE ACERTAR ───────────────────────────────────────────
17//! `EFI_LOAD_OPTION` (UEFI 2.10 §3.1.3) — todo little-endian, sin relleno:
18//!
19//! ```text
20//!   u32   Attributes            LOAD_OPTION_ACTIVE = 1
21//!   u16   FilePathListLength    bytes del device path, NO del total
22//!   u16[] Description           UTF-16LE terminada en NUL
23//!   u8[]  FilePathList          el device path completo, incluido su nodo final
24//!   u8[]  OptionalData          lo que el firmware pasa como LoadOptions (acá: vacío)
25//! ```
26//!
27//! El device path para «un fichero dentro de una partición» son tres nodos encadenados, cada uno con
28//! su cabecera `(type: u8, subtype: u8, length: u16)` donde `length` **se cuenta con la cabecera**:
29//!
30//! ```text
31//!   MEDIA/HARD_DRIVE (4/1, 42 bytes): nº de partición, LBA de inicio, LBA de tamaño,
32//!                                     firma (16 B), tipo de MBR (1=MBR, 2=GPT), tipo de firma
33//!   MEDIA/FILE_PATH  (4/4, variable): la ruta en UTF-16LE con `\` de separador, terminada en NUL
34//!   END              (0x7F/0xFF, 4 bytes)
35//! ```
36//!
37//! ⚠ **Un device path mal formado no da error: el firmware ignora la entrada en silencio** y el
38//! usuario ve «no arranca» sin una sola pista. Por eso `parse_load_option` existe y por eso el
39//! round-trip se prueba contra bytes REALES del firmware, no sólo contra vectores escritos a mano.
40
41use std::io;
42use std::path::{Path, PathBuf};
43
44/// GUID del espacio de variables globales de EFI (`EFI_GLOBAL_VARIABLE`).
45pub const GLOBAL_GUID: &str = "8be4df61-93ca-11d2-aa0d-00e098032b8c";
46/// Raíz de `efivarfs`. El firmware sólo existe detrás de esto.
47pub const EFIVARS: &str = "/sys/firmware/efi/efivars";
48
49/// NON_VOLATILE | BOOTSERVICE_ACCESS | RUNTIME_ACCESS. Sin las tres, la entrada no sobrevive al
50/// apagado o no la ve el gestor de arranque.
51pub const ATTRS: u32 = 0x0000_0007;
52/// `LOAD_OPTION_ACTIVE` — sin esto la entrada existe y el firmware NO la arranca.
53pub const LOAD_OPTION_ACTIVE: u32 = 0x0000_0001;
54
55/// La partición donde vive el `.efi`, tal como la describe el nodo HARD_DRIVE.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct HdPartition {
58    pub number: u32,
59    pub start_lba: u64,
60    pub size_lba: u64,
61    pub signature: [u8; 16],
62    /// 1 = tabla MBR, 2 = tabla GPT.
63    pub mbr_type: u8,
64    /// 1 = firma de MBR (4 bytes útiles), 2 = GUID de partición GPT.
65    pub sig_type: u8,
66}
67
68/// Una entrada `Boot####` decodificada.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct LoadOption {
71    pub attributes: u32,
72    pub description: String,
73    pub file_path_list: Vec<u8>,
74    pub optional_data: Vec<u8>,
75}
76
77fn utf16_nul(s: &str) -> Vec<u8> {
78    let mut v: Vec<u8> = s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
79    v.extend_from_slice(&[0, 0]);
80    v
81}
82
83/// Nodo MEDIA/HARD_DRIVE: 42 bytes exactos, y el firmware los cuenta.
84pub fn hd_node(p: &HdPartition) -> Vec<u8> {
85    let mut v = Vec::with_capacity(42);
86    v.extend_from_slice(&[0x04, 0x01]);
87    v.extend_from_slice(&42u16.to_le_bytes());
88    v.extend_from_slice(&p.number.to_le_bytes());
89    v.extend_from_slice(&p.start_lba.to_le_bytes());
90    v.extend_from_slice(&p.size_lba.to_le_bytes());
91    v.extend_from_slice(&p.signature);
92    v.push(p.mbr_type);
93    v.push(p.sig_type);
94    debug_assert_eq!(v.len(), 42);
95    v
96}
97
98/// Nodo MEDIA/FILE_PATH. La ruta va con `\` (separador de la spec), no con `/`.
99pub fn file_node(path_efi: &str) -> Vec<u8> {
100    let p = path_efi.replace('/', "\\");
101    let s = utf16_nul(&p);
102    let len = (4 + s.len()) as u16;
103    let mut v = Vec::with_capacity(len as usize);
104    v.extend_from_slice(&[0x04, 0x04]);
105    v.extend_from_slice(&len.to_le_bytes());
106    v.extend_from_slice(&s);
107    v
108}
109
110/// Nodo final. Sin él, el firmware sigue leyendo memoria que no es suya.
111pub fn end_node() -> Vec<u8> {
112    vec![0x7f, 0xff, 0x04, 0x00]
113}
114
115pub fn device_path(part: &HdPartition, path_efi: &str) -> Vec<u8> {
116    let mut v = hd_node(part);
117    v.extend(file_node(path_efi));
118    v.extend(end_node());
119    v
120}
121
122/// Serializa un `EFI_LOAD_OPTION` completo (sin los 4 bytes de atributos de efivarfs).
123pub fn load_option(description: &str, device_path: &[u8], optional_data: &[u8]) -> Vec<u8> {
124    let desc = utf16_nul(description);
125    let mut v = Vec::with_capacity(4 + 2 + desc.len() + device_path.len() + optional_data.len());
126    v.extend_from_slice(&LOAD_OPTION_ACTIVE.to_le_bytes());
127    v.extend_from_slice(&(device_path.len() as u16).to_le_bytes());
128    v.extend_from_slice(&desc);
129    v.extend_from_slice(device_path);
130    v.extend_from_slice(optional_data);
131    v
132}
133
134/// Decodifica un `EFI_LOAD_OPTION`. Es la mitad que permite COMPROBAR la otra: si `parse(build(x))`
135/// no devuelve `x`, el que está mal es el constructor, y eso se ve acá en vez de en el firmware.
136pub fn parse_load_option(b: &[u8]) -> Result<LoadOption, String> {
137    if b.len() < 6 {
138        return Err(format!("EFI_LOAD_OPTION truncado: {} bytes, mínimo 6", b.len()));
139    }
140    let attributes = u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
141    let fpl = u16::from_le_bytes([b[4], b[5]]) as usize;
142    // La descripción es UTF-16 hasta el primer u16 nulo.
143    let mut i = 6;
144    let mut units: Vec<u16> = Vec::new();
145    loop {
146        if i + 1 >= b.len() {
147            return Err("descripción sin terminador NUL".into());
148        }
149        let u = u16::from_le_bytes([b[i], b[i + 1]]);
150        i += 2;
151        if u == 0 {
152            break;
153        }
154        units.push(u);
155    }
156    let description = String::from_utf16_lossy(&units);
157    if i + fpl > b.len() {
158        return Err(format!(
159            "FilePathListLength={fpl} excede el buffer ({} bytes restantes)",
160            b.len() - i
161        ));
162    }
163    let file_path_list = b[i..i + fpl].to_vec();
164    let optional_data = b[i + fpl..].to_vec();
165    Ok(LoadOption { attributes, description, file_path_list, optional_data })
166}
167
168/// Ruta de la variable `Boot####` en efivarfs.
169pub fn boot_var_path(root: &Path, num: u16) -> PathBuf {
170    root.join(format!("Boot{num:04X}-{GLOBAL_GUID}"))
171}
172
173/// Escribe una variable EFI: los 4 bytes de atributos van PEGADOS al dato, y todo en UNA sola
174/// escritura — efivarfs rechaza la variable si llega partida en dos `write()`.
175pub fn write_var(path: &Path, attrs: u32, data: &[u8]) -> io::Result<()> {
176    use std::io::Write;
177    let mut buf = Vec::with_capacity(4 + data.len());
178    buf.extend_from_slice(&attrs.to_le_bytes());
179    buf.extend_from_slice(data);
180    let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).open(path)?;
181    f.write_all(&buf)?;
182    f.flush()
183}
184
185/// Lee una variable EFI y devuelve `(attrs, data)` ya sin la cabecera de 4 bytes.
186pub fn read_var(path: &Path) -> io::Result<(u32, Vec<u8>)> {
187    let raw = std::fs::read(path)?;
188    if raw.len() < 4 {
189        return Err(io::Error::new(
190            io::ErrorKind::InvalidData,
191            format!("variable EFI de {} bytes: no entra ni la cabecera de atributos", raw.len()),
192        ));
193    }
194    Ok((u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]), raw[4..].to_vec()))
195}
196
197/// `BootOrder` es un array de u16 little-endian: el orden en que el firmware prueba las entradas.
198pub fn parse_boot_order(data: &[u8]) -> Vec<u16> {
199    data.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
200}
201
202pub fn serialize_boot_order(order: &[u16]) -> Vec<u8> {
203    order.iter().flat_map(|n| n.to_le_bytes()).collect()
204}
205
206/// Pone `num` PRIMERO y conserva el resto sin duplicarlo. Idempotente a propósito: el reconciliador
207/// del ADR 0018 §2 corre en CADA arranque, así que la operación tiene que poder repetirse sin
208/// acumular basura ni alterar nada cuando ya está bien.
209pub fn promote_first(order: &[u16], num: u16) -> Vec<u16> {
210    let mut v = vec![num];
211    v.extend(order.iter().copied().filter(|&x| x != num));
212    v
213}
214
215/// Lee del DISCO los datos de la partición `number` para armar el nodo HARD_DRIVE.
216///
217/// Detecta sola la tabla: si el LBA 1 empieza con `EFI PART` es GPT; si no, se trata como MBR. Los
218/// dos casos hacen falta — `install-image-efi.sh` fabrica GPT y `takana-live-install.sh` instala
219/// sobre **MBR** (una ESP de tipo 0xEF en tabla MBR arranca igual por UEFI).
220///
221/// ⚠ Asume sectores lógicos de 512 B. En un disco 4Kn los offsets del GPT se corren y esto lee
222/// basura: por eso valida la firma y el número de partición en vez de confiar, y falla ruidosamente.
223pub fn read_partition(disk: &Path, number: u32) -> io::Result<HdPartition> {
224    use std::io::{Read, Seek, SeekFrom};
225    if number == 0 {
226        return Err(io::Error::new(io::ErrorKind::InvalidInput, "el nº de partición empieza en 1"));
227    }
228    let mut f = std::fs::File::open(disk)?;
229
230    // --- ¿GPT? El header vive en el LBA 1 y empieza con la firma "EFI PART". ---
231    let mut hdr = [0u8; 512];
232    f.seek(SeekFrom::Start(512))?;
233    f.read_exact(&mut hdr)?;
234    if &hdr[0..8] == b"EFI PART" {
235        let entry_lba = u64::from_le_bytes(hdr[72..80].try_into().unwrap());
236        let n_entries = u32::from_le_bytes(hdr[80..84].try_into().unwrap());
237        let entry_sz = u32::from_le_bytes(hdr[84..88].try_into().unwrap()) as usize;
238        if number > n_entries {
239            return Err(io::Error::new(
240                io::ErrorKind::InvalidInput,
241                format!("la partición {number} no existe: el GPT declara {n_entries}"),
242            ));
243        }
244        if entry_sz < 56 || entry_sz > 4096 {
245            return Err(io::Error::new(
246                io::ErrorKind::InvalidData,
247                format!("SizeOfPartitionEntry absurdo ({entry_sz}): ¿sectores de 4K?"),
248            ));
249        }
250        let off = entry_lba * 512 + (number as u64 - 1) * entry_sz as u64;
251        let mut e = vec![0u8; entry_sz];
252        f.seek(SeekFrom::Start(off))?;
253        f.read_exact(&mut e)?;
254        if e[0..16] == [0u8; 16] {
255            return Err(io::Error::new(
256                io::ErrorKind::InvalidInput,
257                format!("la entrada GPT {number} está VACÍA (PartitionTypeGUID todo ceros)"),
258            ));
259        }
260        let start = u64::from_le_bytes(e[32..40].try_into().unwrap());
261        let end = u64::from_le_bytes(e[40..48].try_into().unwrap());
262        let mut signature = [0u8; 16];
263        // El GUID se copia CRUDO, en el mismo orden mixto que tiene en disco: es lo que el firmware
264        // compara byte a byte contra su propia lectura de la tabla.
265        signature.copy_from_slice(&e[16..32]);
266        return Ok(HdPartition {
267            number,
268            start_lba: start,
269            size_lba: end.saturating_sub(start) + 1,
270            signature,
271            mbr_type: 2,
272            sig_type: 2,
273        });
274    }
275
276    // --- MBR: la "firma" son los 4 bytes del NT disk signature en 0x1B8. ---
277    let mut mbr = [0u8; 512];
278    f.seek(SeekFrom::Start(0))?;
279    f.read_exact(&mut mbr)?;
280    if mbr[510] != 0x55 || mbr[511] != 0xAA {
281        return Err(io::Error::new(
282            io::ErrorKind::InvalidData,
283            "ni GPT ni MBR: falta la firma 0x55AA del sector de arranque",
284        ));
285    }
286    if number > 4 {
287        return Err(io::Error::new(
288            io::ErrorKind::InvalidInput,
289            format!("MBR sólo tiene 4 primarias; pediste la {number}"),
290        ));
291    }
292    let e = &mbr[446 + (number as usize - 1) * 16..][..16];
293    if e[4] == 0 {
294        return Err(io::Error::new(
295            io::ErrorKind::InvalidInput,
296            format!("la partición MBR {number} está vacía (tipo 0x00)"),
297        ));
298    }
299    let start = u32::from_le_bytes(e[8..12].try_into().unwrap()) as u64;
300    let size = u32::from_le_bytes(e[12..16].try_into().unwrap()) as u64;
301    let mut signature = [0u8; 16];
302    signature[0..4].copy_from_slice(&mbr[0x1b8..0x1bc]);
303    Ok(HdPartition { number, start_lba: start, size_lba: size, signature, mbr_type: 1, sig_type: 1 })
304}
305
306/// GUID de tipo «EFI System Partition», tal como se almacena en la tabla GPT: los tres primeros
307/// campos van en little-endian y los dos últimos crudos. Escribirlo en el orden «legible» es el
308/// error clásico y no casaría con ninguna ESP del mundo.
309pub const ESP_TYPE_GUID: [u8; 16] = [
310    0x28, 0x73, 0x2a, 0xc1, 0x1f, 0xf8, 0xd2, 0x11, 0xba, 0x4b, 0x00, 0xa0, 0xc9, 0x3e, 0xc9, 0x3b,
311];
312
313/// Discos de bloque reales del sistema, sin los virtuales. Un `loop` o un `ram` con una tabla de
314/// particiones existe y NO es de donde arrancamos.
315pub fn block_disks(sys_block: &Path) -> io::Result<Vec<PathBuf>> {
316    let mut v = Vec::new();
317    for ent in std::fs::read_dir(sys_block)? {
318        let name = ent?.file_name().to_string_lossy().to_string();
319        if name.starts_with("loop")
320            || name.starts_with("ram")
321            || name.starts_with("zram")
322            || name.starts_with("dm-")
323            || name.starts_with("sr")
324        {
325            continue;
326        }
327        let dev = PathBuf::from("/dev").join(&name);
328        if dev.exists() {
329            v.push(dev);
330        }
331    }
332    v.sort();
333    Ok(v)
334}
335
336/// Todas las ESP que haya, como `(disco, nº de partición)`.
337///
338/// Busca por **tipo de partición**, que es el único dato fiable sin montar nada: GUID de ESP en GPT,
339/// tipo `0xEF` en MBR. No mira el contenido — para eso habría que montar la FAT, y un reconciliador
340/// que monta sistemas de ficheros en el arranque es un efecto secundario que no queremos.
341pub fn find_esps(disks: &[PathBuf]) -> Vec<(PathBuf, u32)> {
342    let mut out = Vec::new();
343    for d in disks {
344        // GPT: hasta 128 entradas es lo habitual; read_partition ya valida el límite real.
345        for n in 1..=128u32 {
346            match read_partition(d, n) {
347                Ok(_) => {
348                    if es_esp(d, n).unwrap_or(false) {
349                        out.push((d.clone(), n));
350                    }
351                }
352                // La primera entrada vacía en MBR no significa que las siguientes lo estén, pero un
353                // error de «no existe» sí corta: read_partition distingue los dos casos.
354                Err(e) if e.kind() == io::ErrorKind::InvalidInput && n > 4 => break,
355                Err(_) => continue,
356            }
357        }
358    }
359    out
360}
361
362/// ¿La partición `n` de `disk` es una ESP? Mira el tipo, no el contenido.
363fn es_esp(disk: &Path, n: u32) -> io::Result<bool> {
364    use std::io::{Read, Seek, SeekFrom};
365    let mut f = std::fs::File::open(disk)?;
366    let mut hdr = [0u8; 512];
367    f.seek(SeekFrom::Start(512))?;
368    f.read_exact(&mut hdr)?;
369    if &hdr[0..8] == b"EFI PART" {
370        let entry_lba = u64::from_le_bytes(hdr[72..80].try_into().unwrap());
371        let entry_sz = u32::from_le_bytes(hdr[84..88].try_into().unwrap()) as usize;
372        if !(56..=4096).contains(&entry_sz) {
373            return Ok(false);
374        }
375        let mut e = vec![0u8; entry_sz];
376        f.seek(SeekFrom::Start(entry_lba * 512 + (n as u64 - 1) * entry_sz as u64))?;
377        f.read_exact(&mut e)?;
378        return Ok(e[0..16] == ESP_TYPE_GUID);
379    }
380    // MBR: el tipo 0xEF es «EFI System Partition».
381    let mut mbr = [0u8; 512];
382    f.seek(SeekFrom::Start(0))?;
383    f.read_exact(&mut mbr)?;
384    if mbr[510] != 0x55 || mbr[511] != 0xAA || n > 4 {
385        return Ok(false);
386    }
387    Ok(mbr[446 + (n as usize - 1) * 16 + 4] == 0xEF)
388}
389
390/// ¿Esta máquina arrancó por UEFI? Sin esto no hay NVRAM que reconciliar, y hay que decirlo en vez
391/// de fallar con un error de fichero no encontrado que no explica nada.
392pub fn hay_firmware_efi(efivars: &Path) -> bool {
393    efivars.is_dir() && std::fs::read_dir(efivars).map(|mut d| d.next().is_some()).unwrap_or(false)
394}
395
396/// Busca el primer `Boot####` libre. Empieza en 0 porque es lo que hace todo el mundo y así el
397/// número queda chico y legible en el menú del firmware.
398pub fn first_free_slot(root: &Path) -> io::Result<u16> {
399    for n in 0u16..=0xFFFF {
400        if !boot_var_path(root, n).exists() {
401            return Ok(n);
402        }
403    }
404    Err(io::Error::new(io::ErrorKind::AlreadyExists, "no queda ni un Boot#### libre (0000-FFFF)"))
405}
406
407/// ¿Es una variable de **configuración** del arranque, y no estado de la sesión?
408///
409/// Lista blanca a propósito: sólo `BootOrder-<guid>` y `Boot####-<guid>`. Un «empieza por Boot» deja
410/// entrar `BootCurrent` (por dónde arrancó esta vez — cambia en cada arranque y es de sólo lectura),
411/// `BootOptionSupport` (informativa) y `BootNext` (de un solo uso: restaurarla dispararía un arranque
412/// que nadie pidió). Ninguna de las tres describe cómo debe arrancar la máquina.
413pub fn es_variable_de_config(nombre: &str) -> bool {
414    let Some((base, guid)) = nombre.rsplit_once('-') else { return false };
415    // El GUID lleva guiones: `rsplit_once` sólo corta el último trozo, así que se rearma mirando
416    // el sufijo completo.
417    let _ = (base, guid);
418    let Some(resto) = nombre.strip_suffix(&format!("-{GLOBAL_GUID}")) else { return false };
419    if resto == "BootOrder" {
420        return true;
421    }
422    match resto.strip_prefix("Boot") {
423        Some(n) => n.len() == 4 && n.chars().all(|c| c.is_ascii_hexdigit()),
424        None => false,
425    }
426}
427
428/// Lista las entradas `Boot####` presentes, en orden numérico, con su descripción ya decodificada.
429pub fn list_entries(root: &Path) -> io::Result<Vec<(u16, LoadOption)>> {
430    let mut out = Vec::new();
431    for ent in std::fs::read_dir(root)? {
432        let ent = ent?;
433        let name = ent.file_name().to_string_lossy().to_string();
434        let Some(rest) = name.strip_prefix("Boot") else { continue };
435        let Some((num, guid)) = rest.split_once('-') else { continue };
436        if !guid.eq_ignore_ascii_case(GLOBAL_GUID) || num.len() != 4 {
437            continue;
438        }
439        let Ok(n) = u16::from_str_radix(num, 16) else { continue };
440        let Ok((_, data)) = read_var(&ent.path()) else { continue };
441        if let Ok(lo) = parse_load_option(&data) {
442            out.push((n, lo));
443        }
444    }
445    out.sort_by_key(|(n, _)| *n);
446    Ok(out)
447}
448
449/// Qué tuvo que hacer una reconciliación. El ADR 0018 §2 exige que el reconciliador **reporte
450/// cuando actúa**: si repara en silencio, el usuario convive con una rareza intermitente —«a veces
451/// arranca Windows solo»— sin enterarse nunca de que hay un vecino pisando.
452#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct Reconciliacion {
454    pub slot: u16,
455    pub entrada_escrita: bool,
456    pub orden_cambiado: bool,
457    pub orden_antes: Vec<u16>,
458    pub orden_despues: Vec<u16>,
459}
460
461impl Reconciliacion {
462    /// ¿Hubo que tocar algo? Falso en el caso normal de un sistema que nadie pisó.
463    pub fn actuo(&self) -> bool {
464        self.entrada_escrita || self.orden_cambiado
465    }
466}
467
468/// Deja la NVRAM diciendo lo que debe decir: una entrada para `loader` en `part`, y esa entrada
469/// primera en `BootOrder`. **Idempotente**: si ya está así no escribe nada — importa porque esto
470/// corre en cada arranque y la NVRAM tiene un número finito de ciclos de escritura.
471pub fn reconcile(
472    efivars: &Path,
473    part: &HdPartition,
474    loader: &str,
475    label: &str,
476    dry_run: bool,
477) -> io::Result<Reconciliacion> {
478    let dp = device_path(part, loader);
479    let lo = load_option(label, &dp, &[]);
480
481    let ya = list_entries(efivars)
482        .unwrap_or_default()
483        .into_iter()
484        .find(|(_, e)| e.description == label && e.file_path_list == dp);
485
486    let (slot, entrada_escrita) = match ya {
487        Some((n, _)) => (n, false),
488        None => {
489            let n = first_free_slot(efivars)?;
490            if !dry_run {
491                write_var(&boot_var_path(efivars, n), ATTRS, &lo)?;
492            }
493            (n, true)
494        }
495    };
496
497    let ruta_orden = efivars.join(format!("BootOrder-{GLOBAL_GUID}"));
498    let orden_antes =
499        read_var(&ruta_orden).map(|(_, d)| parse_boot_order(&d)).unwrap_or_default();
500    let orden_despues = promote_first(&orden_antes, slot);
501    let orden_cambiado = orden_despues != orden_antes;
502    if orden_cambiado && !dry_run {
503        write_var(&ruta_orden, ATTRS, &serialize_boot_order(&orden_despues))?;
504    }
505
506    Ok(Reconciliacion { slot, entrada_escrita, orden_cambiado, orden_antes, orden_despues })
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    fn parte() -> HdPartition {
514        HdPartition {
515            number: 1,
516            start_lba: 2048,
517            size_lba: 131_072,
518            signature: [
519                0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
520                0xff, 0x00,
521            ],
522            mbr_type: 2,
523            sig_type: 2,
524        }
525    }
526
527    #[test]
528    fn hd_node_mide_42_y_pone_los_campos_donde_van() {
529        let n = hd_node(&parte());
530        assert_eq!(n.len(), 42, "el nodo HARD_DRIVE es de tamaño fijo por spec");
531        assert_eq!(&n[0..2], &[0x04, 0x01], "type/subtype MEDIA/HARD_DRIVE");
532        assert_eq!(u16::from_le_bytes([n[2], n[3]]), 42, "length se cuenta CON la cabecera");
533        assert_eq!(u32::from_le_bytes([n[4], n[5], n[6], n[7]]), 1);
534        assert_eq!(n[40], 2, "GPT");
535        assert_eq!(n[41], 2, "firma = GUID");
536    }
537
538    #[test]
539    fn file_node_usa_backslash_y_utf16() {
540        let n = file_node("/EFI/takana/takanax64.efi");
541        assert_eq!(&n[0..2], &[0x04, 0x04]);
542        let len = u16::from_le_bytes([n[2], n[3]]) as usize;
543        assert_eq!(len, n.len(), "el length declarado es el real");
544        let units: Vec<u16> =
545            n[4..].chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
546        let s = String::from_utf16_lossy(&units[..units.len() - 1]);
547        assert_eq!(s, "\\EFI\\takana\\takanax64.efi", "la spec separa con backslash");
548    }
549
550    #[test]
551    fn round_trip_de_una_entrada_completa() {
552        let dp = device_path(&parte(), "/EFI/takana/takanax64.efi");
553        let lo = load_option("takana", &dp, &[]);
554        let p = parse_load_option(&lo).expect("debe parsear lo que acabamos de construir");
555        assert_eq!(p.description, "takana");
556        assert_eq!(p.attributes, LOAD_OPTION_ACTIVE);
557        assert_eq!(p.file_path_list, dp, "el device path vuelve entero y sin corrimiento");
558        assert!(p.optional_data.is_empty());
559    }
560
561    #[test]
562    fn el_device_path_termina_en_el_nodo_final() {
563        let dp = device_path(&parte(), "/EFI/BOOT/BOOTX64.EFI");
564        assert_eq!(&dp[dp.len() - 4..], &[0x7f, 0xff, 0x04, 0x00], "sin END el firmware se va de rango");
565    }
566
567    #[test]
568    fn parse_rechaza_lo_truncado_en_vez_de_inventar() {
569        assert!(parse_load_option(&[1, 0, 0]).is_err(), "6 bytes es el mínimo");
570        // FilePathListLength que miente: dice más de lo que hay.
571        let mut malo = load_option("x", &device_path(&parte(), "/a.efi"), &[]);
572        malo[4] = 0xff;
573        malo[5] = 0xff;
574        assert!(parse_load_option(&malo).is_err(), "un fpl imposible debe FALLAR, no truncar");
575    }
576
577    #[test]
578    fn promote_first_es_idempotente_y_no_duplica() {
579        let o = vec![3u16, 1, 2];
580        let a = promote_first(&o, 1);
581        assert_eq!(a, vec![1, 3, 2]);
582        assert_eq!(promote_first(&a, 1), a, "repetirlo no cambia nada: corre en CADA arranque");
583        assert_eq!(promote_first(&[], 5), vec![5], "BootOrder vacío también es un caso");
584    }
585
586    /// Fabrica un disco GPT sintético (sin sfdisk: bytes a mano, determinista) con UNA partición.
587    fn disco_gpt(tmp: &Path) -> PathBuf {
588        let mut d = vec![0u8; 512 * 40];
589        d[510] = 0x55;
590        d[511] = 0xAA; // MBR protector
591        let h = 512; // LBA 1 = header GPT
592        d[h..h + 8].copy_from_slice(b"EFI PART");
593        d[h + 72..h + 80].copy_from_slice(&2u64.to_le_bytes()); // PartitionEntryLBA
594        d[h + 80..h + 84].copy_from_slice(&128u32.to_le_bytes()); // NumberOfPartitionEntries
595        d[h + 84..h + 88].copy_from_slice(&128u32.to_le_bytes()); // SizeOfPartitionEntry
596        let e = 512 * 2; // LBA 2 = primera entrada
597        d[e..e + 16].copy_from_slice(&[0xAA; 16]); // PartitionTypeGUID (no nulo = ocupada)
598        d[e + 16..e + 32].copy_from_slice(&[0x5A; 16]); // UniquePartitionGUID
599        d[e + 32..e + 40].copy_from_slice(&2048u64.to_le_bytes()); // StartingLBA
600        d[e + 40..e + 48].copy_from_slice(&133_119u64.to_le_bytes()); // EndingLBA (inclusive)
601        let f = tmp.join("gpt.img");
602        std::fs::write(&f, &d).unwrap();
603        f
604    }
605
606    #[test]
607    fn lee_una_particion_gpt_con_los_numeros_exactos() {
608        let tmp = std::env::temp_dir().join(format!("takana-efi-gpt-{}", std::process::id()));
609        std::fs::create_dir_all(&tmp).unwrap();
610        let p = read_partition(&disco_gpt(&tmp), 1).expect("debe leer la partición 1");
611        assert_eq!(p.number, 1);
612        assert_eq!(p.start_lba, 2048);
613        // size = end - start + 1, y el +1 importa: es un rango INCLUSIVO.
614        assert_eq!(p.size_lba, 131_072, "EndingLBA es inclusivo");
615        assert_eq!(p.signature, [0x5A; 16], "la firma es el GUID ÚNICO, no el de tipo");
616        assert_eq!((p.mbr_type, p.sig_type), (2, 2), "GPT + firma GUID");
617        std::fs::remove_dir_all(&tmp).ok();
618    }
619
620    #[test]
621    fn rechaza_lo_que_no_es_tabla_y_la_particion_vacia() {
622        let tmp = std::env::temp_dir().join(format!("takana-efi-neg-{}", std::process::id()));
623        std::fs::create_dir_all(&tmp).unwrap();
624        // Un disco sin firma 0x55AA no es ni GPT ni MBR: debe FALLAR, no devolver ceros.
625        let basura = tmp.join("basura.img");
626        std::fs::write(&basura, vec![0u8; 512 * 8]).unwrap();
627        assert!(read_partition(&basura, 1).is_err(), "sin 0x55AA no hay tabla que leer");
628        // Y una entrada GPT vacía tampoco se convierte en una partición de tamaño 0.
629        let g = disco_gpt(&tmp);
630        assert!(read_partition(&g, 2).is_err(), "la entrada 2 está vacía: error, no ceros");
631        assert!(read_partition(&g, 0).is_err(), "las particiones empiezan en 1");
632        std::fs::remove_dir_all(&tmp).ok();
633    }
634
635    #[test]
636    fn lee_una_particion_mbr() {
637        let tmp = std::env::temp_dir().join(format!("takana-efi-mbr-{}", std::process::id()));
638        std::fs::create_dir_all(&tmp).unwrap();
639        let mut d = vec![0u8; 512 * 4];
640        d[0x1b8..0x1bc].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); // NT disk signature
641        let e = 446;
642        d[e + 4] = 0xEF; // tipo ESP
643        d[e + 8..e + 12].copy_from_slice(&2048u32.to_le_bytes());
644        d[e + 12..e + 16].copy_from_slice(&131_072u32.to_le_bytes());
645        d[510] = 0x55;
646        d[511] = 0xAA;
647        let f = tmp.join("mbr.img");
648        std::fs::write(&f, &d).unwrap();
649        let p = read_partition(&f, 1).expect("debe leer la ESP del MBR");
650        assert_eq!((p.start_lba, p.size_lba), (2048, 131_072));
651        assert_eq!(&p.signature[0..4], &[0xDE, 0xAD, 0xBE, 0xEF]);
652        assert_eq!((p.mbr_type, p.sig_type), (1, 1), "MBR + firma de MBR");
653        std::fs::remove_dir_all(&tmp).ok();
654    }
655
656    #[test]
657    fn el_guid_de_esp_esta_en_el_orden_DE_DISCO_y_no_en_el_legible() {
658        // C12A7328-F81F-11D2-BA4B-00A0C93EC93B: los TRES primeros campos van en little-endian y los
659        // dos últimos crudos. Escribirlo "como se lee" es el error clásico y no casaría con ninguna
660        // ESP real — este test es la única forma de que se note acá y no en una máquina.
661        assert_eq!(&ESP_TYPE_GUID[0..4], &[0x28, 0x73, 0x2a, 0xc1], "Data1 va invertido");
662        assert_eq!(&ESP_TYPE_GUID[4..6], &[0x1f, 0xf8], "Data2 va invertido");
663        assert_eq!(&ESP_TYPE_GUID[6..8], &[0xd2, 0x11], "Data3 va invertido");
664        assert_eq!(&ESP_TYPE_GUID[8..16], &[0xba, 0x4b, 0x00, 0xa0, 0xc9, 0x3e, 0xc9, 0x3b], "Data4 crudo");
665    }
666
667    #[test]
668    fn encuentra_la_esp_por_tipo_y_no_se_inventa_otras() {
669        let tmp = std::env::temp_dir().join(format!("takana-efi-esp-{}", std::process::id()));
670        std::fs::create_dir_all(&tmp).unwrap();
671        // Disco con DOS particiones: la 1 es ESP, la 2 no. Sólo debe salir la 1.
672        let mut d = vec![0u8; 512 * 40];
673        d[510] = 0x55;
674        d[511] = 0xAA;
675        let h = 512;
676        d[h..h + 8].copy_from_slice(b"EFI PART");
677        d[h + 72..h + 80].copy_from_slice(&2u64.to_le_bytes());
678        d[h + 80..h + 84].copy_from_slice(&128u32.to_le_bytes());
679        d[h + 84..h + 88].copy_from_slice(&128u32.to_le_bytes());
680        let e1 = 512 * 2;
681        d[e1..e1 + 16].copy_from_slice(&ESP_TYPE_GUID);
682        d[e1 + 16..e1 + 32].copy_from_slice(&[0x5A; 16]);
683        d[e1 + 32..e1 + 40].copy_from_slice(&2048u64.to_le_bytes());
684        d[e1 + 40..e1 + 48].copy_from_slice(&133_119u64.to_le_bytes());
685        let e2 = e1 + 128;
686        d[e2..e2 + 16].copy_from_slice(&[0xBB; 16]); // otro tipo cualquiera
687        d[e2 + 16..e2 + 32].copy_from_slice(&[0x6B; 16]);
688        d[e2 + 32..e2 + 40].copy_from_slice(&133_120u64.to_le_bytes());
689        d[e2 + 40..e2 + 48].copy_from_slice(&264_191u64.to_le_bytes());
690        let f = tmp.join("dos.img");
691        std::fs::write(&f, &d).unwrap();
692        let esps = find_esps(std::slice::from_ref(&f));
693        assert_eq!(esps.len(), 1, "hay UNA sola ESP, no dos: {esps:?}");
694        assert_eq!(esps[0].1, 1, "y es la partición 1");
695        std::fs::remove_dir_all(&tmp).ok();
696    }
697
698    #[test]
699    fn sin_firmware_efi_lo_dice_en_vez_de_fallar_raro() {
700        let tmp = std::env::temp_dir().join(format!("takana-efi-novars-{}", std::process::id()));
701        std::fs::create_dir_all(&tmp).unwrap();
702        assert!(!hay_firmware_efi(&tmp), "un directorio VACÍO no es un firmware EFI");
703        assert!(!hay_firmware_efi(&tmp.join("no-existe")), "y uno que no existe, tampoco");
704        std::fs::write(tmp.join("algo"), b"x").unwrap();
705        assert!(hay_firmware_efi(&tmp), "con al menos una variable, sí");
706        std::fs::remove_dir_all(&tmp).ok();
707    }
708
709    /// El escenario que motiva el ADR entero: otro sistema operativo se instala, crea su entrada y
710    /// se pone PRIMERO. El reconciliador debe reponer el orden — y **no** borrar al vecino.
711    #[test]
712    fn el_vecino_se_pone_primero_y_el_reconciliador_lo_repone_sin_borrarlo() {
713        let tmp = std::env::temp_dir().join(format!("takana-efi-vecino-{}", std::process::id()));
714        std::fs::create_dir_all(&tmp).unwrap();
715        let p = parte();
716
717        // 1) takana se instala.
718        let r1 = reconcile(&tmp, &p, "/EFI/takana/takanax64.efi", "takana", false).unwrap();
719        assert!(r1.entrada_escrita, "la primera vez hay que escribirla");
720        assert_eq!(r1.orden_despues, vec![r1.slot]);
721
722        // 2) llega el vecino: crea su entrada y se pone primero en BootOrder.
723        let vecino = load_option("Windows Boot Manager", &end_node(), &[]);
724        let slot_vecino = first_free_slot(&tmp).unwrap();
725        write_var(&boot_var_path(&tmp, slot_vecino), ATTRS, &vecino).unwrap();
726        let ruta_orden = tmp.join(format!("BootOrder-{GLOBAL_GUID}"));
727        write_var(&ruta_orden, ATTRS, &serialize_boot_order(&[slot_vecino, r1.slot])).unwrap();
728
729        // 3) siguiente arranque de takana.
730        let r2 = reconcile(&tmp, &p, "/EFI/takana/takanax64.efi", "takana", false).unwrap();
731        assert!(r2.actuo(), "tiene que DARSE CUENTA de que lo movieron");
732        assert!(!r2.entrada_escrita, "la entrada seguía ahí: sólo cambió el orden");
733        assert!(r2.orden_cambiado);
734        assert_eq!(r2.orden_despues[0], r1.slot, "takana vuelve a ser la primera");
735        assert!(
736            r2.orden_despues.contains(&slot_vecino),
737            "y el vecino SIGUE en la lista: takana se pone primera, no lo borra"
738        );
739        assert!(
740            boot_var_path(&tmp, slot_vecino).exists(),
741            "la entrada del vecino no se toca nunca"
742        );
743
744        // 4) y el arranque siguiente, con todo en su sitio, no hace nada.
745        let r3 = reconcile(&tmp, &p, "/EFI/takana/takanax64.efi", "takana", false).unwrap();
746        assert!(!r3.actuo(), "sin cambios no se escribe: la NVRAM tiene ciclos finitos");
747        std::fs::remove_dir_all(&tmp).ok();
748    }
749
750    #[test]
751    fn dry_run_no_escribe_ni_un_byte() {
752        let tmp = std::env::temp_dir().join(format!("takana-efi-dry-{}", std::process::id()));
753        std::fs::create_dir_all(&tmp).unwrap();
754        let r = reconcile(&tmp, &parte(), "/EFI/takana/takanax64.efi", "takana", true).unwrap();
755        assert!(r.actuo(), "reporta lo que HARÍA");
756        assert_eq!(std::fs::read_dir(&tmp).unwrap().count(), 0, "y no dejó nada en el directorio");
757        std::fs::remove_dir_all(&tmp).ok();
758    }
759
760    #[test]
761    fn la_lista_blanca_deja_fuera_el_estado_de_la_sesion() {
762        let g = GLOBAL_GUID;
763        assert!(es_variable_de_config(&format!("BootOrder-{g}")));
764        assert!(es_variable_de_config(&format!("Boot0004-{g}")));
765        assert!(es_variable_de_config(&format!("BootFFFF-{g}")));
766        // Las tres que rompían la idempotencia del respaldo o son de sólo lectura:
767        assert!(!es_variable_de_config(&format!("BootCurrent-{g}")), "cambia en CADA arranque");
768        assert!(!es_variable_de_config(&format!("BootNext-{g}")), "es de un solo uso");
769        assert!(!es_variable_de_config(&format!("BootOptionSupport-{g}")), "informativa");
770        // Y nada de otro namespace de GUID, aunque se llame parecido.
771        assert!(!es_variable_de_config("Boot0004-11111111-2222-3333-4444-555555555555"));
772        assert!(!es_variable_de_config("Boot004-{g}"), "3 dígitos no es un slot");
773        assert!(!es_variable_de_config("BootOrder"), "sin GUID no es una variable EFI");
774    }
775
776    #[test]
777    fn boot_order_ida_y_vuelta() {
778        let o = vec![0x0001u16, 0x0000, 0x2001];
779        assert_eq!(parse_boot_order(&serialize_boot_order(&o)), o);
780        assert_eq!(parse_boot_order(&[1]), Vec::<u16>::new(), "un byte suelto se descarta, no rompe");
781    }
782}