From 895401b479b28cb8aa2a4473e3526d0e77f388ac Mon Sep 17 00:00:00 2001 From: sergio Date: Tue, 21 Jul 2026 22:35:03 -0400 Subject: [PATCH] =?UTF-8?q?cierre=20#2=20del=20SDD=2017:=20`hammer=20why-d?= =?UTF-8?q?iffers`=20=E2=80=94=20el=20diffoscope=20propio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cuando un artefacto no reproduce, el store sólo sabe decir "el hash no coincide" y el resto es trabajo artesanal. Esto responde POR QUÉ, en términos de la CAUSA y no del byte: - gzip con MTIME embebido (bytes 4..8) → remedio: `gzip -n` - cabecera `ar` de un `.a` (mtime/uid/gid) → remedio: modo determinista (`ar D`) - secciones ELF, con lectura experta: sólo `.comment` ⇒ otra versión de compilador; sólo `.debug_*` ⇒ rutas de build; sólo `.symtab`/`.dynsym` ⇒ orden de símbolos (código idéntico); sólo build-id ⇒ residuo, no causa raíz. En un `.a` dice QUÉ MIEMBRO difiere. - ruta del árbol de build embebida, texto (línea que difiere), y bytes como último recurso. Y sobre todo trae la EVIDENCIA, no sólo la hipótesis: para las secciones de texto extrae las cadenas que están en un ELF y no en el otro. Caso real que lo motivó (alsa-lib): la interpretación decía "típicamente rutas de build" y la evidencia mostró `/src/target/release/build/libsodium-sys-/out/…`. Sin la cadena era una corazonada; reproducir eso a mano cuesta varios readelf, la herramienta lo da en 40ms. Descenso, no comparación total: sólo baja donde los hashes difieren (el cruce con format/ reconcile del SDD 17). Sin dependencias externas — parsers gzip/ar/ELF propios, como manda el ADR 0004: un diffoscope de verdad se apoya en medio mundo de binarios ajenos. `--json` para el bucle agéntico; exit 0 si reproduce, 1 si diverge (encadenable en scripts). `scripts/why-differs-barrido.sh` lo pasa por todo el store y separa los dos casos que se confunden a ojo: recipe.toml distinto (divergencia esperada) vs recipe.toml IDÉNTICO y artefacto distinto (no-reproducción a investigar). 5 tests nuevos; los 142 de hammer-core siguen en verde. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hammer-cli/src/main.rs | 49 ++ crates/hammer-core/src/differs.rs | 799 ++++++++++++++++++++++++++++++ crates/hammer-core/src/lib.rs | 2 + scripts/why-differs-barrido.sh | 117 +++++ 4 files changed, 967 insertions(+) create mode 100644 crates/hammer-core/src/differs.rs create mode 100755 scripts/why-differs-barrido.sh diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 3a8c4977..63aaeaf7 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -49,6 +49,20 @@ enum Cmd { #[arg(long)] check: bool, }, + /// [SDD 17 §1.2] Explica POR QUÉ dos artefactos difieren — el diffoscope propio. Cuando un hash + /// no reproduce, el store sólo sabe decir "no coincide"; esto nombra la CAUSA: un MTIME embebido + /// en un gzip, la cabecera `ar` de un `.a`, qué sección ELF cambió (`.comment` ⇒ otro compilador), + /// una ruta del árbol de build filtrada al binario. Desciende sólo donde los hashes difieren. + /// Exit 0 si los dos árboles son idénticos, 1 si divergen. + WhyDiffers { + /// Artefacto A: un directorio del store (`store/-`) o cualquier árbol. + a: String, + /// Artefacto B, el que se compara contra A. + b: String, + /// Informe JSON — evidencia legible por máquina para el barrido de granja y el bucle agéntico. + #[arg(long)] + json: bool, + }, /// [Atestación] Verifica un árbol FHS (p.ej. un product-rootfs hidratado) contra su /// `/ente/attest.json`: recomputa el BLAKE3 de cada binario crítico y lo compara. Es el gate de /// integridad que arje aplicará al boot, hecho hoy por hammer ("reproducir, no confiar"). Exit≠0 @@ -785,6 +799,41 @@ fn main() -> anyhow::Result<()> { println!("{hash}"); } } + Cmd::WhyDiffers { a, b, json } => { + let informe = hammer_core::why_differs(std::path::Path::new(&a), std::path::Path::new(&b))?; + if json { + println!("{}", serde_json::to_string_pretty(&informe)?); + } else { + println!("== why-differs"); + println!(" A: {}", informe.a); + println!(" B: {}", informe.b); + println!( + " {} entradas idénticas · {} divergen", + informe.identicos, + informe.divergencias.len() + ); + for d in &informe.divergencias { + println!("\n {}", d.ruta); + println!(" causa: {}", d.explicacion); + if let Some(r) = &d.remedio { + println!(" → {r}"); + } + } + if informe.reproduce() { + println!("\n ✓ los dos árboles son idénticos: REPRODUCE"); + } else { + let resumen: Vec = informe + .por_causa() + .iter() + .map(|(c, n)| format!("{c}×{n}")) + .collect(); + println!("\n resumen: {}", resumen.join(" ")); + } + } + if !informe.reproduce() { + std::process::exit(1); + } + } Cmd::Attest { rootfs } => { let report = hammer_bootstrap::verify_attestation(std::path::Path::new(&rootfs))?; for p in &report.ok { diff --git a/crates/hammer-core/src/differs.rs b/crates/hammer-core/src/differs.rs new file mode 100644 index 00000000..6e26ba10 --- /dev/null +++ b/crates/hammer-core/src/differs.rs @@ -0,0 +1,799 @@ +//! `why-differs` — el diffoscope propio de hammer ([SDD 17 §1.2](../../docs/17-cierres-frontera.md)). +//! +//! Cuando un artefacto **no reproduce**, el store sólo sabe decir "el hash no coincide". Eso deja el +//! trabajo entero al humano: desempacar los dos árboles, `cmp` a mano, adivinar. Este módulo responde +//! la pregunta siguiente — **POR QUÉ** difieren — y la responde en términos de la CAUSA, no del byte: +//! un MTIME embebido en un gzip, la cabecera `ar` de un `.a`, la sección `.comment` de un ELF (=otro +//! compilador), una ruta del árbol de build que se coló en el binario. +//! +//! Es un multiplicador doble (por eso es uno de los dos elegidos del SDD 17 §5): hace escalar el +//! barrido de la granja —hoy debuggear una no-reproducción es artesanal— y produce evidencia +//! **legible por máquina** (`--json`) para el bucle agéntico. +//! +//! **Descenso, no comparación total** (el cruce con `format`/`reconcile`): dos subárboles idénticos +//! colapsan al mismo hash, así que el diff sólo desciende donde los hashes difieren. Comparar dos +//! artefactos de miles de ficheros cuesta un hash por fichero y un diagnóstico sólo por los que +//! divergen. +//! +//! **Sin dependencias externas**: los parsers (gzip/ar/ELF) son mínimos y viven acá, en el mismo +//! estilo que `query::parse_elf_info`. Un diffoscope de verdad se apoya en medio mundo de binarios +//! ajenos; eso es exactamente lo que hammer no puede permitirse (ADR 0004: el catálogo se construye, +//! no se importa). + +use serde::Serialize; +use std::collections::BTreeMap; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +/// Cuánto de un fichero divergente se lee para diagnosticar. Un artefacto puede traer un firmware de +/// cientos de MB; el diagnóstico no mejora por leerlo entero, y sí empeora la latencia. +const MAX_DIAGNOSTICO: u64 = 256 * 1024 * 1024; + +/// Bytes de contexto que se muestran alrededor del primer byte divergente. +const VENTANA: usize = 24; + +/// Una entrada del árbol, reducida a lo que decide si dos artefactos son el mismo. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Entrada { + Directorio, + /// El destino del symlink (NO se sigue: un symlink que apunta a otro lado ES una divergencia). + Symlink(String), + Fichero { + hash: String, + tamano: u64, + /// Sólo el bit de ejecución importa: el resto del modo lo normaliza el sellado. + ejecutable: bool, + }, +} + +impl Entrada { + fn tipo(&self) -> &'static str { + match self { + Entrada::Directorio => "directorio", + Entrada::Symlink(_) => "symlink", + Entrada::Fichero { .. } => "fichero", + } + } +} + +/// La causa de una divergencia — el veredicto que este módulo existe para dar. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case", tag = "causa")] +pub enum Causa { + /// Presente sólo en el artefacto A. + SoloEnA, + /// Presente sólo en el artefacto B. + SoloEnB, + /// La misma ruta es fichero en uno y symlink/directorio en el otro. + TipoDistinto { a: String, b: String }, + /// Symlinks que apuntan a destinos distintos. + SymlinkDistinto { a: String, b: String }, + /// Mismo contenido, distinto bit de ejecución. + ModoDistinto { a_ejecutable: bool, b_ejecutable: bool }, + /// gzip con el MTIME embebido en la cabecera (bytes 4..8). **La causa clásica** de + /// no-reproducibilidad en tarballs y páginas de man comprimidas: se mata con `gzip -n`. + GzipMtime { a: u32, b: u32 }, + /// Cabecera de un miembro de archivo `ar` (los `.a`): mtime/uid/gid. Se mata con `ar D` o + /// `ARFLAGS=Dcr` (modo determinista, que pone esos campos a 0). + ArCabecera { miembro: String, campo: String, a: String, b: String }, + /// Secciones ELF que difieren. `.comment` ⇒ otro compilador; `.note.gnu.build-id` sólo ⇒ el + /// build-id deriva del resto y no es causa raíz; `.debug_*` ⇒ típicamente rutas del árbol de + /// build sin `-fdebug-prefix-map`. + ElfSecciones { + secciones: Vec, + interpretacion: String, + /// Cadenas concretas que están en un ELF y no en el otro — la evidencia detrás de la + /// interpretación (ver `evidencia_de_cadenas`). + evidencia: Vec, + }, + /// Fichero de texto: la primera línea que difiere. + TextoLinea { linea: usize, a: String, b: String }, + /// Un fragmento divergente contiene una ruta del árbol de build (`/build`, `work/sources`, + /// `/tmp/…`): el build path se filtró al artefacto. + RutaDeBuild { a: String, b: String }, + /// Nada más específico: primer offset divergente y su ventana en hex. + Bytes { offset: u64, a_hex: String, b_hex: String }, + /// Mismo prefijo, distinto tamaño (uno es truncamiento/extensión del otro). + Tamano { a: u64, b: u64 }, +} + +impl Causa { + /// Etiqueta corta y estable — es la que el bucle agéntico agrupa y cuenta. + pub fn etiqueta(&self) -> &'static str { + match self { + Causa::SoloEnA => "solo-en-a", + Causa::SoloEnB => "solo-en-b", + Causa::TipoDistinto { .. } => "tipo-distinto", + Causa::SymlinkDistinto { .. } => "symlink-distinto", + Causa::ModoDistinto { .. } => "modo-distinto", + Causa::GzipMtime { .. } => "gzip-mtime", + Causa::ArCabecera { .. } => "ar-cabecera", + Causa::ElfSecciones { .. } => "elf-secciones", + Causa::TextoLinea { .. } => "texto-linea", + Causa::RutaDeBuild { .. } => "ruta-de-build", + Causa::Bytes { .. } => "bytes", + Causa::Tamano { .. } => "tamano", + } + } + + /// Si esta causa se arregla con una acción conocida, cuál. `None` = hay que investigar. + pub fn remedio(&self) -> Option<&'static str> { + match self { + Causa::GzipMtime { .. } => Some("comprimir con `gzip -n` (no embeber MTIME)"), + Causa::ArCabecera { .. } => Some("archivar en modo determinista (`ar D` / `ARFLAGS=Dcr`)"), + Causa::RutaDeBuild { .. } => { + Some("normalizar el build path (`-ffile-prefix-map`) o construir en ruta fija") + } + _ => None, + } + } +} + +/// Una divergencia concreta, ya diagnosticada. +#[derive(Debug, Clone, Serialize)] +pub struct Divergencia { + /// Ruta relativa a la raíz del artefacto. + pub ruta: String, + #[serde(flatten)] + pub causa: Causa, + /// Frase lista para imprimir. + pub explicacion: String, + /// Qué hacer, si se sabe. + pub remedio: Option, +} + +/// El resultado completo de comparar dos artefactos. +#[derive(Debug, Clone, Serialize)] +pub struct Informe { + pub a: String, + pub b: String, + /// Entradas con hash idéntico (las que el descenso NO tuvo que mirar por dentro). + pub identicos: usize, + pub divergencias: Vec, +} + +impl Informe { + /// `true` si los dos árboles son idénticos entrada por entrada. + pub fn reproduce(&self) -> bool { + self.divergencias.is_empty() + } + + /// Cuántas divergencias hay de cada causa — el resumen que el barrido de granja agrega. + pub fn por_causa(&self) -> BTreeMap<&'static str, usize> { + let mut m = BTreeMap::new(); + for d in &self.divergencias { + *m.entry(d.causa.etiqueta()).or_insert(0) += 1; + } + m + } +} + +/// Compara dos árboles de artefacto y explica cada divergencia. +pub fn why_differs(a: &Path, b: &Path) -> crate::Result { + let arbol_a = walk(a)?; + let arbol_b = walk(b)?; + + let mut divergencias = Vec::new(); + let mut identicos = 0usize; + + // Unión ordenada de rutas: BTreeMap ya las da ordenadas, así el informe es estable (y por tanto + // diffeable entre corridas — un informe que cambia de orden no sirve como evidencia). + let mut rutas: Vec<&String> = arbol_a.keys().chain(arbol_b.keys()).collect(); + rutas.sort(); + rutas.dedup(); + + for ruta in rutas { + match (arbol_a.get(ruta), arbol_b.get(ruta)) { + (Some(ea), Some(eb)) if ea == eb => identicos += 1, + (Some(ea), Some(eb)) => { + let causa = comparar_entradas(ea, eb, &a.join(ruta), &b.join(ruta)); + divergencias.push(construir(ruta, causa)); + } + (Some(_), None) => divergencias.push(construir(ruta, Causa::SoloEnA)), + (None, Some(_)) => divergencias.push(construir(ruta, Causa::SoloEnB)), + (None, None) => unreachable!("la ruta salió de la unión de ambos árboles"), + } + } + + Ok(Informe { + a: a.display().to_string(), + b: b.display().to_string(), + identicos, + divergencias, + }) +} + +fn construir(ruta: &str, causa: Causa) -> Divergencia { + Divergencia { + ruta: ruta.to_string(), + explicacion: explicar(&causa), + remedio: causa.remedio().map(str::to_string), + causa, + } +} + +fn comparar_entradas(ea: &Entrada, eb: &Entrada, pa: &Path, pb: &Path) -> Causa { + match (ea, eb) { + (Entrada::Symlink(sa), Entrada::Symlink(sb)) => Causa::SymlinkDistinto { + a: sa.clone(), + b: sb.clone(), + }, + ( + Entrada::Fichero { hash: ha, ejecutable: xa, .. }, + Entrada::Fichero { hash: hb, ejecutable: xb, .. }, + ) if ha == hb && xa != xb => Causa::ModoDistinto { + a_ejecutable: *xa, + b_ejecutable: *xb, + }, + (Entrada::Fichero { tamano: ta, .. }, Entrada::Fichero { tamano: tb, .. }) => { + diagnosticar(pa, pb, *ta, *tb) + } + _ => Causa::TipoDistinto { + a: ea.tipo().to_string(), + b: eb.tipo().to_string(), + }, + } +} + +/// El corazón: dos ficheros con distinto contenido — ¿por qué? +/// +/// El orden de los detectores importa: van de la causa **más específica y accionable** (gzip, ar) a +/// la más genérica (primer byte divergente). El primero que reconoce el formato gana. +fn diagnosticar(pa: &Path, pb: &Path, ta: u64, tb: u64) -> Causa { + if ta > MAX_DIAGNOSTICO || tb > MAX_DIAGNOSTICO { + return Causa::Tamano { a: ta, b: tb }; + } + let (da, db) = match (std::fs::read(pa), std::fs::read(pb)) { + (Ok(x), Ok(y)) => (x, y), + _ => return Causa::Tamano { a: ta, b: tb }, + }; + + if let Some(c) = detectar_gzip(&da, &db) { + return c; + } + if let Some(c) = detectar_ar(&da, &db) { + return c; + } + if let Some(c) = detectar_elf(&da, &db) { + return c; + } + if let Some(c) = detectar_texto(&da, &db) { + return c; + } + detectar_bytes(&da, &db) +} + +// ── detectores ─────────────────────────────────────────────────────────────────────────────── + +/// gzip: `1f 8b`, y el MTIME en los bytes 4..8 (little-endian). Si es lo único que cambia, la +/// divergencia entera se explica por no haber usado `gzip -n`. +fn detectar_gzip(a: &[u8], b: &[u8]) -> Option { + if a.len() < 8 || b.len() < 8 || a[0..2] != [0x1f, 0x8b] || b[0..2] != [0x1f, 0x8b] { + return None; + } + let ma = u32::from_le_bytes(a[4..8].try_into().ok()?); + let mb = u32::from_le_bytes(b[4..8].try_into().ok()?); + if ma == mb { + return None; + } + Some(Causa::GzipMtime { a: ma, b: mb }) +} + +/// Un miembro de archivo `ar`: cabecera ASCII de 60 bytes — +/// nombre[16] mtime[12] uid[6] gid[6] modo[8] tamaño[10] fmag[2]. +struct MiembroAr { + nombre: String, + mtime: String, + uid: String, + gid: String, + modo: String, + tamano: usize, + inicio: usize, +} + +fn parsear_ar(d: &[u8]) -> Option> { + if d.len() < 8 || &d[0..8] != b"!\n" { + return None; + } + let campo = |b: &[u8]| String::from_utf8_lossy(b).trim().to_string(); + let mut out = Vec::new(); + let mut cur = 8usize; + while cur + 60 <= d.len() { + let h = &d[cur..cur + 60]; + if &h[58..60] != b"`\n" { + break; + } + let tamano: usize = campo(&h[48..58]).parse().ok()?; + out.push(MiembroAr { + nombre: campo(&h[0..16]), + mtime: campo(&h[16..28]), + uid: campo(&h[28..34]), + gid: campo(&h[34..40]), + modo: campo(&h[40..48]), + tamano, + inicio: cur + 60, + }); + // Los miembros se alinean a 2 bytes. + cur = cur + 60 + tamano + (tamano % 2); + } + Some(out) +} + +/// `.a` (y `.deb`/`.lib`): si las cabeceras traen mtime/uid/gid distintos, ésa es la causa — el +/// contenido de los objetos puede ser idéntico. Se mata archivando en modo determinista. +fn detectar_ar(a: &[u8], b: &[u8]) -> Option { + let (ma, mb) = (parsear_ar(a)?, parsear_ar(b)?); + for (x, y) in ma.iter().zip(mb.iter()) { + for (campo, va, vb) in [ + ("mtime", &x.mtime, &y.mtime), + ("uid", &x.uid, &y.uid), + ("gid", &x.gid, &y.gid), + ("modo", &x.modo, &y.modo), + ] { + if va != vb { + return Some(Causa::ArCabecera { + miembro: x.nombre.clone(), + campo: campo.to_string(), + a: va.clone(), + b: vb.clone(), + }); + } + } + // Cabeceras iguales pero contenido distinto ⇒ la causa está DENTRO del objeto: se + // diagnostica recursivamente (un `.o` es ELF, así que casi siempre cae en detectar_elf). + let (fa, fb) = (x.inicio + x.tamano, y.inicio + y.tamano); + if fa <= a.len() && fb <= b.len() { + let (ca, cb) = (&a[x.inicio..fa], &b[y.inicio..fb]); + if ca != cb { + if let Some(Causa::ElfSecciones { secciones, interpretacion, evidencia }) = + detectar_elf(ca, cb) + { + // Un `.a` trae decenas de objetos: sin decir cuál, el veredicto obliga a + // desarmar el archivo a mano — justo el trabajo artesanal que esto elimina. + return Some(Causa::ElfSecciones { + secciones, + interpretacion: format!("miembro `{}` — {interpretacion}", x.nombre), + evidencia, + }); + } + } + } + } + None +} + +/// Los bytes de una sección concreta, si existe y cae dentro del fichero. +fn bytes_de_seccion<'a>(d: &'a [u8], nombre: &str) -> Option<&'a [u8]> { + let (_, off, size) = secciones_crudas(d)?.into_iter().find(|(n, ..)| n == nombre)?; + d.get(off..off + size) +} + +/// Cadenas NUL-terminadas imprimibles de una sección (`.debug_str`, `.comment`: son tablas de +/// cadenas). Es lo que convierte "difieren las secciones de depuración" en evidencia concreta. +fn cadenas_de(d: &[u8], nombre: &str) -> Vec { + let Some(b) = bytes_de_seccion(d, nombre) else { + return Vec::new(); + }; + b.split(|&c| c == 0) + .filter(|s| s.len() >= 4 && s.iter().all(|&c| (0x20..0x7f).contains(&c))) + .map(|s| String::from_utf8_lossy(s).to_string()) + .collect() +} + +/// La tabla de secciones como (nombre, offset, tamaño). +/// +/// Cabecera de sección ELF64: sh_name[4] sh_type[4] sh_flags[8] sh_addr[8] sh_offset[8] sh_size[8] … +fn secciones_crudas(d: &[u8]) -> Option> { + if d.len() < 64 || &d[0..4] != b"\x7fELF" || d[4] != 2 || d[5] != 1 { + return None; + } + let e_shoff = u64::from_le_bytes(d[40..48].try_into().ok()?) as usize; + let e_shentsize = u16::from_le_bytes(d[58..60].try_into().ok()?) as usize; + let e_shnum = u16::from_le_bytes(d[60..62].try_into().ok()?) as usize; + let e_shstrndx = u16::from_le_bytes(d[62..64].try_into().ok()?) as usize; + if e_shoff == 0 || e_shnum == 0 || e_shstrndx >= e_shnum || e_shentsize < 64 { + return None; + } + let leer = |i: usize| -> Option<(u32, u64, u64)> { + let off = e_shoff + i * e_shentsize; + if off + 64 > d.len() { + return None; + } + let sh_name = u32::from_le_bytes(d[off..off + 4].try_into().ok()?); + let sh_offset = u64::from_le_bytes(d[off + 24..off + 32].try_into().ok()?); + let sh_size = u64::from_le_bytes(d[off + 32..off + 40].try_into().ok()?); + Some((sh_name, sh_offset, sh_size)) + }; + // La tabla de nombres de sección: sus bytes son cadenas NUL-terminadas indexadas por sh_name. + let (_, str_off, str_size) = leer(e_shstrndx)?; + let (str_off, str_size) = (str_off as usize, str_size as usize); + if str_off + str_size > d.len() { + return None; + } + let strtab = &d[str_off..str_off + str_size]; + + let mut out = Vec::with_capacity(e_shnum); + for i in 0..e_shnum { + let (sh_name, sh_offset, sh_size) = leer(i)?; + let ini = sh_name as usize; + let nombre = if ini < strtab.len() { + let fin = strtab[ini..].iter().position(|&c| c == 0).unwrap_or(0) + ini; + String::from_utf8_lossy(&strtab[ini..fin]).to_string() + } else { + format!("") + }; + out.push((nombre, sh_offset as usize, sh_size as usize)); + } + Some(out) +} + +/// Las secciones con el hash de su contenido — la base de la comparación. +fn secciones_elf(d: &[u8]) -> Option> { + let mut out = BTreeMap::new(); + for (nombre, off, size) in secciones_crudas(d)? { + // SHT_NOBITS (.bss) y las secciones fuera de rango no tienen bytes que hashear. + let contenido = match d.get(off..off + size) { + Some(b) => blake3::hash(b).to_hex().to_string(), + None => String::from(""), + }; + out.insert(nombre, (size as u64, contenido)); + } + Some(out) +} + +/// ELF: comparar sección por sección. Saber CUÁLES difieren es casi todo el diagnóstico — es la +/// diferencia entre "el binario cambió" y "sólo cambió `.comment` ⇒ otro compilador". +fn detectar_elf(a: &[u8], b: &[u8]) -> Option { + let (sa, sb) = (secciones_elf(a)?, secciones_elf(b)?); + let mut difieren: Vec = Vec::new(); + for (nombre, va) in &sa { + match sb.get(nombre) { + Some(vb) if vb == va => {} + Some(_) => difieren.push(nombre.clone()), + None => difieren.push(format!("{nombre} (sólo en A)")), + } + } + for nombre in sb.keys() { + if !sa.contains_key(nombre) { + difieren.push(format!("{nombre} (sólo en B)")); + } + } + if difieren.is_empty() { + return None; + } + Some(Causa::ElfSecciones { + interpretacion: interpretar_secciones(&difieren), + evidencia: evidencia_de_cadenas(a, b, &difieren), + secciones: difieren, + }) +} + +/// Cuántas cadenas divergentes se muestran como evidencia. Más que esto es ruido: el patrón se ve +/// con dos o tres. +const MAX_EVIDENCIA: usize = 3; + +/// Saca las cadenas que están en un ELF y no en el otro, para las secciones de texto que difieren. +/// +/// Esto es lo que separa la sospecha del hecho. `.debug_str` difiere ⇒ la hipótesis es "rutas del +/// árbol de build"; mostrar la cadena la confirma o la desmiente. Caso real que motivó esto +/// (barrido del 2026-07-21, `alsa-lib`): la interpretación decía "típicamente rutas de build" y la +/// evidencia mostró `/src/target/release/build/libsodium-sys-/out/…` — una ruta con un +/// hash de cargo que cambia entre builds. Sin la cadena, eso era una corazonada. +fn evidencia_de_cadenas(a: &[u8], b: &[u8], difieren: &[String]) -> Vec { + const INTERESANTES: [&str; 3] = [".debug_str", ".comment", ".rodata"]; + let mut out = Vec::new(); + for sec in difieren { + let nombre = sec.split(' ').next().unwrap_or(sec); + if !INTERESANTES.contains(&nombre) { + continue; + } + let (ca, cb) = (cadenas_de(a, nombre), cadenas_de(b, nombre)); + let (sa, sb): (std::collections::BTreeSet<_>, std::collections::BTreeSet<_>) = + (ca.into_iter().collect(), cb.into_iter().collect()); + for s in sa.difference(&sb).take(MAX_EVIDENCIA) { + out.push(format!("{nombre} sólo en A: {}", recortar(s))); + } + for s in sb.difference(&sa).take(MAX_EVIDENCIA) { + out.push(format!("{nombre} sólo en B: {}", recortar(s))); + } + if out.len() >= MAX_EVIDENCIA * 2 { + break; + } + } + out +} + +/// La lectura experta: qué significa que difieran ESAS secciones y no otras. +fn interpretar_secciones(difieren: &[String]) -> String { + let tiene = |p: &str| difieren.iter().any(|s| s.starts_with(p)); + let solo = |ps: &[&str]| difieren.iter().all(|s| ps.iter().any(|p| s.starts_with(p))); + + if solo(&[".comment"]) { + return "sólo `.comment`: mismo código, distinta VERSIÓN de compilador".into(); + } + if solo(&[".note.gnu.build-id"]) { + return "sólo el build-id: deriva del resto del binario — si nada más difiere, \ + es un residuo, no la causa" + .into(); + } + if solo(&[".debug", ".zdebug", ".note.gnu.build-id"]) { + return "sólo info de depuración: típicamente rutas del árbol de build sin \ + `-ffile-prefix-map` (el código ejecutable es idéntico)" + .into(); + } + if tiene(".text") || tiene(".data") || tiene(".rodata") { + let mut s = String::from("difiere código/datos ejecutables"); + if tiene(".comment") { + s.push_str(" Y `.comment` ⇒ sospechar primero la versión del compilador"); + } + return s; + } + if solo(&[".symtab", ".strtab", ".dynsym", ".dynstr"]) { + return "sólo tablas de símbolos: el ORDEN de los símbolos cambió (enlazado no \ + determinista); el código es idéntico" + .into(); + } + "secciones sin patrón conocido — hay que mirar".into() +} + +/// Rutas del árbol de build que no deberían acabar dentro de un artefacto. +const MARCAS_BUILD: [&str; 5] = ["work/sources/", "/build/", "/tmp/", "/nix/store/", "/out/"]; + +/// Texto: la primera línea que difiere dice más que cualquier offset. +fn detectar_texto(a: &[u8], b: &[u8]) -> Option { + let (ta, tb) = (std::str::from_utf8(a).ok()?, std::str::from_utf8(b).ok()?); + for (i, (la, lb)) in ta.lines().zip(tb.lines()).enumerate() { + if la != lb { + if MARCAS_BUILD.iter().any(|m| la.contains(m) || lb.contains(m)) { + return Some(Causa::RutaDeBuild { + a: recortar(la), + b: recortar(lb), + }); + } + return Some(Causa::TextoLinea { + linea: i + 1, + a: recortar(la), + b: recortar(lb), + }); + } + } + // Mismo prefijo línea a línea: uno tiene más líneas que el otro. + let (na, nb) = (ta.lines().count(), tb.lines().count()); + Some(Causa::TextoLinea { + linea: na.min(nb) + 1, + a: if na > nb { "".into() } else { "".into() }, + b: if nb > na { "".into() } else { "".into() }, + }) +} + +/// Último recurso: el primer byte que difiere y su ventana. Si en la ventana aparece una ruta de +/// build, eso es más informativo que el hex y se reporta como tal. +fn detectar_bytes(a: &[u8], b: &[u8]) -> Causa { + let offset = a.iter().zip(b.iter()).position(|(x, y)| x != y); + let Some(off) = offset else { + return Causa::Tamano { + a: a.len() as u64, + b: b.len() as u64, + }; + }; + let ini = off.saturating_sub(VENTANA / 2); + let va = &a[ini..(ini + VENTANA).min(a.len())]; + let vb = &b[ini..(ini + VENTANA).min(b.len())]; + + let (sa, sb) = (imprimible(va), imprimible(vb)); + if MARCAS_BUILD.iter().any(|m| sa.contains(m) || sb.contains(m)) { + return Causa::RutaDeBuild { a: sa, b: sb }; + } + Causa::Bytes { + offset: off as u64, + a_hex: hex(va), + b_hex: hex(vb), + } +} + +fn hex(d: &[u8]) -> String { + d.iter().map(|b| format!("{b:02x}")).collect::>().join(" ") +} + +fn imprimible(d: &[u8]) -> String { + d.iter() + .map(|&b| if (0x20..0x7f).contains(&b) { b as char } else { '.' }) + .collect() +} + +fn recortar(s: &str) -> String { + const MAX: usize = 120; + if s.chars().count() <= MAX { + return s.to_string(); + } + let corto: String = s.chars().take(MAX).collect(); + format!("{corto}…") +} + +/// La frase que se imprime. Vive acá (y no en el CLI) para que el informe JSON y el humano digan +/// exactamente lo mismo. +fn explicar(c: &Causa) -> String { + match c { + Causa::SoloEnA => "existe sólo en A".into(), + Causa::SoloEnB => "existe sólo en B".into(), + Causa::TipoDistinto { a, b } => format!("es {a} en A y {b} en B"), + Causa::SymlinkDistinto { a, b } => format!("symlink → `{a}` en A, → `{b}` en B"), + Causa::ModoDistinto { a_ejecutable, .. } => format!( + "mismo contenido, distinto bit de ejecución (A {}, B {})", + if *a_ejecutable { "x" } else { "no-x" }, + if *a_ejecutable { "no-x" } else { "x" } + ), + Causa::GzipMtime { a, b } => { + format!("gzip con MTIME embebido: {a} vs {b} (cabecera, bytes 4..8)") + } + Causa::ArCabecera { miembro, campo, a, b } => { + format!("cabecera `ar` del miembro `{miembro}`: {campo} {a} vs {b}") + } + Causa::ElfSecciones { secciones, interpretacion, evidencia } => { + let mut t = format!("ELF, difieren [{}] — {interpretacion}", secciones.join(", ")); + for e in evidencia { + t.push_str(&format!("\n · {e}")); + } + t + } + Causa::TextoLinea { linea, a, b } => format!("texto, línea {linea}: `{a}` vs `{b}`"), + Causa::RutaDeBuild { a, b } => { + format!("ruta del árbol de build embebida: `{a}` vs `{b}`") + } + Causa::Bytes { offset, a_hex, b_hex } => { + format!("primer byte distinto en 0x{offset:x}\n A: {a_hex}\n B: {b_hex}") + } + Causa::Tamano { a, b } => format!("distinto tamaño: {a} vs {b} bytes"), + } +} + +// ── recorrido del árbol ────────────────────────────────────────────────────────────────────── + +fn walk(raiz: &Path) -> crate::Result> { + if !raiz.exists() { + return Err(crate::Error::Store(format!("no existe: {}", raiz.display()))); + } + let mut out = BTreeMap::new(); + let mut pila: Vec = vec![raiz.to_path_buf()]; + while let Some(dir) = pila.pop() { + for entrada in std::fs::read_dir(&dir)? { + let entrada = entrada?; + let ruta = entrada.path(); + let rel = ruta + .strip_prefix(raiz) + .map_err(|e| crate::Error::Store(e.to_string()))? + .to_string_lossy() + .to_string(); + let md = std::fs::symlink_metadata(&ruta)?; + if md.is_symlink() { + let destino = std::fs::read_link(&ruta)?.to_string_lossy().to_string(); + out.insert(rel, Entrada::Symlink(destino)); + } else if md.is_dir() { + out.insert(rel, Entrada::Directorio); + pila.push(ruta); + } else { + out.insert( + rel, + Entrada::Fichero { + hash: hash_fichero(&ruta)?, + tamano: md.len(), + ejecutable: md.permissions().mode() & 0o111 != 0, + }, + ); + } + } + } + Ok(out) +} + +/// BLAKE3 por streaming: un artefacto puede traer ficheros de cientos de MB y no hay razón para +/// tenerlos enteros en memoria sólo para saber si son iguales. +fn hash_fichero(p: &Path) -> crate::Result { + let mut f = std::fs::File::open(p)?; + let mut h = blake3::Hasher::new(); + std::io::copy(&mut f, &mut h)?; + Ok(h.finalize().to_hex().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn escribir(dir: &Path, rel: &str, datos: &[u8]) { + let p = dir.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::File::create(&p).unwrap().write_all(datos).unwrap(); + } + + /// Dos árboles idénticos reproducen: sin divergencias y todo contado como idéntico. + #[test] + fn arboles_identicos_reproducen() { + let t = tempfile::tempdir().unwrap(); + let (a, b) = (t.path().join("a"), t.path().join("b")); + for r in [&a, &b] { + escribir(r, "usr/bin/hola", b"contenido"); + escribir(r, "usr/share/doc/leeme", b"texto\n"); + } + let inf = why_differs(&a, &b).unwrap(); + assert!(inf.reproduce(), "divergencias: {:?}", inf.divergencias); + // usr, usr/bin, usr/bin/hola, usr/share, usr/share/doc, usr/share/doc/leeme + assert_eq!(inf.identicos, 6); + } + + /// El MTIME de un gzip es LA causa clásica; tiene que salir nombrada, no como "bytes distintos". + #[test] + fn gzip_mtime_se_nombra_y_trae_remedio() { + let t = tempfile::tempdir().unwrap(); + let (a, b) = (t.path().join("a"), t.path().join("b")); + // cabecera gzip mínima: magic, CM, FLG, MTIME(4), XFL, OS + let mut ga = vec![0x1f, 0x8b, 0x08, 0x00]; + ga.extend_from_slice(&1_000u32.to_le_bytes()); + ga.extend_from_slice(&[0x00, 0x03, 0xde, 0xad]); + let mut gb = ga.clone(); + gb[4..8].copy_from_slice(&2_000u32.to_le_bytes()); + escribir(&a, "m.gz", &ga); + escribir(&b, "m.gz", &gb); + + let inf = why_differs(&a, &b).unwrap(); + assert_eq!(inf.divergencias.len(), 1); + let d = &inf.divergencias[0]; + assert_eq!(d.causa, Causa::GzipMtime { a: 1000, b: 2000 }); + assert!(d.remedio.as_ref().unwrap().contains("gzip -n")); + } + + /// Una ruta del árbol de build embebida se reporta como tal (no como una línea cualquiera). + #[test] + fn ruta_de_build_embebida() { + let t = tempfile::tempdir().unwrap(); + let (a, b) = (t.path().join("a"), t.path().join("b")); + escribir(&a, "cfg", b"prefix=/home/x/work/sources/zlib-aaa\n"); + escribir(&b, "cfg", b"prefix=/home/x/work/sources/zlib-bbb\n"); + let inf = why_differs(&a, &b).unwrap(); + assert_eq!(inf.divergencias[0].causa.etiqueta(), "ruta-de-build"); + assert!(inf.divergencias[0].remedio.is_some()); + } + + /// Presencia/ausencia y symlinks divergentes son divergencias de pleno derecho. + #[test] + fn presencia_y_symlinks() { + let t = tempfile::tempdir().unwrap(); + let (a, b) = (t.path().join("a"), t.path().join("b")); + escribir(&a, "solo-a", b"x"); + escribir(&b, "solo-b", b"x"); + std::fs::create_dir_all(a.join("d")).unwrap(); + std::fs::create_dir_all(b.join("d")).unwrap(); + std::os::unix::fs::symlink("destino-1", a.join("d/enlace")).unwrap(); + std::os::unix::fs::symlink("destino-2", b.join("d/enlace")).unwrap(); + + let inf = why_differs(&a, &b).unwrap(); + let etiquetas = inf.por_causa(); + assert_eq!(etiquetas.get("solo-en-a"), Some(&1)); + assert_eq!(etiquetas.get("solo-en-b"), Some(&1)); + assert_eq!(etiquetas.get("symlink-distinto"), Some(&1)); + } + + /// Un `.a` cuyos miembros sólo difieren en el mtime de la cabecera: causa nombrada + remedio. + #[test] + fn ar_mtime_de_cabecera() { + let t = tempfile::tempdir().unwrap(); + let (a, b) = (t.path().join("a"), t.path().join("b")); + let armar = |mtime: &str| { + let mut v = Vec::from(*b"!\n"); + v.extend_from_slice(format!("{:<16}", "obj.o").as_bytes()); + v.extend_from_slice(format!("{mtime:<12}").as_bytes()); + v.extend_from_slice(format!("{:<6}", "0").as_bytes()); + v.extend_from_slice(format!("{:<6}", "0").as_bytes()); + v.extend_from_slice(format!("{:<8}", "100644").as_bytes()); + v.extend_from_slice(format!("{:<10}", "4").as_bytes()); + v.extend_from_slice(b"`\n"); + v.extend_from_slice(b"data"); + v + }; + escribir(&a, "lib.a", &armar("1700000000")); + escribir(&b, "lib.a", &armar("1800000000")); + let inf = why_differs(&a, &b).unwrap(); + assert_eq!(inf.divergencias[0].causa.etiqueta(), "ar-cabecera"); + assert!(inf.divergencias[0].remedio.as_ref().unwrap().contains("determinista")); + } +} diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs index 03e35922..471d16fb 100644 --- a/crates/hammer-core/src/lib.rs +++ b/crates/hammer-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod apply; pub mod caps; pub mod compat; +pub mod differs; pub mod hash; pub mod installed; pub mod proto; @@ -18,6 +19,7 @@ pub mod store; pub mod swm; pub use caps::{AgentCapsConfig, CapRule}; +pub use differs::{why_differs, Causa, Divergencia, Informe}; pub use hash::ArtifactHash; pub use installed::{InstalledDb, InstalledPackage}; pub use compat::Veredicto as CompatVeredicto; diff --git a/scripts/why-differs-barrido.sh b/scripts/why-differs-barrido.sh new file mode 100755 index 00000000..72182151 --- /dev/null +++ b/scripts/why-differs-barrido.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""why-differs-barrido — pasa `hammer why-differs` por TODO el store y agrega las causas. + +Para qué: el store guarda varios sellados por paquete (recetas que cambiaron a lo largo del tiempo). +Comparar dos sellados del mismo nombre responde una pregunta que hasta ahora nadie hacía a escala: +**¿por qué difieren?** Y sobre todo separa los dos casos que se confunden a ojo: + + · `.hammer/recipe.toml` DIFIERE ⇒ son recetas distintas. Que el artefacto cambie es lo esperado. + · `.hammer/recipe.toml` IDÉNTICO ⇒ **misma receta, distinto artefacto = NO-REPRODUCCIÓN REAL.** + Eso es un bug de reproducibilidad, y el informe ya dice la causa (gzip mtime, cabecera ar, + sección ELF, ruta de build embebida…). + +Es el "multiplicador de granja" del SDD 17 §1.2: debuggear una no-reproducción deja de ser artesanal. + +Uso: scripts/why-differs-barrido.sh # barre todo el store + LIMITE=50 scripts/why-differs-barrido.sh # sólo los primeros 50 paquetes + JSON=informe.json scripts/why-differs-barrido.sh +""" +import collections +import json +import os +import pathlib +import subprocess +import sys + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +STORE = RAIZ / "store" +HAMMER = RAIZ / "target/release/hammer" +LIMITE = int(os.environ.get("LIMITE", "0")) +SALIDA_JSON = os.environ.get("JSON") +# Un artefacto puede pesar GB (qt6, kernels): hashearlo entero para el barrido no aporta. +TIMEOUT = int(os.environ.get("TIMEOUT", "120")) + + +def sellados_por_nombre(): + """{nombre: [rutas…]} — el nombre es lo que sigue al hash en `-`.""" + m = collections.defaultdict(list) + for d in STORE.iterdir(): + if not d.is_dir(): + continue + _, sep, nombre = d.name.partition("-") + if sep: + m[nombre].append(d) + return {n: sorted(v, key=lambda p: p.stat().st_mtime) for n, v in m.items() if len(v) >= 2} + + +def main(): + if not HAMMER.exists(): + sys.exit(f"falta {HAMMER} (cargo build --release --bin hammer)") + + grupos = sellados_por_nombre() + nombres = sorted(grupos) + if LIMITE: + nombres = nombres[:LIMITE] + print(f"== barrido why-differs: {len(nombres)} paquetes con ≥2 sellados\n") + + causas = collections.Counter() + no_reproducen = [] # misma receta, distinto artefacto ⇒ bug real + recetas_distintas = 0 + errores = [] + + for i, nombre in enumerate(nombres, 1): + a, b = grupos[nombre][-2], grupos[nombre][-1] # los dos más recientes + try: + r = subprocess.run( + [str(HAMMER), "why-differs", str(a), str(b), "--json"], + capture_output=True, text=True, timeout=TIMEOUT, + ) + informe = json.loads(r.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError) as e: + errores.append((nombre, type(e).__name__)) + continue + + divs = informe["divergencias"] + if not divs: + continue + receta_cambio = any(d["ruta"] == ".hammer/recipe.toml" for d in divs) + for d in divs: + causas[d["causa"]] += 1 + if receta_cambio: + recetas_distintas += 1 + else: + no_reproducen.append((nombre, a.name[:12], b.name[:12], divs)) + if i % 50 == 0: + print(f" … {i}/{len(nombres)}") + + print(f"\n── recetas distintas (divergencia esperada): {recetas_distintas}") + print(f"── MISMA receta, artefacto distinto (NO REPRODUCE): {len(no_reproducen)}") + if errores: + print(f"── no analizados: {len(errores)} ({errores[:3]}…)") + + print("\n── causas agregadas:") + for c, n in causas.most_common(): + print(f" {c:22s} {n}") + + if no_reproducen: + print("\n══ NO-REPRODUCCIONES (misma receta ⇒ el artefacto debería ser idéntico):") + for nombre, ha, hb, divs in no_reproducen: + print(f"\n {nombre} [{ha}… vs {hb}…] {len(divs)} divergencia(s)") + for d in divs[:4]: + print(f" · {d['ruta']}: {d['explicacion'].splitlines()[0][:150]}") + if d.get("remedio"): + print(f" → {d['remedio']}") + + if SALIDA_JSON: + pathlib.Path(SALIDA_JSON).write_text(json.dumps({ + "causas": dict(causas), + "recetas_distintas": recetas_distintas, + "no_reproducen": [ + {"nombre": n, "a": a, "b": b, "divergencias": d} for n, a, b, d in no_reproducen + ], + }, indent=2, ensure_ascii=False)) + print(f"\n informe → {SALIDA_JSON}") + + +if __name__ == "__main__": + main()