sandbox: forzar archivos ar deterministas — dos builds sellaban bytes distintos

CÓMO SE VIO. Construir zlib en gioser y en el worker nuevo dio el MISMO ArtifactHash
y BYTES DISTINTOS. `hammer why-differs` lo nombró exacto: 13 entradas idénticas, 1
diverge — `usr/lib/libz.a`, cabecera `ar` del miembro `/` (la tabla de símbolos),
mtime 1787326910 vs 1787950875. Es la hora de pared de cada build.

CAUSA, AISLADA POR BISECCIÓN EN EL LAB (no por lectura de código):
  · `zig ar` (llvm-ar)          → mtime 0   
  · `ranlib` del rootfs Alpine  → mtime 0    (Alpine compila binutils con
                                              --enable-deterministic-archives)
  · `ar` y `ranlib` de NUESTRO recipes/binutils.toml → hora de pared  
  · los mismos con `-D`         → mtime 0   
La receta de binutils no pasa --enable-deterministic-archives, así que toda receta
que lo materialice como build-dep y llame a ar/ranlib hereda la hora.

Y el comentario de SOURCE_DATE_EPOCH en este mismo fichero AFIRMABA cubrir `ar`.
Era falso: binutils no lee esa variable. Corregido, porque una suposición escrita
en un comentario es la que impide que alguien vuelva a medir.

ARREGLO. Wrappers `ar`/`ranlib` en /usr/local/bin (que gana al /usr/bin del PATH)
que anteponen `-D`. Mismo patrón que ya usa el sandbox para arreglar esta CLASE de
bug sin re-hashear: -mcpu=baseline, CARGO_PROFILE_RELEASE_CODEGEN_UNITS,
CARGO_BUILD_JOBS. `-D` antepuesto y no como modificador de la cadena de operación,
porque la operación la pone el llamador en "$@" y no se puede reescribir.
Verificado: la secuencia exacta de zlib (`ar rc` + `ranlib`) da mtime 0 y el índice
del archivo sigue siendo legible (`ar t`, `nm -s`).

POR QUÉ AQUÍ Y NO EN LA RECETA. Arreglar recipes/binutils.toml es la cura de raíz,
pero `yupana radio binutils` da 421 dependientes transitivos y 365 sellados a deuda,
en las siete imágenes — desproporcionado frente a los 9 artefactos realmente
contaminados (medidos barriendo los 185 artefactos con .a del store: elfutils,
elfutils-libdw ×4, zlib, musl-fts, musl-obstack, argp-standalone). La cura de raíz
se agenda para el próximo rebuild del corpus que ya haya que hacer por otro motivo.

PENDIENTE Y DICHO EN VOZ ALTA: esto cambia los BYTES sin cambiar la DIRECCIÓN de
esos 9. Hay que re-sellarlos a propósito — es el mismo peligro que la huella del lab
existe para eliminar, y no se cierra solo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LykB7vw38b4Ck1Zij15RQ4
This commit is contained in:
Sergio
2026-08-28 21:31:37 +00:00
co-authored by Claude Opus 5
parent 853853d919
commit 7106ec0645
+54 -1
View File
@@ -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/<tool>` 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(())
}