> 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…
store: un destino VACÍO ya no se traga el sellado en silencio
`has()` define presencia como «tiene al menos una entrada» —hay test desde hace tiempo—
pero `seal()` usaba `is_dir()`, que también es cierto para un directorio vacío. Con eso
`seal` devolvía Ok, tiraba el árbol recién construido, y el build logueaba `sealed` y
salía 0. Dos nociones distintas de «está» en el mismo struct, y la que decide si el
trabajo se guarda era la mala.
Medido en el worker dev.gioser.net: 1457 directorios vacíos en el store se comían cada
sellado. `openssl-threads` compilaba entero —headers, libcrypto.a, libssl.a, los .pc— y
sellaba NADA; python3 se construía después sin openssl y quedaba sin `_ssl`, y con eso
morían glib y 4 recetas más de GNOME. El error salía a tres recetas de distancia de la
causa y no nombraba openssl ni una vez. Probado quitando el vacío: el MISMO build sella
150 ficheros con OPENSSL_THREADS, mismo hash.
Se usa `remove_dir` y no `remove_dir_all`: sólo tiene éxito sobre un vacío, así que si
algo dejó contenido ahí entremedio falla ruidoso en vez de borrar un artefacto bueno.
No mueve hashes (testigo zlib idéntico).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LykB7vw38b4Ck1Zij15RQ4
diff --git a/crates/hammer-core/src/store.rs b/crates/hammer-core/src/store.rs
index 278784f3..6a890225 100644
--- a/crates/hammer-core/src/store.rs
+++ b/crates/hammer-core/src/store.rs
@@ -136,7 +136,34 @@ impl Store {
) -> crate::Result<PathBuf> {
let dst = self.path_of(h, name);
if dst.is_dir() {
- return Ok(dst);
+ // ── UN DIRECTORIO VACÍO NO ES UN ARTEFACTO, ES UN NOMBRE ──────────────────────────
+ // `has()` (arriba) ya define presencia como "tiene AL MENOS una entrada". Acá se usaba
+ // `is_dir()`, que también es verdadero para un directorio VACÍO ⇒ `seal` devolvía Ok,
+ // TIRABA el árbol recién construido y el build logueaba `sealed` y salía 0. Dos
+ // nociones distintas de "está" en el mismo struct, y la de `seal` es la que decide si
+ // el trabajo se guarda.
+ //
+ // Medido en el worker dev.gioser.net (2026-08-29): 1457 directorios vacíos en el store
+ // se comían cada sellado. `openssl-threads` compilaba entero —instalaba headers,
+ // libcrypto.a, libssl.a, los .pc— y sellaba NADA; después python3 se construía sin
+ // openssl y quedaba sin `_ssl`, y con eso morían glib y 4 recetas más de GNOME. El
+ // error salía a 3 recetas de distancia de la causa y no mencionaba openssl ni una vez.
+ //
+ // `remove_dir` (NO `remove_dir_all`) a propósito: sólo tiene éxito sobre un directorio
+ // vacío. Si algo dejó contenido ahí entremedio, esto falla RUIDOSO en vez de borrar un
+ // artefacto bueno — que es la regla: un ausente falla ruidosamente, un vacío no.
+ let vacio = std::fs::read_dir(&dst)
+ .map(|mut e| e.next().is_none())
+ .unwrap_or(false);
+ if !vacio {
+ return Ok(dst);
+ }
+ std::fs::remove_dir(&dst).map_err(|e| {
+ crate::Error::Store(format!(
+ "destino vacío {} no se pudo retirar antes de sellar: {e}",
+ dst.display()
+ ))
+ })?;
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
@@ -417,4 +444,29 @@ sha256 = "abc123"
assert_eq!(p1, p2);
assert!(second.path().is_dir()); // intacto
}
+
+ /// REGRESIÓN (2026-08-29): un `dst` VACÍO se tragaba el sellado en silencio.
+ /// `seal` usaba `is_dir()`, verdadero para un directorio vacío ⇒ devolvía Ok sin escribir
+ /// nada y el build decía `sealed`. En el worker eso dejó 1457 artefactos hueco y rompió
+ /// GNOME por una cadena de 3 recetas (openssl-threads vacío → python3 sin _ssl → glib).
+ #[test]
+ fn seal_no_se_traga_un_destino_vacio() {
+ let store_dir = tempfile::tempdir().unwrap();
+ let store = Store::open(store_dir.path()).unwrap();
+
+ // Un directorio vacío plantado en el store, como el que deja un rsync a medias.
+ let dst = store.path_of(&hash(), "demo");
+ std::fs::create_dir_all(&dst).unwrap();
+ assert!(dst.is_dir());
+ assert!(!store.has(&hash(), "demo")); // `has` ya lo rechazaba…
+
+ let out = tempfile::tempdir_in(store_dir.path()).unwrap();
+ std::fs::write(out.path().join("a"), b"contenido").unwrap();
+ let sellado = store.seal(&out.keep(), &hash(), "demo").unwrap();
+
+ // …y ahora `seal` tampoco lo acepta: el contenido llega al store de verdad.
+ assert_eq!(sellado, dst);
+ assert_eq!(std::fs::read(dst.join("a")).unwrap(), b"contenido");
+ assert!(store.has(&hash(), "demo"));
+ }
}