From bd1fa8bf8ac17a52ceb04005e5e6f90d1589bae2 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 12 Sep 2026 00:34:21 +0000 Subject: [PATCH] =?UTF-8?q?boot=20entry=20backup/restore:=20el=20arranque?= =?UTF-8?q?=20como=20estado=20reproducible=20(ADR=200018=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01HHAKWoBqof9XvsYCvDicEj --- crates/takana-cli/src/fat_ro.rs | 200 +++++++++++++++++++++++ crates/takana-cli/src/main.rs | 203 ++++++++++++++++++++++++ docs/adr/0018-soberania-del-arranque.md | 43 ++++- scripts/install-image-efi.sh | 10 ++ scripts/takana-live-install.sh | 10 ++ 5 files changed, 464 insertions(+), 2 deletions(-) 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>> { + 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> { + 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> { + 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> { let mut out = Vec::new(); let mut c = inicio; @@ -354,6 +442,48 @@ fn parse_dir(b: &[u8]) -> Vec { 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)> = Vec::new(); + for e in dir.chunks_exact(32) { + if e[0] == 0x00 { + break; + } + if e[0] == 0xE5 { + lfn.clear(); + continue; + } + let attr = e[0x0B]; + if attr & 0x0F == 0x0F { + let mut u = Vec::with_capacity(13); + for &(o, n) in &[(1usize, 5usize), (14, 6), (28, 2)] { + for i in 0..n { + u.push(u16::from_le_bytes([e[o + i * 2], e[o + i * 2 + 1]])); + } + } + lfn.push((e[0] & 0x3F, u)); + continue; + } + let corto = nombre_83(e); + let largo = if lfn.is_empty() { + String::new() + } else { + lfn.sort_by_key(|(n, _)| *n); + let mut u: Vec = lfn.iter().flat_map(|(_, x)| x.iter().copied()).collect(); + if let Some(p) = u.iter().position(|&c| c == 0 || c == 0xFFFF) { + u.truncate(p); + } + String::from_utf16_lossy(&u) + }; + lfn.clear(); + if corto.eq_ignore_ascii_case(nombre) || largo.eq_ignore_ascii_case(nombre) { + return Some(((le16(e, 0x14) << 16) | le16(e, 0x1A), le32(e, 0x1C))); + } + } + None +} + fn buscar_cluster(dir: &[u8], nombre: &str) -> Option { let mut lfn: Vec<(u8, Vec)> = Vec::new(); for e in dir.chunks_exact(32) { @@ -397,6 +527,7 @@ fn buscar_cluster(dir: &[u8], nombre: &str) -> Option { #[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 = (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, + /// 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::>().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::()); + } + } + } + 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 = 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\\\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::(); + 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 = nvram + .get(&format!("BootOrder-{}", efi_boot::GLOBAL_GUID)) + .and_then(|v| v.as_str()) + .map(|hex| { + let b: Vec = (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 = 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 = (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 = 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"