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 1532Shell 196Rust 108Markdown 993686 Definitions

⎇ 726068e653c06a7b4d7fce1d86ad7b36e4093e82 crates/hammer-build/src/hydrate.rs Rust · 479 lines · 21.0 KB History Who touched what raw

hidratación: no se escribe a través de un symlink que sale del root (SDD 25 H3) 726068e65 · Sergio ·

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