La primera instalación real de un paquete en la caja (`takana install zsh`, sin --prefix) dejó el sistema A MEDIAS: `commit` desmontó 5 de 7 targets y murió con `target is busy` en /bin, con dos overlays montados y el estado sin promocionar. La causa no es un fd abierto: los procesos tienen su EJECUTABLE mapeado desde /bin y /usr/bin —empezando por PID 1, /usr/bin/arje-zero— y un ejecutable mapeado pin-ea el montaje. En un FHS vivo eso no se puede evitar esperando. El comentario de `do_umount_lenient` prometía el desmontaje perezoso «desde la Fase 2» y el código NUNCA pasaba `-l`. Ahora, ante `busy`, reintenta perezoso: desengancha el montaje ya mismo (las rutas vuelven a resolver al directorio real, que es lo que el promote necesita) y libera cuando el último proceso que lo miraba se muere; los vivos ven el mismo contenido porque el commit copia el upper al lower justo después. Probado en la caja con un paquete nuevo: install tree ⇒ overlay; commit ⇒ WARN del perezoso sobre /usr/bin y «commit OK — 1 archivo(s) promocionados»; 0 overlays, /usr/bin/tree real, tree v2.3.2. El SDD 28 §5.10 anota las otras cuatro cosas que ese `sudo takana install zsh` destapó: el repo por defecto que no existía, el lab que no se encontraba desde /root, el binario del sistema incapaz de leer los .tkn nuevos, y —la peor— que la instalación queda PARTIDA porque el overlay cubre siete directorios y /usr/share no es uno: 1296 de los 1331 ficheros fueron directos al FHS real mientras 2 binarios esperaban el commit, y el mensaje decía `apply OK` igual.
728 lines
27 KiB
Rust
728 lines
27 KiB
Rust
//! El overlay de experimentación. Ver `docs/04-overlay.md`.
|
|
//!
|
|
//! Layout en disco bajo `state_root` (default `/var/lib/hammer/overlays/`):
|
|
//!
|
|
//! ```text
|
|
//! <state_root>/
|
|
//! <id>/
|
|
//! state.json manifiesto: targets, hora de creación, etc.
|
|
//! mounts/
|
|
//! <target_slug>/
|
|
//! upper/ escrituras
|
|
//! work/ workdir requerido por overlayfs (mismo FS que upper)
|
|
//! ```
|
|
//!
|
|
//! Diseño: la librería no asume privilegios — emite los comandos `mount`/`umount` y deja
|
|
//! que el proceso que la invoca tenga (o no) CAP_SYS_ADMIN. Esto permite:
|
|
//! - El CLI `takana try` lo invoca con sudo (o setcap) en una máquina real.
|
|
//! - `hammerd` lo invoca desde su contexto privilegiado (Fase 5).
|
|
//! - Los tests lo ejercitan dentro de `bwrap --unshare-user-try --unshare-pid` que da
|
|
//! CAP_SYS_ADMIN en un user-ns aislado.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
pub const DEFAULT_STATE_ROOT: &str = "/var/lib/hammer/overlays";
|
|
|
|
/// Targets por defecto cuando `try` se invoca sin argumentos. Cubren el FHS clásico de
|
|
/// userland (binarios + libs + config); el store y `/home` quedan fuera a propósito.
|
|
pub fn default_targets() -> Vec<PathBuf> {
|
|
["/usr/bin", "/bin", "/usr/sbin", "/sbin", "/usr/lib", "/lib", "/etc"]
|
|
.into_iter()
|
|
.map(PathBuf::from)
|
|
.collect()
|
|
}
|
|
|
|
/// Identificador de un overlay activo. Por ahora es una cadena corta legible
|
|
/// (timestamp + nonce) generada por el caller; mantenemos el tipo opaco para poder
|
|
/// cambiar el formato sin tocar consumidores.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct OverlayId(pub String);
|
|
|
|
impl OverlayId {
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for OverlayId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
/// Una mount concreta dentro de un overlay: un único target (p. ej. `/usr/bin`) con sus
|
|
/// `upper`/`work` dedicados.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct OverlayMount {
|
|
/// Punto de montaje real: el path del FHS. También sirve como `lowerdir`.
|
|
pub target: PathBuf,
|
|
/// Donde caen las escrituras. Vive bajo `<state>/<id>/mounts/<slug>/upper`.
|
|
pub upper: PathBuf,
|
|
/// Workdir que overlayfs necesita (mismo FS que `upper`, vacío al montar).
|
|
pub work: PathBuf,
|
|
}
|
|
|
|
/// Manifiesto persistente del overlay — se serializa a `state.json` para que `status`
|
|
/// pueda enumerarlos sin depender de procesos anteriores.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct OverlayState {
|
|
pub id: OverlayId,
|
|
pub mounts: Vec<OverlayMount>,
|
|
/// Segundos desde UNIX epoch al `try`. No tomamos `chrono` por ahora.
|
|
pub created_at: u64,
|
|
}
|
|
|
|
/// Reporte de un `commit`. Lista cada archivo que se promocionó del upper al lower.
|
|
#[derive(Debug, Default)]
|
|
pub struct CommitReport {
|
|
pub copied: Vec<PathBuf>,
|
|
pub removed: Vec<PathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum Error {
|
|
#[error("io: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("json: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
#[error("overlay: {0}")]
|
|
Overlay(String),
|
|
#[error("mount falló: {0}")]
|
|
Mount(String),
|
|
#[error("umount falló: {0}")]
|
|
Umount(String),
|
|
#[error(
|
|
"overlay {id} tiene overlays más jóvenes apilados encima ({}); \
|
|
commitea/descarta primero los de arriba (LIFO)",
|
|
.shadowed_by.iter().map(|o| o.as_str()).collect::<Vec<_>>().join(", ")
|
|
)]
|
|
Shadowed { id: OverlayId, shadowed_by: Vec<OverlayId> },
|
|
}
|
|
|
|
pub type Result<T> = std::result::Result<T, Error>;
|
|
|
|
/// Convierte un target absoluto en un slug usable como nombre de directorio.
|
|
/// `/usr/bin` → `usr-bin`, `/etc` → `etc`. Por diseño NO uses sufijo: queremos colisión
|
|
/// si alguien intenta el mismo target dos veces dentro del mismo overlay.
|
|
fn slug_for(target: &Path) -> String {
|
|
let s: String = target
|
|
.components()
|
|
.filter_map(|c| match c {
|
|
std::path::Component::Normal(n) => Some(n.to_string_lossy().to_string()),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("-");
|
|
if s.is_empty() { "root".to_string() } else { s }
|
|
}
|
|
|
|
/// Genera un `OverlayId` legible: `<unix_ts>-<pid>-<seq>`. El `seq` es un contador atómico
|
|
/// por proceso (zero-padded) que garantiza unicidad aunque dos `try` caigan en el mismo
|
|
/// segundo desde el mismo PID — el caso de la anidación real, donde un orquestador apila
|
|
/// varios overlays en ráfaga. El padding mantiene el orden lexicográfico del id alineado con
|
|
/// el orden de creación, que es justo lo que [`stack_key`] usa como desempate del apilamiento.
|
|
/// (La unicidad estricta entre procesos concurrentes no la cubre el seq; ver `stack_key`.)
|
|
fn fresh_id() -> OverlayId {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static SEQ: AtomicU64 = AtomicU64::new(0);
|
|
let ts = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
|
|
OverlayId(format!("{ts}-{}-{seq:06}", std::process::id()))
|
|
}
|
|
|
|
/// Monta un overlay nuevo sobre `targets` y persiste su manifiesto.
|
|
///
|
|
/// `state_root` es donde viven los `upper`/`work` y el `state.json`. Cada overlay queda
|
|
/// bajo `<state_root>/<id>/`. Si `targets` está vacío usa [`default_targets`].
|
|
pub fn try_overlay(targets: &[PathBuf], state_root: &Path) -> Result<OverlayId> {
|
|
let targets: Vec<PathBuf> = if targets.is_empty() {
|
|
default_targets()
|
|
} else {
|
|
targets.to_vec()
|
|
};
|
|
|
|
let id = fresh_id();
|
|
let base = state_root.join(id.as_str());
|
|
let mounts_root = base.join("mounts");
|
|
std::fs::create_dir_all(&mounts_root)?;
|
|
|
|
let mut mounts: Vec<OverlayMount> = Vec::with_capacity(targets.len());
|
|
for target in &targets {
|
|
if !target.is_dir() {
|
|
return Err(Error::Overlay(format!(
|
|
"target {} no existe o no es directorio",
|
|
target.display()
|
|
)));
|
|
}
|
|
let slot = mounts_root.join(slug_for(target));
|
|
let upper = slot.join("upper");
|
|
let work = slot.join("work");
|
|
std::fs::create_dir_all(&upper)?;
|
|
std::fs::create_dir_all(&work)?;
|
|
do_mount(target, &upper, &work)?;
|
|
mounts.push(OverlayMount {
|
|
target: target.clone(),
|
|
upper,
|
|
work,
|
|
});
|
|
}
|
|
|
|
let state = OverlayState {
|
|
id: id.clone(),
|
|
mounts,
|
|
created_at: std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0),
|
|
};
|
|
let state_file = base.join("state.json");
|
|
std::fs::write(&state_file, serde_json::to_vec_pretty(&state)?)?;
|
|
Ok(id)
|
|
}
|
|
|
|
/// Lista todos los overlays con manifiesto bajo `state_root`. No verifica que sus mounts
|
|
/// estén vivos; eso lo añade un consumidor que parsea `/proc/mounts` si lo necesita.
|
|
pub fn status(state_root: &Path) -> Result<Vec<OverlayState>> {
|
|
if !state_root.is_dir() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut out = Vec::new();
|
|
for entry in std::fs::read_dir(state_root)? {
|
|
let entry = entry?;
|
|
let state_file = entry.path().join("state.json");
|
|
if !state_file.is_file() {
|
|
continue;
|
|
}
|
|
let bytes = std::fs::read(&state_file)?;
|
|
let state: OverlayState = serde_json::from_slice(&bytes)?;
|
|
out.push(state);
|
|
}
|
|
out.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
|
Ok(out)
|
|
}
|
|
|
|
/// Desmonta todos los targets de un overlay y borra su árbol de estado. Idempotente sobre
|
|
/// mounts ya desmontados (errores de umount "not mounted" se ignoran).
|
|
pub fn discard(id: &OverlayId, state_root: &Path) -> Result<()> {
|
|
let base = state_root.join(id.as_str());
|
|
let state_file = base.join("state.json");
|
|
if !state_file.is_file() {
|
|
return Err(Error::Overlay(format!(
|
|
"no encuentro overlay {} bajo {}",
|
|
id,
|
|
state_root.display()
|
|
)));
|
|
}
|
|
let bytes = std::fs::read(&state_file)?;
|
|
let state: OverlayState = serde_json::from_slice(&bytes)?;
|
|
// LIFO: no desmontes una capa que aún tiene overlays más jóvenes apilados encima.
|
|
let blockers = blocking_overlays(state_root, id)?;
|
|
if !blockers.is_empty() {
|
|
return Err(Error::Shadowed { id: id.clone(), shadowed_by: blockers });
|
|
}
|
|
// Desmonta en orden inverso al de montaje dentro de este overlay.
|
|
for m in state.mounts.iter().rev() {
|
|
do_umount_lenient(&m.target)?;
|
|
}
|
|
std::fs::remove_dir_all(&base)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Fusiona los `upper`s al FHS y desmonta. Sin diario.
|
|
///
|
|
/// Para registrar cada promoción en el diario (SDD 04 §2 + SDD 05), usa
|
|
/// [`commit_with_journal`].
|
|
pub fn commit(id: &OverlayId, state_root: &Path) -> Result<CommitReport> {
|
|
commit_inner(id, state_root, None)
|
|
}
|
|
|
|
/// Como [`commit`] pero registra cada archivo promocionado/eliminado como
|
|
/// `MutationEvent` con `source = HammerCommit { overlay }`. El SDD insiste en que el ruido
|
|
/// del experimento no entra al diario hasta el commit — esto es el momento exacto.
|
|
pub fn commit_with_journal(
|
|
id: &OverlayId,
|
|
state_root: &Path,
|
|
journal: &takana_journal::Journal,
|
|
) -> Result<CommitReport> {
|
|
commit_inner(id, state_root, Some(journal))
|
|
}
|
|
|
|
fn commit_inner(
|
|
id: &OverlayId,
|
|
state_root: &Path,
|
|
journal: Option<&takana_journal::Journal>,
|
|
) -> Result<CommitReport> {
|
|
let base = state_root.join(id.as_str());
|
|
let state_file = base.join("state.json");
|
|
if !state_file.is_file() {
|
|
return Err(Error::Overlay(format!(
|
|
"no encuentro overlay {} bajo {}",
|
|
id,
|
|
state_root.display()
|
|
)));
|
|
}
|
|
let bytes = std::fs::read(&state_file)?;
|
|
let state: OverlayState = serde_json::from_slice(&bytes)?;
|
|
// LIFO: commitear una capa con overlays más jóvenes encima promocionaría el upper a un
|
|
// target todavía sombreado por otro mount. Rechazamos hasta que se resuelvan los de arriba.
|
|
let blockers = blocking_overlays(state_root, id)?;
|
|
if !blockers.is_empty() {
|
|
return Err(Error::Shadowed { id: id.clone(), shadowed_by: blockers });
|
|
}
|
|
|
|
let mut report = CommitReport::default();
|
|
|
|
// Desmonta primero — necesitamos el upper "crudo".
|
|
for m in state.mounts.iter().rev() {
|
|
do_umount_lenient(&m.target)?;
|
|
}
|
|
|
|
for m in &state.mounts {
|
|
promote_upper_to_lower(&m.upper, &m.target, &mut report)?;
|
|
}
|
|
|
|
if let Some(j) = journal {
|
|
record_commit_to_journal(j, id, &report);
|
|
}
|
|
std::fs::remove_dir_all(&base)?;
|
|
Ok(report)
|
|
}
|
|
|
|
fn record_commit_to_journal(
|
|
j: &takana_journal::Journal,
|
|
id: &OverlayId,
|
|
report: &CommitReport,
|
|
) {
|
|
use takana_journal::{Actor, MutationEvent, MutationOp, Source};
|
|
let actor = Actor {
|
|
source: Source::HammerCommit {
|
|
overlay: id.as_str().to_string(),
|
|
artifact: None,
|
|
},
|
|
pid: Some(std::process::id()),
|
|
uid: Some(unsafe { libc::getuid() }),
|
|
};
|
|
let ts = takana_journal::now_rfc3339();
|
|
for p in &report.copied {
|
|
let ev = MutationEvent {
|
|
ts: ts.clone(),
|
|
op: MutationOp::Replace,
|
|
path: p.clone(),
|
|
by: actor.clone(),
|
|
content_hash: None,
|
|
note: None,
|
|
};
|
|
if let Err(e) = j.record(&ev) {
|
|
tracing::warn!(error = %e, path = %p.display(), "journal: fallo al registrar commit");
|
|
}
|
|
}
|
|
for p in &report.removed {
|
|
let ev = MutationEvent {
|
|
ts: ts.clone(),
|
|
op: MutationOp::Delete,
|
|
path: p.clone(),
|
|
by: actor.clone(),
|
|
content_hash: None,
|
|
note: None,
|
|
};
|
|
if let Err(e) = j.record(&ev) {
|
|
tracing::warn!(error = %e, path = %p.display(), "journal: fallo al registrar remove");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clave de orden de apilamiento total y determinista: primero por instante de creación,
|
|
/// desempatado por el `id` (que lleva ts+pid). Un overlay con clave mayor es "más joven" y,
|
|
/// si comparte targets, queda apilado *encima*.
|
|
fn stack_key(s: &OverlayState) -> (u64, &str) {
|
|
(s.created_at, s.id.as_str())
|
|
}
|
|
|
|
/// `true` si dos targets se solapan en el árbol del FHS: iguales, o uno ancestro del otro
|
|
/// (p. ej. un overlay sobre `/usr` y otro sobre `/usr/bin` comparten el mountpoint efectivo).
|
|
fn targets_overlap(a: &Path, b: &Path) -> bool {
|
|
a.starts_with(b) || b.starts_with(a)
|
|
}
|
|
|
|
/// Overlays más jóvenes que `id` que solapan alguno de sus targets — es decir, los que
|
|
/// están apilados *encima* en el FHS. Mientras existan, `commit`/`discard` de `id` violaría
|
|
/// el orden LIFO (desmontaría la capa equivocada del target compartido), así que los
|
|
/// rechazamos. Es inspección pura del estado, sin tocar mounts.
|
|
fn blocking_overlays(state_root: &Path, id: &OverlayId) -> Result<Vec<OverlayId>> {
|
|
let all = status(state_root)?;
|
|
let Some(me) = all.iter().find(|s| &s.id == id) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let me_key = stack_key(me);
|
|
let mut blockers: Vec<OverlayId> = all
|
|
.iter()
|
|
.filter(|other| &other.id != id && stack_key(other) > me_key)
|
|
.filter(|other| {
|
|
other.mounts.iter().any(|om| {
|
|
me.mounts.iter().any(|mm| targets_overlap(&om.target, &mm.target))
|
|
})
|
|
})
|
|
.map(|other| other.id.clone())
|
|
.collect();
|
|
blockers.sort_by(|a, b| a.as_str().cmp(b.as_str()));
|
|
Ok(blockers)
|
|
}
|
|
|
|
fn do_mount(lower: &Path, upper: &Path, work: &Path) -> Result<()> {
|
|
// overlayfs requiere `,` como separador. Si alguno de los paths contiene una coma,
|
|
// overlayfs sólo soporta escaparla a través de opciones específicas — rechazamos en
|
|
// lugar de adivinar (en el FHS clásico no aparece nunca).
|
|
for p in [lower, upper, work] {
|
|
let s = p.to_string_lossy();
|
|
if s.contains(',') || s.contains(':') {
|
|
return Err(Error::Mount(format!(
|
|
"path con ',' o ':' no soportado por overlayfs: {}",
|
|
p.display()
|
|
)));
|
|
}
|
|
}
|
|
let opts = format!(
|
|
"lowerdir={},upperdir={},workdir={}",
|
|
lower.display(),
|
|
upper.display(),
|
|
work.display()
|
|
);
|
|
let st = Command::new("mount")
|
|
.args(["-t", "overlay", "overlay", "-o"])
|
|
.arg(&opts)
|
|
.arg(lower)
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.status()
|
|
.map_err(|e| Error::Mount(format!("spawn mount: {e}")))?;
|
|
if !st.success() {
|
|
return Err(Error::Mount(format!(
|
|
"mount overlay sobre {} falló (exit {:?})",
|
|
lower.display(),
|
|
st.code()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn do_umount_lenient(target: &Path) -> Result<()> {
|
|
// `umount` retorna 32 para "not mounted".
|
|
let out = Command::new("umount")
|
|
.arg(target)
|
|
.output()
|
|
.map_err(|e| Error::Umount(format!("spawn umount: {e}")))?;
|
|
if out.status.success() {
|
|
return Ok(());
|
|
}
|
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
|
if stderr.contains("not mounted") || stderr.contains("not found") {
|
|
return Ok(());
|
|
}
|
|
|
|
// ⚠ `target is busy` NO es un error acá, y esto es medido (2026-09-21, caja `takana`): un
|
|
// `commit` sobre el FHS VIVO falla siempre en `/bin` y `/usr/bin`, porque los procesos de la
|
|
// máquina —empezando por PID 1, `/usr/bin/arje-zero`— tienen su ejecutable mapeado desde ahí,
|
|
// y un ejecutable mapeado pin-ea el montaje. Pasó con la primera instalación real de un
|
|
// paquete en la caja: desmontó 5 de 7 targets, murió en `/bin`, y dejó el sistema **a medias**
|
|
// —dos overlays montados y el estado sin promocionar— que es el peor de los finales.
|
|
//
|
|
// El desmontaje PEREZOSO es la salida correcta y la que este mismo comentario prometía desde
|
|
// la Fase 2 sin que nadie la hubiera cableado: desengancha el montaje del árbol ya mismo (las
|
|
// rutas vuelven a resolver al directorio real, que es lo que el promote necesita) y libera
|
|
// cuando el último proceso que lo mira se muera. Los que siguen vivos ven el mismo contenido,
|
|
// porque lo primero que hace el commit después es copiar el upper al lower.
|
|
if stderr.contains("busy") {
|
|
let lazy = Command::new("umount")
|
|
.arg("-l")
|
|
.arg(target)
|
|
.output()
|
|
.map_err(|e| Error::Umount(format!("spawn umount -l: {e}")))?;
|
|
if lazy.status.success() {
|
|
tracing::warn!(
|
|
target = %target.display(),
|
|
"umount: el target estaba ocupado (procesos con ejecutables mapeados ahí, \
|
|
normal en un FHS vivo) ⇒ desmontaje perezoso"
|
|
);
|
|
return Ok(());
|
|
}
|
|
return Err(Error::Umount(format!(
|
|
"umount {} falló ocupado, y el perezoso tampoco pudo: {}",
|
|
target.display(),
|
|
String::from_utf8_lossy(&lazy.stderr).trim()
|
|
)));
|
|
}
|
|
|
|
Err(Error::Umount(format!(
|
|
"umount {} falló (exit {:?}): {}",
|
|
target.display(),
|
|
out.status.code(),
|
|
stderr.trim()
|
|
)))
|
|
}
|
|
|
|
/// Camina el `upper` y replica cada entrada al `lower`, anotando en el reporte. Maneja:
|
|
///
|
|
/// - Archivos regulares → `cp -a` (preserva metadata + hardlinks dentro del upper).
|
|
/// - Directorios → `mkdir -p` con los permisos del upper.
|
|
/// - Symlinks → recreación literal.
|
|
/// - "Whiteouts" overlayfs (char dev 0/0) → `rm -rf` el correspondiente en el lower.
|
|
fn promote_upper_to_lower(
|
|
upper: &Path,
|
|
lower: &Path,
|
|
report: &mut CommitReport,
|
|
) -> Result<()> {
|
|
use std::os::unix::fs::FileTypeExt;
|
|
walk(upper, &mut |path: &Path| -> Result<()> {
|
|
let rel = path.strip_prefix(upper).expect("rel ⊂ upper");
|
|
if rel.as_os_str().is_empty() {
|
|
return Ok(());
|
|
}
|
|
let dst = lower.join(rel);
|
|
let meta = std::fs::symlink_metadata(path)?;
|
|
let ft = meta.file_type();
|
|
if ft.is_char_device() && rdev_is_zero(&meta) {
|
|
// Whiteout: el upper marca "borra esto del lower".
|
|
if dst.exists() || std::fs::symlink_metadata(&dst).is_ok() {
|
|
if dst.is_dir() && !dst.is_symlink() {
|
|
std::fs::remove_dir_all(&dst)?;
|
|
} else {
|
|
std::fs::remove_file(&dst)?;
|
|
}
|
|
report.removed.push(dst);
|
|
}
|
|
} else if ft.is_dir() {
|
|
std::fs::create_dir_all(&dst)?;
|
|
} else if ft.is_symlink() {
|
|
let tgt = std::fs::read_link(path)?;
|
|
let _ = std::fs::remove_file(&dst);
|
|
std::os::unix::fs::symlink(&tgt, &dst)?;
|
|
report.copied.push(dst);
|
|
} else if ft.is_file() {
|
|
if let Some(parent) = dst.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
// `cp -a` para preservar perms/timestamps/ownership; copy_file_range para
|
|
// archivos grandes se queda en futuro si lo necesitamos.
|
|
let st = Command::new("cp")
|
|
.arg("-a")
|
|
.arg("--remove-destination")
|
|
.arg(path)
|
|
.arg(&dst)
|
|
.status()?;
|
|
if !st.success() {
|
|
return Err(Error::Overlay(format!(
|
|
"cp -a {} → {} falló (exit {:?})",
|
|
path.display(),
|
|
dst.display(),
|
|
st.code()
|
|
)));
|
|
}
|
|
report.copied.push(dst);
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn rdev_is_zero(meta: &std::fs::Metadata) -> bool {
|
|
use std::os::unix::fs::MetadataExt;
|
|
meta.rdev() == 0
|
|
}
|
|
|
|
fn walk(root: &Path, f: &mut dyn FnMut(&Path) -> Result<()>) -> Result<()> {
|
|
f(root)?;
|
|
let rd = match std::fs::read_dir(root) {
|
|
Ok(r) => r,
|
|
Err(_) => return Ok(()),
|
|
};
|
|
for entry in rd {
|
|
let entry = entry?;
|
|
let p = entry.path();
|
|
let ft = entry.file_type()?;
|
|
if ft.is_dir() && !ft.is_symlink() {
|
|
walk(&p, f)?;
|
|
} else {
|
|
f(&p)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn slug_basic() {
|
|
assert_eq!(slug_for(Path::new("/usr/bin")), "usr-bin");
|
|
assert_eq!(slug_for(Path::new("/etc")), "etc");
|
|
assert_eq!(slug_for(Path::new("/")), "root");
|
|
}
|
|
|
|
#[test]
|
|
fn default_targets_are_fhs_userland() {
|
|
let t = default_targets();
|
|
assert!(t.iter().any(|p| p == Path::new("/usr/bin")));
|
|
assert!(t.iter().any(|p| p == Path::new("/etc")));
|
|
assert!(!t.iter().any(|p| p == Path::new("/")));
|
|
}
|
|
|
|
#[test]
|
|
fn status_empty_dir_returns_empty() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let v = status(d.path()).unwrap();
|
|
assert!(v.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn status_missing_dir_returns_empty() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let v = status(&d.path().join("no-existe")).unwrap();
|
|
assert!(v.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn status_reads_planted_manifest() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let id = OverlayId("9999-1".into());
|
|
let base = d.path().join(id.as_str());
|
|
std::fs::create_dir_all(&base).unwrap();
|
|
let st = OverlayState {
|
|
id: id.clone(),
|
|
mounts: vec![],
|
|
created_at: 1234,
|
|
};
|
|
std::fs::write(base.join("state.json"), serde_json::to_vec(&st).unwrap()).unwrap();
|
|
let v = status(d.path()).unwrap();
|
|
assert_eq!(v.len(), 1);
|
|
assert_eq!(v[0].id, id);
|
|
assert_eq!(v[0].created_at, 1234);
|
|
}
|
|
|
|
#[test]
|
|
fn discard_on_unknown_id_errors_clearly() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let err = discard(&OverlayId("nope".into()), d.path()).unwrap_err().to_string();
|
|
assert!(err.contains("no encuentro overlay"), "msg = {err}");
|
|
}
|
|
|
|
/// Planta un manifiesto de overlay (sin mounts reales) para ejercitar la lógica de
|
|
/// apilamiento sin overlayfs. `targets` son los paths que el overlay cubriría.
|
|
fn plant(state_root: &Path, id: &str, created_at: u64, targets: &[&str]) {
|
|
let base = state_root.join(id);
|
|
std::fs::create_dir_all(&base).unwrap();
|
|
let mounts = targets
|
|
.iter()
|
|
.map(|t| OverlayMount {
|
|
target: PathBuf::from(t),
|
|
upper: base.join("upper"),
|
|
work: base.join("work"),
|
|
})
|
|
.collect();
|
|
let st = OverlayState { id: OverlayId(id.into()), mounts, created_at };
|
|
std::fs::write(base.join("state.json"), serde_json::to_vec(&st).unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn no_blockers_for_disjoint_targets() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
plant(d.path(), "100-1", 100, &["/usr/bin"]);
|
|
plant(d.path(), "200-1", 200, &["/etc"]); // más joven pero target disjunto
|
|
assert!(blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn younger_overlapping_overlay_blocks() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
plant(d.path(), "100-1", 100, &["/usr/bin"]);
|
|
plant(d.path(), "200-1", 200, &["/usr/bin"]); // apilado encima del mismo target
|
|
let blockers = blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap();
|
|
assert_eq!(blockers, vec![OverlayId("200-1".into())]);
|
|
// El de arriba no tiene a nadie por encima: se puede resolver primero.
|
|
assert!(blocking_overlays(d.path(), &OverlayId("200-1".into())).unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn ancestor_target_counts_as_overlap() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
plant(d.path(), "100-1", 100, &["/usr"]);
|
|
plant(d.path(), "200-1", 200, &["/usr/bin"]); // /usr/bin queda sombreado por /usr
|
|
let blockers = blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap();
|
|
assert_eq!(blockers, vec![OverlayId("200-1".into())]);
|
|
}
|
|
|
|
#[test]
|
|
fn same_second_ties_broken_by_id() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
// Mismo created_at: el desempate por id define quién está encima.
|
|
plant(d.path(), "100-1", 100, &["/etc"]);
|
|
plant(d.path(), "100-2", 100, &["/etc"]);
|
|
assert_eq!(
|
|
blocking_overlays(d.path(), &OverlayId("100-1".into())).unwrap(),
|
|
vec![OverlayId("100-2".into())]
|
|
);
|
|
assert!(blocking_overlays(d.path(), &OverlayId("100-2".into())).unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn commit_refuses_when_shadowed() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
plant(d.path(), "100-1", 100, &["/usr/bin"]);
|
|
plant(d.path(), "200-1", 200, &["/usr/bin"]);
|
|
let err = commit(&OverlayId("100-1".into()), d.path()).unwrap_err();
|
|
match err {
|
|
Error::Shadowed { id, shadowed_by } => {
|
|
assert_eq!(id.as_str(), "100-1");
|
|
assert_eq!(shadowed_by, vec![OverlayId("200-1".into())]);
|
|
}
|
|
other => panic!("esperaba Shadowed, vino {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn discard_refuses_when_shadowed() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
plant(d.path(), "100-1", 100, &["/etc"]);
|
|
plant(d.path(), "200-1", 200, &["/etc"]);
|
|
let err = discard(&OverlayId("100-1".into()), d.path()).unwrap_err();
|
|
assert!(matches!(err, Error::Shadowed { .. }), "vino {err:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn journal_hook_records_copied_and_removed() {
|
|
// No tocamos mount real: ejercitamos directamente la función privada que el commit
|
|
// delega para anotar al diario. Eso valida el contrato sin necesidad de overlayfs.
|
|
let d = tempfile::tempdir().unwrap();
|
|
let journal = takana_journal::Journal::open(d.path().join("journal")).unwrap();
|
|
let report = CommitReport {
|
|
copied: vec![PathBuf::from("/etc/hosts"), PathBuf::from("/bin/grep")],
|
|
removed: vec![PathBuf::from("/bin/obsoleto")],
|
|
};
|
|
super::record_commit_to_journal(&journal, &OverlayId("1234-1".into()), &report);
|
|
let events = journal.read_all().unwrap();
|
|
assert_eq!(events.len(), 3, "2 copied + 1 removed = 3 eventos");
|
|
// Los promocionados van como Replace, los removidos como Delete.
|
|
let ops_paths: Vec<_> = events
|
|
.iter()
|
|
.map(|e| (e.op, e.path.display().to_string()))
|
|
.collect();
|
|
assert!(ops_paths.contains(&(takana_journal::MutationOp::Replace, "/etc/hosts".into())));
|
|
assert!(ops_paths.contains(&(takana_journal::MutationOp::Replace, "/bin/grep".into())));
|
|
assert!(ops_paths.contains(&(takana_journal::MutationOp::Delete, "/bin/obsoleto".into())));
|
|
// Todos comparten el mismo source con overlay id.
|
|
for e in &events {
|
|
match &e.by.source {
|
|
takana_journal::Source::HammerCommit { overlay, .. } => {
|
|
assert_eq!(overlay, "1234-1");
|
|
}
|
|
other => panic!("source inesperado: {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
}
|