diff --git a/crates/takana-build/src/hydrate.rs b/crates/takana-build/src/hydrate.rs index 6bb756a1..c470d793 100644 --- a/crates/takana-build/src/hydrate.rs +++ b/crates/takana-build/src/hydrate.rs @@ -1,6 +1,7 @@ //! Hidratación: del store al FHS. Ver `docs/03-hydration.md`. //! -//! Estrategia primaria (`LinkMode::Static`): hardlink directo. Cero copia, cero RPATH que +//! 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). @@ -202,13 +203,31 @@ fn atomic_hardlink(src: &Path, dst: &Path) -> takana_core::Result<()> { let tmp = with_suffix(dst, ".hammer-tmp"); // Limpia un tmp olvidado de una corrida anterior. let _ = std::fs::remove_file(&tmp); - std::fs::hard_link(src, &tmp).map_err(|e| { - takana_core::Error::Store(format!( - "hardlink {} → {}: {e}", - src.display(), - tmp.display() - )) - })?; + 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); @@ -476,4 +495,48 @@ mod tests { 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); + } } diff --git a/crates/takana-build/src/swm_bridge.rs b/crates/takana-build/src/swm_bridge.rs index 349cb58b..97c7bdfb 100644 --- a/crates/takana-build/src/swm_bridge.rs +++ b/crates/takana-build/src/swm_bridge.rs @@ -316,6 +316,9 @@ logo = "b3:azul" flags: vec!["--enable-foo".into()], phases: Default::default(), zig_version: None, + // Agregado en 9bc51889 («no viajaba en el paquete, y es ENTRADA DE HASH»): este + // constructor de test se quedó sin él y dejó al crate ENTERO sin compilar sus tests. + strip_debug: None, } }