//! 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` al destino y marca el árbol read-only (todos los archivos /// pierden `w`; directorios mantienen `x` para poder atravesarlos). /// /// Camino rápido: `rename` atómico cuando `out_dir` y el store comparten filesystem. /// Camino EXDEV (Etapa B3, `/store` es una partición dedicada ≠ la de `/work`): `rename(2)` /// no cruza filesystems ⇒ caemos a copia recursiva a un staging DENTRO del store + rename /// store-interno (mismo FS, atómico) + borrado del original. La publicación sigue siendo /// atómica (nadie ve el `dst` a medio escribir). 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)?; } match std::fs::rename(out_dir, &dst) { Ok(()) => {} // EXDEV (18) = Cross-device link: out_dir y el store viven en filesystems distintos. Err(e) if e.raw_os_error() == Some(EXDEV) => { let parent = dst.parent().unwrap_or_else(|| Path::new(".")); let fname = dst.file_name().expect("dst con nombre"); let staging = parent.join(format!(".seal-tmp-{}", fname.to_string_lossy())); let _ = std::fs::remove_dir_all(&staging); // restos de un intento previo copy_tree(out_dir, &staging).map_err(|e| { crate::Error::Store(format!( "copia cross-device {} → {}: {e}", out_dir.display(), staging.display() )) })?; std::fs::rename(&staging, &dst).map_err(|e| { let _ = std::fs::remove_dir_all(&staging); crate::Error::Store(format!("rename store-interno → {}: {e}", dst.display())) })?; std::fs::remove_dir_all(out_dir).map_err(|e| { crate::Error::Store(format!("limpiar origen {}: {e}", out_dir.display())) })?; } Err(e) => { return Err(crate::Error::Store(format!( "rename {} → {}: {e}", out_dir.display(), dst.display() ))) } } make_tree_read_only(&dst)?; Ok(dst) } } /// EXDEV en Linux. Evita una dependencia de `libc` por una sola constante. const EXDEV: i32 = 18; /// Copia recursiva de `src` a `dst` preservando symlinks (target literal, sin resolver) y /// los modos de archivos y directorios. Usada por el fallback cross-device de `seal`. fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> { let meta = std::fs::symlink_metadata(src)?; let ft = meta.file_type(); if ft.is_symlink() { let target = std::fs::read_link(src)?; std::os::unix::fs::symlink(target, dst)?; } else if ft.is_dir() { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; copy_tree(&entry.path(), &dst.join(entry.file_name()))?; } // Modos del directorio DESPUÉS de poblarlo (un dir 0o555 no admitiría escrituras). std::fs::set_permissions(dst, meta.permissions())?; } else { // Archivo regular: `std::fs::copy` lleva contenido + bits de permiso. std::fs::copy(src, dst)?; } Ok(()) } /// 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 copy_tree_preserva_symlinks_y_modos() { use std::os::unix::fs::PermissionsExt; let tmp = tempfile::tempdir().unwrap(); let src = tmp.path().join("src"); std::fs::create_dir_all(src.join("sub")).unwrap(); std::fs::write(src.join("sub/exe"), b"#!/bin/sh\n").unwrap(); std::fs::set_permissions(src.join("sub/exe"), std::fs::Permissions::from_mode(0o755)) .unwrap(); std::os::unix::fs::symlink("sub/exe", src.join("enlace")).unwrap(); let dst = tmp.path().join("dst"); copy_tree(&src, &dst).unwrap(); // El symlink se replica con su target literal (no se resuelve). let link = std::fs::symlink_metadata(dst.join("enlace")).unwrap(); assert!(link.file_type().is_symlink()); assert_eq!(std::fs::read_link(dst.join("enlace")).unwrap().to_str(), Some("sub/exe")); // El modo 0o755 del archivo sobrevive. let exe = std::fs::metadata(dst.join("sub/exe")).unwrap(); assert_eq!(exe.permissions().mode() & 0o777, 0o755); assert_eq!(std::fs::read(dst.join("sub/exe")).unwrap(), b"#!/bin/sh\n"); } #[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 } }