> 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 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
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"