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 1463Shell 159Markdown 91Rust 853029 Definiciones

⎇ 98f0b2d228ffebb06941dfc1f436167ab40d8f21 crates/hammer-core/src/store.rs Rust · 304 líneas · 11.2 KB Historia Quién tocó qué crudo

Fase 4 — provenance en `hammer export` (sidecar de receta en el store) de036d366 · 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    pub fn has(&self, h: &ArtifactHash, name: &str) -> bool {
39        self.path_of(h, name).is_dir()
40    }
41
42    /// Resuelve el directorio de un artefacto a partir de un prefijo del hex de su hash
43    /// (con o sin `b3:`), sin necesidad de conocer el `name`. Devuelve error si no hay
44    /// match o si el prefijo es ambiguo (lista los candidatos).
45    pub fn find_by_hash(&self, hex_or_prefixed: &str) -> crate::Result<PathBuf> {
46        let prefix = hex_or_prefixed
47            .strip_prefix("b3:")
48            .unwrap_or(hex_or_prefixed);
49        if prefix.is_empty() {
50            return Err(crate::Error::Store("hash vacío".into()));
51        }
52        let mut matches = Vec::new();
53        let read = std::fs::read_dir(&self.root)?;
54        for entry in read {
55            let entry = entry?;
56            let name = entry.file_name();
57            let Some(name) = name.to_str() else { continue };
58            if name.starts_with(prefix) && entry.file_type()?.is_dir() {
59                matches.push(entry.path());
60            }
61        }
62        match matches.len() {
63            0 => Err(crate::Error::Store(format!(
64                "ningún artefacto en {} con prefix '{prefix}'",
65                self.root.display()
66            ))),
67            1 => Ok(matches.pop().unwrap()),
68            _ => Err(crate::Error::Store(format!(
69                "prefix '{prefix}' ambiguo, {} candidatos: {}",
70                matches.len(),
71                matches
72                    .iter()
73                    .map(|p| p.display().to_string())
74                    .collect::<Vec<_>>()
75                    .join(", ")
76            ))),
77        }
78    }
79
80    /// Lee la receta sidecar (`.hammer/recipe.toml`) dentro de un artefacto sellado, si
81    /// existe. Devuelve `None` para artefactos viejos sin sidecar (builds anteriores a la
82    /// introducción del provenance) o cuando el sidecar fue eliminado a mano. Es
83    /// best-effort: nunca falla por ausencia, sólo por sidecar corrupto.
84    pub fn recipe_for_dir(&self, artifact_dir: &Path) -> crate::Result<Option<Recipe>> {
85        let p = artifact_dir.join(RECIPE_SIDECAR_REL);
86        if !p.is_file() {
87            return Ok(None);
88        }
89        let text = std::fs::read_to_string(&p)?;
90        Recipe::from_toml(&text).map(Some)
91    }
92
93    /// Como `recipe_for_dir` pero resuelve el artefacto por hash (con o sin `b3:`).
94    pub fn recipe_for_hash(&self, hex_or_prefixed: &str) -> crate::Result<Option<Recipe>> {
95        let dir = match self.find_by_hash(hex_or_prefixed) {
96            Ok(d) => d,
97            Err(_) => return Ok(None),
98        };
99        self.recipe_for_dir(&dir)
100    }
101
102    /// Sella el árbol de salida de un build en el store bajo su hash.
103    ///
104    /// - Si el destino ya existe (caché), devuelve la ruta sin tocar `out_dir`.
105    /// - Si no, mueve `out_dir` por rename atómico al destino y marca el árbol read-only
106    ///   (todos los archivos pierden `w`; directorios mantienen `x` para poder atravesarlos).
107    ///
108    /// Requisito: `out_dir` y el store deben estar en el mismo filesystem para que el rename
109    /// sea atómico. El lab garantiza esto colocando el tmpfs/DESTDIR del sandbox bajo el
110    /// mismo punto de montaje que el store, o haciendo una copia previa si no.
111    pub fn seal(
112        &self,
113        out_dir: &Path,
114        h: &ArtifactHash,
115        name: &str,
116    ) -> crate::Result<PathBuf> {
117        let dst = self.path_of(h, name);
118        if dst.is_dir() {
119            return Ok(dst);
120        }
121        if let Some(parent) = dst.parent() {
122            std::fs::create_dir_all(parent)?;
123        }
124        std::fs::rename(out_dir, &dst).map_err(|e| {
125            crate::Error::Store(format!(
126                "rename {} → {}: {e}",
127                out_dir.display(),
128                dst.display()
129            ))
130        })?;
131        make_tree_read_only(&dst)?;
132        Ok(dst)
133    }
134}
135
136/// Quita los bits de escritura de cada archivo regular del árbol. Los directorios mantienen
137/// sus permisos originales (típicamente 0o755): eso es suficiente para garantizar
138/// "contenido inmutable" sin estorbar al GC o a operaciones administrativas como `rm -rf`.
139/// La inmutabilidad estricta del store entero (impedir crear archivos *nuevos* dentro de un
140/// dir sellado) se aplicará vía mount read-only en el track de distro propia.
141fn make_tree_read_only(root: &Path) -> crate::Result<()> {
142    use std::os::unix::fs::PermissionsExt;
143
144    fn walk(path: &Path) -> std::io::Result<()> {
145        let meta = std::fs::symlink_metadata(path)?;
146        let ft = meta.file_type();
147        if ft.is_symlink() {
148            return Ok(());
149        }
150        if ft.is_dir() {
151            for entry in std::fs::read_dir(path)? {
152                walk(&entry?.path())?;
153            }
154        } else if ft.is_file() {
155            let mut perms = meta.permissions();
156            perms.set_mode(perms.mode() & !0o222);
157            std::fs::set_permissions(path, perms)?;
158        }
159        Ok(())
160    }
161
162    walk(root).map_err(|e| crate::Error::Store(format!("chmod r/o en {}: {e}", root.display())))
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn hash() -> ArtifactHash {
170        ArtifactHash::from_hex("deadbeef")
171    }
172
173    #[test]
174    fn seal_moves_tree_and_marks_readonly() {
175        let store_dir = tempfile::tempdir().unwrap();
176        let store = Store::open(store_dir.path()).unwrap();
177
178        let out = tempfile::tempdir_in(store_dir.path()).unwrap(); // mismo FS → rename atómico
179        std::fs::write(out.path().join("hola.bin"), b"x").unwrap();
180        std::fs::create_dir(out.path().join("sub")).unwrap();
181        std::fs::write(out.path().join("sub/anidado.bin"), b"y").unwrap();
182
183        // tempdir_in nos devuelve un wrapper que borra el dir al hacer Drop. Nosotros vamos
184        // a moverlo con rename; suprimimos el Drop quedándonos con el PathBuf.
185        let out_path = out.keep();
186        let dst = store.seal(&out_path, &hash(), "demo").unwrap();
187        assert!(dst.ends_with("deadbeef-demo"));
188        assert!(dst.is_dir());
189        assert!(dst.join("hola.bin").is_file());
190
191        // Read-only: intentar escribir debe fallar.
192        let write_err = std::fs::write(dst.join("hola.bin"), b"nuevo").unwrap_err();
193        assert_eq!(write_err.kind(), std::io::ErrorKind::PermissionDenied);
194
195        // Y has() lo ve.
196        assert!(store.has(&hash(), "demo"));
197    }
198
199    #[test]
200    fn find_by_hash_resolves_by_prefix() {
201        let store_dir = tempfile::tempdir().unwrap();
202        let store = Store::open(store_dir.path()).unwrap();
203        std::fs::create_dir(store.root().join("aaa111-foo")).unwrap();
204        std::fs::create_dir(store.root().join("bbb222-bar")).unwrap();
205        let p = store.find_by_hash("aaa1").unwrap();
206        assert!(p.ends_with("aaa111-foo"));
207        let p = store.find_by_hash("b3:bbb").unwrap();
208        assert!(p.ends_with("bbb222-bar"));
209        let err = store.find_by_hash("nope").unwrap_err().to_string();
210        assert!(err.contains("ningún artefacto"), "{err}");
211    }
212
213    #[test]
214    fn find_by_hash_ambiguous_lists_candidates() {
215        let store_dir = tempfile::tempdir().unwrap();
216        let store = Store::open(store_dir.path()).unwrap();
217        std::fs::create_dir(store.root().join("aaa111-foo")).unwrap();
218        std::fs::create_dir(store.root().join("aaa222-bar")).unwrap();
219        let err = store.find_by_hash("aaa").unwrap_err().to_string();
220        assert!(err.contains("ambiguo"), "{err}");
221        assert!(err.contains("aaa111-foo") && err.contains("aaa222-bar"), "{err}");
222    }
223
224    #[test]
225    fn recipe_for_dir_returns_none_without_sidecar() {
226        let store_dir = tempfile::tempdir().unwrap();
227        let store = Store::open(store_dir.path()).unwrap();
228        let art = store_dir.path().join("aaa-foo");
229        std::fs::create_dir_all(&art).unwrap();
230        let r = store.recipe_for_dir(&art).unwrap();
231        assert!(r.is_none());
232    }
233
234    #[test]
235    fn recipe_for_dir_reads_sidecar() {
236        let store_dir = tempfile::tempdir().unwrap();
237        let store = Store::open(store_dir.path()).unwrap();
238        let art = store_dir.path().join("aaa-foo");
239        std::fs::create_dir_all(art.join(".hammer")).unwrap();
240        std::fs::write(
241            art.join(RECIPE_SIDECAR_REL),
242            r#"
243name = "grep"
244version = "3.12"
245[source]
246repo   = "git://example/grep.git"
247commit = "deadbeef"
248[build]
249compiler = "zig-cc"
250target   = "x86_64-linux-musl"
251link     = "static"
252flags    = []
253"#,
254        )
255        .unwrap();
256        let r = store.recipe_for_dir(&art).unwrap().expect("Some");
257        assert_eq!(r.name, "grep");
258        assert_eq!(r.version, "3.12");
259    }
260
261    #[test]
262    fn recipe_for_hash_resolves_by_prefix_then_reads() {
263        let store_dir = tempfile::tempdir().unwrap();
264        let store = Store::open(store_dir.path()).unwrap();
265        let art = store.root().join("cafe1234-demo");
266        std::fs::create_dir_all(art.join(".hammer")).unwrap();
267        std::fs::write(
268            art.join(RECIPE_SIDECAR_REL),
269            r#"
270name = "demo"
271version = "0.1"
272[source]
273tarball = "https://x/y.tar.gz"
274sha256  = "abc123"
275[build]
276"#,
277        )
278        .unwrap();
279        let r = store.recipe_for_hash("cafe").unwrap().expect("Some");
280        assert_eq!(r.name, "demo");
281        // Hash inexistente: None (no error).
282        assert!(store.recipe_for_hash("ffffff").unwrap().is_none());
283    }
284
285    #[test]
286    fn seal_is_idempotent_when_dst_exists() {
287        let store_dir = tempfile::tempdir().unwrap();
288        let store = Store::open(store_dir.path()).unwrap();
289
290        // Pre-sellar una vez.
291        let first = tempfile::tempdir_in(store_dir.path()).unwrap();
292        std::fs::write(first.path().join("a"), b"1").unwrap();
293        let first_path = first.keep();
294        let p1 = store.seal(&first_path, &hash(), "demo").unwrap();
295
296        // Segundo intento: el out_dir no debe consumirse (devuelve la ruta cacheada).
297        let second = tempfile::tempdir_in(store_dir.path()).unwrap();
298        std::fs::write(second.path().join("b"), b"2").unwrap();
299        let p2 = store.seal(second.path(), &hash(), "demo").unwrap();
300
301        assert_eq!(p1, p2);
302        assert!(second.path().is_dir()); // intacto
303    }
304}