//! Hidratación: del store al FHS. Ver `docs/03-hydration.md`. //! //! Estrategia primaria (`LinkMode::Static`): hardlink directo. Cero copia —salvo que el destino //! esté en otro filesystem, donde `linkat` da EXDEV y no queda más que copiar—, cero RPATH que //! reescribir. El FHS queda con archivos reales (no symlinks) compartiendo inode con el store. //! Pisar un archivo del FHS rompe el hardlink (CoW al kernel) sin tocar el artefacto del store //! — base de rollback (`takana hydrate ` lo restaura). //! //! 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 takana_core::LinkMode; #[derive(Debug, Clone)] pub struct HydratedFile { pub src: PathBuf, pub dst: PathBuf, } #[derive(Debug, Default)] 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 /// relativa: `/usr/bin/grep` → `/usr/bin/grep`. /// /// Sobre archivos preexistentes en el target, sobrescribe atómicamente (link tmp + rename) — /// los archivos antiguos se descartan, pero los inodes del store siguen intactos. pub fn hydrate( artifact_dir: &Path, target_fhs: &Path, mode: LinkMode, dynamic: Option<&DynamicSpec>, ) -> takana_core::Result { // `is_dir()` a secas acepta un directorio VACÍO: hydrate proyectaría 0 ficheros y devolvería // un HydrateReport de éxito. Es el mismo agujero que ya se tapó en `Store::has` (2026-08-10) y // en `Store::seal` (2026-08-29): un vacío no es un artefacto, es un nombre. Acá duele especial // porque el escritorio se hidrata así (`takana hydrate --into`), y un FHS proyectado // desde nada sale "OK" y falla mucho más tarde, sin rastro de cuál artefacto faltaba. let vacio = std::fs::read_dir(artifact_dir) .map(|mut e| e.next().is_none()) .unwrap_or(true); if !artifact_dir.is_dir() || vacio { return Err(takana_core::Error::Store(format!( "artefacto inexistente o VACÍO: {}", 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)?; // La raíz REAL contra la que se mide la contención (ver `no_escapa`). Se resuelve una sola vez: // si el propio `target_fhs` es un symlink, eso es elección de quien hidrata y vale como raíz. let raiz = target_fhs.canonicalize().map_err(|e| { takana_core::Error::Store(format!("canonicalize {}: {e}", target_fhs.display())) })?; let mut report = HydrateReport::default(); walk_and_link(artifact_dir, artifact_dir, target_fhs, &raiz, spec.as_ref(), &mut report)?; Ok(report) } /// El destino no puede salirse de la raíz. Se comprueba **por directorio**, no por fichero. /// /// EL FALLO QUE TAPA (medido 2026-08-30, `tests/hidratacion_no_escapa.rs`): hidratar es /// `target_fhs.join(rel)` y escribir por ruta, así que **un symlink de directorio ya presente en el /// FHS se sigue**. Un artefacto que trae `usr/share/pkg → /algún/lado` deja ese symlink puesto (se /// replican literales, y así debe ser), y la hidratación SIGUIENTE escribe `usr/share/pkg/archivo` /// **fuera del root** — devolviendo `Ok`. Con `--into` sobre una imagen, eso es escribir en el /// sistema anfitrión diciendo que todo fue bien: exactamente el fallo de CLAUDE.md §3. /// /// Hoy el corpus NO lo dispara —de 160 symlinks absolutos ninguno apunta a un directorio, y de /// ~42 000 relativos ninguno sale de su artefacto— pero nada lo impedía. /// /// Semántica elegida: symlinks **permitidos mientras no salgan** (un `/lib → usr/lib` de usr-merge, /// o los `22@2x → 22` de los temas de iconos, tienen que seguir funcionando). Es la de /// `openat2(RESOLVE_IN_ROOT)`; acá se hace con `canonicalize` porque las escrituras siguen siendo /// por ruta (`hard_link`, `rename`, `patchelf`). La diferencia que queda: `canonicalize`+escribir /// es comprobar-y-después-usar, o sea que un adversario CONCURRENTE podría cambiar el componente /// entre las dos. Para el fallo real —un symlink persistente puesto por otro artefacto— alcanza y /// es determinista; la versión sin carrera es mover las escrituras a `openat2` + `linkat`/`renameat` /// (SDD 25 §7-H3), y eso arrastra el camino de `patchelf`. fn no_escapa(raiz: &Path, dst: &Path) -> takana_core::Result<()> { let real = dst.canonicalize().map_err(|e| { takana_core::Error::Store(format!("canonicalize {}: {e}", dst.display())) })?; if !real.starts_with(raiz) { return Err(takana_core::Error::Store(format!( "la hidratación se saldría del root: {} lleva a {}, fuera de {}.\n\ Es un symlink de directorio ya presente en el FHS (probablemente puesto por otro \ artefacto). No se escribe a través de él: borralo o hidratá sobre un root limpio.", dst.display(), real.display(), raiz.display() ))); } Ok(()) } fn walk_and_link( root: &Path, cur: &Path, target_fhs: &Path, raiz: &Path, spec: Option<&DynamicSpec>, report: &mut HydrateReport, ) -> takana_core::Result<()> { for entry in std::fs::read_dir(cur)? { let entry = entry?; let src = entry.path(); let rel = src.strip_prefix(root).expect("rel under root"); let dst = target_fhs.join(rel); let ft = entry.file_type()?; if ft.is_dir() { std::fs::create_dir_all(&dst)?; // Acá y no en cada fichero: los ficheros cuelgan de un directorio ya comprobado, y // `canonicalize` por fichero costaría un realpath por entrada (SDD 25 T8/§9.4). no_escapa(raiz, &dst)?; report.dirs_created += 1; walk_and_link(root, &src, target_fhs, raiz, 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)?; atomic_replace_symlink(&link_target, &dst)?; report.symlinks += 1; report.files.push(HydratedFile { src, dst }); } else if ft.is_file() { // 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. return Err(takana_core::Error::Other(anyhow::anyhow!( "tipo no soportado en {}: {ft:?}", src.display() ))); } } Ok(()) } /// Crea un hardlink `dst → src` reemplazando atómicamente cualquier archivo previo en `dst`. /// Implementación: `link(src, dst.tmp)` + `rename(dst.tmp, dst)`. La técnica garantiza que /// el archivo en `dst` nunca queda en estado intermedio y que el inode del store no se toca. fn atomic_hardlink(src: &Path, dst: &Path) -> takana_core::Result<()> { if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent)?; } let tmp = with_suffix(dst, ".hammer-tmp"); // Limpia un tmp olvidado de una corrida anterior. let _ = std::fs::remove_file(&tmp); if let Err(e) = std::fs::hard_link(src, &tmp) { // ⚠ EXDEV NO ES UN FALLO: es «el destino está en otro sistema de ficheros». Y `linkat` lo // devuelve incluso entre dos MOUNTS del mismo dispositivo —un bind-mount cuenta como otro—, // así que pasa en máquinas perfectamente normales: el store bindeado a un volumen y un // `--prefix` fuera de él. Sin este camino, `takana install --prefix` moría con // `Invalid cross-device link` justo en el caso que su propia ayuda promete («staging en otro // filesystem»). Medido el 2026-09-12 instalando el modelo opcional del §6.3. // // Se COPIA, que es lo único posible: el precio es el espacio (deja de compartir inode con el // store) y está aceptado, porque la alternativa es no poder instalar. if e.raw_os_error() != Some(libc::EXDEV) { return Err(takana_core::Error::Store(format!( "hardlink {} → {}: {e}", src.display(), tmp.display() ))); } std::fs::copy(src, &tmp).map_err(|e2| { takana_core::Error::Store(format!( "el destino está en otro filesystem y la copia {} → {} también falló: {e2}", src.display(), tmp.display() )) })?; } // rename sobreescribe atómicamente si dst es un archivo (no si es directorio). if let Err(e) = std::fs::rename(&tmp, dst) { let _ = std::fs::remove_file(&tmp); return Err(takana_core::Error::Store(format!( "rename {} → {}: {e}", tmp.display(), dst.display() ))); } 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) -> takana_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() -> takana_core::Result<()> { match Command::new("patchelf").arg("--version").output() { Ok(o) if o.status.success() => Ok(()), Ok(o) => Err(takana_core::Error::Other(anyhow::anyhow!( "patchelf falló al invocarse: {}", String::from_utf8_lossy(&o.stderr).trim() ))), Err(e) => Err(takana_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) -> takana_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| { takana_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(takana_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) -> takana_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| { takana_core::Error::Other(anyhow::anyhow!( "no pude ejecutar patchelf sobre {}: {e}", file.display() )) })?; if !out.status.success() { return Err(takana_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) -> takana_core::Result<()> { use std::os::unix::fs::symlink; 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); symlink(link_target, &tmp).map_err(|e| { takana_core::Error::Store(format!("symlink {} → {}: {e}", link_target.display(), tmp.display())) })?; if let Err(e) = std::fs::rename(&tmp, dst) { let _ = std::fs::remove_file(&tmp); return Err(takana_core::Error::Store(format!( "rename {} → {}: {e}", tmp.display(), dst.display() ))); } Ok(()) } fn with_suffix(p: &Path, suffix: &str) -> PathBuf { let mut s = p.as_os_str().to_owned(); s.push(suffix); PathBuf::from(s) } #[cfg(test)] mod tests { use super::*; use std::os::unix::fs::MetadataExt; fn populate(dir: &Path) { std::fs::create_dir_all(dir.join("usr/bin")).unwrap(); std::fs::write(dir.join("usr/bin/hello"), b"#!/bin/sh\necho hi\n").unwrap(); std::fs::create_dir_all(dir.join("usr/share/doc")).unwrap(); std::fs::write(dir.join("usr/share/doc/README"), b"docs").unwrap(); // symlink: usr/sbin/hello -> ../bin/hello (target relativo) std::fs::create_dir_all(dir.join("usr/sbin")).unwrap(); std::os::unix::fs::symlink("../bin/hello", dir.join("usr/sbin/hello")).unwrap(); } #[test] fn hydrate_creates_hardlinks_with_shared_inode() { let artifact = tempfile::tempdir().unwrap(); let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap(); populate(artifact.path()); 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); 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, "hardlink ⇒ mismo inode"); } #[test] fn hydrate_overwrites_existing_without_touching_store() { let artifact = tempfile::tempdir().unwrap(); let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap(); populate(artifact.path()); // Pre-existe un archivo distinto en el target. 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, None).unwrap(); // El target ahora apunta al inode del store. 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); // El contenido viejo desapareció; el del store sigue intacto. let got = std::fs::read_to_string(target.path().join("usr/bin/hello")).unwrap(); assert!(got.starts_with("#!/bin/sh")); let store_still = std::fs::read_to_string(artifact.path().join("usr/bin/hello")).unwrap(); assert!(store_still.starts_with("#!/bin/sh")); } #[test] fn hydrate_is_idempotent() { 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, None).unwrap(); // Segundo paso no debe fallar. hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap(); } #[test] fn hydrate_replicates_symlink_target_literally() { 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, 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_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 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 (`TAKANA_HOST_ELF_TESTS=1`). #[test] fn hydrate_dynamic_patchea_elf_real() { if takana_core::env::var("TAKANA_HOST_ELF_TESTS").is_none() { 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"); } /// ⚠ El destino en OTRO filesystem no es un error: es un `--prefix` de staging, que la ayuda de /// `install` promete por su nombre. `linkat` devuelve EXDEV y hay que copiar. /// /// La prueba usa `/dev/shm` (tmpfs) contra el temporal del test, que están en mounts distintos. /// Si en esta máquina no lo estuvieran, el test se calla — pero NO en silencio: imprime por qué, /// porque un test que se salta sin decirlo es un test que no existe. #[test] fn hidratar_a_otro_filesystem_copia_en_vez_de_morir() { let origen = tempfile::tempdir().unwrap(); std::fs::create_dir_all(origen.path().join("usr/share")).unwrap(); let fichero = origen.path().join("usr/share/dato.bin"); std::fs::write(&fichero, b"contenido").unwrap(); let destino = std::path::Path::new("/dev/shm").join(format!("takana-exdev-{}", std::process::id())); let _ = std::fs::remove_dir_all(&destino); std::fs::create_dir_all(&destino).unwrap(); // ¿Están de verdad en mounts distintos? Si no, no hay nada que medir acá. let sonda = destino.join("sonda"); let cruza = std::fs::hard_link(&fichero, &sonda) .err() .map(|e| e.raw_os_error() == Some(libc::EXDEV)) .unwrap_or(false); let _ = std::fs::remove_file(&sonda); if !cruza { eprintln!("saltado: /dev/shm y el tmp de este test están en el MISMO mount"); let _ = std::fs::remove_dir_all(&destino); return; } let informe = hydrate(origen.path(), &destino, LinkMode::Static, None).unwrap(); assert_eq!(informe.files.len(), 1, "{informe:?}"); let copiado = destino.join("usr/share/dato.bin"); assert_eq!(std::fs::read(&copiado).unwrap(), b"contenido"); // Copia, no hardlink: inodes distintos. Es el precio aceptado — el espacio a cambio de poder // instalar fuera del filesystem del store. use std::os::unix::fs::MetadataExt; assert_ne!( std::fs::metadata(&fichero).unwrap().ino(), std::fs::metadata(&copiado).unwrap().ino() ); let _ = std::fs::remove_dir_all(&destino); } }