gioser

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…

TOML 1389Shell 137Markdown 80Rust 802602 Definiciones

⎇ main crates/takana-build/src/hydrate.rs Rust · 542 líneas · 24.4 KB Historia Quién tocó qué crudo

hydrate: instalar a otro filesystem ya no muere — EXDEV se copia, que es lo único posible 21d44c13c · Sergio ·

1//! Hidratación: del store al FHS. Ver `docs/03-hydration.md`.
2//!
3//! Estrategia primaria (`LinkMode::Static`): hardlink directo. Cero copia —salvo que el destino
4//! esté en otro filesystem, donde `linkat` da EXDEV y no queda más que copiar—, cero RPATH que
5//! reescribir. El FHS queda con archivos reales (no symlinks) compartiendo inode con el store.
6//! Pisar un archivo del FHS rompe el hardlink (CoW al kernel) sin tocar el artefacto del store
7//! — base de rollback (`takana hydrate <hash>` lo restaura).
8//!
9//! Estrategia secundaria (`LinkMode::Dynamic`): los ELF que necesitan normalización de
10//! interpreter/RPATH se **copian** (no se hardlinkean: `patchelf` reescribe el binario y un
11//! hardlink mutaría el inode del store) y se parchean con `patchelf` según el [`DynamicSpec`].
12//! Los archivos no-ELF (datos, scripts, libs ya correctas) siguen hardlinkeándose como en
13//! estático. Ver `docs/03-hydration.md` §4.
14
15use std::os::unix::fs::FileTypeExt;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use takana_core::LinkMode;
20
21#[derive(Debug, Clone)]
22pub struct HydratedFile {
23    pub src: PathBuf,
24    pub dst: PathBuf,
25}
26
27#[derive(Debug, Default)]
28pub struct HydrateReport {
29    pub files: Vec<HydratedFile>,
30    pub dirs_created: usize,
31    pub symlinks: usize,
32    /// Cuántos ELF se copiaron+parchearon con patchelf (sólo en modo dinámico).
33    pub patched: usize,
34}
35
36/// Parámetros de la hidratación dinámica: qué fijarle a cada ELF antes de proyectarlo. Ambos
37/// son opcionales; si los dos son `None`, el modo dinámico se comporta como el estático
38/// (nada que parchear ⇒ hardlink directo). En producción salen del contexto de la distro
39/// (loader musl + layout de `/usr/lib`).
40#[derive(Debug, Clone, Default)]
41pub struct DynamicSpec {
42    /// Interpreter (dynamic loader) a fijar, p. ej. `/lib/ld-musl-x86_64.so.1`.
43    pub interpreter: Option<String>,
44    /// RPATH a fijar, p. ej. `/usr/lib:/lib`.
45    pub rpath: Option<String>,
46}
47
48impl DynamicSpec {
49    /// `true` si hay algo que patchelf debería reescribir.
50    fn has_work(&self) -> bool {
51        self.interpreter.is_some() || self.rpath.is_some()
52    }
53}
54
55/// Proyecta el contenido de `artifact_dir` al árbol bajo `target_fhs`. Conserva la jerarquía
56/// relativa: `<artifact>/usr/bin/grep` → `<target>/usr/bin/grep`.
57///
58/// Sobre archivos preexistentes en el target, sobrescribe atómicamente (link tmp + rename) —
59/// los archivos antiguos se descartan, pero los inodes del store siguen intactos.
60pub fn hydrate(
61    artifact_dir: &Path,
62    target_fhs: &Path,
63    mode: LinkMode,
64    dynamic: Option<&DynamicSpec>,
65) -> takana_core::Result<HydrateReport> {
66    // `is_dir()` a secas acepta un directorio VACÍO: hydrate proyectaría 0 ficheros y devolvería
67    // un HydrateReport de éxito. Es el mismo agujero que ya se tapó en `Store::has` (2026-08-10) y
68    // en `Store::seal` (2026-08-29): un vacío no es un artefacto, es un nombre. Acá duele especial
69    // porque el escritorio se hidrata así (`takana hydrate <hash> --into`), y un FHS proyectado
70    // desde nada sale "OK" y falla mucho más tarde, sin rastro de cuál artefacto faltaba.
71    let vacio = std::fs::read_dir(artifact_dir)
72        .map(|mut e| e.next().is_none())
73        .unwrap_or(true);
74    if !artifact_dir.is_dir() || vacio {
75        return Err(takana_core::Error::Store(format!(
76            "artefacto inexistente o VACÍO: {}",
77            artifact_dir.display()
78        )));
79    }
80    // En modo dinámico con patcheo real, exigimos que `patchelf` exista de antemano: mejor
81    // fallar limpio que dejar el FHS a medio proyectar.
82    let spec = match mode {
83        LinkMode::Dynamic => {
84            let spec = dynamic.cloned().unwrap_or_default();
85            if spec.has_work() {
86                ensure_patchelf()?;
87            }
88            Some(spec)
89        }
90        LinkMode::Static => None,
91    };
92    std::fs::create_dir_all(target_fhs)?;
93
94    // La raíz REAL contra la que se mide la contención (ver `no_escapa`). Se resuelve una sola vez:
95    // si el propio `target_fhs` es un symlink, eso es elección de quien hidrata y vale como raíz.
96    let raiz = target_fhs.canonicalize().map_err(|e| {
97        takana_core::Error::Store(format!("canonicalize {}: {e}", target_fhs.display()))
98    })?;
99
100    let mut report = HydrateReport::default();
101    walk_and_link(artifact_dir, artifact_dir, target_fhs, &raiz, spec.as_ref(), &mut report)?;
102    Ok(report)
103}
104
105/// El destino no puede salirse de la raíz. Se comprueba **por directorio**, no por fichero.
106///
107/// EL FALLO QUE TAPA (medido 2026-08-30, `tests/hidratacion_no_escapa.rs`): hidratar es
108/// `target_fhs.join(rel)` y escribir por ruta, así que **un symlink de directorio ya presente en el
109/// FHS se sigue**. Un artefacto que trae `usr/share/pkg → /algún/lado` deja ese symlink puesto (se
110/// replican literales, y así debe ser), y la hidratación SIGUIENTE escribe `usr/share/pkg/archivo`
111/// **fuera del root** — devolviendo `Ok`. Con `--into` sobre una imagen, eso es escribir en el
112/// sistema anfitrión diciendo que todo fue bien: exactamente el fallo de CLAUDE.md §3.
113///
114/// Hoy el corpus NO lo dispara —de 160 symlinks absolutos ninguno apunta a un directorio, y de
115/// ~42 000 relativos ninguno sale de su artefacto— pero nada lo impedía.
116///
117/// Semántica elegida: symlinks **permitidos mientras no salgan** (un `/lib → usr/lib` de usr-merge,
118/// o los `22@2x → 22` de los temas de iconos, tienen que seguir funcionando). Es la de
119/// `openat2(RESOLVE_IN_ROOT)`; acá se hace con `canonicalize` porque las escrituras siguen siendo
120/// por ruta (`hard_link`, `rename`, `patchelf`). La diferencia que queda: `canonicalize`+escribir
121/// es comprobar-y-después-usar, o sea que un adversario CONCURRENTE podría cambiar el componente
122/// entre las dos. Para el fallo real —un symlink persistente puesto por otro artefacto— alcanza y
123/// es determinista; la versión sin carrera es mover las escrituras a `openat2` + `linkat`/`renameat`
124/// (SDD 25 §7-H3), y eso arrastra el camino de `patchelf`.
125fn no_escapa(raiz: &Path, dst: &Path) -> takana_core::Result<()> {
126    let real = dst.canonicalize().map_err(|e| {
127        takana_core::Error::Store(format!("canonicalize {}: {e}", dst.display()))
128    })?;
129    if !real.starts_with(raiz) {
130        return Err(takana_core::Error::Store(format!(
131            "la hidratación se saldría del root: {} lleva a {}, fuera de {}.\n\
132             Es un symlink de directorio ya presente en el FHS (probablemente puesto por otro \
133             artefacto). No se escribe a través de él: borralo o hidratá sobre un root limpio.",
134            dst.display(),
135            real.display(),
136            raiz.display()
137        )));
138    }
139    Ok(())
140}
141
142fn walk_and_link(
143    root: &Path,
144    cur: &Path,
145    target_fhs: &Path,
146    raiz: &Path,
147    spec: Option<&DynamicSpec>,
148    report: &mut HydrateReport,
149) -> takana_core::Result<()> {
150    for entry in std::fs::read_dir(cur)? {
151        let entry = entry?;
152        let src = entry.path();
153        let rel = src.strip_prefix(root).expect("rel under root");
154        let dst = target_fhs.join(rel);
155        let ft = entry.file_type()?;
156
157        if ft.is_dir() {
158            std::fs::create_dir_all(&dst)?;
159            // Acá y no en cada fichero: los ficheros cuelgan de un directorio ya comprobado, y
160            // `canonicalize` por fichero costaría un realpath por entrada (SDD 25 T8/§9.4).
161            no_escapa(raiz, &dst)?;
162            report.dirs_created += 1;
163            walk_and_link(root, &src, target_fhs, raiz, spec, report)?;
164        } else if ft.is_symlink() {
165            // Replicamos el symlink con el mismo target literal (no resolvemos).
166            let link_target = std::fs::read_link(&src)?;
167            atomic_replace_symlink(&link_target, &dst)?;
168            report.symlinks += 1;
169            report.files.push(HydratedFile { src, dst });
170        } else if ft.is_file() {
171            // Modo dinámico: un ELF que necesita interpreter/RPATH se copia y se parchea
172            // (hardlinkearlo mutaría el store). Todo lo demás se hardlinkea como en estático.
173            let patch = match spec {
174                Some(s) if s.has_work() && is_elf(&src)? => Some(s),
175                _ => None,
176            };
177            match patch {
178                Some(s) => {
179                    atomic_copy_and_patch(&src, &dst, s)?;
180                    report.patched += 1;
181                }
182                None => atomic_hardlink(&src, &dst)?,
183            }
184            report.files.push(HydratedFile { src, dst });
185        } else if ft.is_block_device() || ft.is_char_device() || ft.is_fifo() || ft.is_socket() {
186            // Los artefactos del lab no deberían contener nodos especiales; lo señalamos.
187            return Err(takana_core::Error::Other(anyhow::anyhow!(
188                "tipo no soportado en {}: {ft:?}",
189                src.display()
190            )));
191        }
192    }
193    Ok(())
194}
195
196/// Crea un hardlink `dst → src` reemplazando atómicamente cualquier archivo previo en `dst`.
197/// Implementación: `link(src, dst.tmp)` + `rename(dst.tmp, dst)`. La técnica garantiza que
198/// el archivo en `dst` nunca queda en estado intermedio y que el inode del store no se toca.
199fn atomic_hardlink(src: &Path, dst: &Path) -> takana_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    // Limpia un tmp olvidado de una corrida anterior.
205    let _ = std::fs::remove_file(&tmp);
206    if let Err(e) = std::fs::hard_link(src, &tmp) {
207        // ⚠ EXDEV NO ES UN FALLO: es «el destino está en otro sistema de ficheros». Y `linkat` lo
208        // devuelve incluso entre dos MOUNTS del mismo dispositivo —un bind-mount cuenta como otro—,
209        // así que pasa en máquinas perfectamente normales: el store bindeado a un volumen y un
210        // `--prefix` fuera de él. Sin este camino, `takana install --prefix` moría con
211        // `Invalid cross-device link` justo en el caso que su propia ayuda promete («staging en otro
212        // filesystem»). Medido el 2026-09-12 instalando el modelo opcional del §6.3.
213        //
214        // Se COPIA, que es lo único posible: el precio es el espacio (deja de compartir inode con el
215        // store) y está aceptado, porque la alternativa es no poder instalar.
216        if e.raw_os_error() != Some(libc::EXDEV) {
217            return Err(takana_core::Error::Store(format!(
218                "hardlink {} → {}: {e}",
219                src.display(),
220                tmp.display()
221            )));
222        }
223        std::fs::copy(src, &tmp).map_err(|e2| {
224            takana_core::Error::Store(format!(
225                "el destino está en otro filesystem y la copia {} → {} también falló: {e2}",
226                src.display(),
227                tmp.display()
228            ))
229        })?;
230    }
231    // rename sobreescribe atómicamente si dst es un archivo (no si es directorio).
232    if let Err(e) = std::fs::rename(&tmp, dst) {
233        let _ = std::fs::remove_file(&tmp);
234        return Err(takana_core::Error::Store(format!(
235            "rename {} → {}: {e}",
236            tmp.display(),
237            dst.display()
238        )));
239    }
240    Ok(())
241}
242
243/// `true` si el archivo empieza por el magic ELF (`\x7fELF`). Errores de I/O se propagan;
244/// un archivo más corto que 4 bytes simplemente no es ELF.
245fn is_elf(path: &Path) -> takana_core::Result<bool> {
246    use std::io::Read;
247    let mut f = std::fs::File::open(path)?;
248    let mut magic = [0u8; 4];
249    match f.read_exact(&mut magic) {
250        Ok(()) => Ok(magic == [0x7f, b'E', b'L', b'F']),
251        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
252        Err(e) => Err(e.into()),
253    }
254}
255
256/// Verifica que `patchelf` esté disponible antes de empezar a proyectar.
257fn ensure_patchelf() -> takana_core::Result<()> {
258    match Command::new("patchelf").arg("--version").output() {
259        Ok(o) if o.status.success() => Ok(()),
260        Ok(o) => Err(takana_core::Error::Other(anyhow::anyhow!(
261            "patchelf falló al invocarse: {}",
262            String::from_utf8_lossy(&o.stderr).trim()
263        ))),
264        Err(e) => Err(takana_core::Error::Other(anyhow::anyhow!(
265            "hidratación dinámica requiere `patchelf` en el PATH y no se pudo ejecutar: {e}"
266        ))),
267    }
268}
269
270/// Copia `src → dst` atómicamente (copy a tmp → patchelf el tmp → rename), preservando los
271/// permisos (`std::fs::copy` los replica, incluido el bit de ejecución). Patcheamos el tmp
272/// **antes** del rename para que `dst` nunca quede en un estado a medio parchear.
273fn atomic_copy_and_patch(src: &Path, dst: &Path, spec: &DynamicSpec) -> takana_core::Result<()> {
274    if let Some(parent) = dst.parent() {
275        std::fs::create_dir_all(parent)?;
276    }
277    let tmp = with_suffix(dst, ".hammer-tmp");
278    let _ = std::fs::remove_file(&tmp);
279    std::fs::copy(src, &tmp).map_err(|e| {
280        takana_core::Error::Store(format!("copy {} → {}: {e}", src.display(), tmp.display()))
281    })?;
282    if let Err(e) = run_patchelf(&tmp, spec) {
283        let _ = std::fs::remove_file(&tmp);
284        return Err(e);
285    }
286    if let Err(e) = std::fs::rename(&tmp, dst) {
287        let _ = std::fs::remove_file(&tmp);
288        return Err(takana_core::Error::Store(format!(
289            "rename {} → {}: {e}",
290            tmp.display(),
291            dst.display()
292        )));
293    }
294    Ok(())
295}
296
297/// Ejecuta `patchelf [--set-interpreter I] [--set-rpath R] <file>`. Asume que el caller ya
298/// comprobó (`has_work`) que hay al menos una de las dos.
299fn run_patchelf(file: &Path, spec: &DynamicSpec) -> takana_core::Result<()> {
300    let mut cmd = Command::new("patchelf");
301    if let Some(interp) = &spec.interpreter {
302        cmd.arg("--set-interpreter").arg(interp);
303    }
304    if let Some(rpath) = &spec.rpath {
305        cmd.arg("--set-rpath").arg(rpath);
306    }
307    cmd.arg(file);
308    let out = cmd.output().map_err(|e| {
309        takana_core::Error::Other(anyhow::anyhow!(
310            "no pude ejecutar patchelf sobre {}: {e}",
311            file.display()
312        ))
313    })?;
314    if !out.status.success() {
315        return Err(takana_core::Error::Other(anyhow::anyhow!(
316            "patchelf falló sobre {}: {}",
317            file.display(),
318            String::from_utf8_lossy(&out.stderr).trim()
319        )));
320    }
321    Ok(())
322}
323
324fn atomic_replace_symlink(link_target: &Path, dst: &Path) -> takana_core::Result<()> {
325    use std::os::unix::fs::symlink;
326    if let Some(parent) = dst.parent() {
327        std::fs::create_dir_all(parent)?;
328    }
329    let tmp = with_suffix(dst, ".hammer-tmp");
330    let _ = std::fs::remove_file(&tmp);
331    symlink(link_target, &tmp).map_err(|e| {
332        takana_core::Error::Store(format!("symlink {} → {}: {e}", link_target.display(), tmp.display()))
333    })?;
334    if let Err(e) = std::fs::rename(&tmp, dst) {
335        let _ = std::fs::remove_file(&tmp);
336        return Err(takana_core::Error::Store(format!(
337            "rename {} → {}: {e}",
338            tmp.display(),
339            dst.display()
340        )));
341    }
342    Ok(())
343}
344
345fn with_suffix(p: &Path, suffix: &str) -> PathBuf {
346    let mut s = p.as_os_str().to_owned();
347    s.push(suffix);
348    PathBuf::from(s)
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use std::os::unix::fs::MetadataExt;
355
356    fn populate(dir: &Path) {
357        std::fs::create_dir_all(dir.join("usr/bin")).unwrap();
358        std::fs::write(dir.join("usr/bin/hello"), b"#!/bin/sh\necho hi\n").unwrap();
359        std::fs::create_dir_all(dir.join("usr/share/doc")).unwrap();
360        std::fs::write(dir.join("usr/share/doc/README"), b"docs").unwrap();
361        // symlink: usr/sbin/hello -> ../bin/hello (target relativo)
362        std::fs::create_dir_all(dir.join("usr/sbin")).unwrap();
363        std::os::unix::fs::symlink("../bin/hello", dir.join("usr/sbin/hello")).unwrap();
364    }
365
366    #[test]
367    fn hydrate_creates_hardlinks_with_shared_inode() {
368        let artifact = tempfile::tempdir().unwrap();
369        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
370        populate(artifact.path());
371
372        let report = hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
373        assert_eq!(report.files.len(), 3); // hello, README, symlink
374        assert_eq!(report.symlinks, 1);
375
376        let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
377        let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
378        assert_eq!(src_ino, dst_ino, "hardlink ⇒ mismo inode");
379    }
380
381    #[test]
382    fn hydrate_overwrites_existing_without_touching_store() {
383        let artifact = tempfile::tempdir().unwrap();
384        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
385        populate(artifact.path());
386
387        // Pre-existe un archivo distinto en el target.
388        std::fs::create_dir_all(target.path().join("usr/bin")).unwrap();
389        std::fs::write(target.path().join("usr/bin/hello"), b"contenido viejo").unwrap();
390
391        hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
392
393        // El target ahora apunta al inode del store.
394        let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
395        let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
396        assert_eq!(src_ino, dst_ino);
397        // El contenido viejo desapareció; el del store sigue intacto.
398        let got = std::fs::read_to_string(target.path().join("usr/bin/hello")).unwrap();
399        assert!(got.starts_with("#!/bin/sh"));
400        let store_still = std::fs::read_to_string(artifact.path().join("usr/bin/hello")).unwrap();
401        assert!(store_still.starts_with("#!/bin/sh"));
402    }
403
404    #[test]
405    fn hydrate_is_idempotent() {
406        let artifact = tempfile::tempdir().unwrap();
407        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
408        populate(artifact.path());
409        hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
410        // Segundo paso no debe fallar.
411        hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
412    }
413
414    #[test]
415    fn hydrate_replicates_symlink_target_literally() {
416        let artifact = tempfile::tempdir().unwrap();
417        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
418        populate(artifact.path());
419        hydrate(artifact.path(), target.path(), LinkMode::Static, None).unwrap();
420        let link = std::fs::read_link(target.path().join("usr/sbin/hello")).unwrap();
421        assert_eq!(link, std::path::PathBuf::from("../bin/hello"));
422    }
423
424    #[test]
425    fn hydrate_dynamic_sin_spec_se_comporta_como_estatico() {
426        // Sin interpreter ni rpath no hay nada que parchear ⇒ hardlink directo (mismo inode),
427        // sin necesitar patchelf. Es el caso que prueba que el modo dinámico no rompe.
428        let artifact = tempfile::tempdir().unwrap();
429        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
430        populate(artifact.path());
431        let report = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, None).unwrap();
432        assert_eq!(report.patched, 0);
433        let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
434        let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
435        assert_eq!(src_ino, dst_ino, "sin spec ⇒ hardlink ⇒ mismo inode");
436    }
437
438    #[test]
439    fn is_elf_detecta_magic() {
440        let d = tempfile::tempdir().unwrap();
441        let elf = d.path().join("bin");
442        std::fs::write(&elf, [0x7f, b'E', b'L', b'F', 0x02, 0x01]).unwrap();
443        let noelf = d.path().join("script");
444        std::fs::write(&noelf, b"#!/bin/sh\n").unwrap();
445        let corto = d.path().join("corto");
446        std::fs::write(&corto, b"hi").unwrap();
447        assert!(is_elf(&elf).unwrap());
448        assert!(!is_elf(&noelf).unwrap());
449        assert!(!is_elf(&corto).unwrap(), "archivo <4 bytes no es ELF");
450    }
451
452    #[test]
453    fn hydrate_dynamic_con_spec_pero_sin_patchelf_falla_limpio() {
454        // Con un interpreter a fijar y un "ELF" presente, se exige patchelf. En este entorno
455        // patchelf no está instalado ⇒ debe fallar con mensaje claro ANTES de proyectar.
456        if Command::new("patchelf").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) {
457            eprintln!("patchelf presente; salto el test del camino de error");
458            return;
459        }
460        let artifact = tempfile::tempdir().unwrap();
461        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
462        std::fs::create_dir_all(artifact.path().join("usr/bin")).unwrap();
463        std::fs::write(artifact.path().join("usr/bin/elf"), [0x7f, b'E', b'L', b'F', 0, 0, 0, 0]).unwrap();
464        let spec = DynamicSpec {
465            interpreter: Some("/lib/ld-musl-x86_64.so.1".into()),
466            rpath: Some("/usr/lib:/lib".into()),
467        };
468        let err = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, Some(&spec))
469            .unwrap_err()
470            .to_string();
471        assert!(err.contains("patchelf"), "{err}");
472    }
473
474    /// Camino feliz real: requiere patchelf + un ELF de verdad. Gated como el resto de tests
475    /// dependientes del host (`TAKANA_HOST_ELF_TESTS=1`).
476    #[test]
477    fn hydrate_dynamic_patchea_elf_real() {
478        if takana_core::env::var("TAKANA_HOST_ELF_TESTS").is_none() {
479            return;
480        }
481        let host_bin = std::path::Path::new("/bin/sh");
482        if !host_bin.exists() {
483            return;
484        }
485        let artifact = tempfile::tempdir().unwrap();
486        let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
487        std::fs::create_dir_all(artifact.path().join("usr/bin")).unwrap();
488        std::fs::copy(host_bin, artifact.path().join("usr/bin/sh")).unwrap();
489        let spec = DynamicSpec { interpreter: None, rpath: Some("/usr/lib:/lib".into()) };
490        let report = hydrate(artifact.path(), target.path(), LinkMode::Dynamic, Some(&spec)).unwrap();
491        assert_eq!(report.patched, 1);
492        // El binario proyectado es una COPIA (distinto inode del artefacto), porque patchelf lo
493        // reescribió sin tocar el store.
494        let src_ino = std::fs::metadata(artifact.path().join("usr/bin/sh")).unwrap().ino();
495        let dst_ino = std::fs::metadata(target.path().join("usr/bin/sh")).unwrap().ino();
496        assert_ne!(src_ino, dst_ino, "ELF parcheado ⇒ copia ⇒ inode distinto");
497    }
498
499    /// ⚠ El destino en OTRO filesystem no es un error: es un `--prefix` de staging, que la ayuda de
500    /// `install` promete por su nombre. `linkat` devuelve EXDEV y hay que copiar.
501    ///
502    /// La prueba usa `/dev/shm` (tmpfs) contra el temporal del test, que están en mounts distintos.
503    /// Si en esta máquina no lo estuvieran, el test se calla — pero NO en silencio: imprime por qué,
504    /// porque un test que se salta sin decirlo es un test que no existe.
505    #[test]
506    fn hidratar_a_otro_filesystem_copia_en_vez_de_morir() {
507        let origen = tempfile::tempdir().unwrap();
508        std::fs::create_dir_all(origen.path().join("usr/share")).unwrap();
509        let fichero = origen.path().join("usr/share/dato.bin");
510        std::fs::write(&fichero, b"contenido").unwrap();
511
512        let destino = std::path::Path::new("/dev/shm").join(format!("takana-exdev-{}", std::process::id()));
513        let _ = std::fs::remove_dir_all(&destino);
514        std::fs::create_dir_all(&destino).unwrap();
515
516        // ¿Están de verdad en mounts distintos? Si no, no hay nada que medir acá.
517        let sonda = destino.join("sonda");
518        let cruza = std::fs::hard_link(&fichero, &sonda)
519            .err()
520            .map(|e| e.raw_os_error() == Some(libc::EXDEV))
521            .unwrap_or(false);
522        let _ = std::fs::remove_file(&sonda);
523        if !cruza {
524            eprintln!("saltado: /dev/shm y el tmp de este test están en el MISMO mount");
525            let _ = std::fs::remove_dir_all(&destino);
526            return;
527        }
528
529        let informe = hydrate(origen.path(), &destino, LinkMode::Static, None).unwrap();
530        assert_eq!(informe.files.len(), 1, "{informe:?}");
531        let copiado = destino.join("usr/share/dato.bin");
532        assert_eq!(std::fs::read(&copiado).unwrap(), b"contenido");
533        // Copia, no hardlink: inodes distintos. Es el precio aceptado — el espacio a cambio de poder
534        // instalar fuera del filesystem del store.
535        use std::os::unix::fs::MetadataExt;
536        assert_ne!(
537            std::fs::metadata(&fichero).unwrap().ino(),
538            std::fs::metadata(&copiado).unwrap().ino()
539        );
540        let _ = std::fs::remove_dir_all(&destino);
541    }
542}