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