diff --git a/crates/takana-cli/src/fat_ro.rs b/crates/takana-cli/src/fat_ro.rs new file mode 100644 index 00000000..eba068da --- /dev/null +++ b/crates/takana-cli/src/fat_ro.rs @@ -0,0 +1,518 @@ +//! Lector FAT de **sólo lectura**, para mirar una ESP sin montarla (ADR 0018 §4). +//! +//! ── POR QUÉ NO SE MONTA Y YA ──────────────────────────────────────────────────────────────────── +//! El §4 pide que el instalador reporte el terreno **antes de tocar nada**: qué hay en la ESP, +//! cuánto espacio queda y con quién se comparte. Montar para averiguarlo tiene tres problemas: pide +//! privilegios, deja un efecto secundario justo cuando prometimos no tocar nada, y falla feo si el +//! sistema operativo del vecino dejó la FAT sucia por su hibernación (el «Fast Startup» de Windows). +//! +//! Leer la estructura es más barato que todo eso: el BPB da la geometría, la FAT da los clusters +//! libres y el directorio da los vendors. Nada de esto escribe un byte. +//! +//! ⚠ Sólo entiende lo que hace falta y lo dice cuando no entiende algo: FAT12/16/32 para la +//! geometría, nombres 8.3 y LFN para el listado. No hay escritura ni la va a haber — para eso está +//! mtools, que ya se usa en los scripts de imagen. + +use std::io::{self, Read, Seek, SeekFrom}; +use std::path::Path; + +/// Geometría y ocupación de un volumen FAT. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FatInfo { + pub bytes_por_sector: u32, + pub sectores_por_cluster: u32, + pub total_sectores: u64, + pub clusters_totales: u64, + pub clusters_libres: u64, + /// 12, 16 o 32. + pub tipo: u8, + pub etiqueta: String, +} + +impl FatInfo { + pub fn bytes_totales(&self) -> u64 { + self.total_sectores * self.bytes_por_sector as u64 + } + pub fn bytes_libres(&self) -> u64 { + self.clusters_libres * self.sectores_por_cluster as u64 * self.bytes_por_sector as u64 + } + pub fn bytes_usados(&self) -> u64 { + let cl_usados = self.clusters_totales.saturating_sub(self.clusters_libres); + cl_usados * self.sectores_por_cluster as u64 * self.bytes_por_sector as u64 + } +} + +/// Una entrada de directorio, ya resuelta a nombre legible. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entrada { + pub nombre: String, + pub es_dir: bool, + pub tam: u32, +} + +fn le16(b: &[u8], o: usize) -> u32 { + u16::from_le_bytes([b[o], b[o + 1]]) as u32 +} +fn le32(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} + +/// Abre el volumen FAT que empieza en `offset` bytes dentro de `dev`. +pub struct Fat { + f: std::fs::File, + base: u64, + pub info: FatInfo, + fat_inicio: u64, + datos_inicio: u64, + root_cluster: u32, + /// FAT12/16: el root es un área fija, no una cadena de clusters. + root_fijo: Option<(u64, u32)>, +} + +impl Fat { + pub fn abrir(dev: &Path, offset: u64) -> io::Result { + let mut f = std::fs::File::open(dev)?; + let mut bpb = [0u8; 512]; + f.seek(SeekFrom::Start(offset))?; + f.read_exact(&mut bpb)?; + + if bpb[510] != 0x55 || bpb[511] != 0xAA { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "no parece un volumen FAT: falta la firma 0x55AA", + )); + } + let bps = le16(&bpb, 0x0B); + let spc = bpb[0x0D] as u32; + // Un BPB con ceros acá no es «FAT vacía»: es que no estamos mirando un BPB. + if !(512..=4096).contains(&bps) || !bps.is_power_of_two() || spc == 0 || !spc.is_power_of_two() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("BPB imposible: {bps} B/sector, {spc} sectores/cluster"), + )); + } + let reservados = le16(&bpb, 0x0E); + let n_fats = bpb[0x10] as u32; + let root_entries = le16(&bpb, 0x11); + let tot16 = le16(&bpb, 0x13) as u64; + let fat16 = le16(&bpb, 0x16) as u64; + let tot32 = le32(&bpb, 0x20) as u64; + let fat32 = le32(&bpb, 0x24) as u64; + + let total_sectores = if tot16 != 0 { tot16 } else { tot32 }; + let fat_sectores = if fat16 != 0 { fat16 } else { fat32 }; + if n_fats == 0 || fat_sectores == 0 || total_sectores == 0 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "BPB sin FAT ni sectores")); + } + + // El área del root es fija en FAT12/16 y una cadena de clusters en FAT32. + let root_sectores = (root_entries * 32).div_ceil(bps) as u64; + let primer_dato = reservados as u64 + n_fats as u64 * fat_sectores + root_sectores; + let clusters_totales = (total_sectores - primer_dato) / spc as u64; + + // El tipo se decide POR EL NÚMERO DE CLUSTERS, no por la cadena de texto del BPB: esa es + // informativa y hay formateadores que mienten en ella. + let tipo = if clusters_totales < 4085 { + 12 + } else if clusters_totales < 65525 { + 16 + } else { + 32 + }; + + let etiqueta = { + let off = if tipo == 32 { 0x47 } else { 0x2B }; + String::from_utf8_lossy(&bpb[off..off + 11]).trim().to_string() + }; + + let fat_inicio = offset + reservados as u64 * bps as u64; + let datos_inicio = offset + primer_dato * bps as u64; + let root_fijo = if tipo == 32 { + None + } else { + Some(( + offset + (reservados as u64 + n_fats as u64 * fat_sectores) * bps as u64, + root_entries, + )) + }; + + let mut fat = Fat { + f, + base: offset, + info: FatInfo { + bytes_por_sector: bps, + sectores_por_cluster: spc, + total_sectores, + clusters_totales, + clusters_libres: 0, + tipo, + etiqueta, + }, + fat_inicio, + datos_inicio, + root_cluster: if tipo == 32 { le32(&bpb, 0x2C) } else { 0 }, + root_fijo, + }; + fat.info.clusters_libres = fat.contar_libres()?; + Ok(fat) + } + + /// Cuenta clusters libres recorriendo la FAT. **No se usa el `FSInfo`** de FAT32 a propósito: + /// ese campo es una pista que el sistema operativo que desmontó mal deja desactualizada, y acá + /// el número se usa para decidir si una instalación entra — un número optimista de más haría + /// fallar la instalación a mitad, que es justo lo que el §4 existe para evitar. + fn contar_libres(&mut self) -> io::Result { + let n = self.info.clusters_totales; + let mut libres = 0u64; + match self.info.tipo { + 32 => { + let mut buf = vec![0u8; 4096]; + let mut leidos = 0u64; + self.f.seek(SeekFrom::Start(self.fat_inicio))?; + // Los clusters 0 y 1 son reservados: la cuenta útil empieza en 2. + while leidos < n + 2 { + let k = self.f.read(&mut buf)?; + if k == 0 { + break; + } + for c in buf[..k].chunks_exact(4) { + if leidos >= 2 && leidos < n + 2 && le32(c, 0) & 0x0FFF_FFFF == 0 { + libres += 1; + } + leidos += 1; + } + } + } + 16 => { + let mut buf = vec![0u8; 4096]; + let mut leidos = 0u64; + self.f.seek(SeekFrom::Start(self.fat_inicio))?; + while leidos < n + 2 { + let k = self.f.read(&mut buf)?; + if k == 0 { + break; + } + for c in buf[..k].chunks_exact(2) { + if leidos >= 2 && leidos < n + 2 && le16(c, 0) == 0 { + libres += 1; + } + leidos += 1; + } + } + } + _ => { + // FAT12 son entradas de 12 bits a caballo entre bytes. Una ESP nunca es FAT12, así + // que en vez de implementarlo a medias se declara desconocido: un 0 aquí se leería + // como «llena» y eso es una mentira con consecuencias. + return Ok(0); + } + } + Ok(libres) + } + + fn siguiente_cluster(&mut self, c: u32) -> io::Result> { + let (off, val) = match self.info.tipo { + 32 => { + let mut b = [0u8; 4]; + self.f.seek(SeekFrom::Start(self.fat_inicio + c as u64 * 4))?; + self.f.read_exact(&mut b)?; + (0, le32(&b, 0) & 0x0FFF_FFFF) + } + 16 => { + let mut b = [0u8; 2]; + self.f.seek(SeekFrom::Start(self.fat_inicio + c as u64 * 2))?; + self.f.read_exact(&mut b)?; + (0, le16(&b, 0)) + } + _ => return Ok(None), + }; + let _ = off; + let fin = if self.info.tipo == 32 { 0x0FFF_FFF8 } else { 0xFFF8 }; + if val >= fin || val < 2 { + Ok(None) + } else { + Ok(Some(val)) + } + } + + fn leer_cluster(&mut self, c: u32) -> io::Result> { + let tam = self.info.sectores_por_cluster as usize * self.info.bytes_por_sector as usize; + let mut v = vec![0u8; tam]; + let off = self.datos_inicio + (c as u64 - 2) * tam as u64; + self.f.seek(SeekFrom::Start(off))?; + self.f.read_exact(&mut v)?; + Ok(v) + } + + /// Entradas del directorio raíz. + pub fn raiz(&mut self) -> io::Result> { + if let Some((off, n)) = self.root_fijo { + let mut v = vec![0u8; n as usize * 32]; + self.f.seek(SeekFrom::Start(off))?; + self.f.read_exact(&mut v)?; + return Ok(parse_dir(&v)); + } + self.leer_cadena(self.root_cluster).map(|b| parse_dir(&b)) + } + + /// Entradas de un subdirectorio del raíz (un solo nivel, que es lo que el §4 necesita: ver los + /// *vendors* que cuelgan de `\EFI`). + pub fn subdir(&mut self, nombre: &str) -> io::Result>> { + let dir = if let Some((off, n)) = self.root_fijo { + let mut v = vec![0u8; n as usize * 32]; + self.f.seek(SeekFrom::Start(off))?; + self.f.read_exact(&mut v)?; + v + } else { + self.leer_cadena(self.root_cluster)? + }; + let Some(c) = buscar_cluster(&dir, nombre) else { return Ok(None) }; + if c < 2 { + return Ok(None); + } + Ok(Some(parse_dir(&self.leer_cadena(c)?))) + } + + fn leer_cadena(&mut self, inicio: u32) -> io::Result> { + let mut out = Vec::new(); + let mut c = inicio; + // Tope duro: una cadena circular por corrupción colgaría el instalador, y colgar es peor + // que reportar de menos. + for _ in 0..65536 { + out.extend(self.leer_cluster(c)?); + match self.siguiente_cluster(c)? { + Some(n) => c = n, + None => break, + } + } + Ok(out) + } + + pub fn base(&self) -> u64 { + self.base + } +} + +fn nombre_83(e: &[u8]) -> String { + let base = String::from_utf8_lossy(&e[0..8]).trim_end().to_string(); + let ext = String::from_utf8_lossy(&e[8..11]).trim_end().to_string(); + if ext.is_empty() { + base + } else { + format!("{base}.{ext}") + } +} + +/// Decodifica un área de entradas de directorio. Junta los LFN (nombres largos) cuando están, que +/// es lo normal en una ESP escrita por Windows: sin esto `Microsoft` se vería como `MICROS~1`. +fn parse_dir(b: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut lfn: Vec<(u8, Vec)> = Vec::new(); + for e in b.chunks_exact(32) { + match e[0] { + 0x00 => break, // fin del directorio + 0xE5 => { + lfn.clear(); + continue; + } // borrada + _ => {} + } + let attr = e[0x0B]; + if attr & 0x0F == 0x0F { + // Entrada LFN: 13 unidades UTF-16 repartidas en tres tramos. + let mut u = Vec::with_capacity(13); + for &(o, n) in &[(1usize, 5usize), (14, 6), (28, 2)] { + for i in 0..n { + u.push(u16::from_le_bytes([e[o + i * 2], e[o + i * 2 + 1]])); + } + } + lfn.push((e[0] & 0x3F, u)); + continue; + } + if attr & 0x08 != 0 { + lfn.clear(); + continue; // etiqueta de volumen + } + let nombre = if lfn.is_empty() { + nombre_83(e) + } else { + lfn.sort_by_key(|(n, _)| *n); + let mut u: Vec = lfn.iter().flat_map(|(_, x)| x.iter().copied()).collect(); + if let Some(p) = u.iter().position(|&c| c == 0 || c == 0xFFFF) { + u.truncate(p); + } + String::from_utf16_lossy(&u) + }; + lfn.clear(); + let nombre = nombre.trim().to_string(); + if nombre == "." || nombre == ".." || nombre.is_empty() { + continue; + } + out.push(Entrada { nombre, es_dir: attr & 0x10 != 0, tam: le32(e, 0x1C) }); + } + out +} + +fn buscar_cluster(dir: &[u8], nombre: &str) -> Option { + let mut lfn: Vec<(u8, Vec)> = Vec::new(); + for e in dir.chunks_exact(32) { + if e[0] == 0x00 { + break; + } + if e[0] == 0xE5 { + lfn.clear(); + continue; + } + let attr = e[0x0B]; + if attr & 0x0F == 0x0F { + let mut u = Vec::with_capacity(13); + for &(o, n) in &[(1usize, 5usize), (14, 6), (28, 2)] { + for i in 0..n { + u.push(u16::from_le_bytes([e[o + i * 2], e[o + i * 2 + 1]])); + } + } + lfn.push((e[0] & 0x3F, u)); + continue; + } + let corto = nombre_83(e); + let largo = if lfn.is_empty() { + String::new() + } else { + lfn.sort_by_key(|(n, _)| *n); + let mut u: Vec = lfn.iter().flat_map(|(_, x)| x.iter().copied()).collect(); + if let Some(p) = u.iter().position(|&c| c == 0 || c == 0xFFFF) { + u.truncate(p); + } + String::from_utf16_lossy(&u) + }; + lfn.clear(); + if corto.eq_ignore_ascii_case(nombre) || largo.eq_ignore_ascii_case(nombre) { + return Some((le16(e, 0x14) << 16) | le16(e, 0x1A)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// BPB fabricado a mano: prueba la geometría y, sobre todo, que un BPB imposible se RECHACE. + #[test] + fn rechaza_un_bpb_imposible_en_vez_de_devolver_ceros() { + let tmp = std::env::temp_dir().join(format!("takana-fat-neg-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + // Todo ceros salvo la firma: bytes/sector = 0 ⇒ no es un BPB. + let mut d = vec![0u8; 512 * 64]; + d[510] = 0x55; + d[511] = 0xAA; + let f = tmp.join("falso.img"); + std::fs::write(&f, &d).unwrap(); + let e = match Fat::abrir(&f, 0) { + Err(e) => e, + Ok(_) => panic!("un BPB de ceros NO puede abrirse como FAT"), + }; + assert!( + e.to_string().contains("BPB imposible"), + "un BPB de ceros tiene que fallar, no leerse como «FAT llena»: {e}" + ); + // Y sin la firma tampoco. + std::fs::write(&f, vec![0u8; 512 * 64]).unwrap(); + assert!(Fat::abrir(&f, 0).is_err(), "sin 0x55AA no hay volumen"); + std::fs::remove_dir_all(&tmp).ok(); + } + + /// Fabrica una entrada LFN + su 8.3, como las escribe Windows. + fn entradas_lfn(largo: &str, corto: &[u8; 11], dir: bool) -> Vec { + let u: Vec = largo.encode_utf16().collect(); + let mut out = Vec::new(); + let trozos: Vec<&[u16]> = u.chunks(13).collect(); + // Los LFN se escriben AL REVÉS, con el último marcado 0x40. + for (i, t) in trozos.iter().enumerate().rev() { + let mut e = [0u8; 32]; + e[0] = (i as u8 + 1) | if i == trozos.len() - 1 { 0x40 } else { 0 }; + e[0x0B] = 0x0F; + let mut rel = Vec::from(*t); + while rel.len() < 13 { + rel.push(if rel.len() == t.len() { 0 } else { 0xFFFF }); + } + let mut k = 0; + for &(o, n) in &[(1usize, 5usize), (14, 6), (28, 2)] { + for j in 0..n { + e[o + j * 2..o + j * 2 + 2].copy_from_slice(&rel[k].to_le_bytes()); + k += 1; + } + } + out.extend_from_slice(&e); + } + let mut e = [0u8; 32]; + e[0..11].copy_from_slice(corto); + e[0x0B] = if dir { 0x10 } else { 0x20 }; + out.extend_from_slice(&e); + out + } + + #[test] + fn el_nombre_largo_gana_al_8_3() { + // Sin juntar los LFN, «Microsoft» se vería como «MICROS~1» y la advertencia de vecino + // Windows —que es la razón de ser del §4— no dispararía nunca. + let mut d = entradas_lfn("Microsoft", b"MICROS~1 ", true); + d.extend(entradas_lfn("takana", b"TAKANA ", true)); + d.extend_from_slice(&[0u8; 32]); // fin de directorio + let v = parse_dir(&d); + assert_eq!(v.len(), 2, "dos directorios: {v:?}"); + assert_eq!(v[0].nombre, "Microsoft", "el LFN manda sobre el 8.3"); + assert!(v[0].es_dir); + assert_eq!(v[1].nombre, "takana"); + } + + #[test] + fn sin_lfn_se_usa_el_8_3_y_se_saltean_las_borradas() { + let mut d = Vec::new(); + let mut e = [0u8; 32]; + e[0..11].copy_from_slice(b"BOOT "); + e[0x0B] = 0x10; + d.extend_from_slice(&e); + let mut borrada = [0u8; 32]; + borrada[0] = 0xE5; // borrada: no debe aparecer + borrada[1..11].copy_from_slice(b"ORRADA "); + d.extend_from_slice(&borrada); + let mut vol = [0u8; 32]; + vol[0..11].copy_from_slice(b"ESP "); + vol[0x0B] = 0x08; // etiqueta de volumen: tampoco es una entrada + d.extend_from_slice(&vol); + d.extend_from_slice(&[0u8; 32]); + let v = parse_dir(&d); + assert_eq!(v.len(), 1, "sólo BOOT: {v:?}"); + assert_eq!(v[0].nombre, "BOOT"); + } + + #[test] + fn el_tipo_sale_del_numero_de_clusters_y_no_del_texto_del_bpb() { + // Un volumen chico es FAT16 aunque el BPB diga "FAT32 ": el texto es informativo y hay + // formateadores que mienten. La cuenta de clusters es la que manda (spec de Microsoft). + let tmp = std::env::temp_dir().join(format!("takana-fat-tipo-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + // 8192 sectores ⇒ (8192 − 49) = 8143 clusters, que cae en la franja FAT16 (4085..65525). + // El primer intento de este test usaba 2048 sectores = 1999 clusters, que es FAT12 de + // verdad: el que estaba mal era el TEST, no el código. Queda anotado porque la franja se + // olvida y el error se ve idéntico a un bug del lector. + let mut d = vec![0u8; 512 * 8192]; + d[0x0B..0x0D].copy_from_slice(&512u16.to_le_bytes()); + d[0x0D] = 1; + d[0x0E..0x10].copy_from_slice(&1u16.to_le_bytes()); + d[0x10] = 2; + d[0x11..0x13].copy_from_slice(&512u16.to_le_bytes()); + d[0x13..0x15].copy_from_slice(&8192u16.to_le_bytes()); + d[0x16..0x18].copy_from_slice(&8u16.to_le_bytes()); + d[0x52..0x5A].copy_from_slice(b"FAT32 "); // la mentira + d[510] = 0x55; + d[511] = 0xAA; + let f = tmp.join("chico.img"); + std::fs::write(&f, &d).unwrap(); + let fat = Fat::abrir(&f, 0).unwrap(); + assert_eq!(fat.info.tipo, 16, "≈2000 clusters es FAT16, diga lo que diga el texto"); + std::fs::remove_dir_all(&tmp).ok(); + } +} diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs index d6f6378e..2acd3e12 100644 --- a/crates/takana-cli/src/main.rs +++ b/crates/takana-cli/src/main.rs @@ -10,6 +10,7 @@ use clap::{Parser, Subcommand}; mod alpine_import; mod efi_boot; +mod fat_ro; mod kernel_cmd; mod qorpa; mod qorpa_wl; @@ -623,6 +624,26 @@ enum EntryCmd { #[arg(long, default_value = crate::efi_boot::EFIVARS)] efivars: PathBuf, }, + /// Survey the boot terrain before touching anything: firmware, ESPs, free space, neighbours. + /// + /// ADR 0018 §4. Corre **antes** de instalar y no escribe nada. Existe por un fallo silencioso + /// concreto: las ESP de fábrica son de 100 MiB, el vecino las llena, y una instalación que no + /// entra se descubre a mitad de copiar — o peor, deja un `BOOTX64.EFI` truncado, que **no falla**: + /// arranca y muere en el firmware. + Survey { + /// Where to enumerate block devices from. + #[arg(long, default_value = "/sys/block")] + sys_block: PathBuf, + /// Look at this disk/image instead of enumerating the system's. + #[arg(long)] + disk: Option, + /// Root of efivarfs. + #[arg(long, default_value = crate::efi_boot::EFIVARS)] + efivars: PathBuf, + /// Bytes the install needs in the ESP (kernel x2 + initramfs). 0 = don't judge. + #[arg(long, default_value_t = 0)] + need_bytes: u64, + }, /// 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 @@ -1571,6 +1592,136 @@ fn main() -> anyhow::Result<()> { nuevo.iter().map(|n| format!("{n:04X}")).collect::>().join(",")); } } + EntryCmd::Survey { sys_block, disk, efivars, need_bytes } => { + let mut problemas: Vec = Vec::new(); + + // --- 1) firmware --- + println!("── firmware ──"); + if efi_boot::hay_firmware_efi(&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).unwrap_or_default(); + println!(" UEFI, con {} entrada(s) de arranque", entradas.len()); + print!(" BootOrder: "); + if orden.is_empty() { + println!("(vacío)"); + } else { + println!("{}", orden.iter().map(|n| format!("{n:04X}")).collect::>().join(",")); + } + for n in &orden { + if let Some((_, lo)) = entradas.iter().find(|(m, _)| m == n) { + let primero = Some(n) == orden.first().map(|x| x as &u16); + println!(" {} Boot{n:04X} {}", if primero { "→" } else { " " }, lo.description); + } + } + // Quién arranca hoy. Es el dato que el usuario quiere y nadie le da. + if let Some(primero) = orden.first() { + if let Some((_, lo)) = entradas.iter().find(|(m, _)| m == primero) { + println!(" ⇒ hoy arranca primero: «{}»", lo.description); + } + } + } else if !efivars.exists() { + println!(" BIOS (o /sys sin montar): no hay NVRAM que mirar"); + } else { + println!(" UEFI, pero {} está vacío ⇒ falta `mount -t efivarfs`", efivars.display()); + problemas.push("efivarfs sin montar: no se puede crear la entrada de arranque".into()); + } + + // --- 2) discos y ESPs --- + println!("── discos ──"); + let discos = match &disk { + Some(d) => vec![d.clone()], + None => efi_boot::block_disks(&sys_block).unwrap_or_default(), + }; + if discos.is_empty() { + println!(" (ninguno)"); + } + let esps = efi_boot::find_esps(&discos); + for d in &discos { + let mias: Vec<_> = esps.iter().filter(|(x, _)| x == d).map(|(_, n)| *n).collect(); + println!(" {}{}", d.display(), if mias.is_empty() { + " (sin ESP)".to_string() + } else { + format!(" ESP en p{}", mias.iter().map(|n| n.to_string()).collect::>().join(", p")) + }); + } + if esps.len() > 1 { + problemas.push(format!("hay {} ESP: la instalación tiene que elegir una a mano", esps.len())); + } + + // --- 3) dentro de cada ESP, SIN montarla --- + for (d, n) in &esps { + let parte = match efi_boot::read_partition(d, *n) { + Ok(p) => p, + Err(e) => { + println!("── ESP {} p{n} ── no se puede leer: {e}", d.display()); + continue; + } + }; + println!("── ESP {} p{n} ──", d.display()); + let off = parte.start_lba * 512; + let mut fat = match fat_ro::Fat::abrir(d, off) { + Ok(f) => f, + Err(e) => { + println!(" no parece FAT: {e}"); + problemas.push(format!("{} p{n}: la ESP no se puede leer", d.display())); + continue; + } + }; + let mb = |b: u64| format!("{:.1} MiB", b as f64 / 1048576.0); + println!(" FAT{} «{}» — {} totales, {} usados, {} libres", + fat.info.tipo, fat.info.etiqueta, + mb(fat.info.bytes_totales()), mb(fat.info.bytes_usados()), mb(fat.info.bytes_libres())); + + // ¿Con quién se comparte? Los vendors que cuelgan de \EFI. + match fat.subdir("EFI") { + Ok(Some(v)) => { + let vendors: Vec = v.iter().filter(|e| e.es_dir).map(|e| e.nombre.clone()).collect(); + if vendors.is_empty() { + println!(" \\EFI está vacío"); + } else { + println!(" vecinos en \\EFI: {}", vendors.join(", ")); + for v in &vendors { + let vl = v.to_ascii_lowercase(); + if vl == "microsoft" { + println!(" ⚠ «Microsoft» ⇒ hay Windows en esta ESP. Es el que reordena BootOrder al actualizarse."); + } + if vl == "boot" { + println!(" · «BOOT» es la ruta FALLBACK de medios removibles: tierra de nadie, no la de nadie en particular."); + } + } + } + } + Ok(None) => println!(" sin directorio \\EFI (ESP recién formateada)"), + Err(e) => println!(" no pude listar \\EFI: {e}"), + } + + // ¿Entra lo que queremos instalar? + if need_bytes > 0 { + let libre = fat.info.bytes_libres(); + if libre >= need_bytes { + println!(" ✓ entra: hacen falta {} y hay {} libres", mb(need_bytes), mb(libre)); + } else { + println!(" ✗ NO ENTRA: hacen falta {} y hay {} libres", mb(need_bytes), mb(libre)); + problemas.push(format!( + "{} p{n}: faltan {} en la ESP", + d.display(), mb(need_bytes - libre))); + } + } + } + + // --- 4) el veredicto, junto y al final --- + println!("── veredicto ──"); + if problemas.is_empty() { + println!(" sin problemas a la vista"); + } else { + for p in &problemas { + println!(" ✗ {p}"); + } + println!(" ({} cosa(s) a resolver ANTES de instalar; no se tocó nada)", problemas.len()); + } + } 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. diff --git a/docs/adr/0018-soberania-del-arranque.md b/docs/adr/0018-soberania-del-arranque.md index 380ee683..29e8a94f 100644 --- a/docs/adr/0018-soberania-del-arranque.md +++ b/docs/adr/0018-soberania-del-arranque.md @@ -1,7 +1,7 @@ # ADR 0018 — Soberanía del arranque: convivir con otro gestor sin depender de su buena fe -- **Estado:** PROPUESTO, con **§1 y §3 IMPLEMENTADOS y verificados en OVMF** (2026-09-11). - Queda **una decisión abierta** (§Secure Boot) y el §2/§4 por implementar. +- **Estado:** PROPUESTO, con **§1, §2, §3 y §4 IMPLEMENTADOS y verificados** (2026-09-11). + Queda **una decisión abierta** (§Secure Boot) y el §5 (respaldo del arranque) por implementar. - **Fecha:** 2026-09-11 - **Frontera:** `scripts/install-image-efi.sh`, `scripts/install-image.sh`, `scripts/iso-image.sh`, `scripts/metal-usb-sdboot.sh`, `scripts/takana-live-install.sh`, el instalador TUI. @@ -188,6 +188,44 @@ Un fallo silencioso concreto que muerde mucho: **las ESP de fábrica son de 100 llena, y un bzImage con EFI-stub más su initramfs **no entra**. Eso tiene que fallar **ruidosamente y por adelantado**, con el número medido en pantalla — nunca truncar a mitad de instalación. +> **ENMIENDA 2026-09-11 (4ª) — el §4 implementado: `takana boot entry survey`.** +> +> Reporta firmware, discos, ESPs, con quién se comparten y si la instalación entra. **No escribe +> nada** — y para poder prometer eso hubo que leer la ESP **sin montarla**, que es lo que obligó a +> escribir un lector FAT de sólo lectura (`crates/takana-cli/src/fat_ro.rs`). Montar para averiguarlo +> tenía tres problemas: pide privilegios, deja un efecto secundario justo cuando prometimos no tocar +> nada, y falla si el vecino dejó la FAT sucia por su hibernación (el «Fast Startup» del §7). +> +> Sobre una ESP de fábrica (100 MiB) con Windows dentro y el kernel duplicado a instalar: +> +> ``` +> FAT32 «ESP» — 100.0 MiB totales, 77.1 MiB usados, 22.1 MiB libres +> vecinos en \EFI: Microsoft, BOOT +> ⚠ «Microsoft» ⇒ hay Windows en esta ESP. Es el que reordena BootOrder al actualizarse. +> ✗ NO ENTRA: hacen falta 31.0 MiB y hay 22.1 MiB libres +> ── veredicto ── +> ✗ …p1: faltan 8.9 MiB en la ESP +> ``` +> +> **Contrastado contra `mtools` como oráculo**, que es lo que hace que el número sea creíble: `mdir` +> reporta `23 221 248 bytes free` y el lector propio dice 22,1 MiB — el mismo número. Los clusters +> libres se cuentan recorriendo la FAT y **no** se lee el `FSInfo` de FAT32 a propósito: ese campo es +> una pista que un sistema operativo que desmontó mal deja desactualizada, y un número optimista de +> más haría fallar la instalación a mitad, que es exactamente lo que este §4 existe para evitar. +> +> **Y en el instalador el §4 resultó ser algo más que «cuánto espacio hay».** La rama UEFI se lleva +> el disco **entero** (§Lo medido), así que lo que hay que decir en voz alta es **con qué se lo va a +> llevar puesto**: el survey corre antes de particionar y, si hay un `\EFI\Microsoft` en ese disco, +> lo nombra. El usuario se entera **antes**, y no después de que su Windows dejó de arrancar. +> +> Dos detalles que habrían pasado inadvertidos sin un test: +> - **El nombre largo tiene que ganarle al 8.3.** Sin juntar los LFN, `Microsoft` se lee `MICROS~1` y +> la advertencia —la razón de ser de esto— no dispara nunca. +> - **El tipo de FAT sale del número de clusters, no del texto del BPB**, que es informativo y hay +> formateadores que mienten en él. (El primer test que escribí para esto estaba mal calculado: +> 1999 clusters *es* FAT12. El que fallaba era el test, y el síntoma es idéntico a un bug del +> lector.) + ### 5. Respaldo del arranque como artefacto sellado Volcar NVRAM (`efibootmgr -v`) + el contenido de la ESP al store en cada instalación y cada diff --git a/scripts/takana-live-install.sh b/scripts/takana-live-install.sh index 8acbf929..f1cc946a 100755 --- a/scripts/takana-live-install.sh +++ b/scripts/takana-live-install.sh @@ -283,6 +283,17 @@ if [ -d /sys/firmware/efi ]; then echo "==> takana-install (UEFI): EFI-stub soberano en $DEV (ESP ${ESP_MB}M, root ${ROOT_MB}M, store ${STORE_MB}M, estado=resto)" echo " ¡esto BORRA $DEV!" + # Informe del terreno ANTES de tocar nada (ADR 0018 §4). Esta rama se lleva el disco ENTERO, así + # que lo que hay que decir en voz alta no es sólo «cuánto espacio hay»: es **con qué se va a + # llevar puesto**. Si en la ESP de este disco hay un `\EFI\Microsoft`, el survey lo nombra, y el + # usuario se entera ANTES y no después de que su Windows dejó de arrancar. + # + # No escribe nada y no puede fallar el instalador: `|| true`. Su trabajo es informar. + if [ -x "$PAYLOAD/hammer" ]; then + echo "==> terreno actual de $DEV (nada de esto se ha tocado todavía):" + "$PAYLOAD/hammer" boot entry survey --disk "$DEV" 2>&1 | "$BB" sed 's/^/ /' || true + fi + # MBR, 4 primarias: p1=ESP(0xEF) p2=root p3=store p4=estado. Sectores explícitos (como la rama BIOS). SPM=2048 P1S=2048; P1E=$(( P1S + ESP_MB * SPM - 1 ))