tawasuyu

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…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ main crates/takana-core/src/store.rs Rust · 472 siq'ikuna · 20.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja b818f5249 · Sergio ·

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}