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…
⎇ 98f0b2d228ffebb06941dfc1f436167ab40d8f21
crates/hammer-core/src/store.rs
Historia
Quién tocó qué
crudo
Fase 4 — provenance en `hammer export` (sidecar de receta en el store)
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}