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…
⎇ 4dfb0ff12796e164f20289581e13c0b147edac28
crates/hammer-core/src/store.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
Etapa B3: /store y /var/lib/hammer en particiones dedicadas (GPT)
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}