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…

boot entry survey: el informe del terreno, leyendo la ESP SIN montarla (ADR 0018 §4)

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ó al lector FAT de sólo
lectura (fat_ro.rs). Montar para averiguarlo pedía privilegios, dejaba
un efecto secundario justo cuando prometimos no tocar nada, y falla si
el vecino dejó la FAT sucia por su hibernación.

Sobre una ESP de fábrica (100 MiB) con Windows dentro:

  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

Contrastado contra mtools como oráculo, que es lo que hace creíble el
número: mdir dice "23 221 248 bytes free" y el lector propio dice
22.1 MiB — el mismo. Los clusters libres se cuentan recorriendo la FAT y
NO se lee el FSInfo de FAT32 a propósito: ese campo lo deja
desactualizado un SO que desmontó mal, y un número optimista de más
haría fallar la instalación a mitad — justo lo que el §4 evita.

En el instalador el §4 resultó ser algo más que "cuánto espacio hay": la
rama UEFI se lleva el disco ENTERO, 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 nombra el \EFI\Microsoft si está. El usuario se entera
ANTES, y no después de que su Windows dejó de arrancar.

Dos cosas que habrían pasado inadvertidas sin test: el nombre largo
tiene que ganarle al 8.3 (sin juntar los LFN, "Microsoft" se lee
"MICROS~1" y la advertencia no dispara nunca), y el tipo de FAT sale del
número de clusters y no del texto del BPB, que es informativo y hay
formateadores que mienten. El primer test del tipo lo escribí mal —1999
clusters ES FAT12— y el síntoma es idéntico a un bug del lector.

78/78 del CLI.

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

2201fac08e77b430ca7544672c009c5c8622c119 · por Sergio · (hace 1 mes) · padre beb1c2a59 · ver el árbol en este commit

Delta semántico · 28

crates/takana-cli/src/fat_ro.rs

crates/takana-cli/src/main.rs

4 archivos · +720 −2

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<Fat> {
+        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<u64> {
+        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<Option<u32>> {
+        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<Vec<u8>> {
+        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<Vec<Entrada>> {
+        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<Option<Vec<Entrada>>> {
+        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<Vec<u8>> {
+        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<Entrada> {
+    let mut out = Vec::new();
+    let mut lfn: Vec<(u8, Vec<u16>)> = 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<u16> = 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<u32> {
+    let mut lfn: Vec<(u8, Vec<u16>)> = 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<u16> = 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<u8> {
+        let u: Vec<u16> = 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<PathBuf>,
+        /// 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::<Vec<_>>().join(","));
                         }
                     }
+                    EntryCmd::Survey { sys_block, disk, efivars, need_bytes } => {
+                        let mut problemas: Vec<String> = 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::<Vec<_>>().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::<Vec<_>>().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<String> = 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 ))