feat(hydrate): hidratación dinámica real con patchelf (LinkMode::Dynamic)

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) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-14 03:23:12 +00:00
co-authored by Claude Opus 4.8
parent 8a5e75344a
commit f6b337f6cb
7 changed files with 225 additions and 24 deletions
+2 -2
View File
@@ -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"] {
+208 -19
View File
@@ -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 <hash>` 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<HydratedFile>,
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<String>,
/// RPATH a fijar, p. ej. `/usr/lib:/lib`.
pub rpath: Option<String>,
}
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<HydrateReport> {
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<bool> {
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] <file>`. 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");
}
}
+3 -2
View File
@@ -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<HydrateReport> {
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)]
+1 -1
View File
@@ -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.
+2
View File
@@ -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 {}",
+1
View File
@@ -280,6 +280,7 @@ fn dispatch(
&artifact_dir,
Path::new(&target),
hammer_core::LinkMode::Static,
None,
) {
Ok(r) => r,
Err(e) => {
+8
View File
@@ -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