|
|
|
@@ -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");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|