respaldo del arranque: lista blanca, porque BootCurrent rompía la idempotencia

Dos arranques reales seguidos, sin tocar nada, daban dos respaldos con
nombre distinto: boot-27b952c9….json y boot-f999e1e8….json. La promesa
de "un arranque que no cambió produce el mismo fichero" era falsa.

La causa: el filtro era «empieza por Boot», y eso deja entrar
BootCurrent, que dice por dónde arrancó ESTA vez y cambia en cada
arranque. Y encima es de SÓLO LECTURA, así que restore habría intentado
escribirla.

Ahora es lista blanca —BootOrder y Boot#### y nada más—, que también
deja fuera BootNext (de un solo uso: restaurarla dispararía un arranque
que nadie pidió) y BootOptionSupport (informativa). Ninguna de las tres
describe cómo debe arrancar la máquina.

Comprobado: cambiando BootCurrent a mano, el respaldo da el mismo
fichero y lo dice — "idéntico a uno que ya estaba: el arranque no
cambió".

Esto sólo se veía ARRANCANDO DOS VECES. Un test del respaldo contra un
efivarfs fabricado habría pasado en verde: la variable que rompía la
propiedad la pone el firmware, no el código.

80/80 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:37:13 +00:00
co-authored by Claude Opus 5
parent bd1fa8bf8a
commit 2b87ecb50f
3 changed files with 58 additions and 2 deletions
+37
View File
@@ -404,6 +404,27 @@ pub fn first_free_slot(root: &Path) -> io::Result<u16> {
Err(io::Error::new(io::ErrorKind::AlreadyExists, "no queda ni un Boot#### libre (0000-FFFF)"))
}
/// ¿Es una variable de **configuración** del arranque, y no estado de la sesión?
///
/// Lista blanca a propósito: sólo `BootOrder-<guid>` y `Boot####-<guid>`. Un «empieza por Boot» deja
/// entrar `BootCurrent` (por dónde arrancó esta vez — cambia en cada arranque y es de sólo lectura),
/// `BootOptionSupport` (informativa) y `BootNext` (de un solo uso: restaurarla dispararía un arranque
/// que nadie pidió). Ninguna de las tres describe cómo debe arrancar la máquina.
pub fn es_variable_de_config(nombre: &str) -> bool {
let Some((base, guid)) = nombre.rsplit_once('-') else { return false };
// El GUID lleva guiones: `rsplit_once` sólo corta el último trozo, así que se rearma mirando
// el sufijo completo.
let _ = (base, guid);
let Some(resto) = nombre.strip_suffix(&format!("-{GLOBAL_GUID}")) else { return false };
if resto == "BootOrder" {
return true;
}
match resto.strip_prefix("Boot") {
Some(n) => n.len() == 4 && n.chars().all(|c| c.is_ascii_hexdigit()),
None => false,
}
}
/// Lista las entradas `Boot####` presentes, en orden numérico, con su descripción ya decodificada.
pub fn list_entries(root: &Path) -> io::Result<Vec<(u16, LoadOption)>> {
let mut out = Vec::new();
@@ -736,6 +757,22 @@ mod tests {
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn la_lista_blanca_deja_fuera_el_estado_de_la_sesion() {
let g = GLOBAL_GUID;
assert!(es_variable_de_config(&format!("BootOrder-{g}")));
assert!(es_variable_de_config(&format!("Boot0004-{g}")));
assert!(es_variable_de_config(&format!("BootFFFF-{g}")));
// Las tres que rompían la idempotencia del respaldo o son de sólo lectura:
assert!(!es_variable_de_config(&format!("BootCurrent-{g}")), "cambia en CADA arranque");
assert!(!es_variable_de_config(&format!("BootNext-{g}")), "es de un solo uso");
assert!(!es_variable_de_config(&format!("BootOptionSupport-{g}")), "informativa");
// Y nada de otro namespace de GUID, aunque se llame parecido.
assert!(!es_variable_de_config("Boot0004-11111111-2222-3333-4444-555555555555"));
assert!(!es_variable_de_config("Boot004-{g}"), "3 dígitos no es un slot");
assert!(!es_variable_de_config("BootOrder"), "sin GUID no es una variable EFI");
}
#[test]
fn boot_order_ida_y_vuelta() {
let o = vec![0x0001u16, 0x0000, 0x2001];
+9 -2
View File
@@ -1645,8 +1645,15 @@ fn main() -> anyhow::Result<()> {
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 {
// LISTA BLANCA, no «empieza con Boot». Medido en dos arranques
// seguidos: `BootCurrent` dice por dónde arrancó ESTA vez y cambia
// aunque la configuración no haya cambiado, así que el respaldo
// salía con un hash distinto cada arranque y la promesa de «un
// arranque que no cambió produce el mismo fichero» era falsa. Y
// encima es de SÓLO LECTURA: `restore` intentaría escribirla.
// Lo mismo `BootOptionSupport` (informativa) y `BootNext` (de un
// solo uso: restaurarla dispararía un arranque que nadie pidió).
if !efi_boot::es_variable_de_config(&n) {
continue;
}
if let Ok(raw) = std::fs::read(ent.path()) {
+12
View File
@@ -264,6 +264,18 @@ conocido**. Barato, y es exactamente la forma de takana.
> 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.
>
> **Y la promesa de «un arranque que no cambió produce el mismo fichero» empezó siendo falsa.** Dos
> arranques reales seguidos, sin tocar nada, dieron `boot-27b952c9….json` y `boot-f999e1e8….json`.
> La causa: el filtro era «empieza por `Boot`», y eso deja entrar **`BootCurrent`**, que dice por
> dónde arrancó **esta** vez y cambia en cada arranque — además de ser de **sólo lectura**, así que
> `restore` habría intentado escribirla. Ahora es lista blanca (`BootOrder` y `Boot####` y nada más),
> que también deja fuera `BootNext` (de un solo uso: restaurarla dispararía un arranque que nadie
> pidió) y `BootOptionSupport` (informativa). Comprobado: cambiando `BootCurrent` a mano, el respaldo
> da el mismo fichero y lo dice — *«idéntico a uno que ya estaba: el arranque no cambió»*.
>
> Sólo se veía **arrancando dos veces**. Un test del respaldo contra un `efivarfs` fabricado habría
> pasado en verde: la variable que rompía la propiedad la pone el firmware, no el código.
>
> 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