gioser

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 188Rust 55Shell 30Markdown 281211 Sutichaykuna

⎇ 4dfb0ff12796e164f20289581e13c0b147edac28 crates/hammer-core/src/store.rs Rust · 380 siq'ikuna · 14.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

Etapa B3: /store y /var/lib/hammer en particiones dedicadas (GPT) 4dfb0ff12 · 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` al destino y marca el árbol read-only (todos los archivos
106    ///   pierden `w`; directorios mantienen `x` para poder atravesarlos).
107    ///
108    /// Camino rápido: `rename` atómico cuando `out_dir` y el store comparten filesystem.
109    /// Camino EXDEV (Etapa B3, `/store` es una partición dedicada ≠ la de `/work`): `rename(2)`
110    /// no cruza filesystems ⇒ caemos a copia recursiva a un staging DENTRO del store + rename
111    /// store-interno (mismo FS, atómico) + borrado del original. La publicación sigue siendo
112    /// atómica (nadie ve el `dst` a medio escribir).
113    pub fn seal(
114        &self,
115        out_dir: &Path,
116        h: &ArtifactHash,
117        name: &str,
118    ) -> crate::Result<PathBuf> {
119        let dst = self.path_of(h, name);
120        if dst.is_dir() {
121            return Ok(dst);
122        }
123        if let Some(parent) = dst.parent() {
124            std::fs::create_dir_all(parent)?;
125        }
126        match std::fs::rename(out_dir, &dst) {
127            Ok(()) => {}
128            // EXDEV (18) = Cross-device link: out_dir y el store viven en filesystems distintos.
129            Err(e) if e.raw_os_error() == Some(EXDEV) => {
130                let parent = dst.parent().unwrap_or_else(|| Path::new("."));
131                let fname = dst.file_name().expect("dst con nombre");
132                let staging = parent.join(format!(".seal-tmp-{}", fname.to_string_lossy()));
133                let _ = std::fs::remove_dir_all(&staging); // restos de un intento previo
134                copy_tree(out_dir, &staging).map_err(|e| {
135                    crate::Error::Store(format!(
136                        "copia cross-device {} → {}: {e}",
137                        out_dir.display(),
138                        staging.display()
139                    ))
140                })?;
141                std::fs::rename(&staging, &dst).map_err(|e| {
142                    let _ = std::fs::remove_dir_all(&staging);
143                    crate::Error::Store(format!("rename store-interno → {}: {e}", dst.display()))
144                })?;
145                std::fs::remove_dir_all(out_dir).map_err(|e| {
146                    crate::Error::Store(format!("limpiar origen {}: {e}", out_dir.display()))
147                })?;
148            }
149            Err(e) => {
150                return Err(crate::Error::Store(format!(
151                    "rename {} → {}: {e}",
152                    out_dir.display(),
153                    dst.display()
154                )))
155            }
156        }
157        make_tree_read_only(&dst)?;
158        Ok(dst)
159    }
160}
161
162/// EXDEV en Linux. Evita una dependencia de `libc` por una sola constante.
163const EXDEV: i32 = 18;
164
165/// Copia recursiva de `src` a `dst` preservando symlinks (target literal, sin resolver) y
166/// los modos de archivos y directorios. Usada por el fallback cross-device de `seal`.
167fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
168    let meta = std::fs::symlink_metadata(src)?;
169    let ft = meta.file_type();
170    if ft.is_symlink() {
171        let target = std::fs::read_link(src)?;
172        std::os::unix::fs::symlink(target, dst)?;
173    } else if ft.is_dir() {
174        std::fs::create_dir_all(dst)?;
175        for entry in std::fs::read_dir(src)? {
176            let entry = entry?;
177            copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
178        }
179        // Modos del directorio DESPUÉS de poblarlo (un dir 0o555 no admitiría escrituras).
180        std::fs::set_permissions(dst, meta.permissions())?;
181    } else {
182        // Archivo regular: `std::fs::copy` lleva contenido + bits de permiso.
183        std::fs::copy(src, dst)?;
184    }
185    Ok(())
186}
187
188/// Quita los bits de escritura de cada archivo regular del árbol. Los directorios mantienen
189/// sus permisos originales (típicamente 0o755): eso es suficiente para garantizar
190/// "contenido inmutable" sin estorbar al GC o a operaciones administrativas como `rm -rf`.
191/// La inmutabilidad estricta del store entero (impedir crear archivos *nuevos* dentro de un
192/// dir sellado) se aplicará vía mount read-only en el track de distro propia.
193fn make_tree_read_only(root: &Path) -> crate::Result<()> {
194    use std::os::unix::fs::PermissionsExt;
195
196    fn walk(path: &Path) -> std::io::Result<()> {
197        let meta = std::fs::symlink_metadata(path)?;
198        let ft = meta.file_type();
199        if ft.is_symlink() {
200            return Ok(());
201        }
202        if ft.is_dir() {
203            for entry in std::fs::read_dir(path)? {
204                walk(&entry?.path())?;
205            }
206        } else if ft.is_file() {
207            let mut perms = meta.permissions();
208            perms.set_mode(perms.mode() & !0o222);
209            std::fs::set_permissions(path, perms)?;
210        }
211        Ok(())
212    }
213
214    walk(root).map_err(|e| crate::Error::Store(format!("chmod r/o en {}: {e}", root.display())))
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn hash() -> ArtifactHash {
222        ArtifactHash::from_hex("deadbeef")
223    }
224
225    #[test]
226    fn seal_moves_tree_and_marks_readonly() {
227        let store_dir = tempfile::tempdir().unwrap();
228        let store = Store::open(store_dir.path()).unwrap();
229
230        let out = tempfile::tempdir_in(store_dir.path()).unwrap(); // mismo FS → rename atómico
231        std::fs::write(out.path().join("hola.bin"), b"x").unwrap();
232        std::fs::create_dir(out.path().join("sub")).unwrap();
233        std::fs::write(out.path().join("sub/anidado.bin"), b"y").unwrap();
234
235        // tempdir_in nos devuelve un wrapper que borra el dir al hacer Drop. Nosotros vamos
236        // a moverlo con rename; suprimimos el Drop quedándonos con el PathBuf.
237        let out_path = out.keep();
238        let dst = store.seal(&out_path, &hash(), "demo").unwrap();
239        assert!(dst.ends_with("deadbeef-demo"));
240        assert!(dst.is_dir());
241        assert!(dst.join("hola.bin").is_file());
242
243        // Read-only: intentar escribir debe fallar.
244        let write_err = std::fs::write(dst.join("hola.bin"), b"nuevo").unwrap_err();
245        assert_eq!(write_err.kind(), std::io::ErrorKind::PermissionDenied);
246
247        // Y has() lo ve.
248        assert!(store.has(&hash(), "demo"));
249    }
250
251    #[test]
252    fn copy_tree_preserva_symlinks_y_modos() {
253        use std::os::unix::fs::PermissionsExt;
254        let tmp = tempfile::tempdir().unwrap();
255        let src = tmp.path().join("src");
256        std::fs::create_dir_all(src.join("sub")).unwrap();
257        std::fs::write(src.join("sub/exe"), b"#!/bin/sh\n").unwrap();
258        std::fs::set_permissions(src.join("sub/exe"), std::fs::Permissions::from_mode(0o755))
259            .unwrap();
260        std::os::unix::fs::symlink("sub/exe", src.join("enlace")).unwrap();
261
262        let dst = tmp.path().join("dst");
263        copy_tree(&src, &dst).unwrap();
264
265        // El symlink se replica con su target literal (no se resuelve).
266        let link = std::fs::symlink_metadata(dst.join("enlace")).unwrap();
267        assert!(link.file_type().is_symlink());
268        assert_eq!(std::fs::read_link(dst.join("enlace")).unwrap().to_str(), Some("sub/exe"));
269        // El modo 0o755 del archivo sobrevive.
270        let exe = std::fs::metadata(dst.join("sub/exe")).unwrap();
271        assert_eq!(exe.permissions().mode() & 0o777, 0o755);
272        assert_eq!(std::fs::read(dst.join("sub/exe")).unwrap(), b"#!/bin/sh\n");
273    }
274
275    #[test]
276    fn find_by_hash_resolves_by_prefix() {
277        let store_dir = tempfile::tempdir().unwrap();
278        let store = Store::open(store_dir.path()).unwrap();
279        std::fs::create_dir(store.root().join("aaa111-foo")).unwrap();
280        std::fs::create_dir(store.root().join("bbb222-bar")).unwrap();
281        let p = store.find_by_hash("aaa1").unwrap();
282        assert!(p.ends_with("aaa111-foo"));
283        let p = store.find_by_hash("b3:bbb").unwrap();
284        assert!(p.ends_with("bbb222-bar"));
285        let err = store.find_by_hash("nope").unwrap_err().to_string();
286        assert!(err.contains("ningún artefacto"), "{err}");
287    }
288
289    #[test]
290    fn find_by_hash_ambiguous_lists_candidates() {
291        let store_dir = tempfile::tempdir().unwrap();
292        let store = Store::open(store_dir.path()).unwrap();
293        std::fs::create_dir(store.root().join("aaa111-foo")).unwrap();
294        std::fs::create_dir(store.root().join("aaa222-bar")).unwrap();
295        let err = store.find_by_hash("aaa").unwrap_err().to_string();
296        assert!(err.contains("ambiguo"), "{err}");
297        assert!(err.contains("aaa111-foo") && err.contains("aaa222-bar"), "{err}");
298    }
299
300    #[test]
301    fn recipe_for_dir_returns_none_without_sidecar() {
302        let store_dir = tempfile::tempdir().unwrap();
303        let store = Store::open(store_dir.path()).unwrap();
304        let art = store_dir.path().join("aaa-foo");
305        std::fs::create_dir_all(&art).unwrap();
306        let r = store.recipe_for_dir(&art).unwrap();
307        assert!(r.is_none());
308    }
309
310    #[test]
311    fn recipe_for_dir_reads_sidecar() {
312        let store_dir = tempfile::tempdir().unwrap();
313        let store = Store::open(store_dir.path()).unwrap();
314        let art = store_dir.path().join("aaa-foo");
315        std::fs::create_dir_all(art.join(".hammer")).unwrap();
316        std::fs::write(
317            art.join(RECIPE_SIDECAR_REL),
318            r#"
319name = "grep"
320version = "3.12"
321[source]
322repo   = "git://example/grep.git"
323commit = "deadbeef"
324[build]
325compiler = "zig-cc"
326target   = "x86_64-linux-musl"
327link     = "static"
328flags    = []
329"#,
330        )
331        .unwrap();
332        let r = store.recipe_for_dir(&art).unwrap().expect("Some");
333        assert_eq!(r.name, "grep");
334        assert_eq!(r.version, "3.12");
335    }
336
337    #[test]
338    fn recipe_for_hash_resolves_by_prefix_then_reads() {
339        let store_dir = tempfile::tempdir().unwrap();
340        let store = Store::open(store_dir.path()).unwrap();
341        let art = store.root().join("cafe1234-demo");
342        std::fs::create_dir_all(art.join(".hammer")).unwrap();
343        std::fs::write(
344            art.join(RECIPE_SIDECAR_REL),
345            r#"
346name = "demo"
347version = "0.1"
348[source]
349tarball = "https://x/y.tar.gz"
350sha256  = "abc123"
351[build]
352"#,
353        )
354        .unwrap();
355        let r = store.recipe_for_hash("cafe").unwrap().expect("Some");
356        assert_eq!(r.name, "demo");
357        // Hash inexistente: None (no error).
358        assert!(store.recipe_for_hash("ffffff").unwrap().is_none());
359    }
360
361    #[test]
362    fn seal_is_idempotent_when_dst_exists() {
363        let store_dir = tempfile::tempdir().unwrap();
364        let store = Store::open(store_dir.path()).unwrap();
365
366        // Pre-sellar una vez.
367        let first = tempfile::tempdir_in(store_dir.path()).unwrap();
368        std::fs::write(first.path().join("a"), b"1").unwrap();
369        let first_path = first.keep();
370        let p1 = store.seal(&first_path, &hash(), "demo").unwrap();
371
372        // Segundo intento: el out_dir no debe consumirse (devuelve la ruta cacheada).
373        let second = tempfile::tempdir_in(store_dir.path()).unwrap();
374        std::fs::write(second.path().join("b"), b"2").unwrap();
375        let p2 = store.seal(second.path(), &hash(), "demo").unwrap();
376
377        assert_eq!(p1, p2);
378        assert!(second.path().is_dir()); // intacto
379    }
380}