tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1411Shell 141Markdown 85Rust 842918 Definitions

⎇ main crates/takana-core/src/differs.rs Rust · 799 lines · 32.1 KB History Who touched what raw

takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja b818f5249 · Sergio ·

1//! `why-differs` — el diffoscope propio de takana ([SDD 17 §1.2](../../docs/17-cierres-frontera.md)).
2//!
3//! Cuando un artefacto **no reproduce**, el store sólo sabe decir "el hash no coincide". Eso deja el
4//! trabajo entero al humano: desempacar los dos árboles, `cmp` a mano, adivinar. Este módulo responde
5//! la pregunta siguiente — **POR QUÉ** difieren — y la responde en términos de la CAUSA, no del byte:
6//! un MTIME embebido en un gzip, la cabecera `ar` de un `.a`, la sección `.comment` de un ELF (=otro
7//! compilador), una ruta del árbol de build que se coló en el binario.
8//!
9//! Es un multiplicador doble (por eso es uno de los dos elegidos del SDD 17 §5): hace escalar el
10//! barrido de la granja —hoy debuggear una no-reproducción es artesanal— y produce evidencia
11//! **legible por máquina** (`--json`) para el bucle agéntico.
12//!
13//! **Descenso, no comparación total** (el cruce con `format`/`reconcile`): dos subárboles idénticos
14//! colapsan al mismo hash, así que el diff sólo desciende donde los hashes difieren. Comparar dos
15//! artefactos de miles de ficheros cuesta un hash por fichero y un diagnóstico sólo por los que
16//! divergen.
17//!
18//! **Sin dependencias externas**: los parsers (gzip/ar/ELF) son mínimos y viven acá, en el mismo
19//! estilo que `query::parse_elf_info`. Un diffoscope de verdad se apoya en medio mundo de binarios
20//! ajenos; eso es exactamente lo que takana no puede permitirse (ADR 0004: el catálogo se construye,
21//! no se importa).
22
23use serde::Serialize;
24use std::collections::BTreeMap;
25use std::os::unix::fs::PermissionsExt;
26use std::path::{Path, PathBuf};
27
28/// Cuánto de un fichero divergente se lee para diagnosticar. Un artefacto puede traer un firmware de
29/// cientos de MB; el diagnóstico no mejora por leerlo entero, y sí empeora la latencia.
30const MAX_DIAGNOSTICO: u64 = 256 * 1024 * 1024;
31
32/// Bytes de contexto que se muestran alrededor del primer byte divergente.
33const VENTANA: usize = 24;
34
35/// Una entrada del árbol, reducida a lo que decide si dos artefactos son el mismo.
36#[derive(Debug, Clone, PartialEq, Eq)]
37enum Entrada {
38    Directorio,
39    /// El destino del symlink (NO se sigue: un symlink que apunta a otro lado ES una divergencia).
40    Symlink(String),
41    Fichero {
42        hash: String,
43        tamano: u64,
44        /// Sólo el bit de ejecución importa: el resto del modo lo normaliza el sellado.
45        ejecutable: bool,
46    },
47}
48
49impl Entrada {
50    fn tipo(&self) -> &'static str {
51        match self {
52            Entrada::Directorio => "directorio",
53            Entrada::Symlink(_) => "symlink",
54            Entrada::Fichero { .. } => "fichero",
55        }
56    }
57}
58
59/// La causa de una divergencia — el veredicto que este módulo existe para dar.
60#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
61#[serde(rename_all = "snake_case", tag = "causa")]
62pub enum Causa {
63    /// Presente sólo en el artefacto A.
64    SoloEnA,
65    /// Presente sólo en el artefacto B.
66    SoloEnB,
67    /// La misma ruta es fichero en uno y symlink/directorio en el otro.
68    TipoDistinto { a: String, b: String },
69    /// Symlinks que apuntan a destinos distintos.
70    SymlinkDistinto { a: String, b: String },
71    /// Mismo contenido, distinto bit de ejecución.
72    ModoDistinto { a_ejecutable: bool, b_ejecutable: bool },
73    /// gzip con el MTIME embebido en la cabecera (bytes 4..8). **La causa clásica** de
74    /// no-reproducibilidad en tarballs y páginas de man comprimidas: se mata con `gzip -n`.
75    GzipMtime { a: u32, b: u32 },
76    /// Cabecera de un miembro de archivo `ar` (los `.a`): mtime/uid/gid. Se mata con `ar D` o
77    /// `ARFLAGS=Dcr` (modo determinista, que pone esos campos a 0).
78    ArCabecera { miembro: String, campo: String, a: String, b: String },
79    /// Secciones ELF que difieren. `.comment` ⇒ otro compilador; `.note.gnu.build-id` sólo ⇒ el
80    /// build-id deriva del resto y no es causa raíz; `.debug_*` ⇒ típicamente rutas del árbol de
81    /// build sin `-fdebug-prefix-map`.
82    ElfSecciones {
83        secciones: Vec<String>,
84        interpretacion: String,
85        /// Cadenas concretas que están en un ELF y no en el otro — la evidencia detrás de la
86        /// interpretación (ver `evidencia_de_cadenas`).
87        evidencia: Vec<String>,
88    },
89    /// Fichero de texto: la primera línea que difiere.
90    TextoLinea { linea: usize, a: String, b: String },
91    /// Un fragmento divergente contiene una ruta del árbol de build (`/build`, `work/sources`,
92    /// `/tmp/…`): el build path se filtró al artefacto.
93    RutaDeBuild { a: String, b: String },
94    /// Nada más específico: primer offset divergente y su ventana en hex.
95    Bytes { offset: u64, a_hex: String, b_hex: String },
96    /// Mismo prefijo, distinto tamaño (uno es truncamiento/extensión del otro).
97    Tamano { a: u64, b: u64 },
98}
99
100impl Causa {
101    /// Etiqueta corta y estable — es la que el bucle agéntico agrupa y cuenta.
102    pub fn etiqueta(&self) -> &'static str {
103        match self {
104            Causa::SoloEnA => "solo-en-a",
105            Causa::SoloEnB => "solo-en-b",
106            Causa::TipoDistinto { .. } => "tipo-distinto",
107            Causa::SymlinkDistinto { .. } => "symlink-distinto",
108            Causa::ModoDistinto { .. } => "modo-distinto",
109            Causa::GzipMtime { .. } => "gzip-mtime",
110            Causa::ArCabecera { .. } => "ar-cabecera",
111            Causa::ElfSecciones { .. } => "elf-secciones",
112            Causa::TextoLinea { .. } => "texto-linea",
113            Causa::RutaDeBuild { .. } => "ruta-de-build",
114            Causa::Bytes { .. } => "bytes",
115            Causa::Tamano { .. } => "tamano",
116        }
117    }
118
119    /// Si esta causa se arregla con una acción conocida, cuál. `None` = hay que investigar.
120    pub fn remedio(&self) -> Option<&'static str> {
121        match self {
122            Causa::GzipMtime { .. } => Some("comprimir con `gzip -n` (no embeber MTIME)"),
123            Causa::ArCabecera { .. } => Some("archivar en modo determinista (`ar D` / `ARFLAGS=Dcr`)"),
124            Causa::RutaDeBuild { .. } => {
125                Some("normalizar el build path (`-ffile-prefix-map`) o construir en ruta fija")
126            }
127            _ => None,
128        }
129    }
130}
131
132/// Una divergencia concreta, ya diagnosticada.
133#[derive(Debug, Clone, Serialize)]
134pub struct Divergencia {
135    /// Ruta relativa a la raíz del artefacto.
136    pub ruta: String,
137    #[serde(flatten)]
138    pub causa: Causa,
139    /// Frase lista para imprimir.
140    pub explicacion: String,
141    /// Qué hacer, si se sabe.
142    pub remedio: Option<String>,
143}
144
145/// El resultado completo de comparar dos artefactos.
146#[derive(Debug, Clone, Serialize)]
147pub struct Informe {
148    pub a: String,
149    pub b: String,
150    /// Entradas con hash idéntico (las que el descenso NO tuvo que mirar por dentro).
151    pub identicos: usize,
152    pub divergencias: Vec<Divergencia>,
153}
154
155impl Informe {
156    /// `true` si los dos árboles son idénticos entrada por entrada.
157    pub fn reproduce(&self) -> bool {
158        self.divergencias.is_empty()
159    }
160
161    /// Cuántas divergencias hay de cada causa — el resumen que el barrido de granja agrega.
162    pub fn por_causa(&self) -> BTreeMap<&'static str, usize> {
163        let mut m = BTreeMap::new();
164        for d in &self.divergencias {
165            *m.entry(d.causa.etiqueta()).or_insert(0) += 1;
166        }
167        m
168    }
169}
170
171/// Compara dos árboles de artefacto y explica cada divergencia.
172pub fn why_differs(a: &Path, b: &Path) -> crate::Result<Informe> {
173    let arbol_a = walk(a)?;
174    let arbol_b = walk(b)?;
175
176    let mut divergencias = Vec::new();
177    let mut identicos = 0usize;
178
179    // Unión ordenada de rutas: BTreeMap ya las da ordenadas, así el informe es estable (y por tanto
180    // diffeable entre corridas — un informe que cambia de orden no sirve como evidencia).
181    let mut rutas: Vec<&String> = arbol_a.keys().chain(arbol_b.keys()).collect();
182    rutas.sort();
183    rutas.dedup();
184
185    for ruta in rutas {
186        match (arbol_a.get(ruta), arbol_b.get(ruta)) {
187            (Some(ea), Some(eb)) if ea == eb => identicos += 1,
188            (Some(ea), Some(eb)) => {
189                let causa = comparar_entradas(ea, eb, &a.join(ruta), &b.join(ruta));
190                divergencias.push(construir(ruta, causa));
191            }
192            (Some(_), None) => divergencias.push(construir(ruta, Causa::SoloEnA)),
193            (None, Some(_)) => divergencias.push(construir(ruta, Causa::SoloEnB)),
194            (None, None) => unreachable!("la ruta salió de la unión de ambos árboles"),
195        }
196    }
197
198    Ok(Informe {
199        a: a.display().to_string(),
200        b: b.display().to_string(),
201        identicos,
202        divergencias,
203    })
204}
205
206fn construir(ruta: &str, causa: Causa) -> Divergencia {
207    Divergencia {
208        ruta: ruta.to_string(),
209        explicacion: explicar(&causa),
210        remedio: causa.remedio().map(str::to_string),
211        causa,
212    }
213}
214
215fn comparar_entradas(ea: &Entrada, eb: &Entrada, pa: &Path, pb: &Path) -> Causa {
216    match (ea, eb) {
217        (Entrada::Symlink(sa), Entrada::Symlink(sb)) => Causa::SymlinkDistinto {
218            a: sa.clone(),
219            b: sb.clone(),
220        },
221        (
222            Entrada::Fichero { hash: ha, ejecutable: xa, .. },
223            Entrada::Fichero { hash: hb, ejecutable: xb, .. },
224        ) if ha == hb && xa != xb => Causa::ModoDistinto {
225            a_ejecutable: *xa,
226            b_ejecutable: *xb,
227        },
228        (Entrada::Fichero { tamano: ta, .. }, Entrada::Fichero { tamano: tb, .. }) => {
229            diagnosticar(pa, pb, *ta, *tb)
230        }
231        _ => Causa::TipoDistinto {
232            a: ea.tipo().to_string(),
233            b: eb.tipo().to_string(),
234        },
235    }
236}
237
238/// El corazón: dos ficheros con distinto contenido — ¿por qué?
239///
240/// El orden de los detectores importa: van de la causa **más específica y accionable** (gzip, ar) a
241/// la más genérica (primer byte divergente). El primero que reconoce el formato gana.
242fn diagnosticar(pa: &Path, pb: &Path, ta: u64, tb: u64) -> Causa {
243    if ta > MAX_DIAGNOSTICO || tb > MAX_DIAGNOSTICO {
244        return Causa::Tamano { a: ta, b: tb };
245    }
246    let (da, db) = match (std::fs::read(pa), std::fs::read(pb)) {
247        (Ok(x), Ok(y)) => (x, y),
248        _ => return Causa::Tamano { a: ta, b: tb },
249    };
250
251    if let Some(c) = detectar_gzip(&da, &db) {
252        return c;
253    }
254    if let Some(c) = detectar_ar(&da, &db) {
255        return c;
256    }
257    if let Some(c) = detectar_elf(&da, &db) {
258        return c;
259    }
260    if let Some(c) = detectar_texto(&da, &db) {
261        return c;
262    }
263    detectar_bytes(&da, &db)
264}
265
266// ── detectores ───────────────────────────────────────────────────────────────────────────────
267
268/// gzip: `1f 8b`, y el MTIME en los bytes 4..8 (little-endian). Si es lo único que cambia, la
269/// divergencia entera se explica por no haber usado `gzip -n`.
270fn detectar_gzip(a: &[u8], b: &[u8]) -> Option<Causa> {
271    if a.len() < 8 || b.len() < 8 || a[0..2] != [0x1f, 0x8b] || b[0..2] != [0x1f, 0x8b] {
272        return None;
273    }
274    let ma = u32::from_le_bytes(a[4..8].try_into().ok()?);
275    let mb = u32::from_le_bytes(b[4..8].try_into().ok()?);
276    if ma == mb {
277        return None;
278    }
279    Some(Causa::GzipMtime { a: ma, b: mb })
280}
281
282/// Un miembro de archivo `ar`: cabecera ASCII de 60 bytes —
283/// nombre[16] mtime[12] uid[6] gid[6] modo[8] tamaño[10] fmag[2].
284struct MiembroAr {
285    nombre: String,
286    mtime: String,
287    uid: String,
288    gid: String,
289    modo: String,
290    tamano: usize,
291    inicio: usize,
292}
293
294fn parsear_ar(d: &[u8]) -> Option<Vec<MiembroAr>> {
295    if d.len() < 8 || &d[0..8] != b"!<arch>\n" {
296        return None;
297    }
298    let campo = |b: &[u8]| String::from_utf8_lossy(b).trim().to_string();
299    let mut out = Vec::new();
300    let mut cur = 8usize;
301    while cur + 60 <= d.len() {
302        let h = &d[cur..cur + 60];
303        if &h[58..60] != b"`\n" {
304            break;
305        }
306        let tamano: usize = campo(&h[48..58]).parse().ok()?;
307        out.push(MiembroAr {
308            nombre: campo(&h[0..16]),
309            mtime: campo(&h[16..28]),
310            uid: campo(&h[28..34]),
311            gid: campo(&h[34..40]),
312            modo: campo(&h[40..48]),
313            tamano,
314            inicio: cur + 60,
315        });
316        // Los miembros se alinean a 2 bytes.
317        cur = cur + 60 + tamano + (tamano % 2);
318    }
319    Some(out)
320}
321
322/// `.a` (y `.deb`/`.lib`): si las cabeceras traen mtime/uid/gid distintos, ésa es la causa — el
323/// contenido de los objetos puede ser idéntico. Se mata archivando en modo determinista.
324fn detectar_ar(a: &[u8], b: &[u8]) -> Option<Causa> {
325    let (ma, mb) = (parsear_ar(a)?, parsear_ar(b)?);
326    for (x, y) in ma.iter().zip(mb.iter()) {
327        for (campo, va, vb) in [
328            ("mtime", &x.mtime, &y.mtime),
329            ("uid", &x.uid, &y.uid),
330            ("gid", &x.gid, &y.gid),
331            ("modo", &x.modo, &y.modo),
332        ] {
333            if va != vb {
334                return Some(Causa::ArCabecera {
335                    miembro: x.nombre.clone(),
336                    campo: campo.to_string(),
337                    a: va.clone(),
338                    b: vb.clone(),
339                });
340            }
341        }
342        // Cabeceras iguales pero contenido distinto ⇒ la causa está DENTRO del objeto: se
343        // diagnostica recursivamente (un `.o` es ELF, así que casi siempre cae en detectar_elf).
344        let (fa, fb) = (x.inicio + x.tamano, y.inicio + y.tamano);
345        if fa <= a.len() && fb <= b.len() {
346            let (ca, cb) = (&a[x.inicio..fa], &b[y.inicio..fb]);
347            if ca != cb {
348                if let Some(Causa::ElfSecciones { secciones, interpretacion, evidencia }) =
349                    detectar_elf(ca, cb)
350                {
351                    // Un `.a` trae decenas de objetos: sin decir cuál, el veredicto obliga a
352                    // desarmar el archivo a mano — justo el trabajo artesanal que esto elimina.
353                    return Some(Causa::ElfSecciones {
354                        secciones,
355                        interpretacion: format!("miembro `{}` — {interpretacion}", x.nombre),
356                        evidencia,
357                    });
358                }
359            }
360        }
361    }
362    None
363}
364
365/// Los bytes de una sección concreta, si existe y cae dentro del fichero.
366fn bytes_de_seccion<'a>(d: &'a [u8], nombre: &str) -> Option<&'a [u8]> {
367    let (_, off, size) = secciones_crudas(d)?.into_iter().find(|(n, ..)| n == nombre)?;
368    d.get(off..off + size)
369}
370
371/// Cadenas NUL-terminadas imprimibles de una sección (`.debug_str`, `.comment`: son tablas de
372/// cadenas). Es lo que convierte "difieren las secciones de depuración" en evidencia concreta.
373fn cadenas_de(d: &[u8], nombre: &str) -> Vec<String> {
374    let Some(b) = bytes_de_seccion(d, nombre) else {
375        return Vec::new();
376    };
377    b.split(|&c| c == 0)
378        .filter(|s| s.len() >= 4 && s.iter().all(|&c| (0x20..0x7f).contains(&c)))
379        .map(|s| String::from_utf8_lossy(s).to_string())
380        .collect()
381}
382
383/// La tabla de secciones como (nombre, offset, tamaño).
384///
385/// Cabecera de sección ELF64: sh_name[4] sh_type[4] sh_flags[8] sh_addr[8] sh_offset[8] sh_size[8] …
386fn secciones_crudas(d: &[u8]) -> Option<Vec<(String, usize, usize)>> {
387    if d.len() < 64 || &d[0..4] != b"\x7fELF" || d[4] != 2 || d[5] != 1 {
388        return None;
389    }
390    let e_shoff = u64::from_le_bytes(d[40..48].try_into().ok()?) as usize;
391    let e_shentsize = u16::from_le_bytes(d[58..60].try_into().ok()?) as usize;
392    let e_shnum = u16::from_le_bytes(d[60..62].try_into().ok()?) as usize;
393    let e_shstrndx = u16::from_le_bytes(d[62..64].try_into().ok()?) as usize;
394    if e_shoff == 0 || e_shnum == 0 || e_shstrndx >= e_shnum || e_shentsize < 64 {
395        return None;
396    }
397    let leer = |i: usize| -> Option<(u32, u64, u64)> {
398        let off = e_shoff + i * e_shentsize;
399        if off + 64 > d.len() {
400            return None;
401        }
402        let sh_name = u32::from_le_bytes(d[off..off + 4].try_into().ok()?);
403        let sh_offset = u64::from_le_bytes(d[off + 24..off + 32].try_into().ok()?);
404        let sh_size = u64::from_le_bytes(d[off + 32..off + 40].try_into().ok()?);
405        Some((sh_name, sh_offset, sh_size))
406    };
407    // La tabla de nombres de sección: sus bytes son cadenas NUL-terminadas indexadas por sh_name.
408    let (_, str_off, str_size) = leer(e_shstrndx)?;
409    let (str_off, str_size) = (str_off as usize, str_size as usize);
410    if str_off + str_size > d.len() {
411        return None;
412    }
413    let strtab = &d[str_off..str_off + str_size];
414
415    let mut out = Vec::with_capacity(e_shnum);
416    for i in 0..e_shnum {
417        let (sh_name, sh_offset, sh_size) = leer(i)?;
418        let ini = sh_name as usize;
419        let nombre = if ini < strtab.len() {
420            let fin = strtab[ini..].iter().position(|&c| c == 0).unwrap_or(0) + ini;
421            String::from_utf8_lossy(&strtab[ini..fin]).to_string()
422        } else {
423            format!("<sección {i}>")
424        };
425        out.push((nombre, sh_offset as usize, sh_size as usize));
426    }
427    Some(out)
428}
429
430/// Las secciones con el hash de su contenido — la base de la comparación.
431fn secciones_elf(d: &[u8]) -> Option<BTreeMap<String, (u64, String)>> {
432    let mut out = BTreeMap::new();
433    for (nombre, off, size) in secciones_crudas(d)? {
434        // SHT_NOBITS (.bss) y las secciones fuera de rango no tienen bytes que hashear.
435        let contenido = match d.get(off..off + size) {
436            Some(b) => blake3::hash(b).to_hex().to_string(),
437            None => String::from("<fuera de rango>"),
438        };
439        out.insert(nombre, (size as u64, contenido));
440    }
441    Some(out)
442}
443
444/// ELF: comparar sección por sección. Saber CUÁLES difieren es casi todo el diagnóstico — es la
445/// diferencia entre "el binario cambió" y "sólo cambió `.comment` ⇒ otro compilador".
446fn detectar_elf(a: &[u8], b: &[u8]) -> Option<Causa> {
447    let (sa, sb) = (secciones_elf(a)?, secciones_elf(b)?);
448    let mut difieren: Vec<String> = Vec::new();
449    for (nombre, va) in &sa {
450        match sb.get(nombre) {
451            Some(vb) if vb == va => {}
452            Some(_) => difieren.push(nombre.clone()),
453            None => difieren.push(format!("{nombre} (sólo en A)")),
454        }
455    }
456    for nombre in sb.keys() {
457        if !sa.contains_key(nombre) {
458            difieren.push(format!("{nombre} (sólo en B)"));
459        }
460    }
461    if difieren.is_empty() {
462        return None;
463    }
464    Some(Causa::ElfSecciones {
465        interpretacion: interpretar_secciones(&difieren),
466        evidencia: evidencia_de_cadenas(a, b, &difieren),
467        secciones: difieren,
468    })
469}
470
471/// Cuántas cadenas divergentes se muestran como evidencia. Más que esto es ruido: el patrón se ve
472/// con dos o tres.
473const MAX_EVIDENCIA: usize = 3;
474
475/// Saca las cadenas que están en un ELF y no en el otro, para las secciones de texto que difieren.
476///
477/// Esto es lo que separa la sospecha del hecho. `.debug_str` difiere ⇒ la hipótesis es "rutas del
478/// árbol de build"; mostrar la cadena la confirma o la desmiente. Caso real que motivó esto
479/// (barrido del 2026-07-21, `alsa-lib`): la interpretación decía "típicamente rutas de build" y la
480/// evidencia mostró `/src/target/release/build/libsodium-sys-<hash-cargo>/out/…` — una ruta con un
481/// hash de cargo que cambia entre builds. Sin la cadena, eso era una corazonada.
482fn evidencia_de_cadenas(a: &[u8], b: &[u8], difieren: &[String]) -> Vec<String> {
483    const INTERESANTES: [&str; 3] = [".debug_str", ".comment", ".rodata"];
484    let mut out = Vec::new();
485    for sec in difieren {
486        let nombre = sec.split(' ').next().unwrap_or(sec);
487        if !INTERESANTES.contains(&nombre) {
488            continue;
489        }
490        let (ca, cb) = (cadenas_de(a, nombre), cadenas_de(b, nombre));
491        let (sa, sb): (std::collections::BTreeSet<_>, std::collections::BTreeSet<_>) =
492            (ca.into_iter().collect(), cb.into_iter().collect());
493        for s in sa.difference(&sb).take(MAX_EVIDENCIA) {
494            out.push(format!("{nombre} sólo en A: {}", recortar(s)));
495        }
496        for s in sb.difference(&sa).take(MAX_EVIDENCIA) {
497            out.push(format!("{nombre} sólo en B: {}", recortar(s)));
498        }
499        if out.len() >= MAX_EVIDENCIA * 2 {
500            break;
501        }
502    }
503    out
504}
505
506/// La lectura experta: qué significa que difieran ESAS secciones y no otras.
507fn interpretar_secciones(difieren: &[String]) -> String {
508    let tiene = |p: &str| difieren.iter().any(|s| s.starts_with(p));
509    let solo = |ps: &[&str]| difieren.iter().all(|s| ps.iter().any(|p| s.starts_with(p)));
510
511    if solo(&[".comment"]) {
512        return "sólo `.comment`: mismo código, distinta VERSIÓN de compilador".into();
513    }
514    if solo(&[".note.gnu.build-id"]) {
515        return "sólo el build-id: deriva del resto del binario — si nada más difiere, \
516                es un residuo, no la causa"
517            .into();
518    }
519    if solo(&[".debug", ".zdebug", ".note.gnu.build-id"]) {
520        return "sólo info de depuración: típicamente rutas del árbol de build sin \
521                `-ffile-prefix-map` (el código ejecutable es idéntico)"
522            .into();
523    }
524    if tiene(".text") || tiene(".data") || tiene(".rodata") {
525        let mut s = String::from("difiere código/datos ejecutables");
526        if tiene(".comment") {
527            s.push_str(" Y `.comment` ⇒ sospechar primero la versión del compilador");
528        }
529        return s;
530    }
531    if solo(&[".symtab", ".strtab", ".dynsym", ".dynstr"]) {
532        return "sólo tablas de símbolos: el ORDEN de los símbolos cambió (enlazado no \
533                determinista); el código es idéntico"
534            .into();
535    }
536    "secciones sin patrón conocido — hay que mirar".into()
537}
538
539/// Rutas del árbol de build que no deberían acabar dentro de un artefacto.
540const MARCAS_BUILD: [&str; 5] = ["work/sources/", "/build/", "/tmp/", "/nix/store/", "/out/"];
541
542/// Texto: la primera línea que difiere dice más que cualquier offset.
543fn detectar_texto(a: &[u8], b: &[u8]) -> Option<Causa> {
544    let (ta, tb) = (std::str::from_utf8(a).ok()?, std::str::from_utf8(b).ok()?);
545    for (i, (la, lb)) in ta.lines().zip(tb.lines()).enumerate() {
546        if la != lb {
547            if MARCAS_BUILD.iter().any(|m| la.contains(m) || lb.contains(m)) {
548                return Some(Causa::RutaDeBuild {
549                    a: recortar(la),
550                    b: recortar(lb),
551                });
552            }
553            return Some(Causa::TextoLinea {
554                linea: i + 1,
555                a: recortar(la),
556                b: recortar(lb),
557            });
558        }
559    }
560    // Mismo prefijo línea a línea: uno tiene más líneas que el otro.
561    let (na, nb) = (ta.lines().count(), tb.lines().count());
562    Some(Causa::TextoLinea {
563        linea: na.min(nb) + 1,
564        a: if na > nb { "<línea de más>".into() } else { "<fin>".into() },
565        b: if nb > na { "<línea de más>".into() } else { "<fin>".into() },
566    })
567}
568
569/// Último recurso: el primer byte que difiere y su ventana. Si en la ventana aparece una ruta de
570/// build, eso es más informativo que el hex y se reporta como tal.
571fn detectar_bytes(a: &[u8], b: &[u8]) -> Causa {
572    let offset = a.iter().zip(b.iter()).position(|(x, y)| x != y);
573    let Some(off) = offset else {
574        return Causa::Tamano {
575            a: a.len() as u64,
576            b: b.len() as u64,
577        };
578    };
579    let ini = off.saturating_sub(VENTANA / 2);
580    let va = &a[ini..(ini + VENTANA).min(a.len())];
581    let vb = &b[ini..(ini + VENTANA).min(b.len())];
582
583    let (sa, sb) = (imprimible(va), imprimible(vb));
584    if MARCAS_BUILD.iter().any(|m| sa.contains(m) || sb.contains(m)) {
585        return Causa::RutaDeBuild { a: sa, b: sb };
586    }
587    Causa::Bytes {
588        offset: off as u64,
589        a_hex: hex(va),
590        b_hex: hex(vb),
591    }
592}
593
594fn hex(d: &[u8]) -> String {
595    d.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ")
596}
597
598fn imprimible(d: &[u8]) -> String {
599    d.iter()
600        .map(|&b| if (0x20..0x7f).contains(&b) { b as char } else { '.' })
601        .collect()
602}
603
604fn recortar(s: &str) -> String {
605    const MAX: usize = 120;
606    if s.chars().count() <= MAX {
607        return s.to_string();
608    }
609    let corto: String = s.chars().take(MAX).collect();
610    format!("{corto}…")
611}
612
613/// La frase que se imprime. Vive acá (y no en el CLI) para que el informe JSON y el humano digan
614/// exactamente lo mismo.
615fn explicar(c: &Causa) -> String {
616    match c {
617        Causa::SoloEnA => "existe sólo en A".into(),
618        Causa::SoloEnB => "existe sólo en B".into(),
619        Causa::TipoDistinto { a, b } => format!("es {a} en A y {b} en B"),
620        Causa::SymlinkDistinto { a, b } => format!("symlink → `{a}` en A, → `{b}` en B"),
621        Causa::ModoDistinto { a_ejecutable, .. } => format!(
622            "mismo contenido, distinto bit de ejecución (A {}, B {})",
623            if *a_ejecutable { "x" } else { "no-x" },
624            if *a_ejecutable { "no-x" } else { "x" }
625        ),
626        Causa::GzipMtime { a, b } => {
627            format!("gzip con MTIME embebido: {a} vs {b} (cabecera, bytes 4..8)")
628        }
629        Causa::ArCabecera { miembro, campo, a, b } => {
630            format!("cabecera `ar` del miembro `{miembro}`: {campo} {a} vs {b}")
631        }
632        Causa::ElfSecciones { secciones, interpretacion, evidencia } => {
633            let mut t = format!("ELF, difieren [{}] — {interpretacion}", secciones.join(", "));
634            for e in evidencia {
635                t.push_str(&format!("\n      · {e}"));
636            }
637            t
638        }
639        Causa::TextoLinea { linea, a, b } => format!("texto, línea {linea}: `{a}` vs `{b}`"),
640        Causa::RutaDeBuild { a, b } => {
641            format!("ruta del árbol de build embebida: `{a}` vs `{b}`")
642        }
643        Causa::Bytes { offset, a_hex, b_hex } => {
644            format!("primer byte distinto en 0x{offset:x}\n      A: {a_hex}\n      B: {b_hex}")
645        }
646        Causa::Tamano { a, b } => format!("distinto tamaño: {a} vs {b} bytes"),
647    }
648}
649
650// ── recorrido del árbol ──────────────────────────────────────────────────────────────────────
651
652fn walk(raiz: &Path) -> crate::Result<BTreeMap<String, Entrada>> {
653    if !raiz.exists() {
654        return Err(crate::Error::Store(format!("no existe: {}", raiz.display())));
655    }
656    let mut out = BTreeMap::new();
657    let mut pila: Vec<PathBuf> = vec![raiz.to_path_buf()];
658    while let Some(dir) = pila.pop() {
659        for entrada in std::fs::read_dir(&dir)? {
660            let entrada = entrada?;
661            let ruta = entrada.path();
662            let rel = ruta
663                .strip_prefix(raiz)
664                .map_err(|e| crate::Error::Store(e.to_string()))?
665                .to_string_lossy()
666                .to_string();
667            let md = std::fs::symlink_metadata(&ruta)?;
668            if md.is_symlink() {
669                let destino = std::fs::read_link(&ruta)?.to_string_lossy().to_string();
670                out.insert(rel, Entrada::Symlink(destino));
671            } else if md.is_dir() {
672                out.insert(rel, Entrada::Directorio);
673                pila.push(ruta);
674            } else {
675                out.insert(
676                    rel,
677                    Entrada::Fichero {
678                        hash: hash_fichero(&ruta)?,
679                        tamano: md.len(),
680                        ejecutable: md.permissions().mode() & 0o111 != 0,
681                    },
682                );
683            }
684        }
685    }
686    Ok(out)
687}
688
689/// BLAKE3 por streaming: un artefacto puede traer ficheros de cientos de MB y no hay razón para
690/// tenerlos enteros en memoria sólo para saber si son iguales.
691fn hash_fichero(p: &Path) -> crate::Result<String> {
692    let mut f = std::fs::File::open(p)?;
693    let mut h = blake3::Hasher::new();
694    std::io::copy(&mut f, &mut h)?;
695    Ok(h.finalize().to_hex().to_string())
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use std::io::Write;
702
703    fn escribir(dir: &Path, rel: &str, datos: &[u8]) {
704        let p = dir.join(rel);
705        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
706        std::fs::File::create(&p).unwrap().write_all(datos).unwrap();
707    }
708
709    /// Dos árboles idénticos reproducen: sin divergencias y todo contado como idéntico.
710    #[test]
711    fn arboles_identicos_reproducen() {
712        let t = tempfile::tempdir().unwrap();
713        let (a, b) = (t.path().join("a"), t.path().join("b"));
714        for r in [&a, &b] {
715            escribir(r, "usr/bin/hola", b"contenido");
716            escribir(r, "usr/share/doc/leeme", b"texto\n");
717        }
718        let inf = why_differs(&a, &b).unwrap();
719        assert!(inf.reproduce(), "divergencias: {:?}", inf.divergencias);
720        // usr, usr/bin, usr/bin/hola, usr/share, usr/share/doc, usr/share/doc/leeme
721        assert_eq!(inf.identicos, 6);
722    }
723
724    /// El MTIME de un gzip es LA causa clásica; tiene que salir nombrada, no como "bytes distintos".
725    #[test]
726    fn gzip_mtime_se_nombra_y_trae_remedio() {
727        let t = tempfile::tempdir().unwrap();
728        let (a, b) = (t.path().join("a"), t.path().join("b"));
729        // cabecera gzip mínima: magic, CM, FLG, MTIME(4), XFL, OS
730        let mut ga = vec![0x1f, 0x8b, 0x08, 0x00];
731        ga.extend_from_slice(&1_000u32.to_le_bytes());
732        ga.extend_from_slice(&[0x00, 0x03, 0xde, 0xad]);
733        let mut gb = ga.clone();
734        gb[4..8].copy_from_slice(&2_000u32.to_le_bytes());
735        escribir(&a, "m.gz", &ga);
736        escribir(&b, "m.gz", &gb);
737
738        let inf = why_differs(&a, &b).unwrap();
739        assert_eq!(inf.divergencias.len(), 1);
740        let d = &inf.divergencias[0];
741        assert_eq!(d.causa, Causa::GzipMtime { a: 1000, b: 2000 });
742        assert!(d.remedio.as_ref().unwrap().contains("gzip -n"));
743    }
744
745    /// Una ruta del árbol de build embebida se reporta como tal (no como una línea cualquiera).
746    #[test]
747    fn ruta_de_build_embebida() {
748        let t = tempfile::tempdir().unwrap();
749        let (a, b) = (t.path().join("a"), t.path().join("b"));
750        escribir(&a, "cfg", b"prefix=/home/x/work/sources/zlib-aaa\n");
751        escribir(&b, "cfg", b"prefix=/home/x/work/sources/zlib-bbb\n");
752        let inf = why_differs(&a, &b).unwrap();
753        assert_eq!(inf.divergencias[0].causa.etiqueta(), "ruta-de-build");
754        assert!(inf.divergencias[0].remedio.is_some());
755    }
756
757    /// Presencia/ausencia y symlinks divergentes son divergencias de pleno derecho.
758    #[test]
759    fn presencia_y_symlinks() {
760        let t = tempfile::tempdir().unwrap();
761        let (a, b) = (t.path().join("a"), t.path().join("b"));
762        escribir(&a, "solo-a", b"x");
763        escribir(&b, "solo-b", b"x");
764        std::fs::create_dir_all(a.join("d")).unwrap();
765        std::fs::create_dir_all(b.join("d")).unwrap();
766        std::os::unix::fs::symlink("destino-1", a.join("d/enlace")).unwrap();
767        std::os::unix::fs::symlink("destino-2", b.join("d/enlace")).unwrap();
768
769        let inf = why_differs(&a, &b).unwrap();
770        let etiquetas = inf.por_causa();
771        assert_eq!(etiquetas.get("solo-en-a"), Some(&1));
772        assert_eq!(etiquetas.get("solo-en-b"), Some(&1));
773        assert_eq!(etiquetas.get("symlink-distinto"), Some(&1));
774    }
775
776    /// Un `.a` cuyos miembros sólo difieren en el mtime de la cabecera: causa nombrada + remedio.
777    #[test]
778    fn ar_mtime_de_cabecera() {
779        let t = tempfile::tempdir().unwrap();
780        let (a, b) = (t.path().join("a"), t.path().join("b"));
781        let armar = |mtime: &str| {
782            let mut v = Vec::from(*b"!<arch>\n");
783            v.extend_from_slice(format!("{:<16}", "obj.o").as_bytes());
784            v.extend_from_slice(format!("{mtime:<12}").as_bytes());
785            v.extend_from_slice(format!("{:<6}", "0").as_bytes());
786            v.extend_from_slice(format!("{:<6}", "0").as_bytes());
787            v.extend_from_slice(format!("{:<8}", "100644").as_bytes());
788            v.extend_from_slice(format!("{:<10}", "4").as_bytes());
789            v.extend_from_slice(b"`\n");
790            v.extend_from_slice(b"data");
791            v
792        };
793        escribir(&a, "lib.a", &armar("1700000000"));
794        escribir(&b, "lib.a", &armar("1800000000"));
795        let inf = why_differs(&a, &b).unwrap();
796        assert_eq!(inf.divergencias[0].causa.etiqueta(), "ar-cabecera");
797        assert!(inf.divergencias[0].remedio.as_ref().unwrap().contains("determinista"));
798    }
799}