diff --git a/crates/takana-cli/src/efi_boot.rs b/crates/takana-cli/src/efi_boot.rs index 6821748d..3bae81e9 100644 --- a/crates/takana-cli/src/efi_boot.rs +++ b/crates/takana-cli/src/efi_boot.rs @@ -303,6 +303,96 @@ pub fn read_partition(disk: &Path, number: u32) -> io::Result { Ok(HdPartition { number, start_lba: start, size_lba: size, signature, mbr_type: 1, sig_type: 1 }) } +/// GUID de tipo «EFI System Partition», tal como se almacena en la tabla GPT: los tres primeros +/// campos van en little-endian y los dos últimos crudos. Escribirlo en el orden «legible» es el +/// error clásico y no casaría con ninguna ESP del mundo. +pub const ESP_TYPE_GUID: [u8; 16] = [ + 0x28, 0x73, 0x2a, 0xc1, 0x1f, 0xf8, 0xd2, 0x11, 0xba, 0x4b, 0x00, 0xa0, 0xc9, 0x3e, 0xc9, 0x3b, +]; + +/// Discos de bloque reales del sistema, sin los virtuales. Un `loop` o un `ram` con una tabla de +/// particiones existe y NO es de donde arrancamos. +pub fn block_disks(sys_block: &Path) -> io::Result> { + let mut v = Vec::new(); + for ent in std::fs::read_dir(sys_block)? { + let name = ent?.file_name().to_string_lossy().to_string(); + if name.starts_with("loop") + || name.starts_with("ram") + || name.starts_with("zram") + || name.starts_with("dm-") + || name.starts_with("sr") + { + continue; + } + let dev = PathBuf::from("/dev").join(&name); + if dev.exists() { + v.push(dev); + } + } + v.sort(); + Ok(v) +} + +/// Todas las ESP que haya, como `(disco, nº de partición)`. +/// +/// Busca por **tipo de partición**, que es el único dato fiable sin montar nada: GUID de ESP en GPT, +/// tipo `0xEF` en MBR. No mira el contenido — para eso habría que montar la FAT, y un reconciliador +/// que monta sistemas de ficheros en el arranque es un efecto secundario que no queremos. +pub fn find_esps(disks: &[PathBuf]) -> Vec<(PathBuf, u32)> { + let mut out = Vec::new(); + for d in disks { + // GPT: hasta 128 entradas es lo habitual; read_partition ya valida el límite real. + for n in 1..=128u32 { + match read_partition(d, n) { + Ok(_) => { + if es_esp(d, n).unwrap_or(false) { + out.push((d.clone(), n)); + } + } + // La primera entrada vacía en MBR no significa que las siguientes lo estén, pero un + // error de «no existe» sí corta: read_partition distingue los dos casos. + Err(e) if e.kind() == io::ErrorKind::InvalidInput && n > 4 => break, + Err(_) => continue, + } + } + } + out +} + +/// ¿La partición `n` de `disk` es una ESP? Mira el tipo, no el contenido. +fn es_esp(disk: &Path, n: u32) -> io::Result { + use std::io::{Read, Seek, SeekFrom}; + let mut f = std::fs::File::open(disk)?; + 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 entry_sz = u32::from_le_bytes(hdr[84..88].try_into().unwrap()) as usize; + if !(56..=4096).contains(&entry_sz) { + return Ok(false); + } + let mut e = vec![0u8; entry_sz]; + f.seek(SeekFrom::Start(entry_lba * 512 + (n as u64 - 1) * entry_sz as u64))?; + f.read_exact(&mut e)?; + return Ok(e[0..16] == ESP_TYPE_GUID); + } + // MBR: el tipo 0xEF es «EFI System Partition». + let mut mbr = [0u8; 512]; + f.seek(SeekFrom::Start(0))?; + f.read_exact(&mut mbr)?; + if mbr[510] != 0x55 || mbr[511] != 0xAA || n > 4 { + return Ok(false); + } + Ok(mbr[446 + (n as usize - 1) * 16 + 4] == 0xEF) +} + +/// ¿Esta máquina arrancó por UEFI? Sin esto no hay NVRAM que reconciliar, y hay que decirlo en vez +/// de fallar con un error de fichero no encontrado que no explica nada. +pub fn hay_firmware_efi(efivars: &Path) -> bool { + efivars.is_dir() && std::fs::read_dir(efivars).map(|mut d| d.next().is_some()).unwrap_or(false) +} + /// 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 { @@ -335,6 +425,66 @@ pub fn list_entries(root: &Path) -> io::Result> { Ok(out) } +/// Qué tuvo que hacer una reconciliación. El ADR 0018 §2 exige que el reconciliador **reporte +/// cuando actúa**: si repara en silencio, el usuario convive con una rareza intermitente —«a veces +/// arranca Windows solo»— sin enterarse nunca de que hay un vecino pisando. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Reconciliacion { + pub slot: u16, + pub entrada_escrita: bool, + pub orden_cambiado: bool, + pub orden_antes: Vec, + pub orden_despues: Vec, +} + +impl Reconciliacion { + /// ¿Hubo que tocar algo? Falso en el caso normal de un sistema que nadie pisó. + pub fn actuo(&self) -> bool { + self.entrada_escrita || self.orden_cambiado + } +} + +/// Deja la NVRAM diciendo lo que debe decir: una entrada para `loader` en `part`, y esa entrada +/// primera en `BootOrder`. **Idempotente**: si ya está así no escribe nada — importa porque esto +/// corre en cada arranque y la NVRAM tiene un número finito de ciclos de escritura. +pub fn reconcile( + efivars: &Path, + part: &HdPartition, + loader: &str, + label: &str, + dry_run: bool, +) -> io::Result { + let dp = device_path(part, loader); + let lo = load_option(label, &dp, &[]); + + let ya = list_entries(efivars) + .unwrap_or_default() + .into_iter() + .find(|(_, e)| e.description == label && e.file_path_list == dp); + + let (slot, entrada_escrita) = match ya { + Some((n, _)) => (n, false), + None => { + let n = first_free_slot(efivars)?; + if !dry_run { + write_var(&boot_var_path(efivars, n), ATTRS, &lo)?; + } + (n, true) + } + }; + + let ruta_orden = efivars.join(format!("BootOrder-{GLOBAL_GUID}")); + let orden_antes = + read_var(&ruta_orden).map(|(_, d)| parse_boot_order(&d)).unwrap_or_default(); + let orden_despues = promote_first(&orden_antes, slot); + let orden_cambiado = orden_despues != orden_antes; + if orden_cambiado && !dry_run { + write_var(&ruta_orden, ATTRS, &serialize_boot_order(&orden_despues))?; + } + + Ok(Reconciliacion { slot, entrada_escrita, orden_cambiado, orden_antes, orden_despues }) +} + #[cfg(test)] mod tests { use super::*; @@ -482,6 +632,110 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } + #[test] + fn el_guid_de_esp_esta_en_el_orden_DE_DISCO_y_no_en_el_legible() { + // C12A7328-F81F-11D2-BA4B-00A0C93EC93B: los TRES primeros campos van en little-endian y los + // dos últimos crudos. Escribirlo "como se lee" es el error clásico y no casaría con ninguna + // ESP real — este test es la única forma de que se note acá y no en una máquina. + assert_eq!(&ESP_TYPE_GUID[0..4], &[0x28, 0x73, 0x2a, 0xc1], "Data1 va invertido"); + assert_eq!(&ESP_TYPE_GUID[4..6], &[0x1f, 0xf8], "Data2 va invertido"); + assert_eq!(&ESP_TYPE_GUID[6..8], &[0xd2, 0x11], "Data3 va invertido"); + assert_eq!(&ESP_TYPE_GUID[8..16], &[0xba, 0x4b, 0x00, 0xa0, 0xc9, 0x3e, 0xc9, 0x3b], "Data4 crudo"); + } + + #[test] + fn encuentra_la_esp_por_tipo_y_no_se_inventa_otras() { + let tmp = std::env::temp_dir().join(format!("takana-efi-esp-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + // Disco con DOS particiones: la 1 es ESP, la 2 no. Sólo debe salir la 1. + let mut d = vec![0u8; 512 * 40]; + d[510] = 0x55; + d[511] = 0xAA; + let h = 512; + d[h..h + 8].copy_from_slice(b"EFI PART"); + d[h + 72..h + 80].copy_from_slice(&2u64.to_le_bytes()); + d[h + 80..h + 84].copy_from_slice(&128u32.to_le_bytes()); + d[h + 84..h + 88].copy_from_slice(&128u32.to_le_bytes()); + let e1 = 512 * 2; + d[e1..e1 + 16].copy_from_slice(&ESP_TYPE_GUID); + d[e1 + 16..e1 + 32].copy_from_slice(&[0x5A; 16]); + d[e1 + 32..e1 + 40].copy_from_slice(&2048u64.to_le_bytes()); + d[e1 + 40..e1 + 48].copy_from_slice(&133_119u64.to_le_bytes()); + let e2 = e1 + 128; + d[e2..e2 + 16].copy_from_slice(&[0xBB; 16]); // otro tipo cualquiera + d[e2 + 16..e2 + 32].copy_from_slice(&[0x6B; 16]); + d[e2 + 32..e2 + 40].copy_from_slice(&133_120u64.to_le_bytes()); + d[e2 + 40..e2 + 48].copy_from_slice(&264_191u64.to_le_bytes()); + let f = tmp.join("dos.img"); + std::fs::write(&f, &d).unwrap(); + let esps = find_esps(std::slice::from_ref(&f)); + assert_eq!(esps.len(), 1, "hay UNA sola ESP, no dos: {esps:?}"); + assert_eq!(esps[0].1, 1, "y es la partición 1"); + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn sin_firmware_efi_lo_dice_en_vez_de_fallar_raro() { + let tmp = std::env::temp_dir().join(format!("takana-efi-novars-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + assert!(!hay_firmware_efi(&tmp), "un directorio VACÍO no es un firmware EFI"); + assert!(!hay_firmware_efi(&tmp.join("no-existe")), "y uno que no existe, tampoco"); + std::fs::write(tmp.join("algo"), b"x").unwrap(); + assert!(hay_firmware_efi(&tmp), "con al menos una variable, sí"); + std::fs::remove_dir_all(&tmp).ok(); + } + + /// El escenario que motiva el ADR entero: otro sistema operativo se instala, crea su entrada y + /// se pone PRIMERO. El reconciliador debe reponer el orden — y **no** borrar al vecino. + #[test] + fn el_vecino_se_pone_primero_y_el_reconciliador_lo_repone_sin_borrarlo() { + let tmp = std::env::temp_dir().join(format!("takana-efi-vecino-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let p = parte(); + + // 1) takana se instala. + let r1 = reconcile(&tmp, &p, "/EFI/takana/takanax64.efi", "takana", false).unwrap(); + assert!(r1.entrada_escrita, "la primera vez hay que escribirla"); + assert_eq!(r1.orden_despues, vec![r1.slot]); + + // 2) llega el vecino: crea su entrada y se pone primero en BootOrder. + let vecino = load_option("Windows Boot Manager", &end_node(), &[]); + let slot_vecino = first_free_slot(&tmp).unwrap(); + write_var(&boot_var_path(&tmp, slot_vecino), ATTRS, &vecino).unwrap(); + let ruta_orden = tmp.join(format!("BootOrder-{GLOBAL_GUID}")); + write_var(&ruta_orden, ATTRS, &serialize_boot_order(&[slot_vecino, r1.slot])).unwrap(); + + // 3) siguiente arranque de takana. + let r2 = reconcile(&tmp, &p, "/EFI/takana/takanax64.efi", "takana", false).unwrap(); + assert!(r2.actuo(), "tiene que DARSE CUENTA de que lo movieron"); + assert!(!r2.entrada_escrita, "la entrada seguía ahí: sólo cambió el orden"); + assert!(r2.orden_cambiado); + assert_eq!(r2.orden_despues[0], r1.slot, "takana vuelve a ser la primera"); + assert!( + r2.orden_despues.contains(&slot_vecino), + "y el vecino SIGUE en la lista: takana se pone primera, no lo borra" + ); + assert!( + boot_var_path(&tmp, slot_vecino).exists(), + "la entrada del vecino no se toca nunca" + ); + + // 4) y el arranque siguiente, con todo en su sitio, no hace nada. + let r3 = reconcile(&tmp, &p, "/EFI/takana/takanax64.efi", "takana", false).unwrap(); + assert!(!r3.actuo(), "sin cambios no se escribe: la NVRAM tiene ciclos finitos"); + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn dry_run_no_escribe_ni_un_byte() { + let tmp = std::env::temp_dir().join(format!("takana-efi-dry-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let r = reconcile(&tmp, &parte(), "/EFI/takana/takanax64.efi", "takana", true).unwrap(); + assert!(r.actuo(), "reporta lo que HARÍA"); + assert_eq!(std::fs::read_dir(&tmp).unwrap().count(), 0, "y no dejó nada en el directorio"); + std::fs::remove_dir_all(&tmp).ok(); + } + #[test] fn boot_order_ida_y_vuelta() { let o = vec![0x0001u16, 0x0000, 0x2001]; diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs index fa750f54..6697884b 100644 --- a/crates/takana-cli/src/main.rs +++ b/crates/takana-cli/src/main.rs @@ -623,6 +623,35 @@ enum EntryCmd { #[arg(long, default_value = crate::efi_boot::EFIVARS)] efivars: PathBuf, }, + /// Reconcile this system's boot entry against the firmware, finding the ESP by itself. + /// + /// Pensado para correr **en cada arranque** (ADR 0018 §2): la NVRAM es estado compartido que + /// otro sistema operativo reescribe sin avisar, y contra eso no sirve confiar, sirve converger. + /// No falla el arranque por nada: si la máquina no arrancó por UEFI, o no hay una ESP clara, lo + /// DICE y sale limpio. + Reconcile { + /// 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, + /// Where to enumerate block devices from. + #[arg(long, default_value = "/sys/block")] + sys_block: PathBuf, + /// Disk to use instead of auto-detecting (needed when several ESPs exist). + #[arg(long)] + disk: Option, + /// 1-based partition number, only with --disk. + #[arg(long, default_value_t = 1)] + partition: u32, + /// Report what it would do and touch nothing. + #[arg(long)] + dry_run: bool, + }, /// 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 @@ -1542,6 +1571,73 @@ fn main() -> anyhow::Result<()> { nuevo.iter().map(|n| format!("{n:04X}")).collect::>().join(",")); } } + EntryCmd::Reconcile { loader, label, efivars, sys_block, disk, partition, dry_run } => { + // 1) ¿Hay firmware EFI? Sin NVRAM no hay nada que reconciliar, y eso NO es + // un error: puede ser una máquina que arranca por BIOS. Salir limpio. + if !efi_boot::hay_firmware_efi(&efivars) { + println!("boot entry reconcile: sin firmware EFI en {} — nada que hacer", efivars.display()); + println!(" (esta máquina no arrancó por UEFI; el arranque sigue normal)"); + return Ok(()); + } + + // 2) ¿Cuál es nuestra ESP? Se busca por TIPO de partición. Si hay varias no + // se adivina: elegir mal la ESP significa escribir una entrada que apunta + // a un disco que puede no estar, y eso es peor que no escribir nada. + let parte = match disk { + Some(d) => efi_boot::read_partition(&d, partition)?, + None => { + let discos = efi_boot::block_disks(&sys_block).unwrap_or_default(); + let esps = efi_boot::find_esps(&discos); + match esps.len() { + 1 => { + let (d, n) = &esps[0]; + println!("boot entry reconcile: ESP detectada en {} p{n}", d.display()); + efi_boot::read_partition(d, *n)? + } + 0 => { + println!("boot entry reconcile: arrancamos por UEFI pero NO encuentro ninguna ESP"); + println!(" discos mirados: {}", if discos.is_empty() { + "(ninguno)".to_string() + } else { + discos.iter().map(|d| d.display().to_string()).collect::>().join(" ") + }); + println!(" → pasá --disk/--partition. No se tocó la NVRAM."); + return Ok(()); + } + _ => { + println!("boot entry reconcile: hay {} ESP y NO voy a adivinar:", esps.len()); + for (d, n) in &esps { + println!(" {} p{n}", d.display()); + } + println!(" → elegí con --disk/--partition. No se tocó la NVRAM."); + return Ok(()); + } + } + } + }; + + // 3) Converger. + let r = efi_boot::reconcile(&efivars, &parte, &loader, &label, dry_run)?; + let fmt = |o: &Vec| if o.is_empty() { + "(vacío)".to_string() + } else { + o.iter().map(|n| format!("{n:04X}")).collect::>().join(",") + }; + if !r.actuo() { + println!("✓ arranque en orden: Boot{:04X} «{label}» ya es la primera", r.slot); + } else { + // Que se VEA. Si repara en silencio, el usuario nunca se entera de que + // algo le está pisando el arranque (ADR 0018 §2). + println!("⚠ EL ARRANQUE ESTABA CAMBIADO — takana lo repuso{}:", if dry_run { " (--dry-run: NO)" } else { "" }); + if r.entrada_escrita { + println!(" · faltaba la entrada «{label}» ⇒ escrita en Boot{:04X}", r.slot); + } + if r.orden_cambiado { + println!(" · BootOrder: {} → {}", fmt(&r.orden_antes), fmt(&r.orden_despues)); + } + println!(" Si esto se repite en cada arranque, otro sistema operativo te está reescribiendo la NVRAM."); + } + } }, BootCmd::Menu { compositor, out, select } => { // 1) Publicar el grafo (siempre, aunque no haya compositor: otros lo leen). diff --git a/scripts/install-image-efi.sh b/scripts/install-image-efi.sh index 81ed5a04..7d8a0ec1 100755 --- a/scripts/install-image-efi.sh +++ b/scripts/install-image-efi.sh @@ -193,6 +193,21 @@ done if [ -x /usr/bin/hammer ]; then /bin/busybox timeout 15 /usr/bin/hammer boot menu > /dev/ttyS0 2>&1 || true fi + +# Reconciliador del arranque (ADR 0018 §2). La NVRAM del firmware es estado compartido: otro sistema +# operativo instalado al lado la reescribe sin coordinarse —Windows Update poniéndose primero en +# BootOrder es el caso típico—. Contra eso no sirve confiar, sirve CONVERGER en cada arranque. +# +# Va al SERIAL **y a /dev/tty0**, a diferencia del menú de arriba: si takana tuvo que reponer su +# entrada, el usuario tiene que poder enterarse mirando la pantalla. Un reconciliador que repara en +# silencio deja al usuario conviviendo con una rareza intermitente que no entiende (§2). +# +# Mismo `timeout` y mismo `|| true` que arriba, por la misma razón: esto corre ANTES del exec de +# arje-zero y colgarse acá es un arranque muerto. +if [ -x /usr/bin/hammer ]; then + /bin/busybox timeout 15 /usr/bin/hammer boot entry reconcile 2>&1 \ + | /bin/busybox tee /dev/tty0 > /dev/ttyS0 2>/dev/null || true +fi exec /usr/bin/arje-zero RINIT chmod +x "$ROOT_TREE/sbin/init" diff --git a/scripts/takana-live-install.sh b/scripts/takana-live-install.sh index 62a24676..2be0b90a 100755 --- a/scripts/takana-live-install.sh +++ b/scripts/takana-live-install.sh @@ -452,6 +452,11 @@ echo "HAMMER-EFI-INIT-OK: arje-zero PID1" > /dev/ttyS0 2>/dev/null || true # Menú de arranque por grafo (ADR 0010): emite /run/hammer/boot-graph.json y, si hay compositor (mirada), # lo pinta sobre KMS. Graceful (sin compositor sigue) y al serial (no pinta tty0 ⇒ cero-parpadeo). [ -x /usr/bin/hammer ] && /usr/bin/hammer boot menu > /dev/ttyS0 2>&1 || true +# Reconciliador del arranque (ADR 0018 §2): la NVRAM es estado compartido y el vecino la reescribe +# sin avisar. Converger en cada arranque, y DECIRLO en pantalla cuando hubo que actuar — si repara +# en silencio, el usuario nunca se entera de que algo le está pisando el arranque. +[ -x /usr/bin/hammer ] && /bin/busybox timeout 15 /usr/bin/hammer boot entry reconcile 2>&1 \ + | /bin/busybox tee /dev/tty0 > /dev/ttyS0 2>/dev/null || true exec /usr/bin/arje-zero INIT "$BB" chmod +x "$MNT/sbin/init"