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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user