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-build/src/hydrate.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(hydrate): hidratación dinámica real con patchelf (LinkMode::Dynamic)
1//! Hidratación: del store al FHS. Ver `docs/03-hydration.md`.
2//!
3//! Estrategia primaria (`LinkMode::Static`): hardlink directo. Cero copia, cero RPATH que
4//! reescribir. El FHS queda con archivos reales (no symlinks) compartiendo inode con el store.
5//! Pisar un archivo del FHS rompe el hardlink (CoW al kernel) sin tocar el artefacto del store
6//! — base de rollback (`hammer hydrate <hash>` lo restaura).
7//!
8//! Estrategia secundaria (`LinkMode::Dynamic`): los ELF que necesitan normalización de
9//! interpreter/RPATH se **copian** (no se hardlinkean: `patchelf` reescribe el binario y un
10//! hardlink mutaría el inode del store) y se parchean con `patchelf` según el [`DynamicSpec`].
11//! Los archivos no-ELF (datos, scripts, libs ya correctas) siguen hardlinkeándose como en
12//! estático. Ver `docs/03-hydration.md` §4.
13
14use std::os::unix::fs::FileTypeExt;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18use hammer_core::LinkMode;
19
20#[derive(Debug, Clone)]
21pub struct HydratedFile {
22 pub src: PathBuf,
23 pub dst: PathBuf,
24}
25
26#[derive(Debug, Default)]
27pub struct HydrateReport {
28 pub files: Vec<HydratedFile>,
29 pub dirs_created: usize,
30 pub symlinks: usize,
31 /// Cuántos ELF se copiaron+parchearon con patchelf (sólo en modo dinámico).
32 pub patched: usize,
33}
34
35/// Parámetros de la hidratación dinámica: qué fijarle a cada ELF antes de proyectarlo. Ambos
36/// son opcionales; si los dos son `None`, el modo dinámico se comporta como el estático
37/// (nada que parchear ⇒ hardlink directo). En producción salen del contexto de la distro
38/// (loader musl + layout de `/usr/lib`).
39#[derive(Debug, Clone, Default)]
40pub struct DynamicSpec {
41 /// Interpreter (dynamic loader) a fijar, p. ej. `/lib/ld-musl-x86_64.so.1`.
42 pub interpreter: Option<String>,
43 /// RPATH a fijar, p. ej. `/usr/lib:/lib`.
44 pub rpath: Option<String>,
45}
46
47impl DynamicSpec {
48 /// `true` si hay algo que patchelf debería reescribir.
49 fn has_work(&self) -> bool {
50 self.interpreter.is_some() || self.rpath.is_some()
51 }
52}
53
54/// Proyecta el contenido de `artifact_dir` al árbol bajo `target_fhs`. Conserva la jerarquía
55/// relativa: `<artifact>/usr/bin/grep` → `<target>/usr/bin/grep`.
56///
57/// Sobre archivos preexistentes en el target, sobrescribe atómicamente (link tmp + rename) —
58/// los archivos antiguos se descartan, pero los inodes del store siguen intactos.
59pub fn hydrate(
60 artifact_dir: &Path,
61 target_fhs: &Path,
62 mode: LinkMode,
63 dynamic: Option<&DynamicSpec>,
64) -> hammer_core::Result<HydrateReport> {
65 if !artifact_dir.is_dir() {
66 return Err(hammer_core::Error::Store(format!(
67 "artefacto inexistente: {}",
68 artifact_dir.display()
69 )));
70 }
71 // En modo dinámico con patcheo real, exigimos que `patchelf` exista de antemano: mejor
72 // fallar limpio que dejar el FHS a medio proyectar.
73 let spec = match mode {
74 LinkMode::Dynamic => {
75 let spec = dynamic.cloned().unwrap_or_default();
76 if spec.has_work() {
77 ensure_patchelf()?;
78 }
79 Some(spec)
80 }
81 LinkMode::Static => None,
82 };
83 std::fs::create_dir_all(target_fhs)?;
84
85 let mut report = HydrateReport::default();
86 walk_and_link(artifact_dir, artifact_dir, target_fhs, spec.as_ref(), &mut report)?;
87 Ok(report)
88}
89
90fn walk_and_link(
91 root: &Path,
92 cur: &Path,
93 target_fhs: &Path,
94 spec: Option<&DynamicSpec>,
95 report: &mut HydrateReport,
96) -> hammer_core::Result<()> {
97 for entry in std::fs::read_dir(cur)? {
98 let entry = entry?;
99 let src = entry.path();
100 let rel = src.strip_prefix(root).expect("rel under root");
101 let dst = target_fhs.join(rel);
102 let ft = entry.file_type()?;
103
104 if ft.is_dir() {
105 std::fs::create_dir_all(&dst)?;
106 report.dirs_created += 1;
107 walk_and_link(root, &src, target_fhs, spec, report)?;
108 } else if ft.is_symlink() {
109 // Replicamos el symlink con el mismo target literal (no resolvemos).
110 let link_target = std::fs::read_link(&src)?;
111 atomic_replace_symlink(&link_target, &dst)?;
112 report.symlinks += 1;
113 report.files.push(HydratedFile { src, dst });
114 } else if ft.is_file() {
115 // Modo dinámico: un ELF que necesita interpreter/RPATH se copia y se parchea
116 // (hardlinkearlo mutaría el store). Todo lo demás se hardlinkea como en estático.
117 let patch = match spec {
118 Some(s) if s.has_work() && is_elf(&src)? => Some(s),
119 _ => None,
120 };
121 match patch {
122 Some(s) => {
123 atomic_copy_and_patch(&src, &dst, s)?;
124 report.patched += 1;
125 }
126 None => atomic_hardlink(&src, &dst)?,
127 }
128 report.files.push(HydratedFile { src, dst });
129 } else if ft.is_block_device() || ft.is_char_device() || ft.is_fifo() || ft.is_socket() {
130 // Los artefactos del lab no deberían contener nodos especiales; lo señalamos.
131 return Err(hammer_core::Error::Other(anyhow::anyhow!(
132 "tipo no soportado en {}: {ft:?}",
133 src.display()
134 )));
135 }
136 }
137 Ok(())
138}
139
140/// Crea un hardlink `dst → src` reemplazando atómicamente cualquier archivo previo en `dst`.
141/// Implementación: `link(src, dst.tmp)` + `rename(dst.tmp, dst)`. La técnica garantiza que
142/// el archivo en `dst` nunca queda en estado intermedio y que el inode del store no se toca.
143fn atomic_hardlink(src: &Path, dst: &Path) -> hammer_core::Result<()> {
144 if let Some(parent) = dst.parent() {
145 std::fs::create_dir_all(parent)?;
146 }
147 let tmp = with_suffix(dst, ".hammer-tmp");
148 // Limpia un tmp olvidado de una corrida anterior.
149 let _ = std::fs::remove_file(&tmp);
150 std::fs::hard_link(src, &tmp).map_err(|e| {
151 hammer_core::Error::Store(format!(
152 "hardlink {} → {}: {e}",
153 src.display(),
154 tmp.display()
155 ))
156 })?;
157 // rename sobreescribe atómicamente si dst es un archivo (no si es directorio).
158 if let Err(e) = std::fs::rename(&tmp, dst) {
159 let _ = std::fs::remove_file(&tmp);
160 return Err(hammer_core::Error::Store(format!(
161 "rename {} → {}: {e}",
162 tmp.display(),
163 dst.display()
164 )));
165 }
166 Ok(())
167}
168
169/// `true` si el archivo empieza por el magic ELF (`\x7fELF`). Errores de I/O se propagan;
170/// un archivo más corto que 4 bytes simplemente no es ELF.
171fn is_elf(path: &Path) -> hammer_core::Result<bool> {
172 use std::io::Read;
173 let mut f = std::fs::File::open(path)?;
174 let mut magic = [0u8; 4];
175 match f.read_exact(&mut magic) {
176 Ok(()) => Ok(magic == [0x7f, b'E', b'L', b'F']),
177 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
178 Err(e) => Err(e.into()),
179 }
180}
181
182/// Verifica que `patchelf` esté disponible antes de empezar a proyectar.
183fn ensure_patchelf() -> hammer_core::Result<()> {
184 match Command::new("patchelf").arg("--version").output() {
185 Ok(o) if o.status.success() => Ok(()),
186 Ok(o) => Err(hammer_core::Error::Other(anyhow::anyhow!(
187 "patchelf falló al invocarse: {}",
188 String::from_utf8_lossy(&o.stderr).trim()
189 ))),
190 Err(e) => Err(hammer_core::Error::Other(anyhow::anyhow!(
191 "hidratación dinámica requiere `patchelf` en el PATH y no se pudo ejecutar: {e}"
192 ))),
193 }
194}
195
196/// Copia `src → dst` atómicamente (copy a tmp → patchelf el tmp → rename), preservando los
197/// permisos (`std::fs::copy` los replica, incluido el bit de ejecución). Patcheamos el tmp
198/// **antes** del rename para que `dst` nunca quede en un estado a medio parchear.
199fn atomic_copy_and_patch(src: &Path, dst: &Path, spec: &DynamicSpec) -> hammer_core::Result<()> {
200 if let Some(parent) = dst.parent() {
201 std::fs::create_dir_all(parent)?;
202 }
203 let tmp = with_suffix(dst, ".hammer-tmp");
204 let _ = std::fs::remove_file(&tmp);
205 std::fs::copy(src, &tmp).map_err(|e| {
206 hammer_core::Error::Store(format!("copy {} → {}: {e}", src.display(), tmp.display()))
207 })?;
208 if let Err(e) = run_patchelf(&tmp, spec) {
209 let _ = std::fs::remove_file(&tmp);
210 return Err(e);
211 }
212 if let Err(e) = std::fs::rename(&tmp, dst) {
213 let _ = std::fs::remove_file(&tmp);
214 return Err(hammer_core::Error::Store(format!(
215 "rename {} → {}: {e}",
216 tmp.display(),
217 dst.display()
218 )));
219 }
220 Ok(())
221}
222
223/// Ejecuta `patchelf [--set-interpreter I] [--set-rpath R] <file>`. Asume que el caller ya
224/// comprobó (`has_work`) que hay al menos una de las dos.
225fn run_patchelf(file: &Path, spec: &DynamicSpec) -> hammer_core::Result<()> {
226 let mut cmd = Command::new("patchelf");
227 if let Some(interp) = &spec.interpreter {
228 cmd.arg("--set-interpreter").arg(interp);
229 }
230 if let Some(rpath) = &spec.rpath {
231 cmd.arg("--set-rpath").arg(rpath);
232 }
233 cmd.arg(file);
234 let out = cmd.output().map_err(|e| {
235 hammer_core::Error::Other(anyhow::anyhow!(
236 "no pude ejecutar patchelf sobre {}: {e}",
237 file.display()
238 ))
239 })?;
240 if !out.status.success() {
241 return Err(hammer_core::Error::Other(anyhow::anyhow!(
242 "patchelf falló sobre {}: {}",
243 file.display(),
244 String::from_utf8_lossy(&out.stderr).trim()
245 )));
246 }
247 Ok(())
248}
249
250fn atomic_replace_symlink(link_target: &Path, dst: &Path) -> hammer_core::Result<()> {
251 use std::os::unix::fs::symlink;
252 if let Some(parent) = dst.parent() {
253 std::fs::create_dir_all(parent)?;
254 }
255 let tmp = with_suffix(dst, ".hammer-tmp");
256 let _ = std::fs::remove_file(&tmp);
257 symlink(link_target, &tmp).map_err(|e| {
258 hammer_core::Error::Store(format!("symlink {} → {}: {e}", link_target.display(), tmp.display()))
259 })?;
260 if let Err(e) = std::fs::rename(&tmp, dst) {
261 let _ = std::fs::remove_file(&tmp);
262 return Err(hammer_core::Error::Store(format!(
263 "rename {} → {}: {e}",
264 tmp.display(),
265 dst.display()
266 )));
267 }
268 Ok(())
269}
270
271fn with_suffix(p: &Path, suffix: &str) -> PathBuf {
272 let mut s = p.as_os_str().to_owned();
273 s.push(suffix);
274 PathBuf::from(s)
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use std::os::unix::fs::MetadataExt;
281
282 fn populate(dir: &Path) {
283 std::fs::create_dir_all(dir.join("usr/bin")).unwrap();
284 std::fs::write(dir.join("usr/bin/hello"), b"#!/bin/sh\necho hi\n").unwrap();
285 std::fs::create_dir_all(dir.join("usr/share/doc")).unwrap();
286 std::fs::write(dir.join("usr/share/doc/README"), b"docs").unwrap();
287 // symlink: usr/sbin/hello -> ../bin/hello (target relativo)
288 std::fs::create_dir_all(dir.join("usr/sbin")).unwrap();
289 std::os::unix::fs::symlink("../bin/hello", dir.join("usr/sbin/hello")).unwrap();
290 }
291
292 #[test]
293 fn hydrate_creates_hardlinks_with_shared_inode() {
294 let artifact = tempfile::tempdir().unwrap();
295 let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
296 populate(artifact.path());
297
298 let report = hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
299 assert_eq!(report.files.len(), 3); // hello, README, symlink
300 assert_eq!(report.symlinks, 1);
301
302 let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
303 let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
304 assert_eq!(src_ino, dst_ino, "hardlink ⇒ mismo inode");
305 }
306
307 #[test]
308 fn hydrate_overwrites_existing_without_touching_store() {
309 let artifact = tempfile::tempdir().unwrap();
310 let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
311 populate(artifact.path());
312
313 // Pre-existe un archivo distinto en el target.
314 std::fs::create_dir_all(target.path().join("usr/bin")).unwrap();
315 std::fs::write(target.path().join("usr/bin/hello"), b"contenido viejo").unwrap();
316
317 hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
318
319 // El target ahora apunta al inode del store.
320 let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
321 let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
322 assert_eq!(src_ino, dst_ino);
323 // El contenido viejo desapareció; el del store sigue intacto.
324 let got = std::fs::read_to_string(target.path().join("usr/bin/hello")).unwrap();
325 assert!(got.starts_with("#!/bin/sh"));
326 let store_still = std::fs::read_to_string(artifact.path().join("usr/bin/hello")).unwrap();
327 assert!(store_still.starts_with("#!/bin/sh"));
328 }
329
330 #[test]
331 fn hydrate_is_idempotent() {
332 let artifact = tempfile::tempdir().unwrap();
333 let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
334 populate(artifact.path());
335 hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
336 // Segundo paso no debe fallar.
337 hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
338 }
339
340 #[test]
341 fn hydrate_replicates_symlink_target_literally() {
342 let artifact = tempfile::tempdir().unwrap();
343 let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
344 populate(artifact.path());
345 hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
346 let link = std::fs::read_link(target.path().join("usr/sbin/hello")).unwrap();
347 assert_eq!(link, std::path::PathBuf::from("../bin/hello"));
348 }
349
350 #[test]
351 fn hydrate_dynamic_sin_spec_se_comporta_como_estatico() {
352 // Sin interpreter ni rpath no hay nada que parchear ⇒ hardlink directo (mismo inode),
353 // sin necesitar patchelf. Es el caso que prueba que el modo dinámico no rompe.
354 let artifact = tempfile::tempdir().unwrap();
355 let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
356 populate(artifact.path());
357 let report = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, None).unwrap();
358 assert_eq!(report.patched, 0);
359 let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
360 let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
361 assert_eq!(src_ino, dst_ino, "sin spec ⇒ hardlink ⇒ mismo inode");
362 }
363
364 #[test]
365 fn is_elf_detecta_magic() {
366 let d = tempfile::tempdir().unwrap();
367 let elf = d.path().join("bin");
368 std::fs::write(&elf, [0x7f, b'E', b'L', b'F', 0x02, 0x01]).unwrap();
369 let noelf = d.path().join("script");
370 std::fs::write(&noelf, b"#!/bin/sh\n").unwrap();
371 let corto = d.path().join("corto");
372 std::fs::write(&corto, b"hi").unwrap();
373 assert!(is_elf(&elf).unwrap());
374 assert!(!is_elf(&noelf).unwrap());
375 assert!(!is_elf(&corto).unwrap(), "archivo <4 bytes no es ELF");
376 }
377
378 #[test]
379 fn hydrate_dynamic_con_spec_pero_sin_patchelf_falla_limpio() {
380 // Con un interpreter a fijar y un "ELF" presente, se exige patchelf. En este entorno
381 // patchelf no está instalado ⇒ debe fallar con mensaje claro ANTES de proyectar.
382 if Command::new("patchelf").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) {
383 eprintln!("patchelf presente; salto el test del camino de error");
384 return;
385 }
386 let artifact = tempfile::tempdir().unwrap();
387 let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
388 std::fs::create_dir_all(artifact.path().join("usr/bin")).unwrap();
389 std::fs::write(artifact.path().join("usr/bin/elf"), [0x7f, b'E', b'L', b'F', 0, 0, 0, 0]).unwrap();
390 let spec = DynamicSpec {
391 interpreter: Some("/lib/ld-musl-x86_64.so.1".into()),
392 rpath: Some("/usr/lib:/lib".into()),
393 };
394 let err = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, Some(&spec))
395 .unwrap_err()
396 .to_string();
397 assert!(err.contains("patchelf"), "{err}");
398 }
399
400 /// Camino feliz real: requiere patchelf + un ELF de verdad. Gated como el resto de tests
401 /// dependientes del host (`HAMMER_HOST_ELF_TESTS=1`).
402 #[test]
403 fn hydrate_dynamic_patchea_elf_real() {
404 if std::env::var("HAMMER_HOST_ELF_TESTS").is_err() {
405 return;
406 }
407 let host_bin = std::path::Path::new("/bin/sh");
408 if !host_bin.exists() {
409 return;
410 }
411 let artifact = tempfile::tempdir().unwrap();
412 let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
413 std::fs::create_dir_all(artifact.path().join("usr/bin")).unwrap();
414 std::fs::copy(host_bin, artifact.path().join("usr/bin/sh")).unwrap();
415 let spec = DynamicSpec { interpreter: None, rpath: Some("/usr/lib:/lib".into()) };
416 let report = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, Some(&spec)).unwrap();
417 assert_eq!(report.patched, 1);
418 // El binario proyectado es una COPIA (distinto inode del artefacto), porque patchelf lo
419 // reescribió sin tocar el store.
420 let src_ino = std::fs::metadata(artifact.path().join("usr/bin/sh")).unwrap().ino();
421 let dst_ino = std::fs::metadata(target.path().join("usr/bin/sh")).unwrap().ino();
422 assert_ne!(src_ino, dst_ino, "ELF parcheado ⇒ copia ⇒ inode distinto");
423 }
424}