guardianes: un artefacto VACIO deja de contar como presente

Dos eslabones de la misma cadena, que el 2026-08-10 dejo cuatro recetas con
OK sin producir un solo fichero.

Store::has era path_of(..).is_dir(): un directorio vacio contaba como
sellado, asi que build() hacia cache-hit y devolvia Ok sin construir. Ahora
exige al menos una entrada. NO exige el sidecar .hammer/recipe.toml aunque
seria mas expresivo: ese lo escriben los llamantes, no seal(), y
hammer-bootstrap sella sin el ⇒ pedirlo lo haria reconstruir siempre. Va con
test de regresion.

--listar armaba el manifiesto con `ls`, que lista NOMBRES: un vacio es
identico a uno bueno, y de ahi build-state.py lo daba por sellado. Ahora usa
`du -s` (8,6 s sobre 1751, frente a un ls instantaneo), separa los vacios a
work/respaldo-vacios.txt y los DICE siempre, tambien cuando son 0.

Cuidado con el orden en ese awk: recortar la ruta antes se come el contador
de bloques y el filtro compara el nombre en vez del tamano — daba 406 vacios
falsos. Primero filtrar por numero, despues recortar.

Quedan 3 vacios sin curar en el respaldo (dbus x2 y un libxkbcommon de hash
superado); no caen en ninguna clausura construida.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-08-10 13:02:34 +00:00
co-authored by Claude Opus 5
parent be9732add7
commit 0377f4fa7a
2 changed files with 67 additions and 2 deletions
+41 -1
View File
@@ -35,8 +35,26 @@ impl Store {
}
/// ¿Ya existe el artefacto? (caché del lab; ver `docs/02-build-lab.md` §5).
///
/// Exige el sidecar `.hammer/recipe.toml`, **no** que el directorio exista. Un `is_dir()` pelado
/// daba por presente un directorio VACÍO, y eso no es un caso hipotético (2026-08-10): el
/// respaldo tenía 7 artefactos vacíos —de una subida `--files-from` sin `-r`—, `rsync` los bajó
/// como directorios sin contenido y `build()` hizo cache-hit sobre ellos: **selló sin construir
/// y devolvió Ok**. Cuatro recetas dieron OK sin producir un solo fichero.
///
/// El criterio es "tiene al menos una entrada", NO el sidecar `.hammer/recipe.toml`: ese lo
/// escriben los llamantes (`hammer-build`, `hammer-cli`), no `seal()`, y **`hammer-bootstrap`
/// sella sin él** ⇒ exigirlo haría que el bootstrap se creyera nunca sellado y reconstruyera
/// siempre. Un artefacto de verdad nunca está vacío, así que esto basta para el fallo real.
///
/// Lo que esto NO cubre: una copia PARCIAL (algún fichero, no todos). Para integridad de
/// contenido están `of_tree` y `hammer attest`; esto es la caché, no la atestación.
pub fn has(&self, h: &ArtifactHash, name: &str) -> bool {
self.path_of(h, name).is_dir()
let dir = self.path_of(h, name);
// `read_dir(..).next().is_some()`: una sola entrada basta, no recorre el árbol.
std::fs::read_dir(&dir)
.map(|mut e| e.next().is_some())
.unwrap_or(false)
}
/// Resuelve el directorio de un artefacto a partir de un prefijo del hex de su hash
@@ -248,6 +266,28 @@ mod tests {
assert!(store.has(&hash(), "demo"));
}
#[test]
fn has_rechaza_directorio_vacio() {
// REGRESIÓN (2026-08-10): `has` era `path_of(..).is_dir()`, así que un directorio VACÍO
// contaba como artefacto sellado. Pasó de verdad: el respaldo tenía artefactos vacíos, rsync
// los bajó al store y `build()` hizo cache-hit sobre ellos ⇒ selló sin construir y devolvió
// Ok. Cuatro recetas dieron OK sin producir un solo fichero. Un ausente falla ruidosamente;
// un vacío llega hasta el final diciendo que todo fue bien.
let store_dir = tempfile::tempdir().unwrap();
let store = Store::open(store_dir.path()).unwrap();
assert!(!store.has(&hash(), "demo"), "sin directorio no hay artefacto");
std::fs::create_dir_all(store.path_of(&hash(), "demo")).unwrap();
assert!(
!store.has(&hash(), "demo"),
"un directorio VACÍO no es un artefacto sellado"
);
std::fs::write(store.path_of(&hash(), "demo").join("algo.bin"), b"x").unwrap();
assert!(store.has(&hash(), "demo"), "con contenido sí");
}
#[test]
fn copy_tree_preserva_symlinks_y_modos() {
use std::os::unix::fs::PermissionsExt;
+26 -1
View File
@@ -68,10 +68,26 @@ SECO=""
if [ "${1:-}" = "--listar" ]; then
cd "$RAIZ"; mkdir -p work
# `du -s`, NO `ls`. Un `ls` lista NOMBRES DE DIRECTORIO, y un artefacto VACÍO tiene nombre igual
# que uno bueno ⇒ el manifiesto lo avala, `build-state.py` lo da por sellado, rsync lo baja vacío
# y `hammer build` hace cache-hit sobre el directorio vacío: sella sin construir y sale 0. Toda la
# cadena lee el vacío como presencia, y el único síntoma aparece al final, disfrazado de éxito.
# (2026-08-10: 7 vacíos de 1749, todos del stack gráfico, de una subida que falló callada.)
# Cuesta 8,6 s sobre 1751 artefactos frente a un `ls` instantáneo — barato para lo que evita.
# La shell del box no es bash y no tiene `find`, pero SÍ expande globs y SÍ tiene `du`.
ssh -4 -p "$SB_PORT" -i "$KEY" -o StrictHostKeyChecking=accept-new \
"$SB_USER@$SB_HOST" 'ls hammer/store' 2>/dev/null \
"$SB_USER@$SB_HOST" 'du -s hammer/store/*' 2>/dev/null > work/.respaldo-du.tmp
# OJO CON EL ORDEN: recortar la ruta ANTES del awk se come el contador (`sed 's#.*/##'` es codicioso
# y borra "5347<TAB>hammer/store/" entero), y entonces $1 es el NOMBRE, no los bloques: el filtro
# compara basura y devuelve cualquier cosa. Primero se filtra por número, después se recorta.
# Campo 1 = bloques. Un directorio sin contenido da 1..4; con contenido, mucho más.
awk '$1<=4 {sub(/.*\//,"",$2); print $2}' work/.respaldo-du.tmp \
| grep -E '^[0-9a-f]{64}-' | sort -u > work/respaldo-vacios.txt
awk '$1>4 {sub(/.*\//,"",$2); print $2}' work/.respaldo-du.tmp \
| grep -E '^[0-9a-f]{64}-' | sort -u > work/respaldo-sellados.txt.new
rm -f work/.respaldo-du.tmp
N=$(wc -l < work/respaldo-sellados.txt.new)
V=$(wc -l < work/respaldo-vacios.txt)
# Un listado vacío es un fallo de red disfrazado de respaldo borrado. No pisa nada.
if [ "$N" -eq 0 ]; then
echo "⚠ el box devolvió 0 artefactos — no piso el manifiesto (¿enlace caído?)" >&2
@@ -79,6 +95,15 @@ if [ "${1:-}" = "--listar" ]; then
fi
mv work/respaldo-sellados.txt.new work/respaldo-sellados.txt
echo "==> manifiesto del respaldo: $N artefactos → work/respaldo-sellados.txt"
# Se DICE siempre, aunque sea 0: un guardián que sólo habla cuando hay desastre no enseña a nadie
# qué está vigilando, y el día que hable nadie sabrá interpretarlo.
if [ "$V" -gt 0 ]; then
echo "$V artefactos VACÍOS en el respaldo (excluidos del manifiesto) → work/respaldo-vacios.txt" >&2
sed 's/^/ /' work/respaldo-vacios.txt >&2
echo " curar = reconstruir la receta y volver a subirla; NO se cuentan como respaldadas" >&2
else
echo " (0 artefactos vacíos)"
fi
exit 0
fi