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…
takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja
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}