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 1574Shell 204Rust 111Markdown 993763 Definiciones

⎇ 3f560de8af1d652cde5ac5f643b3212337b3d627 crates/hammer-cli/src/qorpa.rs Rust · 2174 líneas · 100.7 KB Historia Quién tocó qué crudo

qorpa D8: el pin de sniper verificado, y el pin deja de ser una cita 3f560de8a · Sergio ·

1//! `hammer qorpa` — imágenes ajenas ([ADR 0015](../../../docs/adr/0015-imagenes-ajenas.md)).
2//!
3//! Superficie en inglés, mensajes en castellano (`CLAUDE.md` regla 4).
4//!
5//! ── LA INVARIANTE QUE ESTE MÓDULO PROTEGE ───────────────────────────────────────────────────────
6//! Una imagen ajena **no es un artefacto y no vive en el store** (ADR 0015 D1). El store promete que
7//! cada entrada se reconstruye desde fuente bit a bit, y un rootfs de Ubuntu no. Por eso todo lo de
8//! acá cuelga de un espacio de nombres paralelo (`/var/lib/hammer/qorpa/`), fuera de `hash_inputs`
9//! de todo, y nada de este fichero toca `Store`.
10//!
11//! ── EL ORDEN DE LAS OPERACIONES NO ES CASUAL ────────────────────────────────────────────────────
12//! Se verifica el sha256 **antes** de desempacar, nunca después. Un tar ajeno que no verificaste es
13//! código ajeno que ya escribió en tu disco: desempacar primero y comprobar luego deja el árbol
14//! sucio justo en el caso que importa. Y el veredicto sigue a ADR 0014: *ausencia ⇒ seguir;
15//! contenido distinto ⇒ ABORTAR*.
16
17use std::io::Read;
18use std::os::unix::fs::PermissionsExt;
19use std::path::{Path, PathBuf};
20use std::process::Command;
21
22use anyhow::{bail, Context, Result};
23use clap::Subcommand;
24use serde::{Deserialize, Serialize};
25
26/// Raíz del espacio de nombres de qorpa. Deliberadamente **fuera** del store.
27pub const DEFAULT_ROOT: &str = "/var/lib/hammer/qorpa";
28/// Para correr sin root (tests, laptop). Mismo patrón que `HAMMER_KCONFIG_ROOT`.
29pub const ROOT_ENV: &str = "HAMMER_QORPA_ROOT";
30/// El manifiesto de la imagen. No confundir con `instancia.toml`, que describe una INSTANCIA.
31const IMAGE_MANIFEST: &str = "image.toml";
32/// El árbol desempacado dentro del directorio de la imagen.
33const TREE: &str = "rootfs";
34/// El manifiesto de una INSTANCIA: la verdad de la que se recrea (D3).
35const INSTANCE_MANIFEST: &str = "instance.toml";
36
37#[derive(Subcommand)]
38pub enum QorpaCmd {
39    /// [ADR 0015 §Orden 2] Trae una imagen ajena y la verifica por sha256 ANTES de desempacarla.
40    ///
41    /// La identidad de la imagen es el sha256 del ARCHIVO original, no del árbol: así dos máquinas
42    /// que la traigan de orígenes distintos coinciden, que es lo que hace gratis el espejo
43    /// (ADR 0013: la URL no entra en la identidad) y posible el multi-origen (ADR 0014).
44    Pull {
45        /// De dónde bajarla. Cualquier esquema que entienda curl, `file://` incluido.
46        url: String,
47        /// sha256 esperado del archivo, en hex. Obligatorio: sin esto no hay identidad.
48        #[arg(long)]
49        sha256: String,
50        /// Etiqueta legible (`arch-bootstrap-2026.09.01`). Informativa; no entra en la identidad.
51        #[arg(long)]
52        label: Option<String>,
53        /// Baja y verifica, pero NO desempaca. Para poblar un espejo sin gastar el disco del árbol.
54        #[arg(long)]
55        verify_only: bool,
56        /// Dónde está el rootfs DENTRO del archivo, si la detección automática no acierta.
57        #[arg(long)]
58        subdir: Option<String>,
59        /// Raíz de qorpa (default: `$HAMMER_QORPA_ROOT` o `/var/lib/hammer/qorpa`).
60        #[arg(long)]
61        root: Option<PathBuf>,
62    },
63    /// Lista las imágenes presentes. Marca a gritos las que están VACÍAS (`CLAUDE.md` regla 3):
64    /// un directorio vacío no es una imagen, es un nombre.
65    List {
66        #[arg(long)]
67        root: Option<PathBuf>,
68        /// Salida JSON, para que un script no tenga que parsear la tabla.
69        #[arg(long)]
70        json: bool,
71    },
72    /// [ADR 0015 §Orden 3] Crea una instancia: capa mutable sobre una imagen + su manifiesto.
73    ///
74    /// Nace SIN NINGUNA concesión (D7): ni red, ni sockets, ni dispositivos, ni directorios del
75    /// host. Se abre lo que se declare en `instance.toml`, y sólo eso.
76    Create {
77        /// Identificador de la instancia (`games`).
78        id: String,
79        /// sha256 de la imagen base, ya traída con `pull`. Acepta prefijos.
80        #[arg(long)]
81        base: String,
82        /// Nombre informativo de la distro (`arch`, `ubuntu-24.04`).
83        #[arg(long)]
84        distro: Option<String>,
85        #[arg(long)]
86        root: Option<PathBuf>,
87    },
88    /// [ADR 0015 §Orden 3] Tira la capa mutable y la rehace desde el manifiesto.
89    ///
90    /// Es la prueba de D3: si esto duele, es que el `upper` se volvió el activo — y un blob
91    /// irreemplazable es justo lo que hammer existe para no tener.
92    Recreate {
93        id: String,
94        #[arg(long)]
95        root: Option<PathBuf>,
96    },
97    /// [ADR 0015 §Orden 3] Entra a la instancia con las concesiones declaradas y nada más.
98    Run {
99        id: String,
100        #[arg(long)]
101        root: Option<PathBuf>,
102        /// Imprime el `bwrap` que se ejecutaría y sale. La jaula tiene que ser inspeccionable.
103        #[arg(long)]
104        dry_run: bool,
105        /// Corre SIN harkaq (sin Landlock, sin seccomp, sin no_new_privs). Para diagnosticar
106        /// cuando algo muere y hay que saber si fue la jaula. Avisa a gritos.
107        #[arg(long)]
108        no_jail: bool,
109        /// Fuerza el userns de un solo id que crea bwrap, en vez del rango de `/etc/subuid`.
110        /// Para comparar: casi todo lo que "no anda adentro" es este mapeo.
111        #[arg(long)]
112        single_id: bool,
113        /// Comando dentro (default: el shell de la imagen). Va después de `--`.
114        #[arg(last = true)]
115        cmd: Vec<String>,
116    },
117    /// [ADR 0015 §Orden 5] Genera los shims y `.desktop` de lo declarado en `[export]`.
118    ///
119    /// **Se GENERAN, no se copian.** El `Exec=` de un `.desktop` ajeno es texto ajeno: copiarlo
120    /// pondría una línea de comando que no escribimos en el menú del usuario (D5).
121    Export {
122        id: String,
123        /// Prefijo donde escribir (`<into>/bin`, `<into>/share/applications`). Default: `~/.local`.
124        #[arg(long)]
125        into: Option<PathBuf>,
126        /// Borra exactamente lo que se generó. Revocable: sin shims, la instancia desaparece del
127        /// sistema sin desmontar nada.
128        #[arg(long)]
129        remove: bool,
130        #[arg(long)]
131        root: Option<PathBuf>,
132    },
133    /// [ADR 0015 §Orden 7] Poda: restos de descargas a medias, imágenes que ninguna instancia usa
134    /// y —si se pide— las capas mutables, que por D3 siempre se pueden tirar.
135    ///
136    /// **Sin `--yes` no borra nada**: dice qué haría y cuánto liberaría. Este repo ya tuvo tres
137    /// emergencias de disco y un `store-gc` que REPORTABA borrados que no ocurrían.
138    Prune {
139        /// Además, tira la capa mutable de cada instancia. El manifiesto es la verdad y el `upper`
140        /// es caché (D3) — pero lo que instalaste a mano y no declaraste en `packages` se va.
141        #[arg(long)]
142        upper: bool,
143        /// Ejecuta de verdad. Sin esto es un simulacro.
144        #[arg(long)]
145        yes: bool,
146        #[arg(long)]
147        root: Option<PathBuf>,
148    },
149}
150
151pub fn run(cmd: QorpaCmd) -> Result<()> {
152    match cmd {
153        QorpaCmd::Pull { url, sha256, label, verify_only, subdir, root } => {
154            pull(&resolve_root(root), &url, &sha256, label.as_deref(), verify_only, subdir.as_deref())
155        }
156        QorpaCmd::List { root, json } => list(&resolve_root(root), json),
157        QorpaCmd::Create { id, base, distro, root } => {
158            create(&resolve_root(root), &id, &base, distro.as_deref())
159        }
160        QorpaCmd::Recreate { id, root } => recreate(&resolve_root(root), &id),
161        QorpaCmd::Run { id, root, dry_run, no_jail, single_id, cmd } => {
162            run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail, single_id)
163        }
164        QorpaCmd::Export { id, into, remove, root } => {
165            export(&resolve_root(root), &id, into.as_deref(), remove)
166        }
167        QorpaCmd::Prune { upper, yes, root } => prune(&resolve_root(root), upper, yes),
168    }
169}
170
171fn resolve_root(flag: Option<PathBuf>) -> PathBuf {
172    flag.or_else(|| std::env::var_os(ROOT_ENV).map(PathBuf::from))
173        .unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT))
174}
175
176/// Lo que queda escrito junto al árbol. Es el registro de PROCEDENCIA: sin él, un directorio con
177/// un rootfs adentro no sabe decir de dónde salió ni contra qué se verificó.
178#[derive(Serialize, Deserialize)]
179struct ImageManifest {
180    sha256: String,
181    url: String,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    label: Option<String>,
184    archive_bytes: u64,
185    tree_files: u64,
186    tree_bytes: u64,
187    /// El rootfs venía dentro de este subdirectorio del archivo (`root.x86_64` de Arch) y se elevó
188    /// a raíz. Ausente = el archivo YA era un rootfs plano (Ubuntu base).
189    #[serde(skip_serializing_if = "Option::is_none")]
190    rootfs_subdir: Option<String>,
191    /// Lo que había al lado del rootfs y NO entró (`version`, `pkglist.x86_64.txt` de Arch). Se
192    /// registra en vez de descartarse en silencio: si mañana falta algo, acá está qué se tiró.
193    #[serde(default, skip_serializing_if = "Vec::is_empty")]
194    dropped_siblings: Vec<String>,
195}
196
197// ── pull ────────────────────────────────────────────────────────────────────────────────────────
198
199fn pull(
200    root: &Path, url: &str, sha_in: &str, label: Option<&str>, verify_only: bool,
201    subdir: Option<&str>,
202) -> Result<()> {
203    let sha = normalize_sha256(sha_in)?;
204    let images = root.join("images");
205    let dest = images.join(&sha);
206
207    // Idempotencia con la regla 3 puesta delante: "ya está" sólo si TIENE CONTENIDO. Un directorio
208    // vacío de un pull interrumpido se leería como presencia y el error viajaría hasta el final.
209    if dest.exists() {
210        match tree_state(&dest.join(TREE)) {
211            TreeState::Populated => {
212                println!("ya presente y verificada: {}", dest.display());
213                return Ok(());
214            }
215            TreeState::Missing if verify_only => {
216                println!("archivo ya verificado antes (sin desempacar): {}", dest.display());
217                return Ok(());
218            }
219            TreeState::Missing | TreeState::Empty => {
220                eprintln!(
221                    "aviso: {} existe pero su árbol está vacío o ausente — pull anterior a medias, \
222                     se rehace",
223                    dest.display()
224                );
225                force_remove_dir_all(&dest)
226                    .with_context(|| format!("no pude limpiar {}", dest.display()))?;
227            }
228        }
229    }
230
231    std::fs::create_dir_all(&images)
232        .with_context(|| format!("no puedo crear {}", images.display()))?;
233
234    let archive = images.join(format!(".{sha}.part"));
235    println!("bajando {url}");
236    download_to_file(url, &archive)?;
237
238    // El corte. Si el contenido no es el que dice ser, se ABORTA — no se avisa y se sigue.
239    hammer_build::download::verify_sha256(&archive, &sha).inspect_err(|_| {
240        std::fs::remove_file(&archive).ok();
241    })?;
242    let archive_bytes = std::fs::metadata(&archive)?.len();
243    println!("sha256 verificado ({} bytes)", archive_bytes);
244
245    if verify_only {
246        std::fs::create_dir_all(&dest)?;
247        write_manifest(&dest, &ImageManifest {
248            sha256: sha.clone(), url: url.into(), label: label.map(Into::into),
249            archive_bytes, tree_files: 0, tree_bytes: 0, rootfs_subdir: None,
250            dropped_siblings: Vec::new(),
251        })?;
252        std::fs::rename(&archive, dest.join("archive"))?;
253        println!("guardada sin desempacar en {}", dest.display());
254        return Ok(());
255    }
256
257    // Desempacado atómico-ish: a un `.tmp` al lado y luego rotar. Extraer directo sobre el destino
258    // dejaría medio rootfs si algo falla, y medio rootfs es justo lo que la regla 3 castiga.
259    let staging = images.join(format!(".{sha}.tmp"));
260    force_remove_dir_all(&staging)
261        .with_context(|| format!("no pude limpiar el staging {}", staging.display()))?;
262    let tree_tmp = staging.join(TREE);
263    std::fs::create_dir_all(&tree_tmp)?;
264    println!("desempacando…");
265    // Cualquier fallo de acá en adelante barre el staging Y el archivo: "no dejar nada a medias" no
266    // vale sólo para el sha que no coincide — un desempacado a medias es la misma trampa.
267    let unpack = || -> Result<(Option<String>, Vec<String>, u64, u64)> {
268        untar(&archive, &tree_tmp)?;
269        let (sub, dropped) = locate_rootfs(&tree_tmp, subdir)?;
270        let (files, bytes) = walk_tree(&tree_tmp)?;
271        Ok((sub, dropped, files, bytes))
272    };
273    let (rootfs_subdir, dropped_siblings, tree_files, tree_bytes) = match unpack() {
274        Ok(v) => v,
275        Err(e) => {
276            force_remove_dir_all(&staging).ok();
277            std::fs::remove_file(&archive).ok();
278            return Err(e);
279        }
280    };
281    std::fs::remove_file(&archive).ok();
282
283    if let Some(d) = &rootfs_subdir {
284        println!("el rootfs venía en {d}/ — elevado a raíz");
285    }
286    if !dropped_siblings.is_empty() {
287        println!("fuera del rootfs, NO entran: {}", dropped_siblings.join(", "));
288    }
289    if tree_files == 0 {
290        force_remove_dir_all(&staging).ok();
291        bail!("el archivo desempacó VACÍO — eso no es una imagen, es un nombre (CLAUDE.md regla 3)");
292    }
293
294    write_manifest(&staging, &ImageManifest {
295        sha256: sha.clone(), url: url.into(), label: label.map(Into::into),
296        archive_bytes, tree_files, tree_bytes, rootfs_subdir, dropped_siblings,
297    })?;
298    std::fs::rename(&staging, &dest)
299        .with_context(|| format!("no puedo rotar {} a {}", staging.display(), dest.display()))?;
300
301    println!(
302        "imagen lista: {} · {tree_files} ficheros · {}",
303        dest.display(),
304        human(tree_bytes)
305    );
306    Ok(())
307}
308
309/// Descarga a fichero. NO se reusa `download::fetch_url_to_file` a propósito: ése trae el cuerpo
310/// entero a memoria (le sirve para un patch de KB) y acá hablamos de cientos de MB, con `--max-time`
311/// de 300 s que un rootfs no respeta.
312///
313/// **`--retry` a secas no cubre el fallo que de verdad pasa.** Medido trayendo el runtime *sniper*
314/// (302 MB): la CDN de Valve cortó el stream al 73% con `HTTP/2 INTERNAL_ERROR` (exit 92) y curl
315/// **no reintentó**, porque sin `--retry-all-errors` sólo considera transitorios los timeouts y los
316/// 5xx — un stream roto a media transferencia no entra en esa lista. Y aun reintentando, sin
317/// `-C -` cada intento vuelve a empezar de cero: en un fichero de cientos de MB sobre una conexión
318/// que se corta, eso no converge nunca.
319///
320/// Por eso el parcial **NO se borra al fallar**: es lo que hace que el siguiente intento reanude en
321/// vez de rebajar todo. Es seguro porque quien decide no es el fichero sino el `sha256` que se
322/// verifica después —un parcial corrupto no puede pasar por bueno— y porque el nombre lleva punto
323/// delante, así que `list` no lo ve como imagen y `prune` ya lo barre como «resto de un pull a
324/// medias».
325fn download_to_file(url: &str, dst: &Path) -> Result<()> {
326    if let Some(p) = dst.parent() {
327        std::fs::create_dir_all(p)?;
328    }
329    let st = Command::new("curl")
330        .args(["-fL", "--retry", "5", "--retry-delay", "2", "--retry-all-errors",
331               "--connect-timeout", "30", "-C", "-", "--progress-bar", "-o"])
332        .arg(dst)
333        .arg(url)
334        .status()
335        .context("no pude ejecutar curl")?;
336    if !st.success() {
337        let reanudable = std::fs::metadata(dst).map(|m| m.len()).unwrap_or(0);
338        if reanudable > 0 {
339            eprintln!(
340                "  (quedaron {} bajados en {}: el siguiente intento reanuda desde ahí; \
341                 `qorpa prune` los barre)",
342                human(reanudable),
343                dst.display()
344            );
345        }
346        bail!("curl falló bajando {url} (exit {:?})", st.code());
347    }
348    Ok(())
349}
350
351fn normalize_sha256(s: &str) -> Result<String> {
352    let s = s.strip_prefix("sha256:").unwrap_or(s).trim().to_ascii_lowercase();
353    if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
354        bail!("--sha256 debe ser 64 dígitos hex (llegó {:?}, {} chars)", s, s.len());
355    }
356    Ok(s)
357}
358
359/// Elige el descompresor por MAGIC, no por extensión: el nombre de un fichero es una sugerencia y
360/// las tres imágenes curadas usan tres compresores distintos (zstd en Arch, gzip en Ubuntu, xz en
361/// Fedora). `tar` extrae con `--no-same-owner` para que el resultado sea el MISMO lo corra root o
362/// no — la propiedad real la pone el mapeo del userns, no el tar.
363fn untar(archive: &Path, into: &Path) -> Result<()> {
364    let mut magic = [0u8; 6];
365    let n = std::fs::File::open(archive)?.read(&mut magic)?;
366    let flag = match &magic[..n.min(6)] {
367        [0x28, 0xb5, 0x2f, 0xfd, ..] => Some("--zstd"),
368        [0x1f, 0x8b, ..] => Some("-z"),
369        [0xfd, b'7', b'z', b'X', b'Z', 0x00] => Some("-J"),
370        [b'B', b'Z', b'h', ..] => Some("-j"),
371        _ => None, // tar plano
372    };
373    let mut c = Command::new("tar");
374    if let Some(f) = flag {
375        c.arg(f);
376    }
377    // `-p` conserva los modos exactos (el setuid de `sudo` adentro importa), y
378    // `--delay-directory-restore` es OBLIGATORIO junto con él: un rootfs real trae directorios de
379    // sólo-lectura (`/etc/ca-certificates/extracted/cadir` en Arch), y sin esto tar los crea con su
380    // modo final ANTES de meterles los ficheros ⇒ "Permission denied" al extraer sin ser root. Con
381    // esto, tar los deja escribibles y aplica los modos al final.
382    // tar ya rechaza rutas absolutas y `..` al extraer: ésa es la defensa contra un tarball ajeno
383    // malicioso. Los xattr `security.capability` se pierden sin privilegios (tar avisa): dentro de
384    // la jaula da igual salvo para binarios que dependan de capabilities de fichero — anotado.
385    c.args(["-x", "-p", "--delay-directory-restore", "--no-same-owner", "-f"])
386        .arg(archive).arg("-C").arg(into);
387    let st = c.status().context("no pude ejecutar tar")?;
388    if !st.success() {
389        bail!("tar falló desempacando {} (exit {:?})", archive.display(), st.code());
390    }
391    Ok(())
392}
393
394/// Dónde está el rootfs dentro de lo que se desempacó.
395///
396/// La primera versión de esto usaba «si hay UN solo directorio arriba, ése es el rootfs». Sonaba
397/// razonable y es FALSA: el bootstrap de Arch trae tres entradas arriba (`root.x86_64/`, `version`,
398/// `pkglist.x86_64.txt`) y la regla no disparaba, así que el rootfs quedaba un nivel abajo — con
399/// todo verde y sin un solo error. Se mide por ESTRUCTURA, que es lo que define a un rootfs:
400/// tiene `etc/` y tiene `usr/` o `bin/`.
401///
402/// Si no acierta, **falla y pide `--subdir`** en vez de adivinar: un rootfs mal anclado no rompe
403/// acá, rompe mucho después, cuando la instancia no encuentra su loader.
404fn looks_like_rootfs(d: &Path) -> bool {
405    d.join("etc").is_dir() && (d.join("usr").is_dir() || d.join("bin").exists())
406}
407
408/// Deja `tree` conteniendo el rootfs. Devuelve (subdir elevado, hermanos descartados).
409fn locate_rootfs(tree: &Path, forced: Option<&str>) -> Result<(Option<String>, Vec<String>)> {
410    if forced.is_none() && looks_like_rootfs(tree) {
411        return Ok((None, Vec::new())); // Ubuntu base: el archivo YA es el rootfs.
412    }
413    let entries: Vec<_> = std::fs::read_dir(tree)
414        .with_context(|| format!("leyendo la raíz desempacada {}", tree.display()))?
415        .collect::<std::io::Result<Vec<_>>>()?;
416    let name = match forced {
417        Some(f) => {
418            if !tree.join(f).is_dir() {
419                bail!("--subdir {f:?} no existe dentro del archivo");
420            }
421            f.to_string()
422        }
423        None => {
424            let mut cands: Vec<String> = entries
425                .iter()
426                .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
427                .filter(|e| looks_like_rootfs(&e.path()))
428                .map(|e| e.file_name().to_string_lossy().into_owned())
429                .collect();
430            cands.sort();
431            match cands.len() {
432                1 => cands.remove(0),
433                0 => bail!(
434                    "no encuentro un rootfs en el archivo (ni la raíz ni ningún subdirectorio tiene \
435                     `etc/` + `usr|bin`). Pasá `--subdir <dir>` si sabés dónde está."
436                ),
437                _ => bail!(
438                    "hay {} subdirectorios que parecen rootfs ({}) — elegí con `--subdir`",
439                    cands.len(), cands.join(", ")
440                ),
441            }
442        }
443    };
444    let dropped: Vec<String> = entries
445        .iter()
446        .map(|e| e.file_name().to_string_lossy().into_owned())
447        .filter(|n| *n != name)
448        .collect();
449
450    let top = tree.join(&name);
451    let parked = tree.with_file_name(format!(
452        "{}.lift",
453        tree.file_name().unwrap_or_default().to_string_lossy()
454    ));
455    // Renombrar un DIRECTORIO exige permiso de escritura sobre el directorio mismo (el kernel tiene
456    // que reescribir su `..`), y el `root.x86_64` de Arch viene `dr-xr-xr-x`. Se abre, se mueve y se
457    // le devuelve su modo exacto: la imagen tiene que quedar como venía, no como nos vino cómodo.
458    let mode = std::fs::metadata(&top)?.permissions().mode();
459    std::fs::set_permissions(&top, std::fs::Permissions::from_mode(mode | 0o700))?;
460    std::fs::rename(&top, &parked)
461        .with_context(|| format!("elevando {} a {}", top.display(), parked.display()))?;
462    force_remove_dir_all(tree)
463        .with_context(|| format!("descartando los hermanos en {}", tree.display()))?;
464    std::fs::rename(&parked, tree)
465        .with_context(|| format!("rotando {} a {}", parked.display(), tree.display()))?;
466    std::fs::set_permissions(tree, std::fs::Permissions::from_mode(mode))?;
467    Ok((Some(name), dropped))
468}
469
470/// `remove_dir_all` NO puede con un árbol que trae directorios sin permiso de escritura — y un
471/// rootfs real los trae (`/etc/ca-certificates/extracted/cadir` de Arch es 0555). Tragarse ese
472/// fallo con `.ok()` deja vivo el staging de un pull roto y el siguiente pull extrae ENCIMA: nos
473/// pasó, y el síntoma («Permission denied» en un directorio que acabás de crear) no se parece en
474/// nada a la causa. Abre los directorios a escritura de arriba abajo y después borra.
475fn force_remove_dir_all(p: &Path) -> std::io::Result<()> {
476    if !p.exists() {
477        return Ok(());
478    }
479    let mut stack = vec![p.to_path_buf()];
480    while let Some(d) = stack.pop() {
481        let _ = std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700));
482        if let Ok(rd) = std::fs::read_dir(&d) {
483            for e in rd.flatten() {
484                // `file_type` de read_dir es lstat: un symlink a directorio NO se sigue.
485                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
486                    stack.push(e.path());
487                }
488            }
489        }
490    }
491    match std::fs::remove_dir_all(p) {
492        Ok(()) => Ok(()),
493        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
494            // SEGUNDA causa, y aparece recién con el mapeo por rango: el `upper` de una instancia
495            // contiene ficheros de los SUBUID mapeados (apt deja los suyos con uid 165577), y
496            // nuestro uid normal no puede borrarlos ni chownearlos. La cura es la misma que los
497            // creó: entrar a un userns donde esos ids son nuestros. `--map-auto` usa newuidmap,
498            // así que esto sólo anda si subuid está provisionado — y si no, el error vuelve a
499            // salir tal cual, que es lo correcto.
500            let st = Command::new("unshare")
501                .args(["-U", "--map-auto", "-r", "rm", "-rf"])
502                .arg(p)
503                .status()?;
504            if st.success() && !p.exists() {
505                Ok(())
506            } else {
507                Err(e)
508            }
509        }
510        Err(e) => Err(e),
511    }
512}
513
514enum TreeState { Missing, Empty, Populated }
515
516fn tree_state(tree: &Path) -> TreeState {
517    match std::fs::read_dir(tree) {
518        Err(_) => TreeState::Missing,
519        Ok(mut it) => {
520            if it.next().is_some() { TreeState::Populated } else { TreeState::Empty }
521        }
522    }
523}
524
525fn walk_tree(root: &Path) -> Result<(u64, u64)> {
526    let (f, b, _) = walk_tree_ciegos(root)?;
527    Ok((f, b))
528}
529
530/// Igual, pero además cuenta los directorios que NO se pudieron leer. Sin ese tercer número, el
531/// tamaño de un `upper` con ficheros de los subuid sale MENOR de lo que es y nadie lo nota: un
532/// número silenciosamente bajo es peor que ninguno cuando lo que se decide con él es borrar.
533fn walk_tree_ciegos(root: &Path) -> Result<(u64, u64, u64)> {
534    let (mut files, mut bytes, mut ciegos) = (0u64, 0u64, 0u64);
535    let mut stack = vec![root.to_path_buf()];
536    while let Some(d) = stack.pop() {
537        // Un rootfs ajeno trae directorios que NI SU DUEÑO puede leer (`/etc/sudoers.d` es 0750,
538        // `/var/log/private` 0700, y alguno 0000). No es corrupción: es el rootfs siendo lo que es.
539        // Se cuentan como 0 y se sigue — abortar el pull por no poder contar sería absurdo.
540        let rd = match std::fs::read_dir(&d) {
541            Ok(rd) => rd,
542            Err(_) => { ciegos += 1; continue }
543        };
544        for e in rd {
545            let Ok(e) = e else { continue };
546            let Ok(ft) = e.file_type() else { continue };
547            if ft.is_dir() {
548                stack.push(e.path());
549            } else {
550                files += 1;
551                bytes += e.metadata().map(|m| m.len()).unwrap_or(0);
552            }
553        }
554    }
555    Ok((files, bytes, ciegos))
556}
557
558fn write_manifest(dir: &Path, m: &ImageManifest) -> Result<()> {
559    let body = format!(
560        "# Lo escribe `hammer qorpa pull`; no editar a mano.\n\
561         # La identidad de la imagen es el sha256 del ARCHIVO original, no de este árbol: por eso\n\
562         # la URL es informativa y espejar es gratis (ADR 0013).\n{}",
563        toml::to_string_pretty(m).context("serializando el manifiesto")?
564    );
565    std::fs::write(dir.join(IMAGE_MANIFEST), body)?;
566    Ok(())
567}
568
569// ── instancias ──────────────────────────────────────────────────────────────────────────────────
570
571/// El manifiesto de una instancia. **Es la verdad**: el `upper/` es caché y se puede tirar entero
572/// (D3). Los nombres de campo van en inglés como el resto de la superficie (`CLAUDE.md` regla 4);
573/// el ADR los escribió en castellano y se corrigieron aquí.
574#[derive(Serialize, Deserialize, Default)]
575struct Instance {
576    /// sha256 de la imagen base. Cambiarlo + `recreate` ES la actualización de versión (D8).
577    base: String,
578    #[serde(skip_serializing_if = "Option::is_none")]
579    distro: Option<String>,
580    /// Lo que debe estar instalado adentro. Declarativo: hoy se registra, instalarlo es el
581    /// siguiente paso. Sin esto, `recreate` no podría reconstruir nada.
582    #[serde(default, skip_serializing_if = "Vec::is_empty")]
583    packages: Vec<String>,
584    /// La política, autorada (D7). **Por defecto vacía**: una instancia nace sin ver nada.
585    #[serde(default)]
586    grants: Grants,
587    /// Correr el comando como este usuario de la imagen, en vez de como root.
588    ///
589    /// ── POR QUÉ ESTO Y NO `root = false` ───────────────────────────────────────────────────────
590    /// MEDIDO con Steam: el cliente **se niega a correr como root** (`bin_steam.sh: Error: Cannot
591    /// run as root user`), así que la instancia de juegos necesita un usuario normal. La vía
592    /// tentadora —cambiar el mapa para caer en el uid 1000— **corrompe la instancia**: un fichero
593    /// creado bajo un mapa aparece con OTRO uid bajo el otro, y el `useradd` de la preparación deja
594    /// un `/home` que después su propio dueño no puede escribir.
595    ///
596    /// La vía correcta es la de cualquier runtime de contenedores: **un solo mapa, y se BAJA de
597    /// privilegio adentro**. Tenemos `CAP_SETUID` en el namespace, así que es un `setpriv` y ya.
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    run_as: Option<String>,
600    /// La capa de transparencia (D5). Vacía por defecto: **se exporta lo DECLARADO**, nunca todo.
601    /// Exportar todo es la falla de Bedrock — su `ls` compitiendo con el nuestro, arbitrado por
602    /// heurística en tiempo de `exec`. Acá la ambigüedad se resuelve al declarar.
603    #[serde(default)]
604    export: Export,
605}
606
607#[derive(Serialize, Deserialize, Default)]
608struct Export {
609    /// Binarios que aparecen en el `PATH` del host como shims.
610    #[serde(default, skip_serializing_if = "Vec::is_empty")]
611    binaries: Vec<String>,
612    /// Ficheros `.desktop` de la imagen cuyo equivalente se GENERA para el menú del host.
613    #[serde(default, skip_serializing_if = "Vec::is_empty")]
614    apps: Vec<String>,
615}
616
617#[derive(Serialize, Deserialize)]
618#[serde(default)]
619struct Grants {
620    /// Nombres cortos: `wayland`, `pipewire`, `dbus`. ⚠ `wayland` es un borde de PRIVILEGIO, no un
621    /// caño: quien lo tiene puede capturar pantalla y sintetizar teclas (ADR §NO-resuelve 1).
622    #[serde(default, skip_serializing_if = "Vec::is_empty")]
623    sockets: Vec<String>,
624    /// Nodos de dispositivo (`/dev/dri`). ABI de kernel, que es lo único que cruza el borde (D2).
625    #[serde(default, skip_serializing_if = "Vec::is_empty")]
626    devices: Vec<String>,
627    #[serde(default, skip_serializing_if = "Vec::is_empty")]
628    dirs: Vec<DirGrant>,
629    /// La concesión más peligrosa, y por eso va sola en su línea. `dnf`/`pacman` la necesitan.
630    #[serde(default)]
631    network: bool,
632    /// Pasar el socket de Wayland **crudo**, sin el proxy filtrante. Apagado por defecto.
633    ///
634    /// Con esto la instancia recupera TODOS los globals del compositor, incluidos los que capturan
635    /// pantalla, sintetizan teclas y leen el portapapeles. Existe para diagnosticar, no para usar.
636    #[serde(default)]
637    wayland_raw: bool,
638    /// Globals de Wayland que el proxy deja ver. Vacío = la lista curada de `qorpa_wl`.
639    #[serde(default, skip_serializing_if = "Vec::is_empty")]
640    wayland_globals: Vec<String>,
641    /// Deja que la instancia monte y cree namespaces adentro. **Steam ya es un contenedor**: su
642    /// pressure-vessel necesita esto para arrancar (D6). Es una concesión real — con ella la
643    /// instancia recupera la familia `mount`/`setns` que la jaula deniega por defecto.
644    #[serde(default)]
645    nesting: bool,
646    /// Mapear tu uid a **root adentro**. Encendido por defecto: es lo que quieren `dnf`/`pacman`.
647    ///
648    /// ⚠ MEDIDO 2026-09-03, y es un conflicto real con `nesting`: con `--uid 0`, un userns anidado
649    /// NO puede escribir su `uid_map` («Operation not permitted»), así que **pressure-vessel de
650    /// Valve no arranca**. Sin remapear a root, el anidamiento funciona pero el gestor de paquetes
651    /// se queja. Las dos mitades se arreglan con lo mismo: mapear un RANGO real de subuid con
652    /// `newuidmap` y pasarle el namespace a bwrap con `--userns FD` — el mismo impuesto que paga
653    /// hoy `apt` con su `setgroups`.
654    #[serde(default = "si")]
655    root: bool,
656    /// Sella el árbol de la IMAGEN (`/usr`, `/bin`, `/lib`, `/opt`…) contra escritura, aunque
657    /// adentro seas root. Apagado por defecto porque un gestor de paquetes necesita escribir en
658    /// `/usr`; encendido para una instancia que sólo CORRE algo, un exploit no puede persistir en
659    /// el `upper`. Es lo único que Landlock aporta acá que el namespace de montaje no da ya.
660    #[serde(default)]
661    seal_image: bool,
662}
663
664/// Dónde vive `harkaq-exec`. Misma convención que el harkaq del build (`HARKAQ_BIN`).
665fn harkaq_exec() -> PathBuf {
666    std::env::var("HARKAQ_BIN")
667        .map(PathBuf::from)
668        .unwrap_or_else(|_| {
669            PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into()))
670                .join(".cache/harkaq")
671        })
672        .join("harkaq-exec")
673}
674
675/// Las capabilities que hacen que el uid 0 de adentro sea root DE VERDAD.
676///
677/// MEDIDO: bwrap tira TODAS las capabilities, y en Linux ser root es tener `CAP_SETUID`, no tener
678/// uid 0. Sin ellas `apt` no puede `seteuid(42)` para bajar a `_apt` y `dpkg` no puede chownear —
679/// que es exactamente el síntoma que parecía de subuid y no lo era.
680///
681/// Son SEGURAS por construcción: una capability dentro de un user namespace sólo alcanza a lo que
682/// ese namespace posee, o sea los ids que le mapeamos (nuestros propios subuid). No dan nada en el
683/// host. Por eso se conceden con `root = true` sin pedir permiso aparte.
684///
685/// `CAP_SYS_ADMIN` NO está en la lista: es la que permite montar, y montar cuelga de la concesión
686/// `nesting` declarada. Abrir sólo lo declarado, también acá.
687const CAPS_ROOT: &[&str] = &[
688    "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_MKNOD",
689    "CAP_SETGID", "CAP_SETUID", "CAP_SETFCAP", "CAP_SETPCAP", "CAP_SYS_CHROOT",
690    "CAP_KILL", "CAP_AUDIT_WRITE",
691];
692
693/// Los directorios que pertenecen a la IMAGEN y que `seal_image` congela.
694const IMAGEN_INMUTABLE: &[&str] = &["usr", "bin", "sbin", "lib", "lib64", "lib32", "opt", "boot"];
695/// Lo que bwrap monta encima del overlay y siempre es de la instancia, no de la imagen.
696const DEL_SANDBOX: &[&str] = &["/proc", "/dev", "/tmp", "/run"];
697
698/// Compila las concesiones a la política que consume `harkaq-exec`.
699///
700/// ── QUÉ APORTA HARKAQ ACÁ, Y QUÉ NO ─────────────────────────────────────────────────────────────
701/// Honestidad primero: en el eje del SISTEMA DE FICHEROS, harkaq casi no agrega nada a una
702/// instancia, porque el namespace de montaje de bwrap ya es una lista blanca — lo que no se bindea
703/// no existe adentro, y un `--ro-bind` es de sólo lectura por el kernel, más fuerte que una regla.
704/// Escribir reglas `ro` que no cambian nada sería un sello de goma, así que no se escriben.
705///
706/// Lo que harkaq SÍ aporta, y es grande para un binario ajeno y opaco:
707///   1. **seccomp.** bwrap no instala filtro alguno: hoy una instancia puede `io_uring`, `bpf`,
708///      `ptrace`, `userfaultfd`, `keyctl`, `perf_event_open`, cargar módulos. Esa superficie
709///      importa MÁS con un prebuilt de terceros que con nuestros propios builds.
710///   2. **`no_new_privs`**: ningún setuid de la imagen escala adentro.
711///   3. **el canal de evidencia**: con ABI≥7 el kernel audita las denegaciones ⇒ se puede saber
712///      qué intentó tocar un binario cerrado. Es la única forma de auditar el montón B.
713///   4. **`seal_image`**: lo único del eje fs que el namespace no da — congelar el árbol de la
714///      imagen aunque adentro seas root.
715fn compile_policy(g: &Grants, image_tree: &Path, dst: &Path) -> Result<()> {
716    use std::fmt::Write as _;
717    let mut out = String::new();
718    out.push_str("# Política de una INSTANCIA qorpa (ADR 0015 D7).\n");
719    out.push_str("# A diferencia de la del build, ésta NO se deriva de una clausura: se AUTORA,\n");
720    out.push_str("# que es el único sitio del sistema donde eso pasa. Se compila del manifiesto.\n");
721
722    if !g.seal_image {
723        // El árbol entero es de la instancia y sus escrituras van al `upper`, que es caché.
724        out.push_str("rw /\n");
725    } else {
726        // Sin regla sobre `/` no se puede ni listar la raíz; listar no es leer ni escribir.
727        out.push_str("list /\n# la imagen, congelada:\n");
728        for d in IMAGEN_INMUTABLE {
729            if image_tree.join(d).exists() {
730                let _ = writeln!(out, "ro /{d}");
731            }
732        }
733        out.push_str("# lo mutable de la instancia:\n");
734        let mut mutables: Vec<String> = std::fs::read_dir(image_tree)
735            .with_context(|| format!("leyendo el árbol de la imagen {}", image_tree.display()))?
736            .flatten()
737            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
738            .map(|e| e.file_name().to_string_lossy().into_owned())
739            .filter(|n| !IMAGEN_INMUTABLE.contains(&n.as_str()))
740            .map(|n| format!("/{n}"))
741            .collect();
742        for d in DEL_SANDBOX {
743            if !mutables.iter().any(|m| m == d) {
744                mutables.push((*d).to_string());
745            }
746        }
747        mutables.sort();
748        mutables.dedup();
749        for m in &mutables {
750            let _ = writeln!(out, "rw {m}");
751        }
752    }
753    std::fs::write(dst, out)
754        .with_context(|| format!("escribiendo la política {}", dst.display()))?;
755    Ok(())
756}
757
758
759impl Default for Grants {
760    fn default() -> Self {
761        // Toda concesión arranca CERRADA menos `root`, que no abre nada del host: sólo decide
762        // quién sos adentro de tu propio rootfs.
763        Grants {
764            sockets: Vec::new(), devices: Vec::new(), dirs: Vec::new(),
765            network: false, nesting: false, root: true, seal_image: false,
766            wayland_raw: false, wayland_globals: Vec::new(),
767        }
768    }
769}
770
771#[derive(Serialize, Deserialize)]
772struct DirGrant {
773    host: String,
774    inside: String,
775    /// `ro` o `rw`.
776    #[serde(default = "ro")]
777    mode: String,
778}
779fn ro() -> String { "ro".into() }
780fn si() -> bool { true }
781
782fn instances_dir(root: &Path) -> PathBuf { root.join("instances") }
783fn instance_dir(root: &Path, id: &str) -> PathBuf { instances_dir(root).join(id) }
784
785/// Resuelve un prefijo de sha contra las imágenes presentes. Que exista y que TENGA CONTENIDO: una
786/// instancia sobre una imagen vacía es un directorio con nombre de rootfs (regla 3).
787fn resolve_image(root: &Path, prefix: &str) -> Result<(String, PathBuf)> {
788    let prefix = prefix.strip_prefix("sha256:").unwrap_or(prefix).to_ascii_lowercase();
789    let images = root.join("images");
790    let mut hits: Vec<String> = std::fs::read_dir(&images)
791        .with_context(|| format!("no hay imágenes en {}", images.display()))?
792        .flatten()
793        .map(|e| e.file_name().to_string_lossy().into_owned())
794        .filter(|n| !n.starts_with('.') && n.starts_with(&prefix))
795        .collect();
796    hits.sort();
797    match hits.len() {
798        1 => {
799            let sha = hits.remove(0);
800            let tree = images.join(&sha).join(TREE);
801            if !matches!(tree_state(&tree), TreeState::Populated) {
802                bail!("la imagen {sha} está VACÍA — no es una imagen, es un nombre");
803            }
804            Ok((sha, tree))
805        }
806        0 => bail!("ninguna imagen empieza por {prefix:?} — traela con `hammer qorpa pull`"),
807        n => bail!("{n} imágenes empiezan por {prefix:?}: dame más dígitos"),
808    }
809}
810
811fn create(root: &Path, id: &str, base: &str, distro: Option<&str>) -> Result<()> {
812    if id.is_empty() || id.contains('/') || id.starts_with('.') {
813        bail!("id de instancia inválido: {id:?}");
814    }
815    let dir = instance_dir(root, id);
816    if dir.exists() {
817        bail!("la instancia {id} ya existe en {} — `recreate` tira su capa mutable", dir.display());
818    }
819    let (sha, _tree) = resolve_image(root, base)?;
820
821    let inst = Instance {
822        base: format!("sha256:{sha}"),
823        distro: distro.map(Into::into),
824        ..Default::default()
825    };
826    std::fs::create_dir_all(dir.join("upper"))?;
827    std::fs::create_dir_all(dir.join("work"))?;
828    write_instance(&dir, &inst)?;
829    println!("instancia {id} creada en {}", dir.display());
830    println!("  base: sha256:{}", &sha[..12]);
831    println!("  concesiones: NINGUNA — se abren declarándolas en {}", INSTANCE_MANIFEST);
832    Ok(())
833}
834
835fn read_instance(root: &Path, id: &str) -> Result<(PathBuf, Instance)> {
836    let dir = instance_dir(root, id);
837    let f = dir.join(INSTANCE_MANIFEST);
838    let body = std::fs::read_to_string(&f)
839        .with_context(|| format!("no existe la instancia {id} ({})", f.display()))?;
840    let inst: Instance = toml::from_str(&body)
841        .with_context(|| format!("manifiesto ilegible: {}", f.display()))?;
842    Ok((dir, inst))
843}
844
845fn write_instance(dir: &Path, inst: &Instance) -> Result<()> {
846    let body = format!(
847        "# Manifiesto de la instancia — ESTO es la verdad, `upper/` es caché descartable (ADR 0015 D3).\n\
848         # `[grants]` es la política, y se AUTORA: es el único sitio del sistema donde no se deriva\n\
849         # de una clausura declarada (D7). Por defecto no hay ninguna.\n{}",
850        toml::to_string_pretty(inst).context("serializando la instancia")?
851    );
852    std::fs::write(dir.join(INSTANCE_MANIFEST), body)?;
853    Ok(())
854}
855
856fn recreate(root: &Path, id: &str) -> Result<()> {
857    let (dir, inst) = read_instance(root, id)?;
858    let (sha, _) = resolve_image(root, &inst.base)?;
859    for capa in ["upper", "work"] {
860        force_remove_dir_all(&dir.join(capa))
861            .with_context(|| format!("no pude tirar {capa}/ de {id}"))?;
862        std::fs::create_dir_all(dir.join(capa))?;
863    }
864    println!("instancia {id} recreada sobre sha256:{} — capa mutable tirada", &sha[..12]);
865    if !inst.packages.is_empty() {
866        println!(
867            "  declara {} paquete(s) ({}) — instalarlos todavía es a mano con `run`",
868            inst.packages.len(),
869            inst.packages.join(", ")
870        );
871    }
872    Ok(())
873}
874
875/// Traduce las concesiones a argumentos de bwrap. Devuelve también las advertencias que el usuario
876/// tiene que LEER, no descubrir: el ADR pide que el socket de Wayland se diga en la cara.
877fn grants_to_args(g: &Grants, destino_xdg: &str, wl: Option<&Path>) -> (Vec<String>, Vec<String>) {
878    let mut a: Vec<String> = Vec::new();
879    let mut warn: Vec<String> = Vec::new();
880    // Sin dep de libc: el dueño de /proc/self ES el uid efectivo.
881    let uid = std::fs::metadata("/proc/self")
882        .map(|m| std::os::unix::fs::MetadataExt::uid(&m))
883        .unwrap_or(1000);
884    let xdg = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| format!("/run/user/{uid}"));
885
886    for s in &g.sockets {
887        let (host, dentro) = match s.as_str() {
888            "wayland" => {
889                let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
890                match wl {
891                    Some(proxy) => (proxy.display().to_string(), format!("{destino_xdg}/wayland-0")),
892                    None => {
893                        warn.push(
894                            "wayland CRUDO (`wayland_raw`): la instancia ve TODOS los globals del \
895                             compositor y podrá CAPTURAR LA PANTALLA, SINTETIZAR TECLAS y LEER EL \
896                             PORTAPAPELES. El proxy filtrante existe; esto lo saltea a propósito."
897                                .into(),
898                        );
899                        (format!("{xdg}/{d}"), format!("{destino_xdg}/wayland-0"))
900                    }
901                }
902            }
903            "pipewire" => (format!("{xdg}/pipewire-0"), format!("{destino_xdg}/pipewire-0")),
904            "dbus" => (format!("{xdg}/bus"), format!("{destino_xdg}/bus")),
905            otro => {
906                warn.push(format!("socket desconocido {otro:?} — ignorado"));
907                continue;
908            }
909        };
910        a.push("--bind-try".into()); a.push(host); a.push(dentro);
911    }
912    for d in &g.devices {
913        a.push("--dev-bind-try".into()); a.push(d.clone()); a.push(d.clone());
914    }
915    for d in &g.dirs {
916        let host = if let Some(rest) = d.host.strip_prefix('~') {
917            format!("{}{}", std::env::var("HOME").unwrap_or_default(), rest)
918        } else { d.host.clone() };
919        a.push(if d.mode == "rw" { "--bind".into() } else { "--ro-bind".into() });
920        a.push(host); a.push(d.inside.clone());
921    }
922    if g.network {
923        // El segundo fallo clásico de la primera corrida, según el propio ADR.
924        a.push("--ro-bind-try".into());
925        a.push("/etc/resolv.conf".into());
926        a.push("/etc/resolv.conf".into());
927    } else {
928        a.push("--unshare-net".into());
929    }
930    (a, warn)
931}
932
933// ── el mapeo por rango (subuid) ──────────────────────────────────────────────────────────────────
934//
935// EL PROBLEMA, medido en los pasos anteriores: bwrap crea el user namespace con UN SOLO id mapeado
936// (`uid_map: 0 1001 1`, `setgroups: deny`). Con eso `apt` no puede bajar a `_apt`, `pacman` no
937// puede chownear su descarga a `alpm`, y un userns anidado —pressure-vessel— no puede escribir su
938// propio mapa. Tres síntomas distintos, una sola causa.
939//
940// LA CURA: crear el namespace NOSOTROS, mapearle un rango real de `/etc/subuid` con `newuidmap`, y
941// pasárselo a bwrap con `--userns FD`. bwrap no llama a `newuidmap` a propósito —es un binario con
942// capability y bwrap no quiere depender de él—, así que el trabajo es de quien lo invoca.
943//
944// El namespace lo sostiene un proceso «tenedor» que espera en un fifo: mientras viva, el fd
945// `/proc/<pid>/ns/user` es válido. La coreografía (hijo avisa por un fifo, padre mapea desde fuera,
946// hijo sigue) es la misma que ya verifica `scripts/qorpa/qorpa-preflight.sh`.
947
948struct Rango { inicio: u32, cantidad: u32 }
949
950fn leer_subid(fichero: &str, usuario: &str) -> Option<Rango> {
951    let txt = std::fs::read_to_string(fichero).ok()?;
952    txt.lines().find_map(|l| {
953        let mut c = l.split(':');
954        let (u, i, n) = (c.next()?, c.next()?, c.next()?);
955        if u != usuario {
956            return None;
957        }
958        Some(Rango { inicio: i.parse().ok()?, cantidad: n.parse().ok()? })
959    })
960}
961
962/// El namespace vivo, con su tenedor. Al soltarlo, se mata al tenedor y el namespace desaparece.
963struct UsernsMapeado {
964    tenedor: std::process::Child,
965    ns_path: String,
966    ids: u32,
967}
968
969impl Drop for UsernsMapeado {
970    fn drop(&mut self) {
971        // El tenedor sólo existe para sostener el namespace. Si sobreviviera al `run`, dejaríamos
972        // un proceso colgado por cada instancia arrancada.
973        let _ = self.tenedor.kill();
974        let _ = self.tenedor.wait();
975    }
976}
977
978/// Crea el namespace y le mapea el rango. Devuelve `Err` con la CAUSA exacta: hay tres formas de
979/// que esto no se pueda, y las tres se arreglan distinto, así que no se colapsan en un "no pude".
980///
981/// `uid_dentro` decide quién sos adentro, y **eso lo hace el MAPA, no un flag de bwrap** — que es
982/// justo lo que permite ser un usuario normal sin renunciar al rango. Hizo falta por Steam: el
983/// cliente se niega a correr como root (`bin_steam.sh: Error: Cannot run as root user`), así que la
984/// instancia de juegos NECESITA no ser root, y antes eso costaba el rango entero.
985fn userns_con_rango(uid_dentro: u32) -> Result<UsernsMapeado> {
986    let uid = std::fs::metadata("/proc/self")
987        .map(|m| std::os::unix::fs::MetadataExt::uid(&m))
988        .unwrap_or(1000);
989    let gid = std::fs::metadata("/proc/self")
990        .map(|m| std::os::unix::fs::MetadataExt::gid(&m))
991        .unwrap_or(1000);
992    let usuario = std::env::var("USER")
993        .or_else(|_| std::env::var("LOGNAME"))
994        .unwrap_or_default();
995    let ru = leer_subid("/etc/subuid", &usuario)
996        .with_context(|| format!("no hay rango para {usuario:?} en /etc/subuid"))?;
997    let rg = leer_subid("/etc/subgid", &usuario)
998        .with_context(|| format!("no hay rango para {usuario:?} en /etc/subgid"))?;
999
1000    let tmp = std::env::temp_dir().join(format!("qorpa-userns-{}", std::process::id()));
1001    std::fs::create_dir_all(&tmp)?;
1002    let listo = tmp.join("listo");
1003    let adelante = tmp.join("adelante");
1004    for f in [&listo, &adelante] {
1005        let _ = std::fs::remove_file(f);
1006        let st = Command::new("mkfifo").arg(f).status().context("mkfifo")?;
1007        if !st.success() {
1008            bail!("no pude crear el fifo {}", f.display());
1009        }
1010    }
1011
1012    let tenedor = Command::new("unshare")
1013        .arg("-U")
1014        .arg("sh")
1015        .arg("-c")
1016        .arg(format!(
1017            "echo r > {l}; read _ < {a}",
1018            l = listo.display(), a = adelante.display()
1019        ))
1020        .spawn()
1021        .context("no pude ejecutar `unshare -U` — ¿está util-linux?")?;
1022    let pid = tenedor.id();
1023    let mut guard = UsernsMapeado {
1024        tenedor,
1025        ns_path: format!("/proc/{pid}/ns/user"),
1026        ids: 1 + ru.cantidad,
1027    };
1028
1029    // Bloquea hasta que el hijo esté DENTRO del namespace. Sin este apretón de manos mapearíamos
1030    // el namespace del padre, que es el del host.
1031    std::fs::read_to_string(&listo).context("el tenedor no llegó al fifo")?;
1032
1033    // Los tramos del mapa. Con `uid_dentro = 0` es el caso simple (somos root adentro y el resto
1034    // del rango va detrás). Con cualquier otro, hay que partir el rango en tres para dejar hueco:
1035    // los ids de abajo (incluido el 0, que tiene que EXISTIR para que la imagen tenga dueño de
1036    // ficheros) salen del subuid, nosotros caemos en `uid_dentro`, y el resto sigue arriba.
1037    let tramos = move |id_afuera: u32, r: &Rango| -> Vec<String> {
1038        if uid_dentro == 0 {
1039            vec!["0".into(), id_afuera.to_string(), "1".into(),
1040                 "1".into(), r.inicio.to_string(), r.cantidad.to_string()]
1041        } else {
1042            let bajos = uid_dentro.min(r.cantidad);
1043            let altos = r.cantidad.saturating_sub(bajos + 1);
1044            let mut v = vec![
1045                "0".into(), r.inicio.to_string(), bajos.to_string(),
1046                uid_dentro.to_string(), id_afuera.to_string(), "1".into(),
1047            ];
1048            if altos > 0 {
1049                v.extend([(uid_dentro + 1).to_string(), (r.inicio + bajos).to_string(),
1050                          altos.to_string()]);
1051            }
1052            v
1053        }
1054    };
1055
1056    let mapear = |bin: &str, id: u32, r: &Rango| -> Result<()> {
1057        let mut argv = vec![pid.to_string()];
1058        argv.extend(tramos(id, r));
1059        let out = Command::new(bin)
1060            .args(&argv)
1061            .output()
1062            .with_context(|| format!("no pude ejecutar {bin}"))?;
1063        if !out.status.success() {
1064            bail!("{bin}: {}", String::from_utf8_lossy(&out.stderr).trim());
1065        }
1066        Ok(())
1067    };
1068    mapear("newuidmap", uid, &ru)?;
1069    mapear("newgidmap", gid, &rg)?;
1070
1071    // Soltar al tenedor: ya mapeado, se queda esperando el fifo (que nadie escribirá) hasta que lo
1072    // matemos. Lo que importa es que el proceso VIVA, no lo que haga.
1073    let _ = std::fs::remove_file(&listo);
1074    let _ = std::fs::remove_file(&adelante);
1075    guard.ids = 1 + ru.cantidad;
1076    Ok(guard)
1077}
1078
1079fn run_instance(
1080    root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: bool, single_id: bool,
1081) -> Result<()> {
1082    let (dir, inst) = read_instance(root, id)?;
1083    let (sha, tree) = resolve_image(root, &inst.base)?;
1084
1085    // Quién sos adentro. Con el mapeo por rango esto NO cuesta el rango: lo decide el propio mapa.
1086    // ⚠ Cambiarlo en una instancia YA USADA la corrompe: los ficheros del `upper` quedaron con los
1087    // uids del mapa anterior. Para correr como usuario normal está `run_as`, que no toca el mapa.
1088    let uid_dentro: u32 = if inst.grants.root { 0 } else { 1000 };
1089    // El XDG_RUNTIME_DIR es del usuario que CORRE, no del uid del mapa: con `run_as`, apuntarlo al
1090    // de root deja a las herramientas del Steam Runtime sin poder crear su temporal
1091    // (`srt-logger: g_mkdtemp: Permission denied`), y eso se lee como un aviso menor hasta que algo
1092    // deja de andar sin explicar por qué.
1093    let uid_corre = match &inst.run_as {
1094        Some(u) => usuario_de(&dir, &tree, u).map(|(x, _, _)| x).unwrap_or(uid_dentro),
1095        None => uid_dentro,
1096    };
1097    let runtime_dir = format!("/run/user/{uid_corre}");
1098
1099    // El proxy vive mientras viva este proceso, que es exactamente lo que dura la instancia.
1100    // `_proxy` se mantiene en scope a propósito.
1101    let mut proxy_sock: Option<PathBuf> = None;
1102    let _proxy = if inst.grants.sockets.iter().any(|s| s == "wayland") && !inst.grants.wayland_raw {
1103        let xdg = std::env::var("XDG_RUNTIME_DIR")
1104            .unwrap_or_else(|_| format!("/run/user/{}", uid_corre));
1105        let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
1106        let real = PathBuf::from(format!("{xdg}/{d}"));
1107        if !real.exists() {
1108            bail!("la instancia pide `wayland` pero no hay compositor en {}", real.display());
1109        }
1110        let permitidos: std::collections::HashSet<String> = if inst.grants.wayland_globals.is_empty() {
1111            crate::qorpa_wl::GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect()
1112        } else {
1113            inst.grants.wayland_globals.iter().cloned().collect()
1114        };
1115        let n = permitidos.len();
1116        let p = crate::qorpa_wl::arrancar(&dir.join("wl"), &real, permitidos)
1117            .context("no pude arrancar el proxy de Wayland")?;
1118        eprintln!("  wayland: proxy filtrante puesto — {n} global(s) visibles, el resto no existe \
1119                   para la instancia");
1120        proxy_sock = Some(p.socket.clone());
1121        Some(p)
1122    } else {
1123        None
1124    };
1125    let (grant_args, warns) = grants_to_args(&inst.grants, &runtime_dir, proxy_sock.as_deref());
1126
1127    // El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo
1128    // después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo
1129    // que no se declara, no entra (D2, "por defecto NADA").
1130    // El mapeo por rango se intenta SIEMPRE que se pueda: es lo que quita el impuesto de
1131    // `apt`/`pacman` y lo que permite anidar. `root = false` lo descarta porque ser otro-que-root
1132    // adentro pide el `--uid` de bwrap, que exige `--unshare-user` y ése es incompatible con
1133    // `--userns`. Cuando no se puede, se dice POR QUÉ y se sigue con un id: degradar en silencio
1134    // haría que "no anda adentro" fuese un misterio en vez de una línea.
1135    let mut mapeado: Option<UsernsMapeado> = None;
1136    if !single_id {
1137        match userns_con_rango(uid_dentro) {
1138            Ok(m) => mapeado = Some(m),
1139            Err(e) => eprintln!(
1140                "⚠ sin mapeo por rango ({e:#}) ⇒ un solo id adentro. Consecuencias MEDIDAS: `apt` \
1141                 no baja a `_apt`, `pacman` no chownea su descarga, y un userns anidado no puede \
1142                 escribir su mapa. Diagnóstico: scripts/qorpa/qorpa-preflight.sh"
1143            ),
1144        }
1145    }
1146
1147    let mut args: Vec<String> = vec![
1148        "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try",
1149        "--hostname", "qorpa",
1150        "--clearenv",
1151        "--die-with-parent",
1152    ].into_iter().map(String::from).collect();
1153    if mapeado.is_none() {
1154        // bwrap crea el suyo, con un solo id.
1155        args.insert(0, "--unshare-user".into());
1156        if inst.grants.root {
1157            args.extend(["--uid", "0", "--gid", "0"].map(String::from));
1158        }
1159    }
1160    if inst.grants.root {
1161        for c in CAPS_ROOT {
1162            args.push("--cap-add".into());
1163            args.push((*c).to_string());
1164        }
1165        if inst.grants.nesting {
1166            // La que permite montar. Sólo con la concesión declarada.
1167            args.push("--cap-add".into());
1168            args.push("CAP_SYS_ADMIN".into());
1169        }
1170    }
1171
1172    args.push("--overlay-src".into()); args.push(tree.display().to_string());
1173    args.push("--overlay".into());
1174    args.push(dir.join("upper").display().to_string());
1175    args.push(dir.join("work").display().to_string());
1176    args.push("/".into());
1177
1178    // /proc y /dev van DESPUÉS del overlay: son del kernel, no de la imagen.
1179    // `--perms 1777` antes del `--tmpfs /tmp` no es cosmético: bwrap lo crearía 0755 y entonces el
1180    // `_apt` (uid 42) al que apt baja para bajar paquetes no puede escribir su fichero temporal.
1181    // Un /tmp que no es 1777 no es /tmp.
1182    args.extend(["--proc", "/proc", "--dev", "/dev",
1183                 "--perms", "1777", "--tmpfs", "/tmp"].map(String::from));
1184    args.extend(["--dir".to_string(), runtime_dir.clone(), "--chdir".into(), "/".into()]);
1185    // El HOME sale del /etc/passwd de la IMAGEN, no de una constante: si la instancia creó un
1186    // usuario, su home es el que ese fichero dice. Un HOME que no existe hace que media app se
1187    // comporte raro sin decir por qué.
1188    let home = home_de(&dir, &tree, uid_dentro);
1189    args.extend([
1190        "--setenv", "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
1191        "--setenv", "HOME", &home,
1192        "--setenv", "TERM", &std::env::var("TERM").unwrap_or_else(|_| "xterm".into()),
1193    ].map(String::from));
1194    args.extend(["--setenv".to_string(), "XDG_RUNTIME_DIR".into(), runtime_dir.clone()]);
1195    args.extend(grant_args);
1196
1197    let mut cmd: Vec<String> = if cmd.is_empty() {
1198        vec!["/bin/sh".into()] // toda imagen trae uno; bash puede no estar.
1199    } else { cmd.to_vec() };
1200
1201    // Bajar de privilegio ADENTRO, con el mapa intacto. `setpriv` viene en util-linux, que trae
1202    // toda imagen de distro; si faltara, se dice en vez de correr como root en silencio — que sería
1203    // exactamente lo que el manifiesto pidió no hacer.
1204    if let Some(usuario) = &inst.run_as {
1205        let (uid_u, gid_u, home_u) = usuario_de(&dir, &tree, usuario).with_context(|| {
1206            format!("`run_as = {usuario:?}` pero no existe en el /etc/passwd de la instancia")
1207        })?;
1208        // Un prelude MÍNIMO como root para darle su runtime dir al usuario, y después `exec`: el
1209        // `"$@"` evita entrecomillar el comando del usuario, que es donde estos wrappers se rompen.
1210        let mut env = vec![
1211            "/bin/sh".to_string(), "-c".into(),
1212            "chown \"$1\":\"$2\" \"$3\" 2>/dev/null; shift 3; exec \"$@\"".into(),
1213            "qorpa".into(), uid_u.to_string(), gid_u.to_string(), runtime_dir.clone(),
1214            "setpriv".into(), "--reuid".into(), uid_u.to_string(),
1215            "--regid".into(), gid_u.to_string(), "--init-groups".into(), "--".into(),
1216        ];
1217        env.extend(cmd);
1218        cmd = env;
1219        // El HOME tiene que ser el SUYO, no el de root: media app se comporta raro sin decir por qué.
1220        if let Some(i) = args.iter().position(|a| a == "HOME") {
1221            args[i + 1] = home_u;
1222        }
1223    }
1224
1225    // harkaq va como ÚLTIMO eslabón antes del comando, igual que en el sandbox del build: bwrap ya
1226    // puso los namespaces y el overlay, y esto pone el grano fino. Cruza el borde un binario
1227    // ESTÁTICO — no una librería —, así que sigue valiendo D2: lo único que comparte con el host
1228    // es la ABI del kernel.
1229    if !no_jail {
1230        let exec = harkaq_exec();
1231        if !exec.exists() {
1232            bail!(
1233                "no encuentro harkaq-exec en {} — la instancia NO va a correr enjaulada y eso no se \
1234                 hace en silencio.\n  construilo:  gcc -O1 -Wall -static -o {} scripts/harkaq/harkaq-exec.c\n                   o corré con --no-jail si sabés lo que estás haciendo.",
1235                exec.display(), exec.display()
1236            );
1237        }
1238        let policy = dir.join("policy");
1239        compile_policy(&inst.grants, &tree, &policy)?;
1240
1241        args.push("--ro-bind".into()); args.push(exec.display().to_string());
1242        args.push("/run/qorpa/harkaq-exec".into());
1243        args.push("--ro-bind".into()); args.push(policy.display().to_string());
1244        args.push("/run/qorpa/policy".into());
1245
1246        let mut envoltura: Vec<String> = vec![
1247            "/run/qorpa/harkaq-exec".into(), "--policy".into(), "/run/qorpa/policy".into(),
1248        ];
1249        if inst.grants.nesting {
1250            // MEDIDO: seccomp no alcanza. Un dominio Landlock activo hace fallar `mount` con
1251            // EACCES aunque el filtro lo permita — el kernel no admite montajes nuevos bajo un
1252            // dominio, porque escaparían de sus reglas por-ruta. ⇒ anidar cuesta la política de
1253            // ficheros. Seccomp y no_new_privs siguen puestos, que es lo que más pesa con un
1254            // binario ajeno, y el confinamiento de fs queda en el namespace de montaje de bwrap,
1255            // que ya es una lista blanca.
1256            envoltura.push("--allow-nesting".into());
1257            envoltura.push("--no-landlock".into());
1258        }
1259        envoltura.push("--".into());
1260        envoltura.extend(cmd);
1261        cmd = envoltura;
1262    } else {
1263        eprintln!("⚠ --no-jail: SIN Landlock, SIN seccomp, SIN no_new_privs. Sólo los namespaces \
1264                   de bwrap. No es el modo normal.");
1265    }
1266    args.extend(cmd.iter().cloned());
1267
1268    for w in &warns {
1269        eprintln!("⚠ {w}");
1270    }
1271    if dry_run {
1272        if let Some(m) = &mapeado {
1273            println!("# el userns lo creamos nosotros y le mapeamos {} ids:", m.ids);
1274            println!("#   exec 3<{}   ⇒   bwrap --userns 3 …", m.ns_path);
1275        }
1276        // Una línea por concesión, no una por token: el punto de `--dry-run` es que se LEA.
1277        let mut linea = String::from("bwrap");
1278        for a in &args {
1279            if a.starts_with("--") { println!("{linea} \\"); linea = format!("  {a}"); }
1280            else { linea.push(' '); linea.push_str(a); }
1281        }
1282        println!("{linea}");
1283        return Ok(());
1284    }
1285    eprintln!("qorpa {id} · sha256:{} · red={} · {} socket(s), {} device(s), {} dir(s) · \
1286               jaula={} · imagen={}",
1287        &sha[..12], inst.grants.network, inst.grants.sockets.len(),
1288        inst.grants.devices.len(), inst.grants.dirs.len(),
1289        if no_jail { "NO" } else if inst.grants.nesting { "sí (anidamiento permitido)" } else { "sí" },
1290        if inst.grants.seal_image { "sellada" } else { "escribible" });
1291    eprintln!("  ids mapeados: {}",
1292        match &mapeado { Some(m) => m.ids.to_string(), None => "1 (sin rango)".into() });
1293
1294    // Cuando el namespace es nuestro hay que pasarle a bwrap un fd ABIERTO, y un fd sólo cruza el
1295    // `exec` si no es CLOEXEC. En vez de arrastrar una dep de C para un `fcntl`, lo abre la shell:
1296    // sus redirecciones no son CLOEXEC por definición, y de paso el `--dry-run` puede mostrar
1297    // exactamente lo que pasa.
1298    let st = match &mapeado {
1299        Some(m) => Command::new("sh")
1300            .arg("-c")
1301            .arg(r#"exec 3<"$1"; shift; exec bwrap --userns 3 "$@""#)
1302            .arg("qorpa")
1303            .arg(&m.ns_path)
1304            .args(&args)
1305            .status()
1306            .context("no pude ejecutar sh/bwrap")?,
1307        None => Command::new("bwrap").args(&args).status()
1308            .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?,
1309    };
1310    match st.code() {
1311        Some(0) => Ok(()),
1312        Some(c) => std::process::exit(c),
1313        None => bail!("la instancia murió por señal"),
1314    }
1315}
1316
1317// ── prune: la poda (§Orden 7) ───────────────────────────────────────────────────────────────────
1318//
1319// Nace CON el subsistema y no después, a propósito: un rootfs son cientos de MB o varios GB, y no
1320// lo alcanzan ni `store-gc` ni la caché `.dmerge` — es un tercer montón sin dueño, como ya lo fue
1321// `work/sources`. La regla que la hace trivial es D3: **un `upper` siempre se puede borrar**.
1322//
1323// Dos cicatrices del repo, cableadas acá:
1324//  · `store-gc` REPORTABA borrados que no ocurrían (corriendo en segundo plano). Por eso esto
1325//    comprueba DESPUÉS de borrar que el directorio se fue, y si sigue ahí lo dice.
1326//  · Un tamaño calculado sobre un árbol que no se puede leer entero sale MENOR de lo que es. Por
1327//    eso se cuentan los directorios ciegos y la cifra se marca como cota inferior.
1328
1329struct Candidato { que: String, ruta: PathBuf, bytes: u64, cota: bool }
1330
1331fn prune(root: &Path, con_upper: bool, de_verdad: bool) -> Result<()> {
1332    let images = root.join("images");
1333    let mut cands: Vec<Candidato> = Vec::new();
1334
1335    // 1. Restos de descargas y desempacados a medias. Basura por definición: llevan el prefijo con
1336    //    punto justo para que `list` no los confunda con una imagen.
1337    if let Ok(rd) = std::fs::read_dir(&images) {
1338        for e in rd.flatten() {
1339            let n = e.file_name().to_string_lossy().into_owned();
1340            if !n.starts_with('.') {
1341                continue;
1342            }
1343            let (bytes, cota) = medir(&e.path());
1344            cands.push(Candidato { que: format!("resto de un pull a medias ({n})"),
1345                                   ruta: e.path(), bytes, cota });
1346        }
1347    }
1348
1349    // 2. Imágenes que ninguna instancia usa. Se pueden volver a traer por digest, así que borrarlas
1350    //    no pierde nada que no se pueda recuperar: es exactamente la propiedad que da el pin.
1351    let usadas: std::collections::BTreeSet<String> = std::fs::read_dir(instances_dir(root))
1352        .into_iter().flatten().flatten()
1353        .filter_map(|e| {
1354            let body = std::fs::read_to_string(e.path().join(INSTANCE_MANIFEST)).ok()?;
1355            let i: Instance = toml::from_str(&body).ok()?;
1356            Some(i.base.strip_prefix("sha256:").unwrap_or(&i.base).to_string())
1357        })
1358        .collect();
1359    if let Ok(rd) = std::fs::read_dir(&images) {
1360        for e in rd.flatten() {
1361            let n = e.file_name().to_string_lossy().into_owned();
1362            if n.starts_with('.') || usadas.contains(&n) {
1363                continue;
1364            }
1365            let m: Option<ImageManifest> = std::fs::read_to_string(e.path().join(IMAGE_MANIFEST))
1366                .ok().and_then(|s| toml::from_str(&s).ok());
1367            // El tamaño ya está en el manifiesto (lo midió `pull` sobre el árbol recién
1368            // desempacado, cuando todavía era legible entero): mejor dato y gratis.
1369            let (bytes, cota) = match &m {
1370                Some(m) if m.tree_bytes > 0 => (m.tree_bytes, false),
1371                _ => medir(&e.path()),
1372            };
1373            let etiqueta = m.and_then(|m| m.label).unwrap_or_else(|| n[..12].to_string());
1374            cands.push(Candidato { que: format!("imagen sin instancia ({etiqueta})"),
1375                                   ruta: e.path(), bytes, cota });
1376        }
1377    }
1378
1379    // 3. Capas mutables. Sólo si se piden: son caché (D3), pero lo que instalaste a mano y no
1380    //    declaraste en `packages` no vuelve solo.
1381    if con_upper {
1382        for e in std::fs::read_dir(instances_dir(root)).into_iter().flatten().flatten() {
1383            let id = e.file_name().to_string_lossy().into_owned();
1384            let up = e.path().join("upper");
1385            if !up.exists() {
1386                continue;
1387            }
1388            let (bytes, cota) = medir(&up);
1389            cands.push(Candidato { que: format!("capa mutable de {id}"), ruta: up, bytes, cota });
1390        }
1391    }
1392
1393    if cands.is_empty() {
1394        println!("nada que podar en {}", root.display());
1395        return Ok(());
1396    }
1397    let total: u64 = cands.iter().map(|c| c.bytes).sum();
1398    let alguna_cota = cands.iter().any(|c| c.cota);
1399    for c in &cands {
1400        println!("  {}{:<9} {}", if c.cota { "≥" } else { " " }, human(c.bytes), c.que);
1401    }
1402    println!("{}{} en {} elemento(s)", if alguna_cota { "≥ " } else { "" }, human(total), cands.len());
1403
1404    if !de_verdad {
1405        println!("\nsimulacro: no se borró nada. Repetí con --yes.");
1406        if !con_upper {
1407            println!("(`--upper` añade las capas mutables, que por D3 siempre se pueden tirar)");
1408        }
1409        return Ok(());
1410    }
1411
1412    let (mut ok, mut fallos) = (0u64, 0u64);
1413    for c in &cands {
1414        // `force_remove_dir_all` sabe abrir los directorios de sólo-lectura de un rootfs y, si el
1415        // árbol tiene ficheros de los subuid, entrar al userns mapeado para borrarlos.
1416        let r = if c.ruta.is_dir() { force_remove_dir_all(&c.ruta) } else { std::fs::remove_file(&c.ruta) };
1417        // La cicatriz de store-gc: no basta con que la llamada devuelva Ok — se COMPRUEBA que se fue.
1418        match (r, c.ruta.exists()) {
1419            (Ok(()), false) => { ok += 1; }
1420            (Ok(()), true) => { fallos += 1; eprintln!("  !! {} dijo que sí y SIGUE AHÍ", c.ruta.display()); }
1421            (Err(e), _) => { fallos += 1; eprintln!("  !! {}: {e}", c.ruta.display()); }
1422        }
1423    }
1424    // La capa mutable es parte de la instancia: si se tiró, hay que dejarla utilizable.
1425    if con_upper {
1426        for e in std::fs::read_dir(instances_dir(root)).into_iter().flatten().flatten() {
1427            let _ = std::fs::create_dir_all(e.path().join("upper"));
1428            let _ = force_remove_dir_all(&e.path().join("work"));
1429            let _ = std::fs::create_dir_all(e.path().join("work"));
1430        }
1431    }
1432    println!("podado: {ok} de {} elemento(s)", cands.len());
1433    if fallos > 0 {
1434        bail!("{fallos} no se pudieron borrar — el número de arriba NO es lo que se liberó");
1435    }
1436    Ok(())
1437}
1438
1439/// Tamaño de un árbol y si la cifra es cota inferior (hubo directorios ilegibles).
1440fn medir(p: &Path) -> (u64, bool) {
1441    if p.is_file() {
1442        return (std::fs::metadata(p).map(|m| m.len()).unwrap_or(0), false);
1443    }
1444    match walk_tree_ciegos(p) {
1445        Ok((_, bytes, ciegos)) => (bytes, ciegos > 0),
1446        Err(_) => (0, true),
1447    }
1448}
1449
1450// ── export: la capa de transparencia (D5) ───────────────────────────────────────────────────────
1451//
1452// El objetivo es el poder de Bedrock Linux —apps de cualquier «stratum» disponibles en todos
1453// lados— SIN sus formas: nada de un FUSE global en el camino de cada `exec`, nada de arbitrar por
1454// heurística qué binario gana. Acá no hace falta, porque **el FHS de esta distro ya es una
1455// proyección**: `hammer hydrate` proyecta artefactos a un árbol, y extender la proyección a
1456// instancias es idiomático.
1457//
1458// Tres propiedades que un FUSE no da: **cero costo en runtime** (no hay proceso en el camino
1459// crítico), **inspeccionable** (`cat` al shim y ves qué hace) y **revocable** (se borran los shims
1460// y la instancia desaparece del sistema sin desmontar nada).
1461
1462/// Registro de lo escrito. Sin esto, `--remove` tendría que ADIVINAR qué ficheros son nuestros, y
1463/// borrar por patrón en el `~/.local/bin` de alguien es exactamente lo que no se hace.
1464const EXPORT_LEDGER: &str = "exported.json";
1465/// Marca dentro de cada fichero generado. Redundante con el registro a propósito: si alguien mueve
1466/// el shim, sigue diciendo de dónde salió.
1467const MARCA: &str = "hammer-qorpa";
1468
1469/// Claves del `.desktop` ajeno que se copian: TEXTO DE PRESENTACIÓN y nada más.
1470///
1471/// Lista blanca, no negra. `Exec`, `TryExec`, `Path`, `DBusActivatable` y `Actions` quedan fuera
1472/// **por definición**: son ejecución, y la ejecución la escribimos nosotros. Una lista negra
1473/// dejaría entrar la próxima clave ejecutable que invente el estándar.
1474const DESKTOP_OK: &[&str] = &[
1475    "Name", "GenericName", "Comment", "Keywords", "Icon", "Categories",
1476    "Terminal", "StartupWMClass", "StartupNotify", "MimeType", "NoDisplay", "Type",
1477];
1478
1479/// Busca un fichero en la vista MERGED de la instancia: primero la capa mutable, después la imagen.
1480/// Es el mismo orden que ve el proceso adentro; mirar sólo la imagen se perdería lo que instaló el
1481/// gestor de paquetes, que es justo lo que se quiere exportar.
1482fn resolver_en_instancia(dir: &Path, tree: &Path, rel: &str) -> Option<PathBuf> {
1483    let rel = rel.trim_start_matches('/');
1484    for base in [dir.join("upper"), tree.to_path_buf()] {
1485        let p = base.join(rel);
1486        if p.exists() {
1487            return Some(p);
1488        }
1489    }
1490    None
1491}
1492
1493fn buscar_desktop(dir: &Path, tree: &Path, nombre: &str) -> Option<PathBuf> {
1494    let nombre = if nombre.ends_with(".desktop") { nombre.to_string() } else { format!("{nombre}.desktop") };
1495    for d in ["usr/share/applications", "usr/local/share/applications", "share/applications"] {
1496        if let Some(p) = resolver_en_instancia(dir, tree, &format!("{d}/{nombre}")) {
1497            return Some(p);
1498        }
1499    }
1500    None
1501}
1502
1503/// El icono referenciado por un `.desktop` puede ser una ruta o un NOMBRE de tema. Si es nombre, se
1504/// busca en los sitios estándar de la imagen y se copia al host: un `.desktop` con un icono que el
1505/// host no puede resolver se ve como un cuadrito gris, que es peor que no exportarlo.
1506fn copiar_icono(dir: &Path, tree: &Path, icono: &str, destino_base: &Path) -> Option<(String, PathBuf)> {
1507    if icono.starts_with('/') {
1508        let src = resolver_en_instancia(dir, tree, icono)?;
1509        let ext = src.extension().and_then(|e| e.to_str()).unwrap_or("png").to_string();
1510        let nombre = format!("{MARCA}-{}", src.file_name()?.to_string_lossy());
1511        let dst = destino_base.join(&nombre);
1512        std::fs::create_dir_all(destino_base).ok()?;
1513        std::fs::copy(&src, &dst).ok()?;
1514        let _ = ext;
1515        return Some((dst.display().to_string(), dst));
1516    }
1517    for tamano in ["scalable", "256x256", "128x128", "64x64", "48x48"] {
1518        for ext in ["svg", "png"] {
1519            let rel = format!("usr/share/icons/hicolor/{tamano}/apps/{icono}.{ext}");
1520            if let Some(src) = resolver_en_instancia(dir, tree, &rel) {
1521                let dst = destino_base.join(format!("{MARCA}-{icono}.{ext}"));
1522                std::fs::create_dir_all(destino_base).ok()?;
1523                if std::fs::copy(&src, &dst).is_ok() {
1524                    return Some((dst.display().to_string(), dst));
1525                }
1526            }
1527        }
1528    }
1529    for rel in [format!("usr/share/pixmaps/{icono}.png"), format!("usr/share/pixmaps/{icono}.svg")] {
1530        if let Some(src) = resolver_en_instancia(dir, tree, &rel) {
1531            let ext = if rel.ends_with("svg") { "svg" } else { "png" };
1532            let dst = destino_base.join(format!("{MARCA}-{icono}.{ext}"));
1533            std::fs::create_dir_all(destino_base).ok()?;
1534            if std::fs::copy(&src, &dst).is_ok() {
1535                return Some((dst.display().to_string(), dst));
1536            }
1537        }
1538    }
1539    None
1540}
1541
1542fn export(root: &Path, id: &str, into: Option<&Path>, remove: bool) -> Result<()> {
1543    let (dir, inst) = read_instance(root, id)?;
1544    let ledger_path = dir.join(EXPORT_LEDGER);
1545
1546    if remove {
1547        let previos: Vec<String> = std::fs::read_to_string(&ledger_path)
1548            .ok()
1549            .and_then(|s| serde_json::from_str(&s).ok())
1550            .unwrap_or_default();
1551        if previos.is_empty() {
1552            println!("la instancia {id} no tiene nada exportado");
1553            return Ok(());
1554        }
1555        for f in &previos {
1556            match std::fs::remove_file(f) {
1557                Ok(()) => println!("  borrado {f}"),
1558                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1559                Err(e) => eprintln!("  no pude borrar {f}: {e}"),
1560            }
1561        }
1562        let _ = std::fs::remove_file(&ledger_path);
1563        println!("{} fichero(s) revocados — la instancia sigue existiendo, ya no se ve", previos.len());
1564        return Ok(());
1565    }
1566
1567    if inst.export.binaries.is_empty() && inst.export.apps.is_empty() {
1568        bail!(
1569            "la instancia {id} no declara nada en `[export]`. Se exporta lo DECLARADO: exportar \
1570             todo haría que el `ls` de la imagen compita con el nuestro, que es la falla de Bedrock."
1571        );
1572    }
1573    let (_sha, tree) = resolve_image(root, &inst.base)?;
1574
1575    let prefijo = into.map(PathBuf::from).unwrap_or_else(|| {
1576        PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into())).join(".local")
1577    });
1578    let bin_dir = prefijo.join("bin");
1579    let app_dir = prefijo.join("share/applications");
1580    let icon_dir = prefijo.join("share/icons/qorpa");
1581    std::fs::create_dir_all(&bin_dir)?;
1582
1583    // La ruta ABSOLUTA de este mismo binario: un shim que dependiera del `PATH` del usuario haría
1584    // que la app arranque o no según quién la lance.
1585    let hammer = std::env::current_exe().context("no sé cuál es mi propia ruta")?;
1586    let raiz_flag = if root == Path::new(DEFAULT_ROOT) {
1587        String::new()
1588    } else {
1589        format!(" --root {}", shell_quote(&root.display().to_string()))
1590    };
1591
1592    let mut escritos: Vec<String> = Vec::new();
1593
1594    for b in &inst.export.binaries {
1595        let dst = bin_dir.join(b);
1596        let cuerpo = format!(
1597            "#!/bin/sh\n\
1598             # GENERADO por `hammer qorpa export {id}` — {MARCA}. No editar: se reescribe.\n\
1599             # Borralo (o corré `hammer qorpa export {id} --remove`) y la instancia deja de verse.\n\
1600             exec {hammer} qorpa run{raiz_flag} {id} -- {b} \"$@\"\n",
1601            hammer = shell_quote(&hammer.display().to_string()),
1602            b = shell_quote(b),
1603        );
1604        std::fs::write(&dst, cuerpo)?;
1605        std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755))?;
1606        escritos.push(dst.display().to_string());
1607        println!("  bin      {}", dst.display());
1608    }
1609
1610    for a in &inst.export.apps {
1611        let src = buscar_desktop(&dir, &tree, a)
1612            .with_context(|| format!("no encuentro {a} dentro de la instancia {id}"))?;
1613        let texto = std::fs::read_to_string(&src)?;
1614        let (campos, exec_original) = leer_desktop(&texto);
1615
1616        let nombre_bin = exec_original
1617            .split_whitespace()
1618            .next()
1619            .map(|c| c.rsplit('/').next().unwrap_or(c).to_string())
1620            .unwrap_or_else(|| a.trim_end_matches(".desktop").to_string());
1621
1622        let mut salida = String::from("[Desktop Entry]\n");
1623        salida.push_str(&format!(
1624            "# GENERADO por `hammer qorpa export {id}` — {MARCA}. El Exec de abajo es NUESTRO:\n\
1625             # el original decía `{exec_original}` y no se copia (D5).\n"
1626        ));
1627        let mut icono_final = None;
1628        for (k, v) in &campos {
1629            if k.starts_with("Icon") {
1630                if let Some((ruta, dst)) = copiar_icono(&dir, &tree, v, &icon_dir) {
1631                    escritos.push(dst.display().to_string());
1632                    icono_final = Some(ruta);
1633                    continue; // se escribe abajo, ya resuelto a ruta del host
1634                }
1635            }
1636            salida.push_str(&format!("{k}={v}\n"));
1637        }
1638        if let Some(i) = icono_final {
1639            salida.push_str(&format!("Icon={i}\n"));
1640        }
1641        if !campos.iter().any(|(k, _)| k == "Type") {
1642            salida.push_str("Type=Application\n");
1643        }
1644        salida.push_str(&format!(
1645            "Exec={} qorpa run{raiz_flag} {id} -- {} %U\n",
1646            shell_quote(&hammer.display().to_string()),
1647            shell_quote(&nombre_bin),
1648        ));
1649        salida.push_str(&format!("X-Qorpa-Instance={id}\n"));
1650
1651        std::fs::create_dir_all(&app_dir)?;
1652        let dst = app_dir.join(src.file_name().unwrap());
1653        std::fs::write(&dst, salida)?;
1654        escritos.push(dst.display().to_string());
1655        println!("  desktop  {}", dst.display());
1656    }
1657
1658    std::fs::write(&ledger_path, serde_json::to_string_pretty(&escritos)?)?;
1659    println!(
1660        "{} fichero(s) generados desde {} — inspeccionables con `cat`, revocables con --remove",
1661        escritos.len(), src_label(&dir)
1662    );
1663    if !bin_dir.display().to_string().is_empty() {
1664        println!("  ⚠ que `{}` esté en tu PATH es cosa tuya", bin_dir.display());
1665    }
1666    Ok(())
1667}
1668
1669/// Busca el home del uid en el `/etc/passwd` de la instancia (vista merged). Cae a `/root` para el
1670/// 0 y a `/home/qorpa` si no hay entrada — nunca a nada, porque un HOME vacío rompe más que uno
1671/// inventado.
1672fn home_de(dir: &Path, tree: &Path, uid: u32) -> String {
1673    if let Some(p) = resolver_en_instancia(dir, tree, "etc/passwd") {
1674        if let Ok(txt) = std::fs::read_to_string(p) {
1675            for l in txt.lines() {
1676                let c: Vec<&str> = l.split(':').collect();
1677                if c.len() >= 6 && c[2].parse::<u32>() == Ok(uid) && !c[5].is_empty() {
1678                    return c[5].to_string();
1679                }
1680            }
1681        }
1682    }
1683    if uid == 0 { "/root".into() } else { "/home/qorpa".into() }
1684}
1685
1686/// (uid, gid, home) de un usuario del `/etc/passwd` de la instancia. Acepta nombre o uid.
1687fn usuario_de(dir: &Path, tree: &Path, quien: &str) -> Result<(u32, u32, String)> {
1688    let p = resolver_en_instancia(dir, tree, "etc/passwd")
1689        .context("la instancia no tiene /etc/passwd")?;
1690    for l in std::fs::read_to_string(p)?.lines() {
1691        let c: Vec<&str> = l.split(':').collect();
1692        if c.len() < 6 {
1693            continue;
1694        }
1695        if c[0] == quien || c[2] == quien {
1696            return Ok((c[2].parse()?, c[3].parse()?,
1697                       if c[5].is_empty() { "/".into() } else { c[5].to_string() }));
1698        }
1699    }
1700    bail!("no encuentro al usuario {quien:?}")
1701}
1702
1703fn src_label(dir: &Path) -> String {
1704    dir.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string())
1705}
1706
1707/// Parsea el `[Desktop Entry]` de un `.desktop` y devuelve (campos permitidos, el `Exec` original).
1708/// El `Exec` se devuelve SÓLO para nombrar el binario y para dejar dicho en el fichero generado qué
1709/// decía el ajeno — nunca se emite.
1710fn leer_desktop(texto: &str) -> (Vec<(String, String)>, String) {
1711    let mut campos = Vec::new();
1712    let mut exec = String::new();
1713    let mut dentro = false;
1714    for l in texto.lines() {
1715        let l = l.trim();
1716        if l.starts_with('[') {
1717            dentro = l == "[Desktop Entry]";
1718            continue;
1719        }
1720        if !dentro || l.is_empty() || l.starts_with('#') {
1721            continue;
1722        }
1723        let Some((k, v)) = l.split_once('=') else { continue };
1724        let (k, v) = (k.trim(), v.trim());
1725        if k == "Exec" && exec.is_empty() {
1726            exec = v.to_string();
1727            continue;
1728        }
1729        // La clave puede venir localizada (`Name[es]`): se compara la parte antes del corchete.
1730        let base = k.split('[').next().unwrap_or(k);
1731        if DESKTOP_OK.contains(&base) {
1732            campos.push((k.to_string(), v.to_string()));
1733        }
1734    }
1735    (campos, exec)
1736}
1737
1738/// Comillas para `sh`. Un shim se lee y se ejecuta: una ruta con espacios no puede romperlo.
1739fn shell_quote(s: &str) -> String {
1740    if !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"@%+=:,./-_".contains(&b)) {
1741        s.to_string()
1742    } else {
1743        format!("'{}'", s.replace('\'', r"'\''"))
1744    }
1745}
1746
1747// ── list ────────────────────────────────────────────────────────────────────────────────────────
1748
1749fn list(root: &Path, json: bool) -> Result<()> {
1750    let images = root.join("images");
1751    let mut rows: Vec<(String, Option<ImageManifest>, TreeState)> = Vec::new();
1752    if let Ok(rd) = std::fs::read_dir(&images) {
1753        for e in rd.flatten() {
1754            let name = e.file_name().to_string_lossy().into_owned();
1755            if name.starts_with('.') {
1756                continue; // restos de un pull a medias
1757            }
1758            let m = std::fs::read_to_string(e.path().join(IMAGE_MANIFEST))
1759                .ok()
1760                .and_then(|s| toml::from_str::<ImageManifest>(&s).ok());
1761            rows.push((name, m, tree_state(&e.path().join(TREE))));
1762        }
1763    }
1764    rows.sort_by(|a, b| a.0.cmp(&b.0));
1765
1766    let insts: Vec<(String, Instance)> = std::fs::read_dir(instances_dir(root))
1767        .into_iter()
1768        .flatten()
1769        .flatten()
1770        .filter_map(|e| {
1771            let id = e.file_name().to_string_lossy().into_owned();
1772            let body = std::fs::read_to_string(e.path().join(INSTANCE_MANIFEST)).ok()?;
1773            Some((id, toml::from_str::<Instance>(&body).ok()?))
1774        })
1775        .collect();
1776
1777    if json {
1778        let out: Vec<_> = rows.iter().map(|(sha, m, st)| {
1779            serde_json::json!({
1780                "sha256": sha,
1781                "label": m.as_ref().and_then(|m| m.label.clone()),
1782                "url": m.as_ref().map(|m| m.url.clone()),
1783                "tree_files": m.as_ref().map(|m| m.tree_files).unwrap_or(0),
1784                "tree_bytes": m.as_ref().map(|m| m.tree_bytes).unwrap_or(0),
1785                "empty": !matches!(st, TreeState::Populated),
1786            })
1787        }).collect();
1788        let insts_json: Vec<_> = insts.iter().map(|(id, i)| serde_json::json!({
1789            "id": id, "base": i.base, "distro": i.distro,
1790            "packages": i.packages, "network": i.grants.network,
1791            "sockets": i.grants.sockets, "devices": i.grants.devices,
1792        })).collect();
1793        println!("{}", serde_json::to_string_pretty(
1794            &serde_json::json!({"images": out, "instances": insts_json}))?);
1795        return Ok(());
1796    }
1797
1798    if rows.is_empty() && insts.is_empty() {
1799        println!("no hay imágenes en {} — `hammer qorpa pull <url> --sha256 <sha>`", images.display());
1800        return Ok(());
1801    }
1802    let mut vacias = 0;
1803    for (sha, m, st) in &rows {
1804        let etiqueta = m.as_ref().and_then(|m| m.label.clone()).unwrap_or_else(|| "—".into());
1805        match st {
1806            TreeState::Populated => println!(
1807                "  {}  {:<32} {:>7} ficheros  {}",
1808                &sha[..12],
1809                etiqueta,
1810                m.as_ref().map(|m| m.tree_files).unwrap_or(0),
1811                human(m.as_ref().map(|m| m.tree_bytes).unwrap_or(0)),
1812            ),
1813            _ => {
1814                vacias += 1;
1815                println!("  {}  {:<32} !! VACÍA — no es una imagen, es un nombre", &sha[..12], etiqueta);
1816            }
1817        }
1818    }
1819    if !insts.is_empty() {
1820        println!("\ninstancias:");
1821        for (id, i) in &insts {
1822            let sha = i.base.strip_prefix("sha256:").unwrap_or(&i.base);
1823            // Se enumeran las concesiones porque una instancia sin política declarada y una con la
1824            // pantalla abierta se ven IGUAL desde fuera, y no son lo mismo (ADR §NO-resuelve 1).
1825            let mut abre: Vec<String> = Vec::new();
1826            if i.grants.network { abre.push("red".into()); }
1827            abre.extend(i.grants.sockets.iter().cloned());
1828            abre.extend(i.grants.devices.iter().cloned());
1829            if !i.grants.dirs.is_empty() { abre.push(format!("{} dir(s)", i.grants.dirs.len())); }
1830            println!("  {:<16} sobre {}  abre: {}", id, &sha[..sha.len().min(12)],
1831                if abre.is_empty() { "nada".into() } else { abre.join(", ") });
1832        }
1833    }
1834    println!("\n{} imagen(es) en {}", rows.len(), images.display());
1835    if vacias > 0 {
1836        bail!("{vacias} imagen(es) VACÍA(s): un ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien");
1837    }
1838    Ok(())
1839}
1840
1841fn human(b: u64) -> String {
1842    const U: [&str; 5] = ["B", "K", "M", "G", "T"];
1843    let (mut v, mut i) = (b as f64, 0);
1844    while v >= 1024.0 && i < 4 { v /= 1024.0; i += 1; }
1845    if i == 0 { format!("{b} B") } else { format!("{v:.1} {}", U[i]) }
1846}
1847
1848#[cfg(test)]
1849mod tests {
1850    use super::*;
1851
1852    fn tar_gz(dir: &Path, dst: &Path, contenido: &[(&str, &str)]) {
1853        for (p, c) in contenido {
1854            let f = dir.join(p);
1855            std::fs::create_dir_all(f.parent().unwrap()).unwrap();
1856            std::fs::write(f, c).unwrap();
1857        }
1858        let st = Command::new("tar").args(["-czf"]).arg(dst).arg("-C").arg(dir)
1859            .args(contenido.iter().map(|(p, _)| p.split('/').next().unwrap()).collect::<std::collections::BTreeSet<_>>())
1860            .status().unwrap();
1861        assert!(st.success());
1862    }
1863
1864    fn sha_of(p: &Path) -> String {
1865        use sha2::{Digest, Sha256};
1866        let mut h = Sha256::new();
1867        std::io::copy(&mut std::fs::File::open(p).unwrap(), &mut h).unwrap();
1868        hex::encode(h.finalize())
1869    }
1870
1871    #[test]
1872    fn pull_verifica_desempaca_y_es_idempotente() {
1873        let d = tempfile::tempdir().unwrap();
1874        let src = d.path().join("src");
1875        std::fs::create_dir_all(&src).unwrap();
1876        let arch = d.path().join("img.tar.gz");
1877        tar_gz(&src, &arch, &[("etc/os-release", "ID=ajena\n"), ("bin/sh", "#!/bin/sh\n")]);
1878        let sha = sha_of(&arch);
1879        let root = d.path().join("qorpa");
1880        let url = format!("file://{}", arch.display());
1881
1882        pull(&root, &url, &sha, Some("prueba"), false, None).unwrap();
1883        let tree = root.join("images").join(&sha).join(TREE);
1884        assert_eq!(std::fs::read_to_string(tree.join("etc/os-release")).unwrap(), "ID=ajena\n");
1885        // Segundo pull: no vuelve a bajar ni rompe.
1886        pull(&root, &url, &sha, Some("prueba"), false, None).unwrap();
1887        assert!(root.join("images").join(&sha).join(IMAGE_MANIFEST).exists());
1888    }
1889
1890    #[test]
1891    fn pull_aborta_si_el_contenido_no_es_el_que_dice_ser() {
1892        let d = tempfile::tempdir().unwrap();
1893        let src = d.path().join("src");
1894        std::fs::create_dir_all(&src).unwrap();
1895        let arch = d.path().join("img.tar.gz");
1896        tar_gz(&src, &arch, &[("etc/hostname", "ajena\n")]);
1897        let root = d.path().join("qorpa");
1898        let mentira = "0".repeat(64);
1899        let err = pull(&root, &format!("file://{}", arch.display()), &mentira, None, false, None)
1900            .unwrap_err().to_string();
1901        assert!(err.contains("sha256 mismatch"), "mensaje inesperado: {err}");
1902        // Y no dejó nada a medias: contenido distinto ⇒ ABORTAR, no "avisar y seguir".
1903        assert!(!root.join("images").join(&mentira).exists());
1904    }
1905
1906    /// La cicatriz de Arch: el rootfs viene en `root.x86_64/` CON HERMANOS al lado (`version`,
1907    /// `pkglist`). La regla vieja ("un solo directorio arriba") no disparaba y dejaba el rootfs un
1908    /// nivel abajo, en verde. Se ancla por estructura.
1909    #[test]
1910    fn el_rootfs_anidado_se_eleva_aunque_tenga_hermanos() {
1911        let d = tempfile::tempdir().unwrap();
1912        let src = d.path().join("src");
1913        std::fs::create_dir_all(&src).unwrap();
1914        let arch = d.path().join("boot.tar.gz");
1915        tar_gz(&src, &arch, &[
1916            ("root.x86_64/etc/os-release", "ID=arch\n"),
1917            ("root.x86_64/usr/lib/keep", ""),
1918            ("version", "2026.09.01\n"),
1919        ]);
1920        let sha = sha_of(&arch);
1921        let root = d.path().join("qorpa");
1922        pull(&root, &format!("file://{}", arch.display()), &sha, None, false, None).unwrap();
1923        let tree = root.join("images").join(&sha).join(TREE);
1924        assert!(tree.join("etc/os-release").exists(), "no ancló el rootfs anidado");
1925        assert!(!tree.join("root.x86_64").exists());
1926        assert!(!tree.join("version").exists(), "el hermano no debía entrar al rootfs");
1927        let m: ImageManifest = toml::from_str(
1928            &std::fs::read_to_string(root.join("images").join(&sha).join(IMAGE_MANIFEST)).unwrap(),
1929        ).unwrap();
1930        assert_eq!(m.rootfs_subdir.as_deref(), Some("root.x86_64"));
1931        assert_eq!(m.dropped_siblings, vec!["version".to_string()]);
1932    }
1933
1934    #[test]
1935    fn un_arbol_vacio_no_pasa_por_imagen() {
1936        let d = tempfile::tempdir().unwrap();
1937        let vacio = d.path().join("vacio");
1938        std::fs::create_dir_all(&vacio).unwrap();
1939        let arch = d.path().join("nada.tar.gz");
1940        assert!(Command::new("tar").args(["-czf"]).arg(&arch).arg("-C").arg(&vacio)
1941            .args(["--files-from", "/dev/null"]).status().unwrap().success());
1942        let sha = sha_of(&arch);
1943        let root = d.path().join("qorpa");
1944        let err = pull(&root, &format!("file://{}", arch.display()), &sha, None, false, None)
1945            .unwrap_err().to_string();
1946        assert!(err.contains("VACÍO") || err.contains("no encuentro un rootfs"),
1947                "mensaje inesperado: {err}");
1948        assert!(!root.join("images").join(&sha).exists());
1949    }
1950
1951    /// Una imagen mínima que pasa por rootfs: `etc/` + `bin/`.
1952    fn imagen_falsa(root: &Path, sha: &str) {
1953        let t = root.join("images").join(sha).join(TREE);
1954        std::fs::create_dir_all(t.join("etc")).unwrap();
1955        std::fs::create_dir_all(t.join("bin")).unwrap();
1956        std::fs::write(t.join("etc/os-release"), "ID=falsa\n").unwrap();
1957    }
1958
1959    #[test]
1960    fn create_nace_sin_ninguna_concesion() {
1961        let d = tempfile::tempdir().unwrap();
1962        let root = d.path();
1963        let sha = "a".repeat(64);
1964        imagen_falsa(root, &sha);
1965        create(root, "prueba", "aaaa", Some("falsa")).unwrap();
1966        let (_, inst) = read_instance(root, "prueba").unwrap();
1967        assert_eq!(inst.base, format!("sha256:{sha}"));
1968        // D7: la política se autora, y arranca VACÍA.
1969        assert!(!inst.grants.network);
1970        assert!(inst.grants.sockets.is_empty() && inst.grants.devices.is_empty());
1971        // Y no se pisa a sí misma.
1972        assert!(create(root, "prueba", "aaaa", None).is_err());
1973    }
1974
1975    #[test]
1976    fn create_rechaza_una_imagen_vacia() {
1977        let d = tempfile::tempdir().unwrap();
1978        let root = d.path();
1979        let sha = "b".repeat(64);
1980        std::fs::create_dir_all(root.join("images").join(&sha).join(TREE)).unwrap();
1981        let err = create(root, "x", &sha, None).unwrap_err().to_string();
1982        assert!(err.contains("VACÍA"), "mensaje inesperado: {err}");
1983    }
1984
1985    #[test]
1986    fn recreate_tira_la_capa_mutable_y_conserva_el_manifiesto() {
1987        let d = tempfile::tempdir().unwrap();
1988        let root = d.path();
1989        let sha = "c".repeat(64);
1990        imagen_falsa(root, &sha);
1991        create(root, "i", &sha, None).unwrap();
1992        let upper = instance_dir(root, "i").join("upper");
1993        std::fs::write(upper.join("basura"), "x").unwrap();
1994        recreate(root, "i").unwrap();
1995        assert!(!upper.join("basura").exists(), "el upper no es caché si sobrevive");
1996        assert!(instance_dir(root, "i").join(INSTANCE_MANIFEST).exists(), "el manifiesto ES la verdad");
1997    }
1998
1999    #[test]
2000    fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() {
2001        let (a, w) = grants_to_args(&Grants::default(), "/run/user/0", None);
2002        assert!(a.contains(&"--unshare-net".to_string()));
2003        assert!(w.is_empty());
2004
2005        let g = Grants { network: true, ..Default::default() };
2006        let (a, _) = grants_to_args(&g, "/run/user/0", None);
2007        assert!(!a.contains(&"--unshare-net".to_string()));
2008        // El segundo fallo clásico de la primera corrida, según el ADR.
2009        assert!(a.iter().any(|x| x == "/etc/resolv.conf"));
2010    }
2011
2012    #[test]
2013    fn el_socket_de_wayland_avisa_que_es_un_borde_de_privilegio() {
2014        let g = Grants { sockets: vec!["wayland".into()], ..Default::default() };
2015        let (_, w) = grants_to_args(&g, "/run/user/0", None);
2016        assert_eq!(w.len(), 1);
2017        assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]);
2018    }
2019
2020    #[test]
2021    fn sin_sellar_la_politica_no_finge_confinar_el_fs() {
2022        let d = tempfile::tempdir().unwrap();
2023        imagen_falsa(d.path(), "d".repeat(64).as_str());
2024        let tree = d.path().join("images").join("d".repeat(64)).join(TREE);
2025        let dst = d.path().join("policy");
2026        compile_policy(&Grants::default(), &tree, &dst).unwrap();
2027        let pol = std::fs::read_to_string(&dst).unwrap();
2028        // El namespace de montaje de bwrap YA es lista blanca. Escribir reglas `ro` que no cambian
2029        // nada sería un sello de goma, así que la política se queda en una línea honesta.
2030        assert!(pol.lines().any(|l| l == "rw /"), "{pol}");
2031        assert!(!pol.contains("\nro "), "no debería haber reglas decorativas:\n{pol}");
2032    }
2033
2034    #[test]
2035    fn sellar_congela_la_imagen_y_deja_mutable_lo_de_la_instancia() {
2036        let d = tempfile::tempdir().unwrap();
2037        let sha = "e".repeat(64);
2038        imagen_falsa(d.path(), &sha);
2039        let tree = d.path().join("images").join(&sha).join(TREE);
2040        std::fs::create_dir_all(tree.join("var")).unwrap();
2041        std::fs::create_dir_all(tree.join("usr")).unwrap();
2042        let dst = d.path().join("policy");
2043        compile_policy(&Grants { seal_image: true, ..Default::default() }, &tree, &dst).unwrap();
2044        let pol = std::fs::read_to_string(&dst).unwrap();
2045        assert!(pol.lines().any(|l| l == "ro /usr"), "{pol}");
2046        assert!(pol.lines().any(|l| l == "rw /etc"), "{pol}");
2047        assert!(pol.lines().any(|l| l == "rw /var"), "{pol}");
2048        assert!(pol.lines().any(|l| l == "list /"), "{pol}");
2049        // Si `rw /` sobreviviera, taparía todas las `ro`: Landlock une derechos por ancestro.
2050        assert!(!pol.lines().any(|l| l == "rw /"), "el `rw /` anula el sellado:\n{pol}");
2051        // Sólo se nombra lo que EXISTE: harkaq-exec falla duro ante un path inexistente.
2052        assert!(!pol.contains("/boot"), "{pol}");
2053    }
2054
2055    #[test]
2056    fn root_es_lo_unico_que_nace_encendido() {
2057        let g = Grants::default();
2058        assert!(g.root, "sin esto un gestor de paquetes no arranca");
2059        assert!(!g.network && !g.nesting && !g.seal_image);
2060        assert!(g.sockets.is_empty() && g.devices.is_empty() && g.dirs.is_empty());
2061    }
2062
2063    #[test]
2064    fn el_rango_de_subuid_se_lee_por_usuario() {
2065        let d = tempfile::tempdir().unwrap();
2066        let f = d.path().join("subuid");
2067        std::fs::write(&f, "otro:100000:65536\nsergio:165536:65536\n").unwrap();
2068        let r = leer_subid(f.to_str().unwrap(), "sergio").unwrap();
2069        assert_eq!((r.inicio, r.cantidad), (165536, 65536));
2070        assert!(leer_subid(f.to_str().unwrap(), "nadie").is_none());
2071    }
2072
2073    #[test]
2074    fn las_caps_de_root_no_incluyen_la_de_montar() {
2075        // CAP_SYS_ADMIN es la que permite `mount`, y montar cuelga de la concesión `nesting`
2076        // declarada. Si se colara acá, `nesting` dejaría de ser una decisión y pasaría a ser el
2077        // comportamiento por defecto — que es justo lo que D7 prohíbe.
2078        assert!(!CAPS_ROOT.contains(&"CAP_SYS_ADMIN"), "{CAPS_ROOT:?}");
2079        // Y las que sí están son las que `apt` y `dpkg` necesitaron de verdad.
2080        for c in ["CAP_SETUID", "CAP_SETGID", "CAP_CHOWN"] {
2081            assert!(CAPS_ROOT.contains(&c), "falta {c}");
2082        }
2083    }
2084
2085    #[test]
2086    fn del_desktop_ajeno_no_sobrevive_nada_ejecutable() {
2087        let (campos, exec) = leer_desktop(
2088            "[Desktop Entry]\nName=Pinentry\nName[es]=Pinentry\nExec=/usr/bin/pinentry-qt --raro\n\
2089             TryExec=/usr/bin/pinentry-qt\nDBusActivatable=true\nPath=/tmp\nIcon=pinentry\n\
2090             X-KDE-Wayland-Interfaces=org_kde_kwin_keystate\n[Desktop Action Otra]\nExec=/mal\n",
2091        );
2092        let claves: Vec<&str> = campos.iter().map(|(k, _)| k.as_str()).collect();
2093        assert!(claves.contains(&"Name") && claves.contains(&"Name[es]") && claves.contains(&"Icon"));
2094        // Lista BLANCA: todo lo que ejecuta queda fuera por definición, no por enumeración.
2095        for prohibida in ["Exec", "TryExec", "Path", "DBusActivatable", "X-KDE-Wayland-Interfaces"] {
2096            assert!(!claves.contains(&prohibida), "se coló {prohibida}: {claves:?}");
2097        }
2098        // El Exec se lee sólo para nombrar el binario y citarlo; nunca se emite.
2099        assert_eq!(exec, "/usr/bin/pinentry-qt --raro");
2100    }
2101
2102    #[test]
2103    fn el_shim_no_se_rompe_con_rutas_raras() {
2104        assert_eq!(shell_quote("/usr/bin/pacman"), "/usr/bin/pacman");
2105        assert_eq!(shell_quote("/con espacio/x"), "'/con espacio/x'");
2106        assert_eq!(shell_quote("a'b"), "'a'\\''b'"); // la comilla se cierra, se escapa y se reabre
2107        assert_eq!(shell_quote("; rm -rf /"), "'; rm -rf /'");
2108    }
2109
2110    #[test]
2111    fn run_as_resuelve_uid_gid_y_home_del_passwd_de_la_imagen() {
2112        let d = tempfile::tempdir().unwrap();
2113        let sha = "f".repeat(64);
2114        imagen_falsa(d.path(), &sha);
2115        let tree = d.path().join("images").join(&sha).join(TREE);
2116        std::fs::write(tree.join("etc/passwd"),
2117            "root:x:0:0::/root:/bin/bash\njugador:x:1000:1000::/home/jugador:/bin/bash\n").unwrap();
2118        let dir = d.path().join("instances/x");
2119        std::fs::create_dir_all(&dir).unwrap();
2120
2121        assert_eq!(usuario_de(&dir, &tree, "jugador").unwrap(), (1000, 1000, "/home/jugador".into()));
2122        assert_eq!(usuario_de(&dir, &tree, "1000").unwrap().0, 1000);
2123        assert!(usuario_de(&dir, &tree, "nadie").is_err(), "un run_as inexistente NO puede caer a root");
2124        // El HOME sale del passwd, no de una constante.
2125        assert_eq!(home_de(&dir, &tree, 1000), "/home/jugador");
2126        assert_eq!(home_de(&dir, &tree, 0), "/root");
2127    }
2128
2129    #[test]
2130    fn la_poda_no_toca_una_imagen_con_instancia_y_si_los_restos() {
2131        let d = tempfile::tempdir().unwrap();
2132        let root = d.path();
2133        let usada = "1".repeat(64);
2134        let huerfana = "2".repeat(64);
2135        imagen_falsa(root, &usada);
2136        imagen_falsa(root, &huerfana);
2137        create(root, "viva", &usada, None).unwrap();
2138        // Un resto de pull a medias: lleva punto justo para no confundirse con una imagen.
2139        std::fs::create_dir_all(root.join("images/.3333.tmp")).unwrap();
2140
2141        // Simulacro: NO borra nada. Es la diferencia entre una poda y un accidente.
2142        prune(root, false, false).unwrap();
2143        assert!(root.join("images").join(&huerfana).exists(), "el simulacro borró");
2144
2145        prune(root, false, true).unwrap();
2146        assert!(root.join("images").join(&usada).exists(), "borró una imagen EN USO");
2147        assert!(!root.join("images").join(&huerfana).exists(), "no borró la huérfana");
2148        assert!(!root.join("images/.3333.tmp").exists(), "no borró el resto");
2149        // La instancia sigue entera: la poda no la toca sin --upper.
2150        assert!(instance_dir(root, "viva").join(INSTANCE_MANIFEST).exists());
2151        assert!(instance_dir(root, "viva").join("upper").exists());
2152    }
2153
2154    #[test]
2155    fn con_upper_la_capa_se_va_pero_la_instancia_queda_usable() {
2156        let d = tempfile::tempdir().unwrap();
2157        let root = d.path();
2158        let sha = "4".repeat(64);
2159        imagen_falsa(root, &sha);
2160        create(root, "i", &sha, None).unwrap();
2161        std::fs::write(instance_dir(root, "i").join("upper/estado"), "x").unwrap();
2162        prune(root, true, true).unwrap();
2163        assert!(!instance_dir(root, "i").join("upper/estado").exists(), "el upper no es caché");
2164        // Y queda utilizable: sin upper/ y work/ la instancia no vuelve a arrancar.
2165        assert!(instance_dir(root, "i").join("upper").is_dir());
2166        assert!(instance_dir(root, "i").join("work").is_dir());
2167    }
2168
2169    #[test]
2170    fn sha_mal_formado_falla_antes_de_tocar_la_red() {
2171        assert!(normalize_sha256("abc").is_err());
2172        assert_eq!(normalize_sha256(&format!("sha256:{}", "A".repeat(64))).unwrap(), "a".repeat(64));
2173    }
2174}