cierre #2 del SDD 17: hammer why-differs — el diffoscope propio

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-<hash-cargo>/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) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 22:35:03 -04:00
co-authored by Claude Opus 4.8
parent 6b38b46293
commit 895401b479
4 changed files with 967 additions and 0 deletions
+49
View File
@@ -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/<hash>-<nombre>`) 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<String> = 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 {
+799
View File
@@ -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<String>,
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<String>,
},
/// 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<String>,
}
/// 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<Divergencia>,
}
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<Informe> {
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<Causa> {
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<Vec<MiembroAr>> {
if d.len() < 8 || &d[0..8] != b"!<arch>\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<Causa> {
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<String> {
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<Vec<(String, usize, usize)>> {
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!("<sección {i}>")
};
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<BTreeMap<String, (u64, String)>> {
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("<fuera de rango>"),
};
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<Causa> {
let (sa, sb) = (secciones_elf(a)?, secciones_elf(b)?);
let mut difieren: Vec<String> = 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-<hash-cargo>/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<String> {
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<Causa> {
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 { "<línea de más>".into() } else { "<fin>".into() },
b: if nb > na { "<línea de más>".into() } else { "<fin>".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::<Vec<_>>().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<BTreeMap<String, Entrada>> {
if !raiz.exists() {
return Err(crate::Error::Store(format!("no existe: {}", raiz.display())));
}
let mut out = BTreeMap::new();
let mut pila: Vec<PathBuf> = 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<String> {
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"!<arch>\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"));
}
}
+2
View File
@@ -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;
+117
View File
@@ -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 `<hash>-<nombre>`."""
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()