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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user