sergio/takana
> 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…
takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja
1//! El content-addressed store. Ver `docs/03-hydration.md` §1.
2//!
3//! Inmutable, append-only, direccionado por `ArtifactHash`. `seal` mueve el árbol de salida
4//! del sandbox a la ruta del store y lo marca read-only recursivo.
5
6use std::path::{Path, PathBuf};
7
8use crate::hash::ArtifactHash;
9use crate::recipe::Recipe;
10
11/// Ruta relativa, dentro de cada artefacto sellado, donde el lab guarda la receta original
12/// como sidecar de provenance. Estable: el receptor de un `.swm` exportado puede asumir
13/// este path para reconstruir la receta de un artefacto cualquiera.
14pub const RECIPE_SIDECAR_REL: &str = ".hammer/recipe.toml";
15
16pub struct Store {
17 root: PathBuf,
18}
19
20impl Store {
21 /// Abre (o prepara) un store en `root` (p. ej. `/store`).
22 pub fn open(root: impl Into<PathBuf>) -> crate::Result<Self> {
23 let root = root.into();
24 std::fs::create_dir_all(&root)?;
25 Ok(Store { root })
26 }
27
28 pub fn root(&self) -> &Path {
29 &self.root
30 }
31
32 /// Ruta del artefacto en el store para un hash + nombre legible.
33 pub fn path_of(&self, h: &ArtifactHash, name: &str) -> PathBuf {
34 self.root.join(h.store_dir_name(name))
35 }
36
37 /// ¿Ya existe el artefacto? (caché del lab; ver `docs/02-build-lab.md` §5).
38 ///
39 /// Exige el sidecar `.hammer/recipe.toml`, **no** que el directorio exista. Un `is_dir()` pelado
40 /// daba por presente un directorio VACÍO, y eso no es un caso hipotético (2026-08-10): el
41 /// respaldo tenía 7 artefactos vacíos —de una subida `--files-from` sin `-r`—, `rsync` los bajó
42 /// como directorios sin contenido y `build()` hizo cache-hit sobre ellos: **selló sin construir
43 /// y devolvió Ok**. Cuatro recetas dieron OK sin producir un solo fichero.
44 ///
45 /// El criterio es "tiene al menos una entrada", NO el sidecar `.hammer/recipe.toml`: ese lo
46 /// escriben los llamantes (`takana-build`, `takana-cli`), no `seal()`, y **`takana-bootstrap`
47 /// sella sin él** ⇒ exigirlo haría que el bootstrap se creyera nunca sellado y reconstruyera
48 /// siempre. Un artefacto de verdad nunca está vacío, así que esto basta para el fallo real.
49 ///
50 /// Lo que esto NO cubre: una copia PARCIAL (algún fichero, no todos). Para integridad de
51 /// contenido están `of_tree` y `takana attest`; esto es la caché, no la atestación.
52 pub fn has(&self, h: &ArtifactHash, name: &str) -> bool {
53 let dir = self.path_of(h, name);
54 // `read_dir(..).next().is_some()`: una sola entrada basta, no recorre el árbol.
55 std::fs::read_dir(&dir)
56 .map(|mut e| e.next().is_some())
57 .unwrap_or(false)
58 }
59
60 /// Resuelve el directorio de un artefacto a partir de un prefijo del hex de su hash
61 /// (con o sin `b3:`), sin necesidad de conocer el `name`. Devuelve error si no hay
62 /// match o si el prefijo es ambiguo (lista los candidatos).
63 pub fn find_by_hash(&self, hex_or_prefixed: &str) -> crate::Result<PathBuf> {
64 let prefix = hex_or_prefixed
65 .strip_prefix("b3:")
66 .unwrap_or(hex_or_prefixed);
67 if prefix.is_empty() {
68 return Err(crate::Error::Store("hash vacío".into()));
69 }
70 let mut matches = Vec::new();
71 let read = std::fs::read_dir(&self.root)?;
72 for entry in read {
73 let entry = entry?;
74 let name = entry.file_name();
75 let Some(name) = name.to_str() else { continue };
76 if name.starts_with(prefix) && entry.file_type()?.is_dir() {
77 matches.push(entry.path());
78 }
79 }
80 match matches.len() {
81 0 => Err(crate::Error::Store(format!(
82 "ningún artefacto en {} con prefix '{prefix}'",
83 self.root.display()
84 ))),
85 1 => Ok(matches.pop().unwrap()),
86 _ => Err(crate::Error::Store(format!(
87 "prefix '{prefix}' ambiguo, {} candidatos: {}",
88 matches.len(),
89 matches
90 .iter()
91 .map(|p| p.display().to_string())
92 .collect::<Vec<_>>()
93 .join(", ")
94 ))),
95 }
96 }
97
98 /// Lee la receta sidecar (`.hammer/recipe.toml`) dentro de un artefacto sellado, si
99 /// existe. Devuelve `None` para artefactos viejos sin sidecar (builds anteriores a la
100 /// introducción del provenance) o cuando el sidecar fue eliminado a mano. Es
101 /// best-effort: nunca falla por ausencia, sólo por sidecar corrupto.
102 pub fn recipe_for_dir(&self, artifact_dir: &Path) -> crate::Result<Option<Recipe>> {
103 let p = artifact_dir.join(RECIPE_SIDECAR_REL);
104 if !p.is_file() {
105 return Ok(None);
106 }
107 let text = std::fs::read_to_string(&p)?;
108 Recipe::from_toml(&text).map(Some)
109 }
110
111 /// Como `recipe_for_dir` pero resuelve el artefacto por hash (con o sin `b3:`).
112 pub fn recipe_for_hash(&self, hex_or_prefixed: &str) -> crate::Result<Option<Recipe>> {
113 let dir = match self.find_by_hash(hex_or_prefixed) {
114 Ok(d) => d,
115 Err(_) => return Ok(None),
116 };
117 self.recipe_for_dir(&dir)
118 }
119
120 /// Sella el árbol de salida de un build en el store bajo su hash.
121 ///
122 /// - Si el destino ya existe (caché), devuelve la ruta sin tocar `out_dir`.
123 /// - Si no, mueve `out_dir` al destino y marca el árbol read-only (todos los archivos
124 /// pierden `w`; directorios mantienen `x` para poder atravesarlos).
125 ///
126 /// Camino rápido: `rename` atómico cuando `out_dir` y el store comparten filesystem.
127 /// Camino EXDEV (Etapa B3, `/store` es una partición dedicada ≠ la de `/work`): `rename(2)`
128 /// no cruza filesystems ⇒ caemos a copia recursiva a un staging DENTRO del store + rename
129 /// store-interno (mismo FS, atómico) + borrado del original. La publicación sigue siendo
130 /// atómica (nadie ve el `dst` a medio escribir).
131 pub fn seal(
132 &self,
133 out_dir: &Path,
134 h: &ArtifactHash,
135 name: &str,
136 ) -> crate::Result<PathBuf> {
137 let dst = self.path_of(h, name);
138 if dst.is_dir() {
139 // ── UN DIRECTORIO VACÍO NO ES UN ARTEFACTO, ES UN NOMBRE ──────────────────────────
140 // `has()` (arriba) ya define presencia como "tiene AL MENOS una entrada". Acá se usaba
141 // `is_dir()`, que también es verdadero para un directorio VACÍO ⇒ `seal` devolvía Ok,
142 // TIRABA el árbol recién construido y el build logueaba `sealed` y salía 0. Dos
143 // nociones distintas de "está" en el mismo struct, y la de `seal` es la que decide si
144 // el trabajo se guarda.
145 //
146 // Medido en el worker dev.gioser.net (2026-08-29): 1457 directorios vacíos en el store
147 // se comían cada sellado. `openssl-threads` compilaba entero —instalaba headers,
148 // libcrypto.a, libssl.a, los .pc— y sellaba NADA; después python3 se construía sin
149 // openssl y quedaba sin `_ssl`, y con eso morían glib y 4 recetas más de GNOME. El
150 // error salía a 3 recetas de distancia de la causa y no mencionaba openssl ni una vez.
151 //
152 // `remove_dir` (NO `remove_dir_all`) a propósito: sólo tiene éxito sobre un directorio
153 // vacío. Si algo dejó contenido ahí entremedio, esto falla RUIDOSO en vez de borrar un
154 // artefacto bueno — que es la regla: un ausente falla ruidosamente, un vacío no.
155 let vacio = std::fs::read_dir(&dst)
156 .map(|mut e| e.next().is_none())
157 .unwrap_or(false);
158 if !vacio {
159 return Ok(dst);
160 }
161 std::fs::remove_dir(&dst).map_err(|e| {
162 crate::Error::Store(format!(
163 "destino vacío {} no se pudo retirar antes de sellar: {e}",
164 dst.display()
165 ))
166 })?;
167 }
168 if let Some(parent) = dst.parent() {
169 std::fs::create_dir_all(parent)?;
170 }
171 match std::fs::rename(out_dir, &dst) {
172 Ok(()) => {}
173 // EXDEV (18) = Cross-device link: out_dir y el store viven en filesystems distintos.
174 Err(e) if e.raw_os_error() == Some(EXDEV) => {
175 let parent = dst.parent().unwrap_or_else(|| Path::new("."));
176 let fname = dst.file_name().expect("dst con nombre");
177 let staging = parent.join(format!(".seal-tmp-{}", fname.to_string_lossy()));
178 let _ = std::fs::remove_dir_all(&staging); // restos de un intento previo
179 copy_tree(out_dir, &staging).map_err(|e| {
180 crate::Error::Store(format!(
181 "copia cross-device {} → {}: {e}",
182 out_dir.display(),
183 staging.display()
184 ))
185 })?;
186 std::fs::rename(&staging, &dst).map_err(|e| {
187 let _ = std::fs::remove_dir_all(&staging);
188 crate::Error::Store(format!("rename store-interno → {}: {e}", dst.display()))
189 })?;
190 std::fs::remove_dir_all(out_dir).map_err(|e| {
191 crate::Error::Store(format!("limpiar origen {}: {e}", out_dir.display()))
192 })?;
193 }
194 Err(e) => {
195 return Err(crate::Error::Store(format!(
196 "rename {} → {}: {e}",
197 out_dir.display(),
198 dst.display()
199 )))
200 }
201 }
202 make_tree_read_only(&dst)?;
203 Ok(dst)
204 }
205}
206
207/// EXDEV en Linux. Evita una dependencia de `libc` por una sola constante.
208const EXDEV: i32 = 18;
209
210/// Copia recursiva de `src` a `dst` preservando symlinks (target literal, sin resolver) y
211/// los modos de archivos y directorios. Usada por el fallback cross-device de `seal`.
212fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
213 let meta = std::fs::symlink_metadata(src)?;
214 let ft = meta.file_type();
215 if ft.is_symlink() {
216 let target = std::fs::read_link(src)?;
217 std::os::unix::fs::symlink(target, dst)?;
218 } else if ft.is_dir() {
219 std::fs::create_dir_all(dst)?;
220 for entry in std::fs::read_dir(src)? {
221 let entry = entry?;
222 copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
223 }
224 // Modos del directorio DESPUÉS de poblarlo (un dir 0o555 no admitiría escrituras).
225 std::fs::set_permissions(dst, meta.permissions())?;
226 } else {
227 // Archivo regular: `std::fs::copy` lleva contenido + bits de permiso.
228 std::fs::copy(src, dst)?;
229 }
230 Ok(())
231}
232
233/// Quita los bits de escritura de cada archivo regular del árbol. Los directorios mantienen
234/// sus permisos originales (típicamente 0o755): eso es suficiente para garantizar
235/// "contenido inmutable" sin estorbar al GC o a operaciones administrativas como `rm -rf`.
236/// La inmutabilidad estricta del store entero (impedir crear archivos *nuevos* dentro de un
237/// dir sellado) se aplicará vía mount read-only en el track de distro propia.
238fn make_tree_read_only(root: &Path) -> crate::Result<()> {
239 use std::os::unix::fs::PermissionsExt;
240
241 fn walk(path: &Path) -> std::io::Result<()> {
242 let meta = std::fs::symlink_metadata(path)?;
243 let ft = meta.file_type();
244 if ft.is_symlink() {
245 return Ok(());
246 }
247 if ft.is_dir() {
248 for entry in std::fs::read_dir(path)? {
249 walk(&entry?.path())?;
250 }
251 } else if ft.is_file() {
252 let mut perms = meta.permissions();
253 perms.set_mode(perms.mode() & !0o222);
254 std::fs::set_permissions(path, perms)?;
255 }
256 Ok(())
257 }
258
259 walk(root).map_err(|e| crate::Error::Store(format!("chmod r/o en {}: {e}", root.display())))
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 fn hash() -> ArtifactHash {
267 ArtifactHash::from_hex("deadbeef")
268 }
269
270 #[test]
271 fn seal_moves_tree_and_marks_readonly() {
272 let store_dir = tempfile::tempdir().unwrap();
273 let store = Store::open(store_dir.path()).unwrap();
274
275 let out = tempfile::tempdir_in(store_dir.path()).unwrap(); // mismo FS → rename atómico
276 std::fs::write(out.path().join("hola.bin"), b"x").unwrap();
277 std::fs::create_dir(out.path().join("sub")).unwrap();
278 std::fs::write(out.path().join("sub/anidado.bin"), b"y").unwrap();
279
280 // tempdir_in nos devuelve un wrapper que borra el dir al hacer Drop. Nosotros vamos
281 // a moverlo con rename; suprimimos el Drop quedándonos con el PathBuf.
282 let out_path = out.keep();
283 let dst = store.seal(&out_path, &hash(), "demo").unwrap();
284 assert!(dst.ends_with("deadbeef-demo"));
285 assert!(dst.is_dir());
286 assert!(dst.join("hola.bin").is_file());
287
288 // Read-only: intentar escribir debe fallar.
289 let write_err = std::fs::write(dst.join("hola.bin"), b"nuevo").unwrap_err();
290 assert_eq!(write_err.kind(), std::io::ErrorKind::PermissionDenied);
291
292 // Y has() lo ve.
293 assert!(store.has(&hash(), "demo"));
294 }
295
296 #[test]
297 fn has_rechaza_directorio_vacio() {
298 // REGRESIÓN (2026-08-10): `has` era `path_of(..).is_dir()`, así que un directorio VACÍO
299 // contaba como artefacto sellado. Pasó de verdad: el respaldo tenía artefactos vacíos, rsync
300 // los bajó al store y `build()` hizo cache-hit sobre ellos ⇒ selló sin construir y devolvió
301 // Ok. Cuatro recetas dieron OK sin producir un solo fichero. Un ausente falla ruidosamente;
302 // un vacío llega hasta el final diciendo que todo fue bien.
303 let store_dir = tempfile::tempdir().unwrap();
304 let store = Store::open(store_dir.path()).unwrap();
305
306 assert!(!store.has(&hash(), "demo"), "sin directorio no hay artefacto");
307
308 std::fs::create_dir_all(store.path_of(&hash(), "demo")).unwrap();
309 assert!(
310 !store.has(&hash(), "demo"),
311 "un directorio VACÍO no es un artefacto sellado"
312 );
313
314 std::fs::write(store.path_of(&hash(), "demo").join("algo.bin"), b"x").unwrap();
315 assert!(store.has(&hash(), "demo"), "con contenido sí");
316 }
317
318 #[test]
319 fn copy_tree_preserva_symlinks_y_modos() {
320 use std::os::unix::fs::PermissionsExt;
321 let tmp = tempfile::tempdir().unwrap();
322 let src = tmp.path().join("src");
323 std::fs::create_dir_all(src.join("sub")).unwrap();
324 std::fs::write(src.join("sub/exe"), b"#!/bin/sh\n").unwrap();
325 std::fs::set_permissions(src.join("sub/exe"), std::fs::Permissions::from_mode(0o755))
326 .unwrap();
327 std::os::unix::fs::symlink("sub/exe", src.join("enlace")).unwrap();
328
329 let dst = tmp.path().join("dst");
330 copy_tree(&src, &dst).unwrap();
331
332 // El symlink se replica con su target literal (no se resuelve).
333 let link = std::fs::symlink_metadata(dst.join("enlace")).unwrap();
334 assert!(link.file_type().is_symlink());
335 assert_eq!(std::fs::read_link(dst.join("enlace")).unwrap().to_str(), Some("sub/exe"));
336 // El modo 0o755 del archivo sobrevive.
337 let exe = std::fs::metadata(dst.join("sub/exe")).unwrap();
338 assert_eq!(exe.permissions().mode() & 0o777, 0o755);
339 assert_eq!(std::fs::read(dst.join("sub/exe")).unwrap(), b"#!/bin/sh\n");
340 }
341
342 #[test]
343 fn find_by_hash_resolves_by_prefix() {
344 let store_dir = tempfile::tempdir().unwrap();
345 let store = Store::open(store_dir.path()).unwrap();
346 std::fs::create_dir(store.root().join("aaa111-foo")).unwrap();
347 std::fs::create_dir(store.root().join("bbb222-bar")).unwrap();
348 let p = store.find_by_hash("aaa1").unwrap();
349 assert!(p.ends_with("aaa111-foo"));
350 let p = store.find_by_hash("b3:bbb").unwrap();
351 assert!(p.ends_with("bbb222-bar"));
352 let err = store.find_by_hash("nope").unwrap_err().to_string();
353 assert!(err.contains("ningún artefacto"), "{err}");
354 }
355
356 #[test]
357 fn find_by_hash_ambiguous_lists_candidates() {
358 let store_dir = tempfile::tempdir().unwrap();
359 let store = Store::open(store_dir.path()).unwrap();
360 std::fs::create_dir(store.root().join("aaa111-foo")).unwrap();
361 std::fs::create_dir(store.root().join("aaa222-bar")).unwrap();
362 let err = store.find_by_hash("aaa").unwrap_err().to_string();
363 assert!(err.contains("ambiguo"), "{err}");
364 assert!(err.contains("aaa111-foo") && err.contains("aaa222-bar"), "{err}");
365 }
366
367 #[test]
368 fn recipe_for_dir_returns_none_without_sidecar() {
369 let store_dir = tempfile::tempdir().unwrap();
370 let store = Store::open(store_dir.path()).unwrap();
371 let art = store_dir.path().join("aaa-foo");
372 std::fs::create_dir_all(&art).unwrap();
373 let r = store.recipe_for_dir(&art).unwrap();
374 assert!(r.is_none());
375 }
376
377 #[test]
378 fn recipe_for_dir_reads_sidecar() {
379 let store_dir = tempfile::tempdir().unwrap();
380 let store = Store::open(store_dir.path()).unwrap();
381 let art = store_dir.path().join("aaa-foo");
382 std::fs::create_dir_all(art.join(".hammer")).unwrap();
383 std::fs::write(
384 art.join(RECIPE_SIDECAR_REL),
385 r#"
386name = "grep"
387version = "3.12"
388[source]
389repo = "git://example/grep.git"
390commit = "deadbeef"
391[build]
392compiler = "zig-cc"
393target = "x86_64-linux-musl"
394link = "static"
395flags = []
396"#,
397 )
398 .unwrap();
399 let r = store.recipe_for_dir(&art).unwrap().expect("Some");
400 assert_eq!(r.name, "grep");
401 assert_eq!(r.version, "3.12");
402 }
403
404 #[test]
405 fn recipe_for_hash_resolves_by_prefix_then_reads() {
406 let store_dir = tempfile::tempdir().unwrap();
407 let store = Store::open(store_dir.path()).unwrap();
408 let art = store.root().join("cafe1234-demo");
409 std::fs::create_dir_all(art.join(".hammer")).unwrap();
410 std::fs::write(
411 art.join(RECIPE_SIDECAR_REL),
412 r#"
413name = "demo"
414version = "0.1"
415[source]
416tarball = "https://x/y.tar.gz"
417sha256 = "abc123"
418[build]
419"#,
420 )
421 .unwrap();
422 let r = store.recipe_for_hash("cafe").unwrap().expect("Some");
423 assert_eq!(r.name, "demo");
424 // Hash inexistente: None (no error).
425 assert!(store.recipe_for_hash("ffffff").unwrap().is_none());
426 }
427
428 #[test]
429 fn seal_is_idempotent_when_dst_exists() {
430 let store_dir = tempfile::tempdir().unwrap();
431 let store = Store::open(store_dir.path()).unwrap();
432
433 // Pre-sellar una vez.
434 let first = tempfile::tempdir_in(store_dir.path()).unwrap();
435 std::fs::write(first.path().join("a"), b"1").unwrap();
436 let first_path = first.keep();
437 let p1 = store.seal(&first_path, &hash(), "demo").unwrap();
438
439 // Segundo intento: el out_dir no debe consumirse (devuelve la ruta cacheada).
440 let second = tempfile::tempdir_in(store_dir.path()).unwrap();
441 std::fs::write(second.path().join("b"), b"2").unwrap();
442 let p2 = store.seal(second.path(), &hash(), "demo").unwrap();
443
444 assert_eq!(p1, p2);
445 assert!(second.path().is_dir()); // intacto
446 }
447
448 /// REGRESIÓN (2026-08-29): un `dst` VACÍO se tragaba el sellado en silencio.
449 /// `seal` usaba `is_dir()`, verdadero para un directorio vacío ⇒ devolvía Ok sin escribir
450 /// nada y el build decía `sealed`. En el worker eso dejó 1457 artefactos hueco y rompió
451 /// GNOME por una cadena de 3 recetas (openssl-threads vacío → python3 sin _ssl → glib).
452 #[test]
453 fn seal_no_se_traga_un_destino_vacio() {
454 let store_dir = tempfile::tempdir().unwrap();
455 let store = Store::open(store_dir.path()).unwrap();
456
457 // Un directorio vacío plantado en el store, como el que deja un rsync a medias.
458 let dst = store.path_of(&hash(), "demo");
459 std::fs::create_dir_all(&dst).unwrap();
460 assert!(dst.is_dir());
461 assert!(!store.has(&hash(), "demo")); // `has` ya lo rechazaba…
462
463 let out = tempfile::tempdir_in(store_dir.path()).unwrap();
464 std::fs::write(out.path().join("a"), b"contenido").unwrap();
465 let sellado = store.seal(&out.keep(), &hash(), "demo").unwrap();
466
467 // …y ahora `seal` tampoco lo acepta: el contenido llega al store de verdad.
468 assert_eq!(sellado, dst);
469 assert_eq!(std::fs::read(dst.join("a")).unwrap(), b"contenido");
470 assert!(store.has(&hash(), "demo"));
471 }
472}