From f6b337f6cbfe8ff6abe3818c99be99588a6c146c Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 14 Jun 2026 03:23:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(hydrate):=20hidrataci=C3=B3n=20din=C3=A1mi?= =?UTF-8?q?ca=20real=20con=20patchelf=20(LinkMode::Dynamic)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deja de ser un error "pendiente": hydrate(.., DynamicSpec{interpreter,rpath}) detecta ELF por magic, copia los que necesitan parcheo (no hardlink: patchelf mutaría el store) y aplica --set-interpreter/--set-rpath atómicamente (copy→patch→rename); no-ELF y spec vacío siguen hardlinkeando. ensure_patchelf falla limpio antes de tocar el FHS si falta la herramienta. HydrateReport.patched cuenta los reescritos. Callers (bus/cli/bootstrap/e2e) pasan None=estático. 4 tests nuevos (incl. camino de error sin patchelf y real gated). Doc §4. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hammer-bootstrap/src/lib.rs | 4 +- crates/hammer-build/src/hydrate.rs | 227 +++++++++++++++++-- crates/hammer-build/src/lib.rs | 5 +- crates/hammer-build/tests/end_to_end_grep.rs | 2 +- crates/hammer-cli/src/main.rs | 2 + crates/hammerd/src/bus.rs | 1 + docs/03-hydration.md | 8 + 7 files changed, 225 insertions(+), 24 deletions(-) diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index 9ba82bd2..b3f545bf 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -367,7 +367,7 @@ fn assemble_rootfs( // Cada componente se proyecta al rootfs por hardlink (mismo store, mismo filesystem). for (name, h) in components { let dir = store.path_of(h, name); - hammer_build::run_hydrate(&dir, staging, hammer_core::LinkMode::Static)?; + hammer_build::run_hydrate(&dir, staging, hammer_core::LinkMode::Static, None)?; } // El sidecar de provenance de cada receta (`.hammer/recipe.toml`) no pertenece al rootfs @@ -714,7 +714,7 @@ fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result stage1_dir.display() ))); } - hammer_build::run_hydrate(&stage1_dir, staging, hammer_core::LinkMode::Static)?; + hammer_build::run_hydrate(&stage1_dir, staging, hammer_core::LinkMode::Static, None)?; // 2) Esqueleto del builder: el sandbox de build (/toolchain), el store, el work y la config. for d in ["toolchain", "store", "work", "usr/bin", "etc/hammer/recipes"] { diff --git a/crates/hammer-build/src/hydrate.rs b/crates/hammer-build/src/hydrate.rs index e63e6559..9635bd40 100644 --- a/crates/hammer-build/src/hydrate.rs +++ b/crates/hammer-build/src/hydrate.rs @@ -5,12 +5,15 @@ //! Pisar un archivo del FHS rompe el hardlink (CoW al kernel) sin tocar el artefacto del store //! — base de rollback (`hammer hydrate ` lo restaura). //! -//! Estrategia secundaria (`LinkMode::Dynamic`): requiere `patchelf` para normalizar -//! interpreter y RPATH antes de proyectar. Se implementa al añadir el primer paquete real con -//! enlazado dinámico. +//! Estrategia secundaria (`LinkMode::Dynamic`): los ELF que necesitan normalización de +//! interpreter/RPATH se **copian** (no se hardlinkean: `patchelf` reescribe el binario y un +//! hardlink mutaría el inode del store) y se parchean con `patchelf` según el [`DynamicSpec`]. +//! Los archivos no-ELF (datos, scripts, libs ya correctas) siguen hardlinkeándose como en +//! estático. Ver `docs/03-hydration.md` §4. use std::os::unix::fs::FileTypeExt; use std::path::{Path, PathBuf}; +use std::process::Command; use hammer_core::LinkMode; @@ -25,6 +28,27 @@ pub struct HydrateReport { pub files: Vec, pub dirs_created: usize, pub symlinks: usize, + /// Cuántos ELF se copiaron+parchearon con patchelf (sólo en modo dinámico). + pub patched: usize, +} + +/// Parámetros de la hidratación dinámica: qué fijarle a cada ELF antes de proyectarlo. Ambos +/// son opcionales; si los dos son `None`, el modo dinámico se comporta como el estático +/// (nada que parchear ⇒ hardlink directo). En producción salen del contexto de la distro +/// (loader musl + layout de `/usr/lib`). +#[derive(Debug, Clone, Default)] +pub struct DynamicSpec { + /// Interpreter (dynamic loader) a fijar, p. ej. `/lib/ld-musl-x86_64.so.1`. + pub interpreter: Option, + /// RPATH a fijar, p. ej. `/usr/lib:/lib`. + pub rpath: Option, +} + +impl DynamicSpec { + /// `true` si hay algo que patchelf debería reescribir. + fn has_work(&self) -> bool { + self.interpreter.is_some() || self.rpath.is_some() + } } /// Proyecta el contenido de `artifact_dir` al árbol bajo `target_fhs`. Conserva la jerarquía @@ -36,23 +60,30 @@ pub fn hydrate( artifact_dir: &Path, target_fhs: &Path, mode: LinkMode, + dynamic: Option<&DynamicSpec>, ) -> hammer_core::Result { - if matches!(mode, LinkMode::Dynamic) { - return Err(hammer_core::Error::Other(anyhow::anyhow!( - "hidratación dinámica pendiente: requiere patchelf (set-interpreter + set-rpath); \ - ver docs/03-hydration.md §4" - ))); - } if !artifact_dir.is_dir() { return Err(hammer_core::Error::Store(format!( "artefacto inexistente: {}", artifact_dir.display() ))); } + // En modo dinámico con patcheo real, exigimos que `patchelf` exista de antemano: mejor + // fallar limpio que dejar el FHS a medio proyectar. + let spec = match mode { + LinkMode::Dynamic => { + let spec = dynamic.cloned().unwrap_or_default(); + if spec.has_work() { + ensure_patchelf()?; + } + Some(spec) + } + LinkMode::Static => None, + }; std::fs::create_dir_all(target_fhs)?; let mut report = HydrateReport::default(); - walk_and_link(artifact_dir, artifact_dir, target_fhs, &mut report)?; + walk_and_link(artifact_dir, artifact_dir, target_fhs, spec.as_ref(), &mut report)?; Ok(report) } @@ -60,6 +91,7 @@ fn walk_and_link( root: &Path, cur: &Path, target_fhs: &Path, + spec: Option<&DynamicSpec>, report: &mut HydrateReport, ) -> hammer_core::Result<()> { for entry in std::fs::read_dir(cur)? { @@ -72,7 +104,7 @@ fn walk_and_link( if ft.is_dir() { std::fs::create_dir_all(&dst)?; report.dirs_created += 1; - walk_and_link(root, &src, target_fhs, report)?; + walk_and_link(root, &src, target_fhs, spec, report)?; } else if ft.is_symlink() { // Replicamos el symlink con el mismo target literal (no resolvemos). let link_target = std::fs::read_link(&src)?; @@ -80,7 +112,19 @@ fn walk_and_link( report.symlinks += 1; report.files.push(HydratedFile { src, dst }); } else if ft.is_file() { - atomic_hardlink(&src, &dst)?; + // Modo dinámico: un ELF que necesita interpreter/RPATH se copia y se parchea + // (hardlinkearlo mutaría el store). Todo lo demás se hardlinkea como en estático. + let patch = match spec { + Some(s) if s.has_work() && is_elf(&src)? => Some(s), + _ => None, + }; + match patch { + Some(s) => { + atomic_copy_and_patch(&src, &dst, s)?; + report.patched += 1; + } + None => atomic_hardlink(&src, &dst)?, + } report.files.push(HydratedFile { src, dst }); } else if ft.is_block_device() || ft.is_char_device() || ft.is_fifo() || ft.is_socket() { // Los artefactos del lab no deberían contener nodos especiales; lo señalamos. @@ -122,6 +166,87 @@ fn atomic_hardlink(src: &Path, dst: &Path) -> hammer_core::Result<()> { Ok(()) } +/// `true` si el archivo empieza por el magic ELF (`\x7fELF`). Errores de I/O se propagan; +/// un archivo más corto que 4 bytes simplemente no es ELF. +fn is_elf(path: &Path) -> hammer_core::Result { + use std::io::Read; + let mut f = std::fs::File::open(path)?; + let mut magic = [0u8; 4]; + match f.read_exact(&mut magic) { + Ok(()) => Ok(magic == [0x7f, b'E', b'L', b'F']), + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false), + Err(e) => Err(e.into()), + } +} + +/// Verifica que `patchelf` esté disponible antes de empezar a proyectar. +fn ensure_patchelf() -> hammer_core::Result<()> { + match Command::new("patchelf").arg("--version").output() { + Ok(o) if o.status.success() => Ok(()), + Ok(o) => Err(hammer_core::Error::Other(anyhow::anyhow!( + "patchelf falló al invocarse: {}", + String::from_utf8_lossy(&o.stderr).trim() + ))), + Err(e) => Err(hammer_core::Error::Other(anyhow::anyhow!( + "hidratación dinámica requiere `patchelf` en el PATH y no se pudo ejecutar: {e}" + ))), + } +} + +/// Copia `src → dst` atómicamente (copy a tmp → patchelf el tmp → rename), preservando los +/// permisos (`std::fs::copy` los replica, incluido el bit de ejecución). Patcheamos el tmp +/// **antes** del rename para que `dst` nunca quede en un estado a medio parchear. +fn atomic_copy_and_patch(src: &Path, dst: &Path, spec: &DynamicSpec) -> hammer_core::Result<()> { + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = with_suffix(dst, ".hammer-tmp"); + let _ = std::fs::remove_file(&tmp); + std::fs::copy(src, &tmp).map_err(|e| { + hammer_core::Error::Store(format!("copy {} → {}: {e}", src.display(), tmp.display())) + })?; + if let Err(e) = run_patchelf(&tmp, spec) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + if let Err(e) = std::fs::rename(&tmp, dst) { + let _ = std::fs::remove_file(&tmp); + return Err(hammer_core::Error::Store(format!( + "rename {} → {}: {e}", + tmp.display(), + dst.display() + ))); + } + Ok(()) +} + +/// Ejecuta `patchelf [--set-interpreter I] [--set-rpath R] `. Asume que el caller ya +/// comprobó (`has_work`) que hay al menos una de las dos. +fn run_patchelf(file: &Path, spec: &DynamicSpec) -> hammer_core::Result<()> { + let mut cmd = Command::new("patchelf"); + if let Some(interp) = &spec.interpreter { + cmd.arg("--set-interpreter").arg(interp); + } + if let Some(rpath) = &spec.rpath { + cmd.arg("--set-rpath").arg(rpath); + } + cmd.arg(file); + let out = cmd.output().map_err(|e| { + hammer_core::Error::Other(anyhow::anyhow!( + "no pude ejecutar patchelf sobre {}: {e}", + file.display() + )) + })?; + if !out.status.success() { + return Err(hammer_core::Error::Other(anyhow::anyhow!( + "patchelf falló sobre {}: {}", + file.display(), + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + Ok(()) +} + fn atomic_replace_symlink(link_target: &Path, dst: &Path) -> hammer_core::Result<()> { use std::os::unix::fs::symlink; if let Some(parent) = dst.parent() { @@ -170,7 +295,7 @@ mod tests { let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap(); populate(artifact.path()); - let report = hydrate(artifact.path(), target.path(), LinkMode::Static).unwrap(); + let report = hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap(); assert_eq!(report.files.len(), 3); // hello, README, symlink assert_eq!(report.symlinks, 1); @@ -189,7 +314,7 @@ mod tests { std::fs::create_dir_all(target.path().join("usr/bin")).unwrap(); std::fs::write(target.path().join("usr/bin/hello"), b"contenido viejo").unwrap(); - hydrate(artifact.path(), target.path(), LinkMode::Static).unwrap(); + hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap(); // El target ahora apunta al inode del store. let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino(); @@ -207,9 +332,9 @@ mod tests { let artifact = tempfile::tempdir().unwrap(); let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap(); populate(artifact.path()); - hydrate(artifact.path(), target.path(), LinkMode::Static).unwrap(); + hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap(); // Segundo paso no debe fallar. - hydrate(artifact.path(), target.path(), LinkMode::Static).unwrap(); + hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap(); } #[test] @@ -217,19 +342,83 @@ mod tests { let artifact = tempfile::tempdir().unwrap(); let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap(); populate(artifact.path()); - hydrate(artifact.path(), target.path(), LinkMode::Static).unwrap(); + hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap(); let link = std::fs::read_link(target.path().join("usr/sbin/hello")).unwrap(); assert_eq!(link, std::path::PathBuf::from("../bin/hello")); } #[test] - fn hydrate_dynamic_errors_with_clear_message() { + fn hydrate_dynamic_sin_spec_se_comporta_como_estatico() { + // Sin interpreter ni rpath no hay nada que parchear ⇒ hardlink directo (mismo inode), + // sin necesitar patchelf. Es el caso que prueba que el modo dinámico no rompe. let artifact = tempfile::tempdir().unwrap(); let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap(); populate(artifact.path()); - let err = hydrate(artifact.path(), target.path(), LinkMode::Dynamic) + let report = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, None).unwrap(); + assert_eq!(report.patched, 0); + let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino(); + let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino(); + assert_eq!(src_ino, dst_ino, "sin spec ⇒ hardlink ⇒ mismo inode"); + } + + #[test] + fn is_elf_detecta_magic() { + let d = tempfile::tempdir().unwrap(); + let elf = d.path().join("bin"); + std::fs::write(&elf, [0x7f, b'E', b'L', b'F', 0x02, 0x01]).unwrap(); + let noelf = d.path().join("script"); + std::fs::write(&noelf, b"#!/bin/sh\n").unwrap(); + let corto = d.path().join("corto"); + std::fs::write(&corto, b"hi").unwrap(); + assert!(is_elf(&elf).unwrap()); + assert!(!is_elf(&noelf).unwrap()); + assert!(!is_elf(&corto).unwrap(), "archivo <4 bytes no es ELF"); + } + + #[test] + fn hydrate_dynamic_con_spec_pero_sin_patchelf_falla_limpio() { + // Con un interpreter a fijar y un "ELF" presente, se exige patchelf. En este entorno + // patchelf no está instalado ⇒ debe fallar con mensaje claro ANTES de proyectar. + if Command::new("patchelf").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) { + eprintln!("patchelf presente; salto el test del camino de error"); + return; + } + let artifact = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap(); + std::fs::create_dir_all(artifact.path().join("usr/bin")).unwrap(); + std::fs::write(artifact.path().join("usr/bin/elf"), [0x7f, b'E', b'L', b'F', 0, 0, 0, 0]).unwrap(); + let spec = DynamicSpec { + interpreter: Some("/lib/ld-musl-x86_64.so.1".into()), + rpath: Some("/usr/lib:/lib".into()), + }; + let err = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, Some(&spec)) .unwrap_err() .to_string(); assert!(err.contains("patchelf"), "{err}"); } + + /// Camino feliz real: requiere patchelf + un ELF de verdad. Gated como el resto de tests + /// dependientes del host (`HAMMER_HOST_ELF_TESTS=1`). + #[test] + fn hydrate_dynamic_patchea_elf_real() { + if std::env::var("HAMMER_HOST_ELF_TESTS").is_err() { + return; + } + let host_bin = std::path::Path::new("/bin/sh"); + if !host_bin.exists() { + return; + } + let artifact = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap(); + std::fs::create_dir_all(artifact.path().join("usr/bin")).unwrap(); + std::fs::copy(host_bin, artifact.path().join("usr/bin/sh")).unwrap(); + let spec = DynamicSpec { interpreter: None, rpath: Some("/usr/lib:/lib".into()) }; + let report = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, Some(&spec)).unwrap(); + assert_eq!(report.patched, 1); + // El binario proyectado es una COPIA (distinto inode del artefacto), porque patchelf lo + // reescribió sin tocar el store. + let src_ino = std::fs::metadata(artifact.path().join("usr/bin/sh")).unwrap().ino(); + let dst_ino = std::fs::metadata(target.path().join("usr/bin/sh")).unwrap().ino(); + assert_ne!(src_ino, dst_ino, "ELF parcheado ⇒ copia ⇒ inode distinto"); + } } diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs index b7122dc8..4602d412 100644 --- a/crates/hammer-build/src/lib.rs +++ b/crates/hammer-build/src/lib.rs @@ -13,7 +13,7 @@ pub mod sandbox; pub mod swm_bridge; pub use config::BuildConfig; -pub use hydrate::{hydrate as run_hydrate, HydrateReport, HydratedFile}; +pub use hydrate::{hydrate as run_hydrate, DynamicSpec, HydrateReport, HydratedFile}; pub use sandbox::{BuildFailure, Sandbox}; pub use swm_bridge::build_source_patch; @@ -608,9 +608,10 @@ pub fn hydrate( store: &Store, target_fhs: &Path, mode: LinkMode, + dynamic: Option<&hydrate::DynamicSpec>, ) -> hammer_core::Result { let artifact_dir = store.find_by_hash(h.as_str())?; - hydrate::hydrate(&artifact_dir, target_fhs, mode) + hydrate::hydrate(&artifact_dir, target_fhs, mode, dynamic) } #[cfg(test)] diff --git a/crates/hammer-build/tests/end_to_end_grep.rs b/crates/hammer-build/tests/end_to_end_grep.rs index 238222b4..a7a43f63 100644 --- a/crates/hammer-build/tests/end_to_end_grep.rs +++ b/crates/hammer-build/tests/end_to_end_grep.rs @@ -111,7 +111,7 @@ fn grep_real_build_hydrate_run() { // 3) Hidratación al mismo filesystem let fhs = scratch.join("fhs"); - let report = run_hydrate(&sealed, &fhs, LinkMode::Static).expect("hydrate"); + let report = run_hydrate(&sealed, &fhs, LinkMode::Static, None).expect("hydrate"); assert!(report.files.iter().any(|f| f.dst.ends_with("usr/bin/grep"))); // Compartir inode con el store: la propiedad central de hidratar por hardlink. diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index de9c17e7..f91571b8 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -383,6 +383,7 @@ fn main() -> anyhow::Result<()> { &artifact_dir, std::path::Path::new(&into), mode, + None, )?; println!( "hydrated {} archivo(s) ({} dir(s), {} symlink(s)) → {}", @@ -809,6 +810,7 @@ fn run_apply( &artifact, &into, hammer_core::LinkMode::Static, + None, )?; eprintln!( " source_patch #{}: {} → hidratados {} archivo(s) en {}", diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs index 0f2e98f6..b93ea063 100644 --- a/crates/hammerd/src/bus.rs +++ b/crates/hammerd/src/bus.rs @@ -280,6 +280,7 @@ fn dispatch( &artifact_dir, Path::new(&target), hammer_core::LinkMode::Static, + None, ) { Ok(r) => r, Err(e) => { diff --git a/docs/03-hydration.md b/docs/03-hydration.md index 634f5dba..b0e7bb9d 100644 --- a/docs/03-hydration.md +++ b/docs/03-hydration.md @@ -52,6 +52,14 @@ contra el store, y la hidratación **reescribe el binario** antes de inyectarlo: `patchelf` (irónicamente, creado por el equipo de Nix) es la herramienta exacta para esto. +**Implementado** (`hammer-build::hydrate`, `LinkMode::Dynamic` + `DynamicSpec{interpreter,rpath}`): +el walker detecta ELF por magic (`\x7fELF`) y, si el `DynamicSpec` trae interpreter y/o +rpath, **copia** ese binario (no lo hardlinkea: patchelf lo reescribiría y mutaría el inode del +store) a un `.hammer-tmp`, lo parchea con `patchelf --set-interpreter/--set-rpath` y lo renombra +atómicamente. Los no-ELF (y los ELF cuando el spec está vacío) siguen hardlinkeándose como en +estático. Si falta `patchelf` en el PATH se falla limpio **antes** de tocar el FHS +(`ensure_patchelf`). `HydrateReport.patched` cuenta los binarios reescritos. + ## 5. El core dinámico curado (matiz honesto sobre el ABI de musl) La idea de "actualizar el sustrato atómicamente y que los binarios no se enteren" **sólo es