diff --git a/crates/hammer-build/src/sandbox.rs b/crates/hammer-build/src/sandbox.rs index 73922584..b9bca334 100644 --- a/crates/hammer-build/src/sandbox.rs +++ b/crates/hammer-build/src/sandbox.rs @@ -426,7 +426,11 @@ impl Sandbox { ("LC_ALL", "C"), ("LANG", "C"), // Reproducibilidad (pre-Stage 2): fija los timestamps que muchas herramientas embeben - // (tar, ar, gzip, __DATE__/__TIME__ vía SOURCE_DATE_EPOCH). Valor fijo y arbitrario + // (tar, gzip, __DATE__/__TIME__ vía SOURCE_DATE_EPOCH). Valor fijo y arbitrario + // ⚠ NO cubre `ar`/`ranlib`: binutils NO lee SOURCE_DATE_EPOCH — la determinismo de + // archivos se decide en `configure` (`--enable-deterministic-archives`) o por opción + // `-D` en cada invocación. Este comentario decía que sí lo cubría y era FALSO; lo + // desmintió comparar dos builds de zlib (ver AR_WRAPPER/RANLIB_WRAPPER abajo). // (1970-01-01 00:00:01). Las rutas ya son deterministas: el source se bindea en `/src` // (constante entre rebuilds, sea cual sea el work dir del host). Ver SDD 11 / runbook. ("SOURCE_DATE_EPOCH", "1"), @@ -529,6 +533,33 @@ pub fn ensure_layout(rootfs: &Path, zig_dir: &Path) -> hammer_core::Result<()> { pub const CC_WRAPPER: &str = "hammer-zig-cc"; pub const CXX_WRAPPER: &str = "hammer-zig-cxx"; +/// Herramientas de archivo que se interponen en `/usr/local/bin` para forzar **modo determinista**. +/// +/// ── POR QUÉ (medido 2026-08-28) ───────────────────────────────────────────────────────────── +/// Construir `zlib` en dos máquinas dio el MISMO `ArtifactHash` y **bytes distintos**: +/// `usr/lib/libz.a` divergía sólo en la cabecera `ar` del miembro `/` (la tabla de símbolos), +/// con la hora de pared del build. `hammer why-differs` lo nombró exacto. +/// +/// La causa NO es `zig ar` (llvm-ar sella mtime 0) ni el binutils del rootfs Alpine (que Alpine +/// compila con `--enable-deterministic-archives`): es **nuestro propio `recipes/binutils.toml`**, +/// que no pasa ese flag. Toda receta que lo materialice como build-dep y llame a `ar`/`ranlib` +/// —directamente o vía el `$(RANLIB)` de su Makefile— hereda la hora de pared. +/// +/// ── POR QUÉ AQUÍ Y NO EN LA RECETA ────────────────────────────────────────────────────────── +/// Arreglar `recipes/binutils.toml` es la cura de raíz, pero su radio son **421 recetas +/// transitivas y 365 sellados a deuda** (medido con `yupana radio binutils`) — desproporcionado +/// frente a los 9 artefactos realmente contaminados. El sandbox es donde este proyecto ya +/// arregla esta CLASE de bug sin re-hashear: `-mcpu=baseline`, `CARGO_PROFILE_RELEASE_CODEGEN_UNITS` +/// y `CARGO_BUILD_JOBS` son exactamente el mismo patrón. La cura de raíz queda pendiente para +/// cuando toque un rebuild del corpus por otro motivo (SDD 23), no como campaña propia. +/// +/// ⚠ CONSECUENCIA QUE HAY QUE EJECUTAR, NO SÓLO DECLARAR: esto cambia los BYTES sin cambiar la +/// DIRECCIÓN de los 9 artefactos ya contaminados. Un artefacto viejo y uno nuevo son distintos en +/// el mismo hash — el peligro exacto que la huella del lab existe para eliminar. Por eso la +/// adopción incluye **re-sellar esos 9 a propósito**, no esperar a que caduquen solos. +pub const AR_WRAPPER: &str = "ar"; +pub const RANLIB_WRAPPER: &str = "ranlib"; + /// Materializa en el rootfs dos wrappers de linker-driver (`hammer-zig-cc`/`-cxx`) que envuelven a /// `zig cc`/`zig c++`. Su ÚNICO efecto es QUITAR `-static` cuando el link REQUIERE enlace dinámico. /// @@ -594,6 +625,28 @@ fn write_cc_wrappers(rootfs: &Path) -> hammer_core::Result<()> { hammer_core::Error::Other(anyhow::anyhow!("no pude chmod {}: {e}", path.display())) })?; } + + // Archivadores en modo determinista. Ver la doc de AR_WRAPPER. + // + // `-D` ANTEPUESTO y no como modificador de la cadena de operación (`Drc`): las dos formas + // funcionan en binutils (verificado), pero anteponerlo es lo único que compone con una línea + // de comando ajena — el llamador pone su propia operación (`rc`, `t`, `x`…) en "$@" y no la + // podemos reescribir. `-D` es inocuo en las operaciones de lectura. + // + // Ruta ABSOLUTA `/usr/bin/` a propósito: el wrapper vive en `/usr/local/bin`, que va + // ANTES en el PATH, así que un `exec ar` se llamaría a sí mismo para siempre. + for tool in [AR_WRAPPER, RANLIB_WRAPPER] { + let path = bindir.join(tool); + let body = format!( + "#!/bin/sh\n # Interpuesto por hammer: fuerza archivos deterministas (sin hora de pared en la\n # cabecera `ar`). Sin esto dos builds idénticos sellan bytes distintos. Ver\n # sandbox.rs::AR_WRAPPER.\n [ -x /usr/bin/{tool} ] || exit 127\n exec /usr/bin/{tool} -D \"$@\"\n" + ); + std::fs::write(&path, body).map_err(|e| { + hammer_core::Error::Other(anyhow::anyhow!("no pude escribir {}: {e}", path.display())) + })?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).map_err(|e| { + hammer_core::Error::Other(anyhow::anyhow!("no pude chmod {}: {e}", path.display())) + })?; + } Ok(()) }