//! El content-addressed store. Ver `docs/03-hydration.md` §1. //! //! Inmutable, append-only, direccionado por `ArtifactHash`. `seal` mueve el árbol de salida //! del sandbox a la ruta del store y lo marca read-only recursivo. use std::path::{Path, PathBuf}; use crate::hash::ArtifactHash; use crate::recipe::Recipe; /// Ruta relativa, dentro de cada artefacto sellado, donde el lab guarda la receta original /// como sidecar de provenance. Estable: el receptor de un `.swm` exportado puede asumir /// este path para reconstruir la receta de un artefacto cualquiera. pub const RECIPE_SIDECAR_REL: &str = ".hammer/recipe.toml"; pub struct Store { root: PathBuf, } impl Store { /// Abre (o prepara) un store en `root` (p. ej. `/store`). pub fn open(root: impl Into) -> crate::Result { let root = root.into(); std::fs::create_dir_all(&root)?; Ok(Store { root }) } pub fn root(&self) -> &Path { &self.root } /// Ruta del artefacto en el store para un hash + nombre legible. pub fn path_of(&self, h: &ArtifactHash, name: &str) -> PathBuf { self.root.join(h.store_dir_name(name)) } /// ¿Ya existe el artefacto? (caché del lab; ver `docs/02-build-lab.md` §5). pub fn has(&self, h: &ArtifactHash, name: &str) -> bool { self.path_of(h, name).is_dir() } /// Resuelve el directorio de un artefacto a partir de un prefijo del hex de su hash /// (con o sin `b3:`), sin necesidad de conocer el `name`. Devuelve error si no hay /// match o si el prefijo es ambiguo (lista los candidatos). pub fn find_by_hash(&self, hex_or_prefixed: &str) -> crate::Result { let prefix = hex_or_prefixed .strip_prefix("b3:") .unwrap_or(hex_or_prefixed); if prefix.is_empty() { return Err(crate::Error::Store("hash vacío".into())); } let mut matches = Vec::new(); let read = std::fs::read_dir(&self.root)?; for entry in read { let entry = entry?; let name = entry.file_name(); let Some(name) = name.to_str() else { continue }; if name.starts_with(prefix) && entry.file_type()?.is_dir() { matches.push(entry.path()); } } match matches.len() { 0 => Err(crate::Error::Store(format!( "ningún artefacto en {} con prefix '{prefix}'", self.root.display() ))), 1 => Ok(matches.pop().unwrap()), _ => Err(crate::Error::Store(format!( "prefix '{prefix}' ambiguo, {} candidatos: {}", matches.len(), matches .iter() .map(|p| p.display().to_string()) .collect::>() .join(", ") ))), } } /// Lee la receta sidecar (`.hammer/recipe.toml`) dentro de un artefacto sellado, si /// existe. Devuelve `None` para artefactos viejos sin sidecar (builds anteriores a la /// introducción del provenance) o cuando el sidecar fue eliminado a mano. Es /// best-effort: nunca falla por ausencia, sólo por sidecar corrupto. pub fn recipe_for_dir(&self, artifact_dir: &Path) -> crate::Result> { let p = artifact_dir.join(RECIPE_SIDECAR_REL); if !p.is_file() { return Ok(None); } let text = std::fs::read_to_string(&p)?; Recipe::from_toml(&text).map(Some) } /// Como `recipe_for_dir` pero resuelve el artefacto por hash (con o sin `b3:`). pub fn recipe_for_hash(&self, hex_or_prefixed: &str) -> crate::Result> { let dir = match self.find_by_hash(hex_or_prefixed) { Ok(d) => d, Err(_) => return Ok(None), }; self.recipe_for_dir(&dir) } /// Sella el árbol de salida de un build en el store bajo su hash. /// /// - Si el destino ya existe (caché), devuelve la ruta sin tocar `out_dir`. /// - Si no, mueve `out_dir` por rename atómico al destino y marca el árbol read-only /// (todos los archivos pierden `w`; directorios mantienen `x` para poder atravesarlos). /// /// Requisito: `out_dir` y el store deben estar en el mismo filesystem para que el rename /// sea atómico. El lab garantiza esto colocando el tmpfs/DESTDIR del sandbox bajo el /// mismo punto de montaje que el store, o haciendo una copia previa si no. pub fn seal( &self, out_dir: &Path, h: &ArtifactHash, name: &str, ) -> crate::Result { let dst = self.path_of(h, name); if dst.is_dir() { return Ok(dst); } if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent)?; } std::fs::rename(out_dir, &dst).map_err(|e| { crate::Error::Store(format!( "rename {} → {}: {e}", out_dir.display(), dst.display() )) })?; make_tree_read_only(&dst)?; Ok(dst) } } /// Quita los bits de escritura de cada archivo regular del árbol. Los directorios mantienen /// sus permisos originales (típicamente 0o755): eso es suficiente para garantizar /// "contenido inmutable" sin estorbar al GC o a operaciones administrativas como `rm -rf`. /// La inmutabilidad estricta del store entero (impedir crear archivos *nuevos* dentro de un /// dir sellado) se aplicará vía mount read-only en el track de distro propia. fn make_tree_read_only(root: &Path) -> crate::Result<()> { use std::os::unix::fs::PermissionsExt; fn walk(path: &Path) -> std::io::Result<()> { let meta = std::fs::symlink_metadata(path)?; let ft = meta.file_type(); if ft.is_symlink() { return Ok(()); } if ft.is_dir() { for entry in std::fs::read_dir(path)? { walk(&entry?.path())?; } } else if ft.is_file() { let mut perms = meta.permissions(); perms.set_mode(perms.mode() & !0o222); std::fs::set_permissions(path, perms)?; } Ok(()) } walk(root).map_err(|e| crate::Error::Store(format!("chmod r/o en {}: {e}", root.display()))) } #[cfg(test)] mod tests { use super::*; fn hash() -> ArtifactHash { ArtifactHash::from_hex("deadbeef") } #[test] fn seal_moves_tree_and_marks_readonly() { let store_dir = tempfile::tempdir().unwrap(); let store = Store::open(store_dir.path()).unwrap(); let out = tempfile::tempdir_in(store_dir.path()).unwrap(); // mismo FS → rename atómico std::fs::write(out.path().join("hola.bin"), b"x").unwrap(); std::fs::create_dir(out.path().join("sub")).unwrap(); std::fs::write(out.path().join("sub/anidado.bin"), b"y").unwrap(); // tempdir_in nos devuelve un wrapper que borra el dir al hacer Drop. Nosotros vamos // a moverlo con rename; suprimimos el Drop quedándonos con el PathBuf. let out_path = out.keep(); let dst = store.seal(&out_path, &hash(), "demo").unwrap(); assert!(dst.ends_with("deadbeef-demo")); assert!(dst.is_dir()); assert!(dst.join("hola.bin").is_file()); // Read-only: intentar escribir debe fallar. let write_err = std::fs::write(dst.join("hola.bin"), b"nuevo").unwrap_err(); assert_eq!(write_err.kind(), std::io::ErrorKind::PermissionDenied); // Y has() lo ve. assert!(store.has(&hash(), "demo")); } #[test] fn find_by_hash_resolves_by_prefix() { let store_dir = tempfile::tempdir().unwrap(); let store = Store::open(store_dir.path()).unwrap(); std::fs::create_dir(store.root().join("aaa111-foo")).unwrap(); std::fs::create_dir(store.root().join("bbb222-bar")).unwrap(); let p = store.find_by_hash("aaa1").unwrap(); assert!(p.ends_with("aaa111-foo")); let p = store.find_by_hash("b3:bbb").unwrap(); assert!(p.ends_with("bbb222-bar")); let err = store.find_by_hash("nope").unwrap_err().to_string(); assert!(err.contains("ningún artefacto"), "{err}"); } #[test] fn find_by_hash_ambiguous_lists_candidates() { let store_dir = tempfile::tempdir().unwrap(); let store = Store::open(store_dir.path()).unwrap(); std::fs::create_dir(store.root().join("aaa111-foo")).unwrap(); std::fs::create_dir(store.root().join("aaa222-bar")).unwrap(); let err = store.find_by_hash("aaa").unwrap_err().to_string(); assert!(err.contains("ambiguo"), "{err}"); assert!(err.contains("aaa111-foo") && err.contains("aaa222-bar"), "{err}"); } #[test] fn recipe_for_dir_returns_none_without_sidecar() { let store_dir = tempfile::tempdir().unwrap(); let store = Store::open(store_dir.path()).unwrap(); let art = store_dir.path().join("aaa-foo"); std::fs::create_dir_all(&art).unwrap(); let r = store.recipe_for_dir(&art).unwrap(); assert!(r.is_none()); } #[test] fn recipe_for_dir_reads_sidecar() { let store_dir = tempfile::tempdir().unwrap(); let store = Store::open(store_dir.path()).unwrap(); let art = store_dir.path().join("aaa-foo"); std::fs::create_dir_all(art.join(".hammer")).unwrap(); std::fs::write( art.join(RECIPE_SIDECAR_REL), r#" name = "grep" version = "3.12" [source] repo = "git://example/grep.git" commit = "deadbeef" [build] compiler = "zig-cc" target = "x86_64-linux-musl" link = "static" flags = [] "#, ) .unwrap(); let r = store.recipe_for_dir(&art).unwrap().expect("Some"); assert_eq!(r.name, "grep"); assert_eq!(r.version, "3.12"); } #[test] fn recipe_for_hash_resolves_by_prefix_then_reads() { let store_dir = tempfile::tempdir().unwrap(); let store = Store::open(store_dir.path()).unwrap(); let art = store.root().join("cafe1234-demo"); std::fs::create_dir_all(art.join(".hammer")).unwrap(); std::fs::write( art.join(RECIPE_SIDECAR_REL), r#" name = "demo" version = "0.1" [source] tarball = "https://x/y.tar.gz" sha256 = "abc123" [build] "#, ) .unwrap(); let r = store.recipe_for_hash("cafe").unwrap().expect("Some"); assert_eq!(r.name, "demo"); // Hash inexistente: None (no error). assert!(store.recipe_for_hash("ffffff").unwrap().is_none()); } #[test] fn seal_is_idempotent_when_dst_exists() { let store_dir = tempfile::tempdir().unwrap(); let store = Store::open(store_dir.path()).unwrap(); // Pre-sellar una vez. let first = tempfile::tempdir_in(store_dir.path()).unwrap(); std::fs::write(first.path().join("a"), b"1").unwrap(); let first_path = first.keep(); let p1 = store.seal(&first_path, &hash(), "demo").unwrap(); // Segundo intento: el out_dir no debe consumirse (devuelve la ruta cacheada). let second = tempfile::tempdir_in(store_dir.path()).unwrap(); std::fs::write(second.path().join("b"), b"2").unwrap(); let p2 = store.seal(second.path(), &hash(), "demo").unwrap(); assert_eq!(p1, p2); assert!(second.path().is_dir()); // intacto } }