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
This commit is contained in:
Sergio
2026-09-12 00:34:21 +00:00
co-authored by Claude Opus 5
parent 61b57ab060
commit bd1fa8bf8a
5 changed files with 464 additions and 2 deletions
+200
View File
@@ -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
+203
View File
@@ -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();
+41 -2
View File
@@ -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
+10
View File
@@ -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"
+10
View File
@@ -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"