tawasuyu

sergio/takana

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

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

boot entry backup/restore: el arranque como estado reproducible (ADR 0018 §5)

Guarda las variables de NVRAM CRUDAS —se reescriben tal cual;
decodificarlas para volver a codificarlas al restaurar sería una
oportunidad de perder algo que no entendemos— y de la ESP un manifiesto
con hashes, no los bytes: el kernel ya vive en el store y copiarlo otra
vez sería churn. El manifiesto es evidencia de qué había; para lo que no
esté en el store dice qué falta, en vez de prometer reponerlo.

El ADR se equivocaba en DÓNDE: decía "volcar al store". No va al store,
porque lo barre store-gc.sh, que clasifica por nombre de receta — un
respaldo ahí sería huérfano y se borraría en el primer --huerfanos. Vive
en /var/lib/hammer/boot/, que en las imágenes es su propia partición. El
nombre sale del CONTENIDO, así que un arranque que no cambió produce el
mismo fichero: corre en cada arranque sin llenar la partición de copias.

Lo que encontró la prueba de punta a punta y no estaba en el diseño:
restaurar es volver al pasado, y lo que llegó DESPUÉS no estaba en ese
pasado. Al reponer el BootOrder del respaldo, el Windows instalado más
tarde quedaba FUERA del orden — correcto, y justo lo que el usuario no
espera de algo llamado "restaurar el arranque". Ahora se avisa antes,
con nombre y apellido, y restore NO escribe por defecto: la NVRAM es lo
único de la máquina que no se rehace desde el store.

El lector FAT ganó lectura de ficheros, con su trampa propia: hay que
truncar al tamaño DECLARADO en el directorio, no al final del último
cluster. Un fichero de 1,5 MB en clusters de 1 KiB termina con relleno y
hashear el relleno daría un hash distinto al del mismo fichero en disco
— el síntoma sería "dos respaldos del mismo arranque difieren".
Verificado contra mtools como oráculo (1 500 000 y 900 000 bytes
exactos, mismo sha256 que los originales) y con un test determinista que
fabrica una FAT16 a mano, sin depender de que mtools esté.

79/79 del CLI.

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

bd1fa8bf8ac17a52ceb04005e5e6f90d1589bae2 · ruwaq Sergio · (1 killa ñawpaq) · tayta 61b57ab06 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 9

crates/takana-cli/src/fat_ro.rs

crates/takana-cli/src/main.rs

  • silq'uyninpi t'ikrakurqan enum EntryCmd
  • ukhunpi t'ikrakurqan fn main

5 qillqakuna · +464 −2

diff --git a/crates/takana-cli/src/fat_ro.rs b/crates/takana-cli/src/fat_ro.rs
index eba068da..db56c85c 100644
--- a/crates/takana-cli/src/fat_ro.rs
+++ b/crates/takana-cli/src/fat_ro.rs
@@ -274,6 +274,94 @@ impl Fat {
         Ok(Some(parse_dir(&self.leer_cadena(c)?)))
     }
 
+    /// Lee un fichero por ruta absoluta dentro del volumen (`/EFI/takana/takanax64.efi`).
+    ///
+    /// Trunca al **tamaño declarado** en la entrada de directorio, no al final del último cluster:
+    /// un fichero de 15 MiB en clusters de 1 KiB termina con relleno, y hashear el relleno daría un
+    /// hash distinto al del mismo fichero en disco. Es el tipo de diferencia que sólo se descubre
+    /// cuando dos respaldos del mismo arranque no coinciden.
+    pub fn leer_archivo(&mut self, ruta: &str) -> io::Result<Option<Vec<u8>>> {
+        let partes: Vec<&str> = ruta.split('/').filter(|x| !x.is_empty()).collect();
+        let Some((archivo, dirs)) = partes.split_last() else { return Ok(None) };
+
+        let mut 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)?
+        };
+        for d in dirs {
+            let Some(c) = buscar_cluster(&dir, d) else { return Ok(None) };
+            if c < 2 {
+                return Ok(None);
+            }
+            dir = self.leer_cadena(c)?;
+        }
+        let Some((cluster, tam)) = buscar_entrada(&dir, archivo) else { return Ok(None) };
+        if cluster < 2 || tam == 0 {
+            return Ok(Some(Vec::new()));
+        }
+        let mut b = self.leer_cadena(cluster)?;
+        b.truncate(tam as usize);
+        Ok(Some(b))
+    }
+
+    /// Todos los ficheros del volumen hasta `prof` niveles, como rutas absolutas. Acotado a
+    /// propósito: una ESP es plana por convención y un recorrido sin tope se cuelga con un
+    /// directorio corrupto que se apunta a sí mismo.
+    pub fn listar_recursivo(&mut self, prof: u32) -> io::Result<Vec<(String, u32)>> {
+        let mut out = Vec::new();
+        let raiz = self.raiz()?;
+        self.recorrer(&raiz, "", prof, &mut out)?;
+        out.sort();
+        Ok(out)
+    }
+
+    fn recorrer(
+        &mut self,
+        entradas: &[Entrada],
+        prefijo: &str,
+        prof: u32,
+        out: &mut Vec<(String, u32)>,
+    ) -> io::Result<()> {
+        for e in entradas {
+            let ruta = format!("{prefijo}/{}", e.nombre);
+            if e.es_dir {
+                if prof == 0 {
+                    continue;
+                }
+                // Releer el directorio por su ruta, que es lo que `subdir` no da más allá del raíz.
+                let hijos = self.listar_ruta(&ruta)?;
+                self.recorrer(&hijos, &ruta, prof - 1, out)?;
+            } else {
+                out.push((ruta, e.tam));
+            }
+        }
+        Ok(())
+    }
+
+    /// Entradas del directorio en `ruta` (absoluta dentro del volumen).
+    pub fn listar_ruta(&mut self, ruta: &str) -> io::Result<Vec<Entrada>> {
+        let mut 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)?
+        };
+        for d in ruta.split('/').filter(|x| !x.is_empty()) {
+            let Some(c) = buscar_cluster(&dir, d) else { return Ok(Vec::new()) };
+            if c < 2 {
+                return Ok(Vec::new());
+            }
+            dir = self.leer_cadena(c)?;
+        }
+        Ok(parse_dir(&dir))
+    }
+
     fn leer_cadena(&mut self, inicio: u32) -> io::Result<Vec<u8>> {
         let mut out = Vec::new();
         let mut c = inicio;
@@ -354,6 +442,48 @@ fn parse_dir(b: &[u8]) -> Vec<Entrada> {
     out
 }
 
+/// Como `buscar_cluster` pero devuelve `(cluster, tamaño)`: para un fichero el tamaño es
+/// imprescindible, porque la cadena de clusters siempre da de más.
+fn buscar_entrada(dir: &[u8], nombre: &str) -> Option<(u32, 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), le32(e, 0x1C)));
+        }
+    }
+    None
+}
+
 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) {
@@ -397,6 +527,7 @@ fn buscar_cluster(dir: &[u8], nombre: &str) -> Option<u32> {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use std::path::PathBuf;
 
     /// BPB fabricado a mano: prueba la geometría y, sobre todo, que un BPB imposible se RECHACE.
     #[test]
@@ -453,6 +584,75 @@ mod tests {
         out
     }
 
+    /// Fabrica una FAT16 mínima y válida con UN fichero de contenido conocido, sin depender de
+    /// mtools: así el test corre en cualquier entorno y no se saltea en silencio.
+    fn fat16_con_fichero(tmp: &Path, nombre_83: &[u8; 11], datos: &[u8]) -> PathBuf {
+        let bps = 512usize;
+        let reservados = 1usize;
+        let n_fats = 1usize;
+        let fat_sectores = 32usize;
+        let root_entries = 16usize;
+        let total = 8192usize; // ⇒ 8158 clusters: franja FAT16
+        let mut d = vec![0u8; bps * total];
+
+        d[0x0B..0x0D].copy_from_slice(&(bps as u16).to_le_bytes());
+        d[0x0D] = 1; // sectores por cluster
+        d[0x0E..0x10].copy_from_slice(&(reservados as u16).to_le_bytes());
+        d[0x10] = n_fats as u8;
+        d[0x11..0x13].copy_from_slice(&(root_entries as u16).to_le_bytes());
+        d[0x13..0x15].copy_from_slice(&(total as u16).to_le_bytes());
+        d[0x16..0x18].copy_from_slice(&(fat_sectores as u16).to_le_bytes());
+        d[0x2B..0x36].copy_from_slice(b"PRUEBA     ");
+        d[510] = 0x55;
+        d[511] = 0xAA;
+
+        // FAT: clusters 0 y 1 reservados; el 2 es el fin de la cadena de nuestro fichero.
+        let fat = reservados * bps;
+        d[fat..fat + 2].copy_from_slice(&0xFFF8u16.to_le_bytes());
+        d[fat + 2..fat + 4].copy_from_slice(&0xFFFFu16.to_le_bytes());
+        d[fat + 4..fat + 6].copy_from_slice(&0xFFFFu16.to_le_bytes());
+
+        // Directorio raíz: un fichero en el cluster 2.
+        let root = (reservados + n_fats * fat_sectores) * bps;
+        d[root..root + 11].copy_from_slice(nombre_83);
+        d[root + 0x0B] = 0x20; // archivo
+        d[root + 0x1A..root + 0x1C].copy_from_slice(&2u16.to_le_bytes());
+        d[root + 0x1C..root + 0x20].copy_from_slice(&(datos.len() as u32).to_le_bytes());
+
+        // Datos: el cluster 2 es el primer sector de datos.
+        let primer_dato = (reservados + n_fats * fat_sectores + root_entries * 32 / bps) * bps;
+        d[primer_dato..primer_dato + datos.len()].copy_from_slice(datos);
+
+        let f = tmp.join("fat16.img");
+        std::fs::write(&f, &d).unwrap();
+        f
+    }
+
+    #[test]
+    fn lee_un_fichero_entero_y_lo_trunca_al_tamano_declarado() {
+        let tmp = std::env::temp_dir().join(format!("takana-fat-file-{}", std::process::id()));
+        std::fs::create_dir_all(&tmp).unwrap();
+        // 300 bytes en un cluster de 512: el resto del cluster es RELLENO. Si no se trunca al
+        // tamaño declarado, el hash del respaldo del ADR 0018 §5 no coincidiría nunca con el del
+        // mismo fichero en disco — y el síntoma sería «dos respaldos del mismo arranque difieren».
+        let datos: Vec<u8> = (0..300u32).map(|i| (i % 251) as u8).collect();
+        let img = fat16_con_fichero(&tmp, b"KERNEL  EFI", &datos);
+        let mut fat = Fat::abrir(&img, 0).unwrap();
+        assert_eq!(fat.info.tipo, 16);
+
+        let leido = fat.leer_archivo("/KERNEL.EFI").unwrap().expect("el fichero está");
+        assert_eq!(leido.len(), 300, "truncado al tamaño declarado, no al del cluster");
+        assert_eq!(leido, datos, "y los bytes son exactamente los que se escribieron");
+
+        // Un fichero que no está devuelve None, no un vector vacío que se confundiría con «vacío».
+        assert!(fat.leer_archivo("/NO-ESTA.EFI").unwrap().is_none());
+        assert!(fat.leer_archivo("/SUB/NO-ESTA.EFI").unwrap().is_none());
+
+        let l = fat.listar_recursivo(3).unwrap();
+        assert_eq!(l, vec![("/KERNEL.EFI".to_string(), 300u32)]);
+        std::fs::remove_dir_all(&tmp).ok();
+    }
+
     #[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
diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs
index 2acd3e12..b20d10b9 100644
--- a/crates/takana-cli/src/main.rs
+++ b/crates/takana-cli/src/main.rs
@@ -624,6 +624,45 @@ enum EntryCmd {
         #[arg(long, default_value = crate::efi_boot::EFIVARS)]
         efivars: PathBuf,
     },
+    /// Snapshot the whole boot state —NVRAM plus a hashed manifest of the ESP— to a file.
+    ///
+    /// ADR 0018 §5. Restaurar el arranque deja de ser adivinar y pasa a ser reproducir un estado
+    /// conocido. Guarda las variables **crudas** (se reescriben tal cual) y de la ESP un
+    /// **manifiesto con hashes**, no los bytes: el kernel ya vive en el store y copiarlo otra vez
+    /// sería churn. El manifiesto es evidencia de qué había; para lo que no esté en el store, dice
+    /// qué falta en vez de prometer que lo puede reponer.
+    Backup {
+        /// Where to write the snapshot.
+        #[arg(long, default_value = "/var/lib/hammer/boot")]
+        out: PathBuf,
+        /// 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,
+        /// Snapshot this disk instead of auto-detecting.
+        #[arg(long)]
+        disk: Option<PathBuf>,
+        /// Print the snapshot instead of writing it.
+        #[arg(long)]
+        stdout: bool,
+    },
+    /// Put the firmware back the way a snapshot says it was.
+    Restore {
+        /// Snapshot file made by `backup`.
+        #[arg(long)]
+        from: PathBuf,
+        /// Root of efivarfs.
+        #[arg(long, default_value = crate::efi_boot::EFIVARS)]
+        efivars: PathBuf,
+        /// Say what would change and touch nothing. THE DEFAULT.
+        #[arg(long, default_value_t = true)]
+        dry_run: bool,
+        /// Actually write the firmware variables.
+        #[arg(long)]
+        apply: bool,
+    },
     /// 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
@@ -1592,6 +1631,170 @@ fn main() -> anyhow::Result<()> {
                                 nuevo.iter().map(|n| format!("{n:04X}")).collect::<Vec<_>>().join(","));
                         }
                     }
+                    EntryCmd::Backup { out, efivars, sys_block, disk, stdout } => {
+                        use std::collections::BTreeMap;
+                        let mut doc = serde_json::Map::new();
+                        doc.insert("schema".into(), "takana-boot-backup/1".into());
+
+                        // --- NVRAM: las variables CRUDAS, atributos incluidos. Se guardan en hex
+                        //     porque es lo que se va a reescribir tal cual; decodificarlas para
+                        //     volver a codificarlas al restaurar sería una oportunidad de perder
+                        //     algo que no entendemos.
+                        let mut vars = BTreeMap::new();
+                        if efi_boot::hay_firmware_efi(&efivars) {
+                            for ent in std::fs::read_dir(&efivars)? {
+                                let ent = ent?;
+                                let n = ent.file_name().to_string_lossy().to_string();
+                                let interesa = n.starts_with("Boot") && n.contains(efi_boot::GLOBAL_GUID);
+                                if !interesa {
+                                    continue;
+                                }
+                                if let Ok(raw) = std::fs::read(ent.path()) {
+                                    vars.insert(n, raw.iter().map(|b| format!("{b:02x}")).collect::<String>());
+                                }
+                            }
+                        }
+                        doc.insert("firmware".into(), if vars.is_empty() { "bios-o-sin-efivarfs" } else { "uefi" }.into());
+                        doc.insert("nvram".into(), serde_json::to_value(&vars)?);
+
+                        // Legible para un humano que abra el fichero dentro de un año.
+                        let entradas = efi_boot::list_entries(&efivars).unwrap_or_default();
+                        let legible: Vec<serde_json::Value> = entradas.iter().map(|(n, lo)| {
+                            serde_json::json!({"slot": format!("{n:04X}"), "description": lo.description})
+                        }).collect();
+                        doc.insert("entries_readable".into(), serde_json::Value::Array(legible));
+
+                        // --- ESP: manifiesto con hash, no los bytes.
+                        let discos = match &disk {
+                            Some(d) => vec![d.clone()],
+                            None => efi_boot::block_disks(&sys_block).unwrap_or_default(),
+                        };
+                        let mut esps = Vec::new();
+                        for (d, n) in efi_boot::find_esps(&discos) {
+                            let Ok(parte) = efi_boot::read_partition(&d, n) else { continue };
+                            let Ok(mut fat) = fat_ro::Fat::abrir(&d, parte.start_lba * 512) else { continue };
+                            let mut ficheros = Vec::new();
+                            // 4 niveles cubre \EFI\<vendor>\<sub>\fichero, que es lo más hondo
+                            // que llega una ESP real.
+                            for (ruta, tam) in fat.listar_recursivo(4).unwrap_or_default() {
+                                let h = match fat.leer_archivo(&ruta) {
+                                    Ok(Some(b)) => takana_core::hash::ArtifactHash::of_bytes(&b).to_string(),
+                                    _ => "(ilegible)".to_string(),
+                                };
+                                ficheros.push(serde_json::json!({"path": ruta, "size": tam, "hash": h}));
+                            }
+                            esps.push(serde_json::json!({
+                                "disk": d.display().to_string(),
+                                "partition": n,
+                                "fat": fat.info.tipo,
+                                "label": fat.info.etiqueta,
+                                "bytes_total": fat.info.bytes_totales(),
+                                "bytes_free": fat.info.bytes_libres(),
+                                "files": ficheros,
+                            }));
+                        }
+                        doc.insert("esps".into(), serde_json::Value::Array(esps));
+
+                        let texto = serde_json::to_string_pretty(&serde_json::Value::Object(doc))?;
+                        if stdout {
+                            println!("{texto}");
+                        } else {
+                            // Nombre por CONTENIDO: dos respaldos de un arranque que no cambió son
+                            // el mismo fichero, así que esto se puede correr en cada arranque sin
+                            // llenar la partición de estado con copias idénticas.
+                            let h = takana_core::hash::ArtifactHash::of_bytes(texto.as_bytes()).to_string();
+                            let corto = h.trim_start_matches("b3:").chars().take(16).collect::<String>();
+                            std::fs::create_dir_all(&out)?;
+                            let f = out.join(format!("boot-{corto}.json"));
+                            let nuevo = !f.exists();
+                            std::fs::write(&f, &texto)?;
+                            std::fs::write(out.join("latest.json"), &texto)?;
+                            println!("{} respaldo del arranque: {}", if nuevo { "✓" } else { "=" }, f.display());
+                            println!("  {} entrada(s), {} variable(s) de NVRAM, {} byte(s)",
+                                entradas.len(), vars.len(), texto.len());
+                            if !nuevo {
+                                println!("  (idéntico a uno que ya estaba: el arranque no cambió)");
+                            }
+                        }
+                    }
+                    EntryCmd::Restore { from, efivars, dry_run, apply } => {
+                        // El default es NO escribir. Restaurar pisa la NVRAM del firmware, que es lo
+                        // único de esta máquina que no se puede rehacer desde el store.
+                        let escribir = apply && !std::env::args().any(|a| a == "--dry-run");
+                        let _ = dry_run;
+                        let texto = std::fs::read_to_string(&from)?;
+                        let doc: serde_json::Value = serde_json::from_str(&texto)?;
+                        let esquema = doc.get("schema").and_then(|v| v.as_str()).unwrap_or("");
+                        if esquema != "takana-boot-backup/1" {
+                            anyhow::bail!("esto no es un respaldo de arranque de takana (schema: {esquema:?})");
+                        }
+                        let Some(nvram) = doc.get("nvram").and_then(|v| v.as_object()) else {
+                            anyhow::bail!("el respaldo no trae la sección `nvram`");
+                        };
+                        println!("respaldo: {} — {} variable(s)", from.display(), nvram.len());
+
+                        // ⚠ Restaurar es volver al pasado, y lo que llegó DESPUÉS no estaba en ese
+                        // pasado. Al reponer el BootOrder del respaldo, cualquier entrada creada
+                        // luego —típicamente el Windows que se instaló al lado— queda FUERA del
+                        // orden y deja de arrancar. Es la semántica correcta y es exactamente lo que
+                        // el usuario no espera, así que se dice ANTES y con nombre y apellido.
+                        let orden_respaldo: Vec<u16> = nvram
+                            .get(&format!("BootOrder-{}", efi_boot::GLOBAL_GUID))
+                            .and_then(|v| v.as_str())
+                            .map(|hex| {
+                                let b: Vec<u8> = (0..hex.len() / 2)
+                                    .map(|i| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).unwrap_or(0))
+                                    .collect();
+                                if b.len() > 4 { efi_boot::parse_boot_order(&b[4..]) } else { Vec::new() }
+                            })
+                            .unwrap_or_default();
+                        if !orden_respaldo.is_empty() {
+                            let hoy = efi_boot::list_entries(&efivars).unwrap_or_default();
+                            let fuera: Vec<String> = hoy
+                                .iter()
+                                .filter(|(n, _)| !orden_respaldo.contains(n))
+                                .map(|(n, lo)| format!("Boot{n:04X} «{}»", lo.description))
+                                .collect();
+                            if !fuera.is_empty() {
+                                println!("  ⚠ ESTO SACA DE BootOrder a {} entrada(s) que hoy sí están:", fuera.len());
+                                for f in &fuera {
+                                    println!("      {f}");
+                                }
+                                println!("    Son las que aparecieron DESPUÉS del respaldo. Si alguna es otro sistema");
+                                println!("    operativo, va a dejar de arrancar desde el menú del firmware hasta que la repongas.");
+                            }
+                        }
+                        for (nombre, val) in nvram {
+                            let Some(hex) = val.as_str() else { continue };
+                            let bytes: Vec<u8> = (0..hex.len() / 2)
+                                .map(|i| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).unwrap_or(0))
+                                .collect();
+                            let destino = efivars.join(nombre);
+                            let actual = std::fs::read(&destino).ok();
+                            let igual = actual.as_deref() == Some(&bytes[..]);
+                            if igual {
+                                println!("  = {nombre} ya coincide");
+                                continue;
+                            }
+                            if !escribir {
+                                println!("  ~ {nombre} CAMBIARÍA ({} → {} bytes)",
+                                    actual.map(|a| a.len()).unwrap_or(0), bytes.len());
+                                continue;
+                            }
+                            if bytes.len() < 4 {
+                                println!("  ! {nombre} truncada en el respaldo; se saltea");
+                                continue;
+                            }
+                            let attrs = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
+                            match efi_boot::write_var(&destino, attrs, &bytes[4..]) {
+                                Ok(()) => println!("  ✓ {nombre} restaurada"),
+                                Err(e) => println!("  ✗ {nombre}: {e}"),
+                            }
+                        }
+                        if !escribir {
+                            println!("  (nada escrito — es el default. Para aplicar: --apply)");
+                        }
+                    }
                     EntryCmd::Survey { sys_block, disk, efivars, need_bytes } => {
                         let mut problemas: Vec<String> = Vec::new();
 
diff --git a/docs/adr/0018-soberania-del-arranque.md b/docs/adr/0018-soberania-del-arranque.md
index 29e8a94f..731d44b3 100644
--- a/docs/adr/0018-soberania-del-arranque.md
+++ b/docs/adr/0018-soberania-del-arranque.md
@@ -1,7 +1,8 @@
 # ADR 0018 — Soberanía del arranque: convivir con otro gestor sin depender de su buena fe
 
-- **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.
+- **Estado:** PROPUESTO, con **§1 a §5 IMPLEMENTADOS y verificados** (2026-09-11/12).
+  Queda **una decisión abierta** (§Secure Boot); el §6 es una no-acción deliberada y el §7 va al
+  manual del piloto (SDD 21).
 - **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.
@@ -232,6 +233,44 @@ Volcar NVRAM (`efibootmgr -v`) + el contenido de la ESP al store en cada instala
 actualización de arranque. «Restaurar el arranque» pasa de adivinar a **reproducir un estado
 conocido**. Barato, y es exactamente la forma de takana.
 
+> **ENMIENDA 2026-09-11 (5ª) — el §5 implementado, y el ADR se equivocaba en DÓNDE guardarlo.**
+>
+> `takana boot entry backup` / `restore`. El respaldo guarda las variables de NVRAM **crudas** —se
+> reescriben tal cual; decodificarlas para volver a codificarlas al restaurar sería una oportunidad
+> de perder algo que no entendemos— y de la ESP un **manifiesto con hashes**, no los bytes: el kernel
+> ya vive en el store y copiarlo otra vez sería churn. El manifiesto es **evidencia de qué había**;
+> para lo que no esté en el store dice qué falta, en vez de prometer que lo puede reponer.
+>
+> **Corrección al §5 tal como estaba escrito:** decía «volcar al store». **No va al store.** Lo barre
+> `store-gc.sh`, que clasifica por nombre de receta: un respaldo ahí sería *huérfano* y se borraría
+> en el primer `--huerfanos`. Vive en `/var/lib/hammer/boot/`, que en las imágenes es **su propia
+> partición** y sobrevive a una reinstalación del sistema. El nombre del fichero sale del
+> **contenido**, así que un arranque que no cambió produce el mismo fichero y esto puede correr en
+> cada arranque sin llenar la partición de estado con copias idénticas.
+>
+> **Lo que encontró la prueba de punta a punta, y que no estaba en el diseño:** restaurar es volver
+> al pasado, **y lo que llegó después no estaba en ese pasado**. Al reponer el `BootOrder` del
+> respaldo, el Windows instalado *más tarde* quedaba **fuera del orden** — semánticamente correcto y
+> exactamente lo que el usuario no espera de algo llamado «restaurar el arranque». Ahora se avisa
+> antes, con nombre y apellido:
+>
+> ```
+>   ⚠ ESTO SACA DE BootOrder a 1 entrada(s) que hoy sí están:
+>       Boot0001 «Windows Boot Manager»
+>     Son las que aparecieron DESPUÉS del respaldo. Si alguna es otro sistema
+>     operativo, va a dejar de arrancar desde el menú del firmware hasta que la repongas.
+> ```
+>
+> Y `restore` **no escribe por defecto**: hay que pasar `--apply`. La NVRAM es lo único de la máquina
+> que no se puede rehacer desde el store.
+>
+> El lector FAT ganó lectura de ficheros para el manifiesto, con su trampa propia: **hay que truncar
+> al tamaño declarado en el directorio**, no al final del último cluster. Un fichero de 1,5 MB en
+> clusters de 1 KiB termina con relleno, y hashear el relleno daría un hash distinto al del mismo
+> fichero en disco — el síntoma sería «dos respaldos del mismo arranque difieren». Verificado contra
+> `mtools` como oráculo (extrae 1 500 000 y 900 000 bytes exactos, mismo sha256 que los originales) y
+> con un test determinista que fabrica una FAT16 a mano, sin depender de que mtools esté.
+
 ### 6. Lo que takana NO hace: `os-prober`
 
 No se escanean discos ajenos para adivinar qué sistemas hay y generar entradas. Es una fuente
diff --git a/scripts/install-image-efi.sh b/scripts/install-image-efi.sh
index 6d0cdb64..3641e34b 100755
--- a/scripts/install-image-efi.sh
+++ b/scripts/install-image-efi.sh
@@ -221,6 +221,16 @@ 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
+
+# Respaldo del arranque (ADR 0018 §5). Se hace DESPUÉS de reconciliar, así lo que queda guardado es
+# el estado bueno y no el que dejó el vecino. El nombre del fichero sale del CONTENIDO: un arranque
+# que no cambió produce el mismo fichero, así que esto corre en cada arranque sin llenar la
+# partición de estado con copias idénticas.
+#
+# Vive en /var/lib/hammer, que en las imágenes es su propia partición y sobrevive a una reinstalación
+# del sistema. **No va al store**: el store lo barre `store-gc.sh`, que clasifica por nombre de
+# receta — un respaldo ahí sería «huérfano» y se borraría en el primer `--huerfanos`.
+[ -x /usr/bin/hammer ] && /bin/busybox timeout 15 /usr/bin/hammer boot entry backup > /dev/ttyS0 2>&1 || true
 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 f1cc946a..1cedd01a 100755
--- a/scripts/takana-live-install.sh
+++ b/scripts/takana-live-install.sh
@@ -474,6 +474,16 @@ echo "HAMMER-EFI-INIT-OK: arje-zero PID1" > /dev/ttyS0 2>/dev/null || true
 # 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
+
+# Respaldo del arranque (ADR 0018 §5). Se hace DESPUÉS de reconciliar, así lo que queda guardado es
+# el estado bueno y no el que dejó el vecino. El nombre del fichero sale del CONTENIDO: un arranque
+# que no cambió produce el mismo fichero, así que esto corre en cada arranque sin llenar la
+# partición de estado con copias idénticas.
+#
+# Vive en /var/lib/hammer, que en las imágenes es su propia partición y sobrevive a una reinstalación
+# del sistema. **No va al store**: el store lo barre `store-gc.sh`, que clasifica por nombre de
+# receta — un respaldo ahí sería «huérfano» y se borraría en el primer `--huerfanos`.
+[ -x /usr/bin/hammer ] && /bin/busybox timeout 15 /usr/bin/hammer boot entry backup > /dev/ttyS0 2>&1 || true
 exec /usr/bin/arje-zero
 INIT
     "$BB" chmod +x "$MNT/sbin/init"