//! `why-differs` — el diffoscope propio de takana ([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 takana 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")); } }