tawasuyu

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 1379Shell 129Rust 79Markdown 732473 Definitions

⎇ 4a68dfe76149bfca9dfb0a3320407b3123070f53 crates/hammer-cli/src/qorpa.rs Rust · 2698 lines · 127.2 KB History Who touched what raw

qorpa D9: el canal de evidencia, cableado — la única forma de auditar el montón B 4a68dfe76 · 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        /// No reinstalar `packages` después de tirar la capa. Deja la instancia como recién creada.
95        #[arg(long)]
96        no_provision: bool,
97        #[arg(long)]
98        root: Option<PathBuf>,
99    },
100    /// [ADR 0015 §Orden 3] Entra a la instancia con las concesiones declaradas y nada más.
101    Run {
102        id: String,
103        #[arg(long)]
104        root: Option<PathBuf>,
105        /// Imprime el `bwrap` que se ejecutaría y sale. La jaula tiene que ser inspeccionable.
106        #[arg(long)]
107        dry_run: bool,
108        /// Corre SIN harkaq (sin Landlock, sin seccomp, sin no_new_privs). Para diagnosticar
109        /// cuando algo muere y hay que saber si fue la jaula. Avisa a gritos.
110        #[arg(long)]
111        no_jail: bool,
112        /// Fuerza el userns de un solo id que crea bwrap, en vez del rango de `/etc/subuid`.
113        /// Para comparar: casi todo lo que "no anda adentro" es este mapeo.
114        #[arg(long)]
115        single_id: bool,
116        /// Abre el canal de evidencia: apunta qué intentó tocar la instancia y no pudo.
117        ///
118        /// **Es la única forma de auditar el montón B.** De un binario ajeno no hay fuente que
119        /// leer: lo único que queda es lo que el kernel deniega y anota. Exige una política que
120        /// pueda denegar algo (`seal_image`) y Landlock vivo (o sea, sin `nesting`); si falta
121        /// alguna, FALLA diciendo cuál — un `denials=[]` sin evidencia posible sería el
122        /// instrumento mintiendo.
123        #[arg(long)]
124        evidence: bool,
125        /// Comando dentro (default: el shell de la imagen). Va después de `--`.
126        #[arg(last = true)]
127        cmd: Vec<String>,
128    },
129    /// [ADR 0015 D3] Instala en la instancia lo declarado en `packages`.
130    ///
131    /// **Es lo que vuelve cierta la frase «el manifiesto es la verdad».** Sin esto `packages` era
132    /// una lista que nadie leía: `recreate` tiraba la capa y lo instalado a mano no volvía, así que
133    /// el `upper` era el activo de verdad — exactamente el blob irreemplazable que hammer existe
134    /// para no tener.
135    Provision {
136        id: String,
137        /// Reinstala aunque el registro diga que esos mismos paquetes ya se instalaron.
138        #[arg(long)]
139        force: bool,
140        /// Imprime el guión que correría dentro de la instancia y sale.
141        #[arg(long)]
142        dry_run: bool,
143        #[arg(long)]
144        root: Option<PathBuf>,
145    },
146    /// [ADR 0015 §Orden 5] Genera los shims y `.desktop` de lo declarado en `[export]`.
147    ///
148    /// **Se GENERAN, no se copian.** El `Exec=` de un `.desktop` ajeno es texto ajeno: copiarlo
149    /// pondría una línea de comando que no escribimos en el menú del usuario (D5).
150    Export {
151        id: String,
152        /// Prefijo donde escribir (`<into>/bin`, `<into>/share/applications`). Default: `~/.local`.
153        #[arg(long)]
154        into: Option<PathBuf>,
155        /// Borra exactamente lo que se generó. Revocable: sin shims, la instancia desaparece del
156        /// sistema sin desmontar nada.
157        #[arg(long)]
158        remove: bool,
159        #[arg(long)]
160        root: Option<PathBuf>,
161    },
162    /// [ADR 0015 §Orden 7] Poda: restos de descargas a medias, imágenes que ninguna instancia usa
163    /// y —si se pide— las capas mutables, que por D3 siempre se pueden tirar.
164    ///
165    /// **Sin `--yes` no borra nada**: dice qué haría y cuánto liberaría. Este repo ya tuvo tres
166    /// emergencias de disco y un `store-gc` que REPORTABA borrados que no ocurrían.
167    Prune {
168        /// Además, tira la capa mutable de cada instancia. El manifiesto es la verdad y el `upper`
169        /// es caché (D3) — pero lo que instalaste a mano y no declaraste en `packages` se va.
170        #[arg(long)]
171        upper: bool,
172        /// Ejecuta de verdad. Sin esto es un simulacro.
173        #[arg(long)]
174        yes: bool,
175        #[arg(long)]
176        root: Option<PathBuf>,
177    },
178}
179
180pub fn run(cmd: QorpaCmd) -> Result<()> {
181    match cmd {
182        QorpaCmd::Pull { url, sha256, label, verify_only, subdir, root } => {
183            pull(&resolve_root(root), &url, &sha256, label.as_deref(), verify_only, subdir.as_deref())
184        }
185        QorpaCmd::List { root, json } => list(&resolve_root(root), json),
186        QorpaCmd::Create { id, base, distro, root } => {
187            create(&resolve_root(root), &id, &base, distro.as_deref())
188        }
189        QorpaCmd::Recreate { id, no_provision, root } => {
190            recreate(&resolve_root(root), &id, no_provision)
191        }
192        QorpaCmd::Run { id, root, dry_run, no_jail, single_id, evidence, cmd } => run_instance(
193            &resolve_root(root), &id, &cmd,
194            Ajustes { dry_run, no_jail, single_id, evidencia: evidence, ..Default::default() },
195        ),
196        QorpaCmd::Provision { id, force, dry_run, root } => {
197            provision(&resolve_root(root), &id, force, dry_run)
198        }
199        QorpaCmd::Export { id, into, remove, root } => {
200            export(&resolve_root(root), &id, into.as_deref(), remove)
201        }
202        QorpaCmd::Prune { upper, yes, root } => prune(&resolve_root(root), upper, yes),
203    }
204}
205
206fn resolve_root(flag: Option<PathBuf>) -> PathBuf {
207    flag.or_else(|| std::env::var_os(ROOT_ENV).map(PathBuf::from))
208        .unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT))
209}
210
211/// Lo que queda escrito junto al árbol. Es el registro de PROCEDENCIA: sin él, un directorio con
212/// un rootfs adentro no sabe decir de dónde salió ni contra qué se verificó.
213#[derive(Serialize, Deserialize)]
214struct ImageManifest {
215    sha256: String,
216    url: String,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    label: Option<String>,
219    archive_bytes: u64,
220    tree_files: u64,
221    tree_bytes: u64,
222    /// El rootfs venía dentro de este subdirectorio del archivo (`root.x86_64` de Arch) y se elevó
223    /// a raíz. Ausente = el archivo YA era un rootfs plano (Ubuntu base).
224    #[serde(skip_serializing_if = "Option::is_none")]
225    rootfs_subdir: Option<String>,
226    /// Lo que había al lado del rootfs y NO entró (`version`, `pkglist.x86_64.txt` de Arch). Se
227    /// registra en vez de descartarse en silencio: si mañana falta algo, acá está qué se tiró.
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    dropped_siblings: Vec<String>,
230}
231
232// ── pull ────────────────────────────────────────────────────────────────────────────────────────
233
234fn pull(
235    root: &Path, url: &str, sha_in: &str, label: Option<&str>, verify_only: bool,
236    subdir: Option<&str>,
237) -> Result<()> {
238    let sha = normalize_sha256(sha_in)?;
239    let images = root.join("images");
240    let dest = images.join(&sha);
241
242    // Idempotencia con la regla 3 puesta delante: "ya está" sólo si TIENE CONTENIDO. Un directorio
243    // vacío de un pull interrumpido se leería como presencia y el error viajaría hasta el final.
244    if dest.exists() {
245        match tree_state(&dest.join(TREE)) {
246            TreeState::Populated => {
247                println!("ya presente y verificada: {}", dest.display());
248                return Ok(());
249            }
250            TreeState::Missing if verify_only => {
251                println!("archivo ya verificado antes (sin desempacar): {}", dest.display());
252                return Ok(());
253            }
254            TreeState::Missing | TreeState::Empty => {
255                eprintln!(
256                    "aviso: {} existe pero su árbol está vacío o ausente — pull anterior a medias, \
257                     se rehace",
258                    dest.display()
259                );
260                force_remove_dir_all(&dest)
261                    .with_context(|| format!("no pude limpiar {}", dest.display()))?;
262            }
263        }
264    }
265
266    std::fs::create_dir_all(&images)
267        .with_context(|| format!("no puedo crear {}", images.display()))?;
268
269    let archive = images.join(format!(".{sha}.part"));
270    println!("bajando {url}");
271    download_to_file(url, &archive)?;
272
273    // El corte. Si el contenido no es el que dice ser, se ABORTA — no se avisa y se sigue.
274    hammer_build::download::verify_sha256(&archive, &sha).inspect_err(|_| {
275        std::fs::remove_file(&archive).ok();
276    })?;
277    let archive_bytes = std::fs::metadata(&archive)?.len();
278    println!("sha256 verificado ({} bytes)", archive_bytes);
279
280    if verify_only {
281        std::fs::create_dir_all(&dest)?;
282        write_manifest(&dest, &ImageManifest {
283            sha256: sha.clone(), url: url.into(), label: label.map(Into::into),
284            archive_bytes, tree_files: 0, tree_bytes: 0, rootfs_subdir: None,
285            dropped_siblings: Vec::new(),
286        })?;
287        std::fs::rename(&archive, dest.join("archive"))?;
288        println!("guardada sin desempacar en {}", dest.display());
289        return Ok(());
290    }
291
292    // Desempacado atómico-ish: a un `.tmp` al lado y luego rotar. Extraer directo sobre el destino
293    // dejaría medio rootfs si algo falla, y medio rootfs es justo lo que la regla 3 castiga.
294    let staging = images.join(format!(".{sha}.tmp"));
295    force_remove_dir_all(&staging)
296        .with_context(|| format!("no pude limpiar el staging {}", staging.display()))?;
297    let tree_tmp = staging.join(TREE);
298    std::fs::create_dir_all(&tree_tmp)?;
299    println!("desempacando…");
300    // Cualquier fallo de acá en adelante barre el staging Y el archivo: "no dejar nada a medias" no
301    // vale sólo para el sha que no coincide — un desempacado a medias es la misma trampa.
302    let unpack = || -> Result<(Option<String>, Vec<String>, u64, u64)> {
303        untar(&archive, &tree_tmp)?;
304        let (sub, dropped) = locate_rootfs(&tree_tmp, subdir)?;
305        let (files, bytes) = walk_tree(&tree_tmp)?;
306        Ok((sub, dropped, files, bytes))
307    };
308    let (rootfs_subdir, dropped_siblings, tree_files, tree_bytes) = match unpack() {
309        Ok(v) => v,
310        Err(e) => {
311            force_remove_dir_all(&staging).ok();
312            std::fs::remove_file(&archive).ok();
313            return Err(e);
314        }
315    };
316    std::fs::remove_file(&archive).ok();
317
318    if let Some(d) = &rootfs_subdir {
319        println!("el rootfs venía en {d}/ — elevado a raíz");
320    }
321    if !dropped_siblings.is_empty() {
322        println!("fuera del rootfs, NO entran: {}", dropped_siblings.join(", "));
323    }
324    if tree_files == 0 {
325        force_remove_dir_all(&staging).ok();
326        bail!("el archivo desempacó VACÍO — eso no es una imagen, es un nombre (CLAUDE.md regla 3)");
327    }
328
329    write_manifest(&staging, &ImageManifest {
330        sha256: sha.clone(), url: url.into(), label: label.map(Into::into),
331        archive_bytes, tree_files, tree_bytes, rootfs_subdir, dropped_siblings,
332    })?;
333    std::fs::rename(&staging, &dest)
334        .with_context(|| format!("no puedo rotar {} a {}", staging.display(), dest.display()))?;
335
336    println!(
337        "imagen lista: {} · {tree_files} ficheros · {}",
338        dest.display(),
339        human(tree_bytes)
340    );
341    Ok(())
342}
343
344/// Descarga a fichero. NO se reusa `download::fetch_url_to_file` a propósito: ése trae el cuerpo
345/// entero a memoria (le sirve para un patch de KB) y acá hablamos de cientos de MB, con `--max-time`
346/// de 300 s que un rootfs no respeta.
347///
348/// **`--retry` a secas no cubre el fallo que de verdad pasa.** Medido trayendo el runtime *sniper*
349/// (302 MB): la CDN de Valve cortó el stream al 73% con `HTTP/2 INTERNAL_ERROR` (exit 92) y curl
350/// **no reintentó**, porque sin `--retry-all-errors` sólo considera transitorios los timeouts y los
351/// 5xx — un stream roto a media transferencia no entra en esa lista. Y aun reintentando, sin
352/// `-C -` cada intento vuelve a empezar de cero: en un fichero de cientos de MB sobre una conexión
353/// que se corta, eso no converge nunca.
354///
355/// Por eso el parcial **NO se borra al fallar**: es lo que hace que el siguiente intento reanude en
356/// vez de rebajar todo. Es seguro porque quien decide no es el fichero sino el `sha256` que se
357/// verifica después —un parcial corrupto no puede pasar por bueno— y porque el nombre lleva punto
358/// delante, así que `list` no lo ve como imagen y `prune` ya lo barre como «resto de un pull a
359/// medias».
360fn download_to_file(url: &str, dst: &Path) -> Result<()> {
361    if let Some(p) = dst.parent() {
362        std::fs::create_dir_all(p)?;
363    }
364    let st = Command::new("curl")
365        .args(["-fL", "--retry", "5", "--retry-delay", "2", "--retry-all-errors",
366               "--connect-timeout", "30", "-C", "-", "--progress-bar", "-o"])
367        .arg(dst)
368        .arg(url)
369        .status()
370        .context("no pude ejecutar curl")?;
371    if !st.success() {
372        let reanudable = std::fs::metadata(dst).map(|m| m.len()).unwrap_or(0);
373        if reanudable > 0 {
374            eprintln!(
375                "  (quedaron {} bajados en {}: el siguiente intento reanuda desde ahí; \
376                 `qorpa prune` los barre)",
377                human(reanudable),
378                dst.display()
379            );
380        }
381        bail!("curl falló bajando {url} (exit {:?})", st.code());
382    }
383    Ok(())
384}
385
386fn normalize_sha256(s: &str) -> Result<String> {
387    let s = s.strip_prefix("sha256:").unwrap_or(s).trim().to_ascii_lowercase();
388    if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
389        bail!("--sha256 debe ser 64 dígitos hex (llegó {:?}, {} chars)", s, s.len());
390    }
391    Ok(s)
392}
393
394/// Elige el descompresor por MAGIC, no por extensión: el nombre de un fichero es una sugerencia y
395/// las tres imágenes curadas usan tres compresores distintos (zstd en Arch, gzip en Ubuntu, xz en
396/// Fedora). `tar` extrae con `--no-same-owner` para que el resultado sea el MISMO lo corra root o
397/// no — la propiedad real la pone el mapeo del userns, no el tar.
398fn untar(archive: &Path, into: &Path) -> Result<()> {
399    let mut magic = [0u8; 6];
400    let n = std::fs::File::open(archive)?.read(&mut magic)?;
401    let flag = match &magic[..n.min(6)] {
402        [0x28, 0xb5, 0x2f, 0xfd, ..] => Some("--zstd"),
403        [0x1f, 0x8b, ..] => Some("-z"),
404        [0xfd, b'7', b'z', b'X', b'Z', 0x00] => Some("-J"),
405        [b'B', b'Z', b'h', ..] => Some("-j"),
406        _ => None, // tar plano
407    };
408    let mut c = Command::new("tar");
409    if let Some(f) = flag {
410        c.arg(f);
411    }
412    // `-p` conserva los modos exactos (el setuid de `sudo` adentro importa), y
413    // `--delay-directory-restore` es OBLIGATORIO junto con él: un rootfs real trae directorios de
414    // sólo-lectura (`/etc/ca-certificates/extracted/cadir` en Arch), y sin esto tar los crea con su
415    // modo final ANTES de meterles los ficheros ⇒ "Permission denied" al extraer sin ser root. Con
416    // esto, tar los deja escribibles y aplica los modos al final.
417    // tar ya rechaza rutas absolutas y `..` al extraer: ésa es la defensa contra un tarball ajeno
418    // malicioso. Los xattr `security.capability` se pierden sin privilegios (tar avisa): dentro de
419    // la jaula da igual salvo para binarios que dependan de capabilities de fichero — anotado.
420    c.args(["-x", "-p", "--delay-directory-restore", "--no-same-owner", "-f"])
421        .arg(archive).arg("-C").arg(into);
422    let st = c.status().context("no pude ejecutar tar")?;
423    if !st.success() {
424        bail!("tar falló desempacando {} (exit {:?})", archive.display(), st.code());
425    }
426    Ok(())
427}
428
429/// Dónde está el rootfs dentro de lo que se desempacó.
430///
431/// La primera versión de esto usaba «si hay UN solo directorio arriba, ése es el rootfs». Sonaba
432/// razonable y es FALSA: el bootstrap de Arch trae tres entradas arriba (`root.x86_64/`, `version`,
433/// `pkglist.x86_64.txt`) y la regla no disparaba, así que el rootfs quedaba un nivel abajo — con
434/// todo verde y sin un solo error. Se mide por ESTRUCTURA, que es lo que define a un rootfs:
435/// tiene `etc/` y tiene `usr/` o `bin/`.
436///
437/// Si no acierta, **falla y pide `--subdir`** en vez de adivinar: un rootfs mal anclado no rompe
438/// acá, rompe mucho después, cuando la instancia no encuentra su loader.
439fn looks_like_rootfs(d: &Path) -> bool {
440    d.join("etc").is_dir() && (d.join("usr").is_dir() || d.join("bin").exists())
441}
442
443/// Deja `tree` conteniendo el rootfs. Devuelve (subdir elevado, hermanos descartados).
444fn locate_rootfs(tree: &Path, forced: Option<&str>) -> Result<(Option<String>, Vec<String>)> {
445    if forced.is_none() && looks_like_rootfs(tree) {
446        return Ok((None, Vec::new())); // Ubuntu base: el archivo YA es el rootfs.
447    }
448    let entries: Vec<_> = std::fs::read_dir(tree)
449        .with_context(|| format!("leyendo la raíz desempacada {}", tree.display()))?
450        .collect::<std::io::Result<Vec<_>>>()?;
451    let name = match forced {
452        Some(f) => {
453            if !tree.join(f).is_dir() {
454                bail!("--subdir {f:?} no existe dentro del archivo");
455            }
456            f.to_string()
457        }
458        None => {
459            let mut cands: Vec<String> = entries
460                .iter()
461                .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
462                .filter(|e| looks_like_rootfs(&e.path()))
463                .map(|e| e.file_name().to_string_lossy().into_owned())
464                .collect();
465            cands.sort();
466            match cands.len() {
467                1 => cands.remove(0),
468                0 => bail!(
469                    "no encuentro un rootfs en el archivo (ni la raíz ni ningún subdirectorio tiene \
470                     `etc/` + `usr|bin`). Pasá `--subdir <dir>` si sabés dónde está."
471                ),
472                _ => bail!(
473                    "hay {} subdirectorios que parecen rootfs ({}) — elegí con `--subdir`",
474                    cands.len(), cands.join(", ")
475                ),
476            }
477        }
478    };
479    let dropped: Vec<String> = entries
480        .iter()
481        .map(|e| e.file_name().to_string_lossy().into_owned())
482        .filter(|n| *n != name)
483        .collect();
484
485    let top = tree.join(&name);
486    let parked = tree.with_file_name(format!(
487        "{}.lift",
488        tree.file_name().unwrap_or_default().to_string_lossy()
489    ));
490    // Renombrar un DIRECTORIO exige permiso de escritura sobre el directorio mismo (el kernel tiene
491    // que reescribir su `..`), y el `root.x86_64` de Arch viene `dr-xr-xr-x`. Se abre, se mueve y se
492    // le devuelve su modo exacto: la imagen tiene que quedar como venía, no como nos vino cómodo.
493    let mode = std::fs::metadata(&top)?.permissions().mode();
494    std::fs::set_permissions(&top, std::fs::Permissions::from_mode(mode | 0o700))?;
495    std::fs::rename(&top, &parked)
496        .with_context(|| format!("elevando {} a {}", top.display(), parked.display()))?;
497    force_remove_dir_all(tree)
498        .with_context(|| format!("descartando los hermanos en {}", tree.display()))?;
499    std::fs::rename(&parked, tree)
500        .with_context(|| format!("rotando {} a {}", parked.display(), tree.display()))?;
501    std::fs::set_permissions(tree, std::fs::Permissions::from_mode(mode))?;
502    Ok((Some(name), dropped))
503}
504
505/// `remove_dir_all` NO puede con un árbol que trae directorios sin permiso de escritura — y un
506/// rootfs real los trae (`/etc/ca-certificates/extracted/cadir` de Arch es 0555). Tragarse ese
507/// fallo con `.ok()` deja vivo el staging de un pull roto y el siguiente pull extrae ENCIMA: nos
508/// pasó, y el síntoma («Permission denied» en un directorio que acabás de crear) no se parece en
509/// nada a la causa. Abre los directorios a escritura de arriba abajo y después borra.
510fn force_remove_dir_all(p: &Path) -> std::io::Result<()> {
511    if !p.exists() {
512        return Ok(());
513    }
514    let mut stack = vec![p.to_path_buf()];
515    while let Some(d) = stack.pop() {
516        let _ = std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700));
517        if let Ok(rd) = std::fs::read_dir(&d) {
518            for e in rd.flatten() {
519                // `file_type` de read_dir es lstat: un symlink a directorio NO se sigue.
520                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
521                    stack.push(e.path());
522                }
523            }
524        }
525    }
526    match std::fs::remove_dir_all(p) {
527        Ok(()) => Ok(()),
528        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
529            // SEGUNDA causa, y aparece recién con el mapeo por rango: el `upper` de una instancia
530            // contiene ficheros de los SUBUID mapeados (apt deja los suyos con uid 165577), y
531            // nuestro uid normal no puede borrarlos ni chownearlos. La cura es la misma que los
532            // creó: entrar a un userns donde esos ids son nuestros. `--map-auto` usa newuidmap,
533            // así que esto sólo anda si subuid está provisionado — y si no, el error vuelve a
534            // salir tal cual, que es lo correcto.
535            let st = Command::new("unshare")
536                .args(["-U", "--map-auto", "-r", "rm", "-rf"])
537                .arg(p)
538                .status()?;
539            if st.success() && !p.exists() {
540                Ok(())
541            } else {
542                Err(e)
543            }
544        }
545        Err(e) => Err(e),
546    }
547}
548
549enum TreeState { Missing, Empty, Populated }
550
551fn tree_state(tree: &Path) -> TreeState {
552    match std::fs::read_dir(tree) {
553        Err(_) => TreeState::Missing,
554        Ok(mut it) => {
555            if it.next().is_some() { TreeState::Populated } else { TreeState::Empty }
556        }
557    }
558}
559
560fn walk_tree(root: &Path) -> Result<(u64, u64)> {
561    let (f, b, _) = walk_tree_ciegos(root)?;
562    Ok((f, b))
563}
564
565/// Igual, pero además cuenta los directorios que NO se pudieron leer. Sin ese tercer número, el
566/// tamaño de un `upper` con ficheros de los subuid sale MENOR de lo que es y nadie lo nota: un
567/// número silenciosamente bajo es peor que ninguno cuando lo que se decide con él es borrar.
568fn walk_tree_ciegos(root: &Path) -> Result<(u64, u64, u64)> {
569    let (mut files, mut bytes, mut ciegos) = (0u64, 0u64, 0u64);
570    let mut stack = vec![root.to_path_buf()];
571    while let Some(d) = stack.pop() {
572        // Un rootfs ajeno trae directorios que NI SU DUEÑO puede leer (`/etc/sudoers.d` es 0750,
573        // `/var/log/private` 0700, y alguno 0000). No es corrupción: es el rootfs siendo lo que es.
574        // Se cuentan como 0 y se sigue — abortar el pull por no poder contar sería absurdo.
575        let rd = match std::fs::read_dir(&d) {
576            Ok(rd) => rd,
577            Err(_) => { ciegos += 1; continue }
578        };
579        for e in rd {
580            let Ok(e) = e else { continue };
581            let Ok(ft) = e.file_type() else { continue };
582            if ft.is_dir() {
583                stack.push(e.path());
584            } else {
585                files += 1;
586                bytes += e.metadata().map(|m| m.len()).unwrap_or(0);
587            }
588        }
589    }
590    Ok((files, bytes, ciegos))
591}
592
593fn write_manifest(dir: &Path, m: &ImageManifest) -> Result<()> {
594    let body = format!(
595        "# Lo escribe `hammer qorpa pull`; no editar a mano.\n\
596         # La identidad de la imagen es el sha256 del ARCHIVO original, no de este árbol: por eso\n\
597         # la URL es informativa y espejar es gratis (ADR 0013).\n{}",
598        toml::to_string_pretty(m).context("serializando el manifiesto")?
599    );
600    std::fs::write(dir.join(IMAGE_MANIFEST), body)?;
601    Ok(())
602}
603
604// ── instancias ──────────────────────────────────────────────────────────────────────────────────
605
606/// El manifiesto de una instancia. **Es la verdad**: el `upper/` es caché y se puede tirar entero
607/// (D3). Los nombres de campo van en inglés como el resto de la superficie (`CLAUDE.md` regla 4);
608/// el ADR los escribió en castellano y se corrigieron aquí.
609#[derive(Serialize, Deserialize, Default)]
610struct Instance {
611    /// sha256 de la imagen base. Cambiarlo + `recreate` ES la actualización de versión (D8).
612    base: String,
613    #[serde(skip_serializing_if = "Option::is_none")]
614    distro: Option<String>,
615    /// Lo que debe estar instalado adentro. **Se instala solo**: `hammer qorpa provision` lo pone
616    /// con el gestor de la imagen, y `recreate` lo llama después de tirar la capa. Sin esto el
617    /// `upper` sería el activo y el manifiesto un adorno.
618    #[serde(default, skip_serializing_if = "Vec::is_empty")]
619    packages: Vec<String>,
620    /// La política, autorada (D7). **Por defecto vacía**: una instancia nace sin ver nada.
621    #[serde(default)]
622    grants: Grants,
623    /// Correr el comando como este usuario de la imagen, en vez de como root.
624    ///
625    /// ── POR QUÉ ESTO Y NO `root = false` ───────────────────────────────────────────────────────
626    /// MEDIDO con Steam: el cliente **se niega a correr como root** (`bin_steam.sh: Error: Cannot
627    /// run as root user`), así que la instancia de juegos necesita un usuario normal. La vía
628    /// tentadora —cambiar el mapa para caer en el uid 1000— **corrompe la instancia**: un fichero
629    /// creado bajo un mapa aparece con OTRO uid bajo el otro, y el `useradd` de la preparación deja
630    /// un `/home` que después su propio dueño no puede escribir.
631    ///
632    /// La vía correcta es la de cualquier runtime de contenedores: **un solo mapa, y se BAJA de
633    /// privilegio adentro**. Tenemos `CAP_SETUID` en el namespace, así que es un `setpriv` y ya.
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    run_as: Option<String>,
636    /// La capa de transparencia (D5). Vacía por defecto: **se exporta lo DECLARADO**, nunca todo.
637    /// Exportar todo es la falla de Bedrock — su `ls` compitiendo con el nuestro, arbitrado por
638    /// heurística en tiempo de `exec`. Acá la ambigüedad se resuelve al declarar.
639    #[serde(default)]
640    export: Export,
641}
642
643#[derive(Serialize, Deserialize, Default)]
644struct Export {
645    /// Binarios que aparecen en el `PATH` del host como shims.
646    #[serde(default, skip_serializing_if = "Vec::is_empty")]
647    binaries: Vec<String>,
648    /// Ficheros `.desktop` de la imagen cuyo equivalente se GENERA para el menú del host.
649    #[serde(default, skip_serializing_if = "Vec::is_empty")]
650    apps: Vec<String>,
651}
652
653#[derive(Serialize, Deserialize)]
654#[serde(default)]
655struct Grants {
656    /// Nombres cortos: `wayland`, `pipewire`, `dbus`. ⚠ `wayland` es un borde de PRIVILEGIO, no un
657    /// caño: quien lo tiene puede capturar pantalla y sintetizar teclas (ADR §NO-resuelve 1).
658    #[serde(default, skip_serializing_if = "Vec::is_empty")]
659    sockets: Vec<String>,
660    /// Nodos de dispositivo (`/dev/dri`). ABI de kernel, que es lo único que cruza el borde (D2).
661    #[serde(default, skip_serializing_if = "Vec::is_empty")]
662    devices: Vec<String>,
663    #[serde(default, skip_serializing_if = "Vec::is_empty")]
664    dirs: Vec<DirGrant>,
665    /// La concesión más peligrosa, y por eso va sola en su línea. `dnf`/`pacman` la necesitan.
666    #[serde(default)]
667    network: bool,
668    /// Pasar el socket de Wayland **crudo**, sin el proxy filtrante. Apagado por defecto.
669    ///
670    /// Con esto la instancia recupera TODOS los globals del compositor, incluidos los que capturan
671    /// pantalla, sintetizan teclas y leen el portapapeles. Existe para diagnosticar, no para usar.
672    #[serde(default)]
673    wayland_raw: bool,
674    /// Globals de Wayland que el proxy deja ver. Vacío = la lista curada de `qorpa_wl`.
675    #[serde(default, skip_serializing_if = "Vec::is_empty")]
676    wayland_globals: Vec<String>,
677    /// Deja que la instancia monte y cree namespaces adentro. **Steam ya es un contenedor**: su
678    /// pressure-vessel necesita esto para arrancar (D6). Es una concesión real — con ella la
679    /// instancia recupera la familia `mount`/`setns` que la jaula deniega por defecto.
680    #[serde(default)]
681    nesting: bool,
682    /// Mapear tu uid a **root adentro**. Encendido por defecto: es lo que quieren `dnf`/`pacman`.
683    ///
684    /// ⚠ MEDIDO 2026-09-03, y es un conflicto real con `nesting`: con `--uid 0`, un userns anidado
685    /// NO puede escribir su `uid_map` («Operation not permitted»), así que **pressure-vessel de
686    /// Valve no arranca**. Sin remapear a root, el anidamiento funciona pero el gestor de paquetes
687    /// se queja. Las dos mitades se arreglan con lo mismo: mapear un RANGO real de subuid con
688    /// `newuidmap` y pasarle el namespace a bwrap con `--userns FD` — el mismo impuesto que paga
689    /// hoy `apt` con su `setgroups`.
690    #[serde(default = "si")]
691    root: bool,
692    /// Sella el árbol de la IMAGEN (`/usr`, `/bin`, `/lib`, `/opt`…) contra escritura, aunque
693    /// adentro seas root. Apagado por defecto porque un gestor de paquetes necesita escribir en
694    /// `/usr`; encendido para una instancia que sólo CORRE algo, un exploit no puede persistir en
695    /// el `upper`. Es lo único que Landlock aporta acá que el namespace de montaje no da ya.
696    #[serde(default)]
697    seal_image: bool,
698}
699
700/// Dónde vive `harkaq-exec`. Misma convención que el harkaq del build (`HARKAQ_BIN`).
701fn harkaq_exec() -> PathBuf {
702    std::env::var("HARKAQ_BIN")
703        .map(PathBuf::from)
704        .unwrap_or_else(|_| {
705            PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into()))
706                .join(".cache/harkaq")
707        })
708        .join("harkaq-exec")
709}
710
711/// Las capabilities que hacen que el uid 0 de adentro sea root DE VERDAD.
712///
713/// MEDIDO: bwrap tira TODAS las capabilities, y en Linux ser root es tener `CAP_SETUID`, no tener
714/// uid 0. Sin ellas `apt` no puede `seteuid(42)` para bajar a `_apt` y `dpkg` no puede chownear —
715/// que es exactamente el síntoma que parecía de subuid y no lo era.
716///
717/// Son SEGURAS por construcción: una capability dentro de un user namespace sólo alcanza a lo que
718/// ese namespace posee, o sea los ids que le mapeamos (nuestros propios subuid). No dan nada en el
719/// host. Por eso se conceden con `root = true` sin pedir permiso aparte.
720///
721/// `CAP_SYS_ADMIN` NO está en la lista: es la que permite montar, y montar cuelga de la concesión
722/// `nesting` declarada. Abrir sólo lo declarado, también acá.
723const CAPS_ROOT: &[&str] = &[
724    "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_MKNOD",
725    "CAP_SETGID", "CAP_SETUID", "CAP_SETFCAP", "CAP_SETPCAP", "CAP_SYS_CHROOT",
726    "CAP_KILL", "CAP_AUDIT_WRITE",
727];
728
729/// Los directorios que pertenecen a la IMAGEN y que `seal_image` congela.
730const IMAGEN_INMUTABLE: &[&str] = &["usr", "bin", "sbin", "lib", "lib64", "lib32", "opt", "boot"];
731/// Lo que bwrap monta encima del overlay y siempre es de la instancia, no de la imagen.
732const DEL_SANDBOX: &[&str] = &["/proc", "/dev", "/tmp", "/run"];
733
734/// Compila las concesiones a la política que consume `harkaq-exec`.
735///
736/// ── QUÉ APORTA HARKAQ ACÁ, Y QUÉ NO ─────────────────────────────────────────────────────────────
737/// Honestidad primero: en el eje del SISTEMA DE FICHEROS, harkaq casi no agrega nada a una
738/// instancia, porque el namespace de montaje de bwrap ya es una lista blanca — lo que no se bindea
739/// no existe adentro, y un `--ro-bind` es de sólo lectura por el kernel, más fuerte que una regla.
740/// Escribir reglas `ro` que no cambian nada sería un sello de goma, así que no se escriben.
741///
742/// Lo que harkaq SÍ aporta, y es grande para un binario ajeno y opaco:
743///   1. **seccomp.** bwrap no instala filtro alguno: hoy una instancia puede `io_uring`, `bpf`,
744///      `ptrace`, `userfaultfd`, `keyctl`, `perf_event_open`, cargar módulos. Esa superficie
745///      importa MÁS con un prebuilt de terceros que con nuestros propios builds.
746///   2. **`no_new_privs`**: ningún setuid de la imagen escala adentro.
747///   3. **el canal de evidencia**: con ABI≥7 el kernel audita las denegaciones ⇒ se puede saber
748///      qué intentó tocar un binario cerrado. Es la única forma de auditar el montón B.
749///   4. **`seal_image`**: lo único del eje fs que el namespace no da — congelar el árbol de la
750///      imagen aunque adentro seas root.
751fn compile_policy(g: &Grants, image_tree: &Path, dst: &Path) -> Result<()> {
752    use std::fmt::Write as _;
753    let mut out = String::new();
754    out.push_str("# Política de una INSTANCIA qorpa (ADR 0015 D7).\n");
755    out.push_str("# A diferencia de la del build, ésta NO se deriva de una clausura: se AUTORA,\n");
756    out.push_str("# que es el único sitio del sistema donde eso pasa. Se compila del manifiesto.\n");
757
758    if !g.seal_image {
759        // El árbol entero es de la instancia y sus escrituras van al `upper`, que es caché.
760        out.push_str("rw /\n");
761    } else {
762        // Sin regla sobre `/` no se puede ni listar la raíz; listar no es leer ni escribir.
763        out.push_str("list /\n# la imagen, congelada:\n");
764        for d in IMAGEN_INMUTABLE {
765            if image_tree.join(d).exists() {
766                let _ = writeln!(out, "ro /{d}");
767            }
768        }
769        out.push_str("# lo mutable de la instancia:\n");
770        let mut mutables: Vec<String> = std::fs::read_dir(image_tree)
771            .with_context(|| format!("leyendo el árbol de la imagen {}", image_tree.display()))?
772            .flatten()
773            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
774            .map(|e| e.file_name().to_string_lossy().into_owned())
775            .filter(|n| !IMAGEN_INMUTABLE.contains(&n.as_str()))
776            .map(|n| format!("/{n}"))
777            .collect();
778        for d in DEL_SANDBOX {
779            if !mutables.iter().any(|m| m == d) {
780                mutables.push((*d).to_string());
781            }
782        }
783        mutables.sort();
784        mutables.dedup();
785        for m in &mutables {
786            let _ = writeln!(out, "rw {m}");
787        }
788    }
789    std::fs::write(dst, out)
790        .with_context(|| format!("escribiendo la política {}", dst.display()))?;
791    Ok(())
792}
793
794
795impl Default for Grants {
796    fn default() -> Self {
797        // Toda concesión arranca CERRADA menos `root`, que no abre nada del host: sólo decide
798        // quién sos adentro de tu propio rootfs.
799        Grants {
800            sockets: Vec::new(), devices: Vec::new(), dirs: Vec::new(),
801            network: false, nesting: false, root: true, seal_image: false,
802            wayland_raw: false, wayland_globals: Vec::new(),
803        }
804    }
805}
806
807#[derive(Serialize, Deserialize)]
808struct DirGrant {
809    host: String,
810    inside: String,
811    /// `ro` o `rw`.
812    #[serde(default = "ro")]
813    mode: String,
814}
815fn ro() -> String { "ro".into() }
816fn si() -> bool { true }
817
818fn instances_dir(root: &Path) -> PathBuf { root.join("instances") }
819fn instance_dir(root: &Path, id: &str) -> PathBuf { instances_dir(root).join(id) }
820
821/// Resuelve un prefijo de sha contra las imágenes presentes. Que exista y que TENGA CONTENIDO: una
822/// instancia sobre una imagen vacía es un directorio con nombre de rootfs (regla 3).
823fn resolve_image(root: &Path, prefix: &str) -> Result<(String, PathBuf)> {
824    let prefix = prefix.strip_prefix("sha256:").unwrap_or(prefix).to_ascii_lowercase();
825    let images = root.join("images");
826    let mut hits: Vec<String> = std::fs::read_dir(&images)
827        .with_context(|| format!("no hay imágenes en {}", images.display()))?
828        .flatten()
829        .map(|e| e.file_name().to_string_lossy().into_owned())
830        .filter(|n| !n.starts_with('.') && n.starts_with(&prefix))
831        .collect();
832    hits.sort();
833    match hits.len() {
834        1 => {
835            let sha = hits.remove(0);
836            let tree = images.join(&sha).join(TREE);
837            if !matches!(tree_state(&tree), TreeState::Populated) {
838                bail!("la imagen {sha} está VACÍA — no es una imagen, es un nombre");
839            }
840            Ok((sha, tree))
841        }
842        0 => bail!("ninguna imagen empieza por {prefix:?} — traela con `hammer qorpa pull`"),
843        n => bail!("{n} imágenes empiezan por {prefix:?}: dame más dígitos"),
844    }
845}
846
847fn create(root: &Path, id: &str, base: &str, distro: Option<&str>) -> Result<()> {
848    if id.is_empty() || id.contains('/') || id.starts_with('.') {
849        bail!("id de instancia inválido: {id:?}");
850    }
851    let dir = instance_dir(root, id);
852    if dir.exists() {
853        bail!("la instancia {id} ya existe en {} — `recreate` tira su capa mutable", dir.display());
854    }
855    let (sha, _tree) = resolve_image(root, base)?;
856
857    let inst = Instance {
858        base: format!("sha256:{sha}"),
859        distro: distro.map(Into::into),
860        ..Default::default()
861    };
862    std::fs::create_dir_all(dir.join("upper"))?;
863    std::fs::create_dir_all(dir.join("work"))?;
864    write_instance(&dir, &inst)?;
865    println!("instancia {id} creada en {}", dir.display());
866    println!("  base: sha256:{}", &sha[..12]);
867    println!("  concesiones: NINGUNA — se abren declarándolas en {}", INSTANCE_MANIFEST);
868    Ok(())
869}
870
871fn read_instance(root: &Path, id: &str) -> Result<(PathBuf, Instance)> {
872    let dir = instance_dir(root, id);
873    let f = dir.join(INSTANCE_MANIFEST);
874    let body = std::fs::read_to_string(&f)
875        .with_context(|| format!("no existe la instancia {id} ({})", f.display()))?;
876    let inst: Instance = toml::from_str(&body)
877        .with_context(|| format!("manifiesto ilegible: {}", f.display()))?;
878    Ok((dir, inst))
879}
880
881fn write_instance(dir: &Path, inst: &Instance) -> Result<()> {
882    let body = format!(
883        "# Manifiesto de la instancia — ESTO es la verdad, `upper/` es caché descartable (ADR 0015 D3).\n\
884         # `[grants]` es la política, y se AUTORA: es el único sitio del sistema donde no se deriva\n\
885         # de una clausura declarada (D7). Por defecto no hay ninguna.\n{}",
886        toml::to_string_pretty(inst).context("serializando la instancia")?
887    );
888    std::fs::write(dir.join(INSTANCE_MANIFEST), body)?;
889    Ok(())
890}
891
892fn recreate(root: &Path, id: &str, no_provision: bool) -> Result<()> {
893    let (dir, inst) = read_instance(root, id)?;
894    let (sha, _) = resolve_image(root, &inst.base)?;
895    for capa in ["upper", "work"] {
896        force_remove_dir_all(&dir.join(capa))
897            .with_context(|| format!("no pude tirar {capa}/ de {id}"))?;
898        std::fs::create_dir_all(dir.join(capa))?;
899    }
900    // El registro vive FUERA del `upper` (si viviera dentro se iría con él y nunca sabríamos qué
901    // había), así que hay que borrarlo a mano: la capa se fue, así que no hay nada instalado.
902    // Dejarlo sería la forma más pura del error de la regla 3 — un registro que afirma presencia
903    // sobre un árbol vacío.
904    let _ = std::fs::remove_file(dir.join(PROVISION_LEDGER));
905    println!("instancia {id} recreada sobre sha256:{} — capa mutable tirada", &sha[..12]);
906    if inst.packages.is_empty() {
907        return Ok(());
908    }
909    if no_provision {
910        println!(
911            "  declara {} paquete(s) y NO se reinstalaron (--no-provision): `hammer qorpa \
912             provision {id}`",
913            inst.packages.len()
914        );
915        return Ok(());
916    }
917    // Acá es donde D3 deja de ser una aspiración: si `recreate` no reinstala, el `upper` es el
918    // activo y el manifiesto un adorno.
919    println!("  reinstalando lo declarado ({} paquete(s))…", inst.packages.len());
920    provision(root, id, false, false)
921}
922
923// ── provision: `packages` deja de ser una lista que nadie lee (D3) ──────────────────────────────
924
925const PROVISION_LEDGER: &str = "provisioned.toml";
926
927/// Lo que quedó instalado, para no reinstalar por gusto y para que `recreate` sepa qué rehacer.
928/// Vive junto al manifiesto y NO dentro del `upper`: si viviera dentro se iría con la capa.
929#[derive(Serialize, Deserialize, Default)]
930struct Provisionado {
931    manager: String,
932    packages: Vec<String>,
933    /// Segundos desde epoch. Sin dep de fechas: se lee con `date -u -d @<n>`.
934    at_unix: u64,
935}
936
937/// El guión que instala, y de qué gestor sale.
938///
939/// **Un gestor por imagen, entrando explícitamente** — el ADR rechaza multiplexar `dnf`/`pacman`
940/// detrás de un comando único (el `pmm` de Bedrock): eso es menos mágico y no miente. Acá no se
941/// multiplexa nada: se DETECTA cuál trae la imagen y se corre ése, con sus manías.
942#[derive(Debug)]
943struct Gestor {
944    nombre: &'static str,
945    guion: String,
946}
947
948/// Comillas simples de shell a prueba de todo: el contenido sale del manifiesto, que lo escribe una
949/// persona, y un nombre de paquete no tiene por qué ser un token seguro.
950fn comillar(s: &str) -> String {
951    format!("'{}'", s.replace('\'', r"'\''"))
952}
953
954fn gestor_de(dir: &Path, tree: &Path, paquetes: &[String]) -> Result<Gestor> {
955    // Nombres de paquete: se validan ANTES de armar el guión. Quotear alcanza para que no se
956    // ejecute nada, pero un nombre raro casi siempre es un error de tipeo, y fallar temprano dice
957    // dónde está mejor que un gestor quejándose de un paquete inexistente.
958    for p in paquetes {
959        if p.is_empty()
960            || !p.chars().all(|c| c.is_ascii_alphanumeric() || "._+-:@/".contains(c))
961        {
962            bail!("nombre de paquete raro en `packages`: {p:?} — se aborta antes de armar el guión");
963        }
964    }
965    let lista = paquetes.iter().map(|p| comillar(p)).collect::<Vec<_>>().join(" ");
966    let hay = |rel: &str| resolver_en_instancia(dir, tree, rel).is_some();
967
968    if hay("usr/bin/apt-get") {
969        return Ok(Gestor { nombre: "apt", guion: format!(
970            "set -e\n\
971             export DEBIAN_FRONTEND=noninteractive\n\
972             apt-get update\n\
973             apt-get install -y --no-install-recommends {lista}\n")});
974    }
975    if hay("usr/bin/pacman") {
976        // Dos manías del bootstrap de Arch, y ninguna se ve hasta que pacman falla:
977        //  · la mirrorlist viene ENTERA comentada ⇒ «no servers configured for repository»;
978        //  · el llavero no está inicializado ⇒ toda firma es inválida.
979        // El mirror se elige acá y se dice cuál: es el geo oficial de Arch. Que lo elija la
980        // herramienta es peor que que lo elija el usuario, pero **no elegir** deja una instancia
981        // que no instala nada, y el fichero queda a la vista en el `upper` para cambiarlo.
982        return Ok(Gestor { nombre: "pacman", guion: format!(
983            "set -e\n\
984             grep -q '^Server' /etc/pacman.d/mirrorlist 2>/dev/null || {{\n\
985             \x20 echo 'aviso: mirrorlist vacía — pongo el mirror geo oficial de Arch' >&2\n\
986             \x20 echo 'Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' >> /etc/pacman.d/mirrorlist\n\
987             }}\n\
988             [ -d /etc/pacman.d/gnupg ] || {{ pacman-key --init; pacman-key --populate archlinux; }}\n\
989             pacman -Sy --noconfirm --needed {lista}\n")});
990    }
991    if hay("usr/bin/dnf") {
992        return Ok(Gestor { nombre: "dnf", guion: format!(
993            "set -e\ndnf install -y --setopt=install_weak_deps=False {lista}\n")});
994    }
995    if hay("sbin/apk") || hay("usr/bin/apk") {
996        return Ok(Gestor { nombre: "apk", guion: format!(
997            "set -e\napk add --no-cache {lista}\n")});
998    }
999    bail!(
1000        "no encuentro gestor de paquetes en la instancia (busqué apt-get, pacman, dnf y apk).\n  \
1001         Si la imagen usa otro, instalá a mano con `run` — pero entonces `recreate` no lo va a \n  \
1002         poder rehacer, y eso es justo lo que D3 dice que no puede pasar."
1003    );
1004}
1005
1006fn provision(root: &Path, id: &str, force: bool, dry_run: bool) -> Result<()> {
1007    let (dir, inst) = read_instance(root, id)?;
1008    let (_, tree) = resolve_image(root, &inst.base)?;
1009    if inst.packages.is_empty() {
1010        println!(
1011            "la instancia {id} no declara `packages`. Se instala lo DECLARADO: es lo que hace que \
1012             la capa mutable sea caché y no el activo."
1013        );
1014        return Ok(());
1015    }
1016
1017    let previo: Option<Provisionado> = std::fs::read_to_string(dir.join(PROVISION_LEDGER))
1018        .ok()
1019        .and_then(|s| toml::from_str(&s).ok());
1020    if let Some(p) = &previo {
1021        if !force && p.packages == inst.packages {
1022            println!(
1023                "ya provisionada con {} ({} paquete(s)) — `--force` para reinstalar",
1024                p.manager, p.packages.len()
1025            );
1026            return Ok(());
1027        }
1028    }
1029
1030    let g = gestor_de(&dir, &tree, &inst.packages)?;
1031    if dry_run {
1032        println!("# gestor detectado: {}", g.nombre);
1033        print!("{}", g.guion);
1034        return Ok(());
1035    }
1036    println!("provisionando {id} con {} — {} paquete(s)", g.nombre, inst.packages.len());
1037    run_instance(root, id, &["/bin/sh".into(), "-c".into(), g.guion.clone()],
1038                 Ajustes { provisionando: true, ..Default::default() })?;
1039
1040    let reg = Provisionado {
1041        manager: g.nombre.into(),
1042        packages: inst.packages.clone(),
1043        at_unix: std::time::SystemTime::now()
1044            .duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
1045    };
1046    std::fs::write(
1047        dir.join(PROVISION_LEDGER),
1048        format!(
1049            "# Lo escribe `hammer qorpa provision`. Es un REGISTRO, no la verdad: la verdad es\n\
1050             # `packages` en instance.toml. Si los dos difieren, gana el manifiesto y se reinstala.\n\
1051             # `at_unix` se lee con `date -u -d @<n>`.\n{}",
1052            toml::to_string_pretty(&reg).context("serializando el registro")?
1053        ),
1054    )?;
1055    println!("listo: {} paquete(s) instalados con {}", reg.packages.len(), reg.manager);
1056    Ok(())
1057}
1058
1059/// Traduce las concesiones a argumentos de bwrap. Devuelve también las advertencias que el usuario
1060/// tiene que LEER, no descubrir: el ADR pide que el socket de Wayland se diga en la cara.
1061fn grants_to_args(g: &Grants, destino_xdg: &str, wl: Option<&Path>) -> (Vec<String>, Vec<String>) {
1062    let mut a: Vec<String> = Vec::new();
1063    let mut warn: Vec<String> = Vec::new();
1064    // Sin dep de libc: el dueño de /proc/self ES el uid efectivo.
1065    let uid = std::fs::metadata("/proc/self")
1066        .map(|m| std::os::unix::fs::MetadataExt::uid(&m))
1067        .unwrap_or(1000);
1068    let xdg = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| format!("/run/user/{uid}"));
1069
1070    for s in &g.sockets {
1071        let (host, dentro) = match s.as_str() {
1072            "wayland" => {
1073                let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
1074                match wl {
1075                    Some(proxy) => (proxy.display().to_string(), format!("{destino_xdg}/wayland-0")),
1076                    None => {
1077                        warn.push(
1078                            "wayland CRUDO (`wayland_raw`): la instancia ve TODOS los globals del \
1079                             compositor y podrá CAPTURAR LA PANTALLA, SINTETIZAR TECLAS y LEER EL \
1080                             PORTAPAPELES. El proxy filtrante existe; esto lo saltea a propósito."
1081                                .into(),
1082                        );
1083                        (format!("{xdg}/{d}"), format!("{destino_xdg}/wayland-0"))
1084                    }
1085                }
1086            }
1087            "pipewire" => (format!("{xdg}/pipewire-0"), format!("{destino_xdg}/pipewire-0")),
1088            "dbus" => (format!("{xdg}/bus"), format!("{destino_xdg}/bus")),
1089            otro => {
1090                warn.push(format!("socket desconocido {otro:?} — ignorado"));
1091                continue;
1092            }
1093        };
1094        a.push("--bind-try".into()); a.push(host); a.push(dentro);
1095    }
1096    for d in &g.devices {
1097        a.push("--dev-bind-try".into()); a.push(d.clone()); a.push(d.clone());
1098    }
1099    for d in &g.dirs {
1100        let host = if let Some(rest) = d.host.strip_prefix('~') {
1101            format!("{}{}", std::env::var("HOME").unwrap_or_default(), rest)
1102        } else { d.host.clone() };
1103        a.push(if d.mode == "rw" { "--bind".into() } else { "--ro-bind".into() });
1104        a.push(host); a.push(d.inside.clone());
1105    }
1106    if g.network {
1107        // El segundo fallo clásico de la primera corrida, según el propio ADR.
1108        a.push("--ro-bind-try".into());
1109        a.push("/etc/resolv.conf".into());
1110        a.push("/etc/resolv.conf".into());
1111    } else {
1112        a.push("--unshare-net".into());
1113    }
1114    (a, warn)
1115}
1116
1117// ── el mapeo por rango (subuid) ──────────────────────────────────────────────────────────────────
1118//
1119// EL PROBLEMA, medido en los pasos anteriores: bwrap crea el user namespace con UN SOLO id mapeado
1120// (`uid_map: 0 1001 1`, `setgroups: deny`). Con eso `apt` no puede bajar a `_apt`, `pacman` no
1121// puede chownear su descarga a `alpm`, y un userns anidado —pressure-vessel— no puede escribir su
1122// propio mapa. Tres síntomas distintos, una sola causa.
1123//
1124// LA CURA: crear el namespace NOSOTROS, mapearle un rango real de `/etc/subuid` con `newuidmap`, y
1125// pasárselo a bwrap con `--userns FD`. bwrap no llama a `newuidmap` a propósito —es un binario con
1126// capability y bwrap no quiere depender de él—, así que el trabajo es de quien lo invoca.
1127//
1128// El namespace lo sostiene un proceso «tenedor» que espera en un fifo: mientras viva, el fd
1129// `/proc/<pid>/ns/user` es válido. La coreografía (hijo avisa por un fifo, padre mapea desde fuera,
1130// hijo sigue) es la misma que ya verifica `scripts/qorpa/qorpa-preflight.sh`.
1131
1132struct Rango { inicio: u32, cantidad: u32 }
1133
1134fn leer_subid(fichero: &str, usuario: &str) -> Option<Rango> {
1135    let txt = std::fs::read_to_string(fichero).ok()?;
1136    txt.lines().find_map(|l| {
1137        let mut c = l.split(':');
1138        let (u, i, n) = (c.next()?, c.next()?, c.next()?);
1139        if u != usuario {
1140            return None;
1141        }
1142        Some(Rango { inicio: i.parse().ok()?, cantidad: n.parse().ok()? })
1143    })
1144}
1145
1146/// El namespace vivo, con su tenedor. Al soltarlo, se mata al tenedor y el namespace desaparece.
1147struct UsernsMapeado {
1148    tenedor: std::process::Child,
1149    ns_path: String,
1150    ids: u32,
1151}
1152
1153impl Drop for UsernsMapeado {
1154    fn drop(&mut self) {
1155        // El tenedor sólo existe para sostener el namespace. Si sobreviviera al `run`, dejaríamos
1156        // un proceso colgado por cada instancia arrancada.
1157        let _ = self.tenedor.kill();
1158        let _ = self.tenedor.wait();
1159    }
1160}
1161
1162/// Crea el namespace y le mapea el rango. Devuelve `Err` con la CAUSA exacta: hay tres formas de
1163/// que esto no se pueda, y las tres se arreglan distinto, así que no se colapsan en un "no pude".
1164///
1165/// `uid_dentro` decide quién sos adentro, y **eso lo hace el MAPA, no un flag de bwrap** — que es
1166/// justo lo que permite ser un usuario normal sin renunciar al rango. Hizo falta por Steam: el
1167/// cliente se niega a correr como root (`bin_steam.sh: Error: Cannot run as root user`), así que la
1168/// instancia de juegos NECESITA no ser root, y antes eso costaba el rango entero.
1169fn userns_con_rango(uid_dentro: u32) -> Result<UsernsMapeado> {
1170    let uid = std::fs::metadata("/proc/self")
1171        .map(|m| std::os::unix::fs::MetadataExt::uid(&m))
1172        .unwrap_or(1000);
1173    let gid = std::fs::metadata("/proc/self")
1174        .map(|m| std::os::unix::fs::MetadataExt::gid(&m))
1175        .unwrap_or(1000);
1176    let usuario = std::env::var("USER")
1177        .or_else(|_| std::env::var("LOGNAME"))
1178        .unwrap_or_default();
1179    let ru = leer_subid("/etc/subuid", &usuario)
1180        .with_context(|| format!("no hay rango para {usuario:?} en /etc/subuid"))?;
1181    let rg = leer_subid("/etc/subgid", &usuario)
1182        .with_context(|| format!("no hay rango para {usuario:?} en /etc/subgid"))?;
1183
1184    let tmp = std::env::temp_dir().join(format!("qorpa-userns-{}", std::process::id()));
1185    std::fs::create_dir_all(&tmp)?;
1186    let listo = tmp.join("listo");
1187    let adelante = tmp.join("adelante");
1188    for f in [&listo, &adelante] {
1189        let _ = std::fs::remove_file(f);
1190        let st = Command::new("mkfifo").arg(f).status().context("mkfifo")?;
1191        if !st.success() {
1192            bail!("no pude crear el fifo {}", f.display());
1193        }
1194    }
1195
1196    let tenedor = Command::new("unshare")
1197        .arg("-U")
1198        .arg("sh")
1199        .arg("-c")
1200        .arg(format!(
1201            "echo r > {l}; read _ < {a}",
1202            l = listo.display(), a = adelante.display()
1203        ))
1204        .spawn()
1205        .context("no pude ejecutar `unshare -U` — ¿está util-linux?")?;
1206    let pid = tenedor.id();
1207    let mut guard = UsernsMapeado {
1208        tenedor,
1209        ns_path: format!("/proc/{pid}/ns/user"),
1210        ids: 1 + ru.cantidad,
1211    };
1212
1213    // Bloquea hasta que el hijo esté DENTRO del namespace. Sin este apretón de manos mapearíamos
1214    // el namespace del padre, que es el del host.
1215    std::fs::read_to_string(&listo).context("el tenedor no llegó al fifo")?;
1216
1217    // Los tramos del mapa. Con `uid_dentro = 0` es el caso simple (somos root adentro y el resto
1218    // del rango va detrás). Con cualquier otro, hay que partir el rango en tres para dejar hueco:
1219    // los ids de abajo (incluido el 0, que tiene que EXISTIR para que la imagen tenga dueño de
1220    // ficheros) salen del subuid, nosotros caemos en `uid_dentro`, y el resto sigue arriba.
1221    let tramos = move |id_afuera: u32, r: &Rango| -> Vec<String> {
1222        if uid_dentro == 0 {
1223            vec!["0".into(), id_afuera.to_string(), "1".into(),
1224                 "1".into(), r.inicio.to_string(), r.cantidad.to_string()]
1225        } else {
1226            let bajos = uid_dentro.min(r.cantidad);
1227            let altos = r.cantidad.saturating_sub(bajos + 1);
1228            let mut v = vec![
1229                "0".into(), r.inicio.to_string(), bajos.to_string(),
1230                uid_dentro.to_string(), id_afuera.to_string(), "1".into(),
1231            ];
1232            if altos > 0 {
1233                v.extend([(uid_dentro + 1).to_string(), (r.inicio + bajos).to_string(),
1234                          altos.to_string()]);
1235            }
1236            v
1237        }
1238    };
1239
1240    let mapear = |bin: &str, id: u32, r: &Rango| -> Result<()> {
1241        let mut argv = vec![pid.to_string()];
1242        argv.extend(tramos(id, r));
1243        let out = Command::new(bin)
1244            .args(&argv)
1245            .output()
1246            .with_context(|| format!("no pude ejecutar {bin}"))?;
1247        if !out.status.success() {
1248            bail!("{bin}: {}", String::from_utf8_lossy(&out.stderr).trim());
1249        }
1250        Ok(())
1251    };
1252    mapear("newuidmap", uid, &ru)?;
1253    mapear("newgidmap", gid, &rg)?;
1254
1255    // Soltar al tenedor: ya mapeado, se queda esperando el fifo (que nadie escribirá) hasta que lo
1256    // matemos. Lo que importa es que el proceso VIVA, no lo que haga.
1257    let _ = std::fs::remove_file(&listo);
1258    let _ = std::fs::remove_file(&adelante);
1259    guard.ids = 1 + ru.cantidad;
1260    Ok(guard)
1261}
1262
1263/// Lo que NO viene del manifiesto: banderas de la invocación. Van juntas para que agregar una no
1264/// mueva la firma de media docena de llamadas.
1265#[derive(Default, Clone, Copy)]
1266struct Ajustes {
1267    dry_run: bool,
1268    no_jail: bool,
1269    single_id: bool,
1270    /// Modo `provision`: la política declarada se ENSANCHA para poder instalar, y sólo mientras
1271    /// dura la instalación. Ver `provision()` — lo que se ensancha y por qué se dice en voz alta.
1272    provisionando: bool,
1273    /// Levanta el lector de audit y recoge el veredicto. Ver `preparar_evidencia`.
1274    evidencia: bool,
1275}
1276
1277fn run_instance(
1278    root: &Path, id: &str, cmd: &[String], aj: Ajustes,
1279) -> Result<()> {
1280    let Ajustes { dry_run, no_jail, single_id, provisionando, evidencia: _ } = aj;
1281    let (dir, mut inst) = read_instance(root, id)?;
1282    let (sha, tree) = resolve_image(root, &inst.base)?;
1283
1284    // ── LA POLÍTICA SE ENSANCHA PARA PROVISIONAR, Y SE DICE ────────────────────────────────────
1285    // Instalar paquetes pide exactamente las tres cosas que una instancia bien declarada NO tiene:
1286    // red (los paquetes se bajan), root (el gestor chownea y escribe en /usr) y la imagen sin
1287    // sellar (lo que instala va a /usr por definición). Ensancharlas en silencio sería la falla
1288    // que el ADR llama por su nombre en D7: la política se AUTORA. Así que se ensancha sólo aquí,
1289    // sólo mientras dura el `provision`, y se imprime en la cara — el manifiesto NO se toca, y el
1290    // siguiente `run` vuelve a la política escrita.
1291    if provisionando {
1292        let mut abierto: Vec<&str> = Vec::new();
1293        if !inst.grants.network { abierto.push("red"); inst.grants.network = true; }
1294        if !inst.grants.root { abierto.push("root"); inst.grants.root = true; }
1295        if inst.grants.seal_image { abierto.push("imagen sin sellar"); inst.grants.seal_image = false; }
1296        if inst.run_as.is_some() { abierto.push("como root, no `run_as`"); inst.run_as = None; }
1297        if !abierto.is_empty() {
1298            eprintln!(
1299                "⚠ provision ENSANCHA la política sólo para esta operación: {}. El manifiesto no \n\
1300                 \x20 cambia y el próximo `run` vuelve a lo declarado.",
1301                abierto.join(", ")
1302            );
1303        }
1304    }
1305
1306    // Quién sos adentro. Con el mapeo por rango esto NO cuesta el rango: lo decide el propio mapa.
1307    // ⚠ Cambiarlo en una instancia YA USADA la corrompe: los ficheros del `upper` quedaron con los
1308    // uids del mapa anterior. Para correr como usuario normal está `run_as`, que no toca el mapa.
1309    let uid_dentro: u32 = if inst.grants.root { 0 } else { 1000 };
1310    // El XDG_RUNTIME_DIR es del usuario que CORRE, no del uid del mapa: con `run_as`, apuntarlo al
1311    // de root deja a las herramientas del Steam Runtime sin poder crear su temporal
1312    // (`srt-logger: g_mkdtemp: Permission denied`), y eso se lee como un aviso menor hasta que algo
1313    // deja de andar sin explicar por qué.
1314    let uid_corre = match &inst.run_as {
1315        Some(u) => usuario_de(&dir, &tree, u).map(|(x, _, _)| x).unwrap_or(uid_dentro),
1316        None => uid_dentro,
1317    };
1318    let runtime_dir = format!("/run/user/{uid_corre}");
1319
1320    // El proxy vive mientras viva este proceso, que es exactamente lo que dura la instancia.
1321    // `_proxy` se mantiene en scope a propósito.
1322    let mut proxy_sock: Option<PathBuf> = None;
1323    let _proxy = if inst.grants.sockets.iter().any(|s| s == "wayland") && !inst.grants.wayland_raw {
1324        let xdg = std::env::var("XDG_RUNTIME_DIR")
1325            .unwrap_or_else(|_| format!("/run/user/{}", uid_corre));
1326        let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
1327        let real = PathBuf::from(format!("{xdg}/{d}"));
1328        if !real.exists() {
1329            bail!("la instancia pide `wayland` pero no hay compositor en {}", real.display());
1330        }
1331        let permitidos: std::collections::HashSet<String> = if inst.grants.wayland_globals.is_empty() {
1332            crate::qorpa_wl::GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect()
1333        } else {
1334            inst.grants.wayland_globals.iter().cloned().collect()
1335        };
1336        let n = permitidos.len();
1337        let p = crate::qorpa_wl::arrancar(&dir.join("wl"), &real, permitidos)
1338            .context("no pude arrancar el proxy de Wayland")?;
1339        eprintln!("  wayland: proxy filtrante puesto — {n} global(s) visibles, el resto no existe \
1340                   para la instancia");
1341        proxy_sock = Some(p.socket.clone());
1342        Some(p)
1343    } else {
1344        None
1345    };
1346    let (grant_args, warns) = grants_to_args(&inst.grants, &runtime_dir, proxy_sock.as_deref());
1347
1348    // El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo
1349    // después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo
1350    // que no se declara, no entra (D2, "por defecto NADA").
1351    // El mapeo por rango se intenta SIEMPRE que se pueda: es lo que quita el impuesto de
1352    // `apt`/`pacman` y lo que permite anidar. `root = false` lo descarta porque ser otro-que-root
1353    // adentro pide el `--uid` de bwrap, que exige `--unshare-user` y ése es incompatible con
1354    // `--userns`. Cuando no se puede, se dice POR QUÉ y se sigue con un id: degradar en silencio
1355    // haría que "no anda adentro" fuese un misterio en vez de una línea.
1356    let mut mapeado: Option<UsernsMapeado> = None;
1357    if !single_id {
1358        match userns_con_rango(uid_dentro) {
1359            Ok(m) => mapeado = Some(m),
1360            Err(e) => eprintln!(
1361                "⚠ sin mapeo por rango ({e:#}) ⇒ un solo id adentro. Consecuencias MEDIDAS: `apt` \
1362                 no baja a `_apt`, `pacman` no chownea su descarga, y un userns anidado no puede \
1363                 escribir su mapa. Diagnóstico: scripts/qorpa/qorpa-preflight.sh"
1364            ),
1365        }
1366    }
1367
1368    let mut args: Vec<String> = vec![
1369        "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try",
1370        "--hostname", "qorpa",
1371        "--clearenv",
1372        "--die-with-parent",
1373    ].into_iter().map(String::from).collect();
1374    if mapeado.is_none() {
1375        // bwrap crea el suyo, con un solo id.
1376        args.insert(0, "--unshare-user".into());
1377        if inst.grants.root {
1378            args.extend(["--uid", "0", "--gid", "0"].map(String::from));
1379        }
1380    }
1381    if inst.grants.root {
1382        for c in CAPS_ROOT {
1383            args.push("--cap-add".into());
1384            args.push((*c).to_string());
1385        }
1386        if inst.grants.nesting {
1387            // La que permite montar. Sólo con la concesión declarada.
1388            args.push("--cap-add".into());
1389            args.push("CAP_SYS_ADMIN".into());
1390        }
1391    }
1392
1393    args.push("--overlay-src".into()); args.push(tree.display().to_string());
1394    args.push("--overlay".into());
1395    args.push(dir.join("upper").display().to_string());
1396    args.push(dir.join("work").display().to_string());
1397    args.push("/".into());
1398
1399    // /proc y /dev van DESPUÉS del overlay: son del kernel, no de la imagen.
1400    // `--perms 1777` antes del `--tmpfs /tmp` no es cosmético: bwrap lo crearía 0755 y entonces el
1401    // `_apt` (uid 42) al que apt baja para bajar paquetes no puede escribir su fichero temporal.
1402    // Un /tmp que no es 1777 no es /tmp.
1403    args.extend(["--proc", "/proc", "--dev", "/dev",
1404                 "--perms", "1777", "--tmpfs", "/tmp"].map(String::from));
1405    args.extend(["--dir".to_string(), runtime_dir.clone(), "--chdir".into(), "/".into()]);
1406    // El HOME sale del /etc/passwd de la IMAGEN, no de una constante: si la instancia creó un
1407    // usuario, su home es el que ese fichero dice. Un HOME que no existe hace que media app se
1408    // comporte raro sin decir por qué.
1409    let home = home_de(&dir, &tree, uid_dentro);
1410    args.extend([
1411        "--setenv", "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
1412        "--setenv", "HOME", &home,
1413        "--setenv", "TERM", &std::env::var("TERM").unwrap_or_else(|_| "xterm".into()),
1414    ].map(String::from));
1415    args.extend(["--setenv".to_string(), "XDG_RUNTIME_DIR".into(), runtime_dir.clone()]);
1416    args.extend(grant_args);
1417
1418    let mut cmd: Vec<String> = if cmd.is_empty() {
1419        vec!["/bin/sh".into()] // toda imagen trae uno; bash puede no estar.
1420    } else { cmd.to_vec() };
1421
1422    // Bajar de privilegio ADENTRO, con el mapa intacto. `setpriv` viene en util-linux, que trae
1423    // toda imagen de distro; si faltara, se dice en vez de correr como root en silencio — que sería
1424    // exactamente lo que el manifiesto pidió no hacer.
1425    if let Some(usuario) = &inst.run_as {
1426        let (uid_u, gid_u, home_u) = usuario_de(&dir, &tree, usuario).with_context(|| {
1427            format!("`run_as = {usuario:?}` pero no existe en el /etc/passwd de la instancia")
1428        })?;
1429        // Un prelude MÍNIMO como root para darle su runtime dir al usuario, y después `exec`: el
1430        // `"$@"` evita entrecomillar el comando del usuario, que es donde estos wrappers se rompen.
1431        let mut env = vec![
1432            "/bin/sh".to_string(), "-c".into(),
1433            "chown \"$1\":\"$2\" \"$3\" 2>/dev/null; shift 3; exec \"$@\"".into(),
1434            "qorpa".into(), uid_u.to_string(), gid_u.to_string(), runtime_dir.clone(),
1435            "setpriv".into(), "--reuid".into(), uid_u.to_string(),
1436            "--regid".into(), gid_u.to_string(), "--init-groups".into(), "--".into(),
1437        ];
1438        env.extend(cmd);
1439        cmd = env;
1440        // El HOME tiene que ser el SUYO, no el de root: media app se comporta raro sin decir por qué.
1441        if let Some(i) = args.iter().position(|a| a == "HOME") {
1442            args[i + 1] = home_u;
1443        }
1444    }
1445
1446    // ── EL CANAL DE EVIDENCIA ──────────────────────────────────────────────────────────────────
1447    // De un binario ajeno no hay fuente que leer. Lo único que queda para saber qué intentó hacer
1448    // es lo que el kernel le NEGÓ y anotó — por eso el ADR lo llama «la única forma de auditar el
1449    // montón B». El lector (`harkaq-audit`) vive en el HOST, fuera de bwrap, porque el audit no
1450    // está namespaceado; se levanta ANTES para no perderse las primeras denegaciones.
1451    let mut ev: Option<Evidencia> = None;
1452    if aj.evidencia && !dry_run {
1453        let e = preparar_evidencia(&dir, id, no_jail, &inst.grants)?;
1454        args.push("--ro-bind".into());
1455        args.push(e.canario_src.display().to_string());
1456        args.push(e.canario.clone());
1457        // El canario se lee ADENTRO y su denegación es lo que revela el `domain=` del dominio
1458        // Landlock, que es un número que nadie puede adivinar desde fuera. Sin esto el lector no
1459        // sabe cuáles de todas las denegaciones del sistema son de ESTA instancia.
1460        let mut w = vec![
1461            "/bin/sh".to_string(), "-c".into(),
1462            // El `{ …; } 2>/dev/null` NO es lo mismo que `read _ < "$1" 2>/dev/null`: las
1463            // redirecciones se aplican de izquierda a derecha, así que en la segunda forma el
1464            // fallo de la entrada se reporta ANTES de silenciar stderr y el usuario ve un
1465            // «Permission denied» que parece un error suyo y es el canario funcionando.
1466            "{ read _ < \"$1\"; } 2>/dev/null || true; shift; exec \"$@\"".into(),
1467            "qorpa".into(), e.canario.clone(),
1468        ];
1469        w.extend(cmd);
1470        cmd = w;
1471        ev = Some(e);
1472    }
1473
1474    // harkaq va como ÚLTIMO eslabón antes del comando, igual que en el sandbox del build: bwrap ya
1475    // puso los namespaces y el overlay, y esto pone el grano fino. Cruza el borde un binario
1476    // ESTÁTICO — no una librería —, así que sigue valiendo D2: lo único que comparte con el host
1477    // es la ABI del kernel.
1478    if !no_jail {
1479        let exec = harkaq_exec();
1480        if !exec.exists() {
1481            bail!(
1482                "no encuentro harkaq-exec en {} — la instancia NO va a correr enjaulada y eso no se \
1483                 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.",
1484                exec.display(), exec.display()
1485            );
1486        }
1487        let policy = dir.join("policy");
1488        compile_policy(&inst.grants, &tree, &policy)?;
1489
1490        args.push("--ro-bind".into()); args.push(exec.display().to_string());
1491        args.push("/run/qorpa/harkaq-exec".into());
1492        args.push("--ro-bind".into()); args.push(policy.display().to_string());
1493        args.push("/run/qorpa/policy".into());
1494
1495        let mut envoltura: Vec<String> = vec![
1496            "/run/qorpa/harkaq-exec".into(), "--policy".into(), "/run/qorpa/policy".into(),
1497        ];
1498        if inst.grants.nesting {
1499            // MEDIDO: seccomp no alcanza. Un dominio Landlock activo hace fallar `mount` con
1500            // EACCES aunque el filtro lo permita — el kernel no admite montajes nuevos bajo un
1501            // dominio, porque escaparían de sus reglas por-ruta. ⇒ anidar cuesta la política de
1502            // ficheros. Seccomp y no_new_privs siguen puestos, que es lo que más pesa con un
1503            // binario ajeno, y el confinamiento de fs queda en el namespace de montaje de bwrap,
1504            // que ya es una lista blanca.
1505            envoltura.push("--allow-nesting".into());
1506            envoltura.push("--no-landlock".into());
1507        }
1508        envoltura.push("--".into());
1509        envoltura.extend(cmd);
1510        cmd = envoltura;
1511    } else {
1512        eprintln!("⚠ --no-jail: SIN Landlock, SIN seccomp, SIN no_new_privs. Sólo los namespaces \
1513                   de bwrap. No es el modo normal.");
1514    }
1515    args.extend(cmd.iter().cloned());
1516
1517    for w in &warns {
1518        eprintln!("⚠ {w}");
1519    }
1520    if dry_run {
1521        if let Some(m) = &mapeado {
1522            println!("# el userns lo creamos nosotros y le mapeamos {} ids:", m.ids);
1523            println!("#   exec 3<{}   ⇒   bwrap --userns 3 …", m.ns_path);
1524        }
1525        // Una línea por concesión, no una por token: el punto de `--dry-run` es que se LEA.
1526        let mut linea = String::from("bwrap");
1527        for a in &args {
1528            if a.starts_with("--") { println!("{linea} \\"); linea = format!("  {a}"); }
1529            else { linea.push(' '); linea.push_str(a); }
1530        }
1531        println!("{linea}");
1532        return Ok(());
1533    }
1534    eprintln!("qorpa {id} · sha256:{} · red={} · {} socket(s), {} device(s), {} dir(s) · \
1535               jaula={} · imagen={}",
1536        &sha[..12], inst.grants.network, inst.grants.sockets.len(),
1537        inst.grants.devices.len(), inst.grants.dirs.len(),
1538        if no_jail { "NO" } else if inst.grants.nesting { "sí (anidamiento permitido)" } else { "sí" },
1539        if inst.grants.seal_image { "sellada" } else { "escribible" });
1540    eprintln!("  ids mapeados: {}",
1541        match &mapeado { Some(m) => m.ids.to_string(), None => "1 (sin rango)".into() });
1542
1543    // Cuando el namespace es nuestro hay que pasarle a bwrap un fd ABIERTO, y un fd sólo cruza el
1544    // `exec` si no es CLOEXEC. En vez de arrastrar una dep de C para un `fcntl`, lo abre la shell:
1545    // sus redirecciones no son CLOEXEC por definición, y de paso el `--dry-run` puede mostrar
1546    // exactamente lo que pasa.
1547    // ── EL OVERLAY PUEDE SEGUIR TOMADO POR LA CORRIDA ANTERIOR ─────────────────────────────────
1548    // MEDIDO: dos `run` seguidos sobre la MISMA instancia y el segundo muere con
1549    //   `bwrap: Can't make overlay mount … Device or resource busy`; el tercero anda.
1550    //
1551    // No es un capricho de overlayfs: el kernel se NIEGA a montar dos overlays vivos que compartan
1552    // `upperdir`/`workdir` porque eso corrompe la capa. O sea que el EBUSY es un GUARDIÁN, no una
1553    // molestia — lo que está mal es el momento: la corrida anterior ya devolvió el prompt y su
1554    // namespace todavía no terminó de reaparse, así que el usuario ve una denegación correcta por
1555    // una razón que ya no existe. Se reintenta acotado; si de verdad hay otra `run` viva, los
1556    // reintentos se agotan y se dice cuál es la causa en vez de dejar el mensaje crudo de bwrap.
1557    const INTENTOS: u32 = 6;
1558    let mut st = None;
1559    for intento in 1..=INTENTOS {
1560        let (status, overlay_tomado) = lanzar_bwrap(&mapeado, &args)?;
1561        if status.success() || !overlay_tomado {
1562            st = Some(status);
1563            break;
1564        }
1565        if intento == INTENTOS {
1566            bail!(
1567                "el overlay de `{id}` sigue tomado tras {INTENTOS} intentos.\n                   ¿Hay otra `hammer qorpa run {id}` viva? Dos overlays con el mismo `upper` \n                   corromperían la capa mutable, y por eso el kernel lo niega."
1568            );
1569        }
1570        eprintln!("  el overlay lo tiene todavía la corrida anterior — reintento {intento}/{INTENTOS}");
1571        std::thread::sleep(std::time::Duration::from_millis(200 * u64::from(intento)));
1572    }
1573    let st = st.expect("el bucle sale con estado o con bail");
1574    if let Some(e) = ev {
1575        recoger_evidencia(&dir, e)?;
1576    }
1577    match st.code() {
1578        Some(0) => Ok(()),
1579        Some(c) => std::process::exit(c),
1580        None => bail!("la instancia murió por señal"),
1581    }
1582}
1583
1584// ── el canal de evidencia (D9 punto 3) ──────────────────────────────────────────────────────────
1585//
1586// LO QUE HACE ÚNICO A ESTE CANAL: con nuestros propios builds, si algo toca lo que no debe, está
1587// la receta y la fuente para mirar. Con un binario ajeno no hay nada que leer. Lo que el kernel
1588// deniega y anota es LA ÚNICA superficie observable — el ADR lo dice así en D9.
1589//
1590// Tres piezas, y ninguna sobra:
1591//   · el LECTOR vive en el host, fuera de bwrap: el audit no está namespaceado y los registros
1592//     cruzan igual. Se levanta ANTES de la instancia o se pierde el principio.
1593//   · el CANARIO es un fichero en un sitio que la política NO alcanza. Al leerlo, el kernel emite
1594//     una denegación con el `domain=` de este dominio Landlock — un número que desde fuera no se
1595//     puede adivinar. Sin canario, el lector no sabe cuáles de todas las denegaciones del sistema
1596//     son de esta instancia.
1597//   · el VEREDICTO se gana: `denials=[]` sólo vale si el canario se vio Y el contador del kernel
1598//     cuadra. Si no, el estado es `SinEvidencia`, que es distinto de «limpio».
1599
1600const EVIDENCE_FILE: &str = "evidence.json";
1601
1602struct Evidencia {
1603    canario: String,
1604    canario_src: PathBuf,
1605    veredicto: PathBuf,
1606    lector: std::process::Child,
1607}
1608
1609fn preparar_evidencia(dir: &Path, id: &str, no_jail: bool, g: &Grants) -> Result<Evidencia> {
1610    // ── LAS TRES CONDICIONES, Y POR QUÉ SE FALLA EN VEZ DE SEGUIR ──────────────────────────────
1611    // Si alguna falta, el lector devolvería `denials=[]` y eso se leería como «la instancia no
1612    // intentó nada raro», cuando la verdad sería «no había forma de enterarse». Un instrumento que
1613    // no puede medir tiene que decirlo, no marcar cero.
1614    if no_jail {
1615        bail!("`--evidence` con `--no-jail` no tiene sentido: sin jaula no hay dominio Landlock y \
1616               no hay nada que el kernel pueda anotar");
1617    }
1618    if g.nesting {
1619        bail!(
1620            "`nesting` apaga Landlock (D9, conflicto 1: el kernel no admite montajes nuevos bajo \n  \
1621             un dominio) ⇒ o la instancia anida o se audita, no las dos. Para auditar Steam hay \n  \
1622             que mirar la instancia SIN pressure-vessel."
1623        );
1624    }
1625    if !g.seal_image {
1626        bail!(
1627            "sin `seal_image` la política de una instancia es UNA línea, `rw /` ⇒ no hay NADA que \n  \
1628             el kernel pueda denegar, y el veredicto diría `denials=[]` por construcción y no por \n  \
1629             mérito. Encendé `seal_image = true` en el manifiesto y volvé a correr."
1630        );
1631    }
1632
1633    let bin = harkaq_exec().parent().unwrap_or(Path::new(".")).join("harkaq-audit");
1634    if !bin.exists() {
1635        bail!(
1636            "no encuentro harkaq-audit en {}\n  \
1637             construilo:  gcc -O1 -Wall -o {} scripts/harkaq/harkaq-audit.c\n  \
1638             y dale las capabilities:  sudo setcap cap_audit_read,cap_audit_control+ep {}",
1639            bin.display(), bin.display(), bin.display()
1640        );
1641    }
1642
1643    // El canario va en la RAÍZ. Con `seal_image` la política concede `list /` y nada más sobre la
1644    // raíz misma, así que un fichero ahí se puede LISTAR pero no LEER: la denegación es exacta.
1645    // En cualquier directorio con `rw` —/etc, /var, /tmp— se leería sin ruido y el canario sería
1646    // mudo, que es la misma trampa que ya documenta el canario del build.
1647    let canario = format!("/harkaq-canary-{id}-{}", std::process::id());
1648    let canario_src = dir.join("canary");
1649    std::fs::write(&canario_src, b"harkaq\n")
1650        .with_context(|| format!("escribiendo el canario {}", canario_src.display()))?;
1651
1652    let veredicto = dir.join(EVIDENCE_FILE);
1653    let vf = std::fs::File::create(&veredicto)
1654        .with_context(|| format!("creando {}", veredicto.display()))?;
1655    let lector = Command::new(&bin)
1656        .arg("--canary").arg(&canario)
1657        .arg("--timeout").arg(std::env::var("HARKAQ_TIMEOUT").unwrap_or_else(|_| "1800".into()))
1658        .stdout(std::process::Stdio::from(vf))
1659        .stderr(std::process::Stdio::inherit())
1660        .spawn()
1661        .with_context(|| format!("no pude lanzar {}", bin.display()))?;
1662    // El lector tiene que estar en el multicast antes de que arranque la instancia: si no, sus
1663    // primeras denegaciones —el canario entre ellas— se emiten sin nadie del otro lado y el
1664    // veredicto sale `SinEvidencia` por una carrera y no por un problema real.
1665    std::thread::sleep(std::time::Duration::from_millis(300));
1666    eprintln!("  evidencia: lector puesto, canario {canario}");
1667    Ok(Evidencia { canario, canario_src, veredicto, lector })
1668}
1669
1670fn recoger_evidencia(dir: &Path, mut e: Evidencia) -> Result<()> {
1671    // Gracia para que llegue el `deallocated` del dominio: el kernel lo emite con retraso (RCU),
1672    // y es lo que le permite al lector comparar su conteo contra el del kernel.
1673    std::thread::sleep(std::time::Duration::from_millis(1500));
1674    let _ = nix::sys::signal::kill(
1675        nix::unistd::Pid::from_raw(e.lector.id() as i32),
1676        nix::sys::signal::Signal::SIGTERM,
1677    );
1678    let salida = e.lector.wait().ok().and_then(|s| s.code());
1679    let _ = std::fs::remove_file(&e.canario_src);
1680
1681    let txt = std::fs::read_to_string(&e.veredicto).unwrap_or_default();
1682    // Un veredicto VACÍO no es un veredicto: es el lector que no llegó a emitir. Se dice con su
1683    // código de salida, que es el único dato que queda — un `SinEvidencia` mudo manda a buscar el
1684    // problema en el sitio equivocado.
1685    if txt.trim().is_empty() {
1686        println!(
1687            "evidencia: SIN EVIDENCIA — el lector no emitió veredicto (salió con {}). NO quiere \
1688             decir «limpio».\n  Comprobá:  getcap {}",
1689            salida.map(|c| c.to_string()).unwrap_or_else(|| "señal".into()),
1690            harkaq_exec().parent().unwrap_or(Path::new(".")).join("harkaq-audit").display(),
1691        );
1692        return Ok(());
1693    }
1694    let v: serde_json::Value = serde_json::from_str(txt.trim()).unwrap_or(serde_json::Value::Null);
1695    let estado = v.get("estado").and_then(|x| x.as_str()).unwrap_or("SinEvidencia");
1696    let denials = v.get("denials").and_then(|x| x.as_array()).map(|a| a.len()).unwrap_or(0);
1697    match estado {
1698        "Hermetico" => println!("evidencia: HERMÉTICO — 0 denegaciones, y el canario las respalda"),
1699        "Impuro" => {
1700            println!("evidencia: IMPURO — {denials} denegación(es); esto es el diagnóstico, no el fallo");
1701            for d in v["denials"].as_array().into_iter().flatten().take(12) {
1702                println!("  {} · {}",
1703                    d.get("blockers").and_then(|x| x.as_str()).unwrap_or("?"),
1704                    d.get("path").and_then(|x| x.as_str()).unwrap_or("?"));
1705            }
1706            if denials > 12 { println!("  … y {} más en {}", denials - 12, e.veredicto.display()); }
1707        }
1708        _ => println!(
1709            "evidencia: SIN EVIDENCIA — {}. NO quiere decir «limpio»: quiere decir que no se pudo \
1710             saber.",
1711            v.get("motivo").and_then(|x| x.as_str()).unwrap_or("el lector no dejó motivo")
1712        ),
1713    }
1714    println!("  veredicto completo: {}", e.veredicto.display());
1715    let _ = dir;
1716    Ok(())
1717}
1718
1719/// Lanza bwrap y devuelve su estado junto con **si el fallo fue el overlay tomado**.
1720///
1721/// El stderr se PASA A TRAVÉS mientras se mira, byte a byte y sin esperar renglones: hay que leerlo
1722/// para distinguir ese fallo de cualquier otro, pero tragárselo sería peor que el bug —adentro
1723/// corre un shell interactivo y su prompt sale por stderr sin salto de línea—. Sólo se guardan los
1724/// primeros KB para buscar la firma; el resto se reenvía y se olvida.
1725fn lanzar_bwrap(
1726    mapeado: &Option<UsernsMapeado>, args: &[String],
1727) -> Result<(std::process::ExitStatus, bool)> {
1728    let mut c = match mapeado {
1729        Some(m) => {
1730            let mut c = Command::new("sh");
1731            c.arg("-c")
1732                .arg(r#"exec 3<"$1"; shift; exec bwrap --userns 3 "$@""#)
1733                .arg("qorpa")
1734                .arg(&m.ns_path)
1735                .args(args);
1736            c
1737        }
1738        None => {
1739            let mut c = Command::new("bwrap");
1740            c.args(args);
1741            c
1742        }
1743    };
1744    c.stderr(std::process::Stdio::piped());
1745    let mut hijo = c
1746        .spawn()
1747        .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?;
1748    let mut err = hijo.stderr.take().expect("stderr pedido arriba");
1749    let hilo = std::thread::spawn(move || {
1750        use std::io::Write;
1751        let mut principio = Vec::new();
1752        let mut buf = [0u8; 4096];
1753        loop {
1754            match err.read(&mut buf) {
1755                Ok(0) | Err(_) => break,
1756                Ok(n) => {
1757                    if principio.len() < 8192 {
1758                        principio.extend_from_slice(&buf[..n]);
1759                    }
1760                    let _ = std::io::stderr().write_all(&buf[..n]);
1761                    let _ = std::io::stderr().flush();
1762                }
1763            }
1764        }
1765        let t = String::from_utf8_lossy(&principio).into_owned();
1766        t.contains("Can't make overlay mount") && t.contains("busy")
1767    });
1768    let st = hijo.wait().context("esperando a bwrap")?;
1769    let overlay_tomado = hilo.join().unwrap_or(false);
1770    Ok((st, overlay_tomado))
1771}
1772
1773// ── prune: la poda (§Orden 7) ───────────────────────────────────────────────────────────────────
1774//
1775// Nace CON el subsistema y no después, a propósito: un rootfs son cientos de MB o varios GB, y no
1776// lo alcanzan ni `store-gc` ni la caché `.dmerge` — es un tercer montón sin dueño, como ya lo fue
1777// `work/sources`. La regla que la hace trivial es D3: **un `upper` siempre se puede borrar**.
1778//
1779// Dos cicatrices del repo, cableadas acá:
1780//  · `store-gc` REPORTABA borrados que no ocurrían (corriendo en segundo plano). Por eso esto
1781//    comprueba DESPUÉS de borrar que el directorio se fue, y si sigue ahí lo dice.
1782//  · Un tamaño calculado sobre un árbol que no se puede leer entero sale MENOR de lo que es. Por
1783//    eso se cuentan los directorios ciegos y la cifra se marca como cota inferior.
1784
1785struct Candidato { que: String, ruta: PathBuf, bytes: u64, cota: bool }
1786
1787fn prune(root: &Path, con_upper: bool, de_verdad: bool) -> Result<()> {
1788    let images = root.join("images");
1789    let mut cands: Vec<Candidato> = Vec::new();
1790
1791    // 1. Restos de descargas y desempacados a medias. Basura por definición: llevan el prefijo con
1792    //    punto justo para que `list` no los confunda con una imagen.
1793    if let Ok(rd) = std::fs::read_dir(&images) {
1794        for e in rd.flatten() {
1795            let n = e.file_name().to_string_lossy().into_owned();
1796            if !n.starts_with('.') {
1797                continue;
1798            }
1799            let (bytes, cota) = medir(&e.path());
1800            cands.push(Candidato { que: format!("resto de un pull a medias ({n})"),
1801                                   ruta: e.path(), bytes, cota });
1802        }
1803    }
1804
1805    // 2. Imágenes que ninguna instancia usa. Se pueden volver a traer por digest, así que borrarlas
1806    //    no pierde nada que no se pueda recuperar: es exactamente la propiedad que da el pin.
1807    let usadas: std::collections::BTreeSet<String> = std::fs::read_dir(instances_dir(root))
1808        .into_iter().flatten().flatten()
1809        .filter_map(|e| {
1810            let body = std::fs::read_to_string(e.path().join(INSTANCE_MANIFEST)).ok()?;
1811            let i: Instance = toml::from_str(&body).ok()?;
1812            Some(i.base.strip_prefix("sha256:").unwrap_or(&i.base).to_string())
1813        })
1814        .collect();
1815    if let Ok(rd) = std::fs::read_dir(&images) {
1816        for e in rd.flatten() {
1817            let n = e.file_name().to_string_lossy().into_owned();
1818            if n.starts_with('.') || usadas.contains(&n) {
1819                continue;
1820            }
1821            let m: Option<ImageManifest> = std::fs::read_to_string(e.path().join(IMAGE_MANIFEST))
1822                .ok().and_then(|s| toml::from_str(&s).ok());
1823            // El tamaño ya está en el manifiesto (lo midió `pull` sobre el árbol recién
1824            // desempacado, cuando todavía era legible entero): mejor dato y gratis.
1825            let (bytes, cota) = match &m {
1826                Some(m) if m.tree_bytes > 0 => (m.tree_bytes, false),
1827                _ => medir(&e.path()),
1828            };
1829            let etiqueta = m.and_then(|m| m.label).unwrap_or_else(|| n[..12].to_string());
1830            cands.push(Candidato { que: format!("imagen sin instancia ({etiqueta})"),
1831                                   ruta: e.path(), bytes, cota });
1832        }
1833    }
1834
1835    // 3. Capas mutables. Sólo si se piden: son caché (D3), pero lo que instalaste a mano y no
1836    //    declaraste en `packages` no vuelve solo.
1837    if con_upper {
1838        for e in std::fs::read_dir(instances_dir(root)).into_iter().flatten().flatten() {
1839            let id = e.file_name().to_string_lossy().into_owned();
1840            let up = e.path().join("upper");
1841            if !up.exists() {
1842                continue;
1843            }
1844            let (bytes, cota) = medir(&up);
1845            cands.push(Candidato { que: format!("capa mutable de {id}"), ruta: up, bytes, cota });
1846        }
1847    }
1848
1849    if cands.is_empty() {
1850        println!("nada que podar en {}", root.display());
1851        return Ok(());
1852    }
1853    let total: u64 = cands.iter().map(|c| c.bytes).sum();
1854    let alguna_cota = cands.iter().any(|c| c.cota);
1855    for c in &cands {
1856        println!("  {}{:<9} {}", if c.cota { "≥" } else { " " }, human(c.bytes), c.que);
1857    }
1858    println!("{}{} en {} elemento(s)", if alguna_cota { "≥ " } else { "" }, human(total), cands.len());
1859
1860    if !de_verdad {
1861        println!("\nsimulacro: no se borró nada. Repetí con --yes.");
1862        if !con_upper {
1863            println!("(`--upper` añade las capas mutables, que por D3 siempre se pueden tirar)");
1864        }
1865        return Ok(());
1866    }
1867
1868    let (mut ok, mut fallos) = (0u64, 0u64);
1869    for c in &cands {
1870        // `force_remove_dir_all` sabe abrir los directorios de sólo-lectura de un rootfs y, si el
1871        // árbol tiene ficheros de los subuid, entrar al userns mapeado para borrarlos.
1872        let r = if c.ruta.is_dir() { force_remove_dir_all(&c.ruta) } else { std::fs::remove_file(&c.ruta) };
1873        // La cicatriz de store-gc: no basta con que la llamada devuelva Ok — se COMPRUEBA que se fue.
1874        match (r, c.ruta.exists()) {
1875            (Ok(()), false) => { ok += 1; }
1876            (Ok(()), true) => { fallos += 1; eprintln!("  !! {} dijo que sí y SIGUE AHÍ", c.ruta.display()); }
1877            (Err(e), _) => { fallos += 1; eprintln!("  !! {}: {e}", c.ruta.display()); }
1878        }
1879    }
1880    // La capa mutable es parte de la instancia: si se tiró, hay que dejarla utilizable.
1881    if con_upper {
1882        for e in std::fs::read_dir(instances_dir(root)).into_iter().flatten().flatten() {
1883            let _ = std::fs::create_dir_all(e.path().join("upper"));
1884            let _ = force_remove_dir_all(&e.path().join("work"));
1885            let _ = std::fs::create_dir_all(e.path().join("work"));
1886        }
1887    }
1888    println!("podado: {ok} de {} elemento(s)", cands.len());
1889    if fallos > 0 {
1890        bail!("{fallos} no se pudieron borrar — el número de arriba NO es lo que se liberó");
1891    }
1892    Ok(())
1893}
1894
1895/// Tamaño de un árbol y si la cifra es cota inferior (hubo directorios ilegibles).
1896fn medir(p: &Path) -> (u64, bool) {
1897    if p.is_file() {
1898        return (std::fs::metadata(p).map(|m| m.len()).unwrap_or(0), false);
1899    }
1900    match walk_tree_ciegos(p) {
1901        Ok((_, bytes, ciegos)) => (bytes, ciegos > 0),
1902        Err(_) => (0, true),
1903    }
1904}
1905
1906// ── export: la capa de transparencia (D5) ───────────────────────────────────────────────────────
1907//
1908// El objetivo es el poder de Bedrock Linux —apps de cualquier «stratum» disponibles en todos
1909// lados— SIN sus formas: nada de un FUSE global en el camino de cada `exec`, nada de arbitrar por
1910// heurística qué binario gana. Acá no hace falta, porque **el FHS de esta distro ya es una
1911// proyección**: `hammer hydrate` proyecta artefactos a un árbol, y extender la proyección a
1912// instancias es idiomático.
1913//
1914// Tres propiedades que un FUSE no da: **cero costo en runtime** (no hay proceso en el camino
1915// crítico), **inspeccionable** (`cat` al shim y ves qué hace) y **revocable** (se borran los shims
1916// y la instancia desaparece del sistema sin desmontar nada).
1917
1918/// Registro de lo escrito. Sin esto, `--remove` tendría que ADIVINAR qué ficheros son nuestros, y
1919/// borrar por patrón en el `~/.local/bin` de alguien es exactamente lo que no se hace.
1920const EXPORT_LEDGER: &str = "exported.json";
1921/// Marca dentro de cada fichero generado. Redundante con el registro a propósito: si alguien mueve
1922/// el shim, sigue diciendo de dónde salió.
1923const MARCA: &str = "hammer-qorpa";
1924
1925/// Claves del `.desktop` ajeno que se copian: TEXTO DE PRESENTACIÓN y nada más.
1926///
1927/// Lista blanca, no negra. `Exec`, `TryExec`, `Path`, `DBusActivatable` y `Actions` quedan fuera
1928/// **por definición**: son ejecución, y la ejecución la escribimos nosotros. Una lista negra
1929/// dejaría entrar la próxima clave ejecutable que invente el estándar.
1930const DESKTOP_OK: &[&str] = &[
1931    "Name", "GenericName", "Comment", "Keywords", "Icon", "Categories",
1932    "Terminal", "StartupWMClass", "StartupNotify", "MimeType", "NoDisplay", "Type",
1933];
1934
1935/// Busca un fichero en la vista MERGED de la instancia: primero la capa mutable, después la imagen.
1936/// Es el mismo orden que ve el proceso adentro; mirar sólo la imagen se perdería lo que instaló el
1937/// gestor de paquetes, que es justo lo que se quiere exportar.
1938fn resolver_en_instancia(dir: &Path, tree: &Path, rel: &str) -> Option<PathBuf> {
1939    let rel = rel.trim_start_matches('/');
1940    for base in [dir.join("upper"), tree.to_path_buf()] {
1941        let p = base.join(rel);
1942        if p.exists() {
1943            return Some(p);
1944        }
1945    }
1946    None
1947}
1948
1949fn buscar_desktop(dir: &Path, tree: &Path, nombre: &str) -> Option<PathBuf> {
1950    let nombre = if nombre.ends_with(".desktop") { nombre.to_string() } else { format!("{nombre}.desktop") };
1951    for d in ["usr/share/applications", "usr/local/share/applications", "share/applications"] {
1952        if let Some(p) = resolver_en_instancia(dir, tree, &format!("{d}/{nombre}")) {
1953            return Some(p);
1954        }
1955    }
1956    None
1957}
1958
1959/// El icono referenciado por un `.desktop` puede ser una ruta o un NOMBRE de tema. Si es nombre, se
1960/// busca en los sitios estándar de la imagen y se copia al host: un `.desktop` con un icono que el
1961/// host no puede resolver se ve como un cuadrito gris, que es peor que no exportarlo.
1962fn copiar_icono(dir: &Path, tree: &Path, icono: &str, destino_base: &Path) -> Option<(String, PathBuf)> {
1963    if icono.starts_with('/') {
1964        let src = resolver_en_instancia(dir, tree, icono)?;
1965        let ext = src.extension().and_then(|e| e.to_str()).unwrap_or("png").to_string();
1966        let nombre = format!("{MARCA}-{}", src.file_name()?.to_string_lossy());
1967        let dst = destino_base.join(&nombre);
1968        std::fs::create_dir_all(destino_base).ok()?;
1969        std::fs::copy(&src, &dst).ok()?;
1970        let _ = ext;
1971        return Some((dst.display().to_string(), dst));
1972    }
1973    for tamano in ["scalable", "256x256", "128x128", "64x64", "48x48"] {
1974        for ext in ["svg", "png"] {
1975            let rel = format!("usr/share/icons/hicolor/{tamano}/apps/{icono}.{ext}");
1976            if let Some(src) = resolver_en_instancia(dir, tree, &rel) {
1977                let dst = destino_base.join(format!("{MARCA}-{icono}.{ext}"));
1978                std::fs::create_dir_all(destino_base).ok()?;
1979                if std::fs::copy(&src, &dst).is_ok() {
1980                    return Some((dst.display().to_string(), dst));
1981                }
1982            }
1983        }
1984    }
1985    for rel in [format!("usr/share/pixmaps/{icono}.png"), format!("usr/share/pixmaps/{icono}.svg")] {
1986        if let Some(src) = resolver_en_instancia(dir, tree, &rel) {
1987            let ext = if rel.ends_with("svg") { "svg" } else { "png" };
1988            let dst = destino_base.join(format!("{MARCA}-{icono}.{ext}"));
1989            std::fs::create_dir_all(destino_base).ok()?;
1990            if std::fs::copy(&src, &dst).is_ok() {
1991                return Some((dst.display().to_string(), dst));
1992            }
1993        }
1994    }
1995    None
1996}
1997
1998fn export(root: &Path, id: &str, into: Option<&Path>, remove: bool) -> Result<()> {
1999    let (dir, inst) = read_instance(root, id)?;
2000    let ledger_path = dir.join(EXPORT_LEDGER);
2001
2002    if remove {
2003        let previos: Vec<String> = std::fs::read_to_string(&ledger_path)
2004            .ok()
2005            .and_then(|s| serde_json::from_str(&s).ok())
2006            .unwrap_or_default();
2007        if previos.is_empty() {
2008            println!("la instancia {id} no tiene nada exportado");
2009            return Ok(());
2010        }
2011        for f in &previos {
2012            match std::fs::remove_file(f) {
2013                Ok(()) => println!("  borrado {f}"),
2014                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
2015                Err(e) => eprintln!("  no pude borrar {f}: {e}"),
2016            }
2017        }
2018        let _ = std::fs::remove_file(&ledger_path);
2019        println!("{} fichero(s) revocados — la instancia sigue existiendo, ya no se ve", previos.len());
2020        return Ok(());
2021    }
2022
2023    if inst.export.binaries.is_empty() && inst.export.apps.is_empty() {
2024        bail!(
2025            "la instancia {id} no declara nada en `[export]`. Se exporta lo DECLARADO: exportar \
2026             todo haría que el `ls` de la imagen compita con el nuestro, que es la falla de Bedrock."
2027        );
2028    }
2029    let (_sha, tree) = resolve_image(root, &inst.base)?;
2030
2031    let prefijo = into.map(PathBuf::from).unwrap_or_else(|| {
2032        PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into())).join(".local")
2033    });
2034    let bin_dir = prefijo.join("bin");
2035    let app_dir = prefijo.join("share/applications");
2036    let icon_dir = prefijo.join("share/icons/qorpa");
2037    std::fs::create_dir_all(&bin_dir)?;
2038
2039    // La ruta ABSOLUTA de este mismo binario: un shim que dependiera del `PATH` del usuario haría
2040    // que la app arranque o no según quién la lance.
2041    let hammer = std::env::current_exe().context("no sé cuál es mi propia ruta")?;
2042    let raiz_flag = if root == Path::new(DEFAULT_ROOT) {
2043        String::new()
2044    } else {
2045        format!(" --root {}", shell_quote(&root.display().to_string()))
2046    };
2047
2048    let mut escritos: Vec<String> = Vec::new();
2049
2050    for b in &inst.export.binaries {
2051        let dst = bin_dir.join(b);
2052        let cuerpo = format!(
2053            "#!/bin/sh\n\
2054             # GENERADO por `hammer qorpa export {id}` — {MARCA}. No editar: se reescribe.\n\
2055             # Borralo (o corré `hammer qorpa export {id} --remove`) y la instancia deja de verse.\n\
2056             exec {hammer} qorpa run{raiz_flag} {id} -- {b} \"$@\"\n",
2057            hammer = shell_quote(&hammer.display().to_string()),
2058            b = shell_quote(b),
2059        );
2060        std::fs::write(&dst, cuerpo)?;
2061        std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755))?;
2062        escritos.push(dst.display().to_string());
2063        println!("  bin      {}", dst.display());
2064    }
2065
2066    for a in &inst.export.apps {
2067        let src = buscar_desktop(&dir, &tree, a)
2068            .with_context(|| format!("no encuentro {a} dentro de la instancia {id}"))?;
2069        let texto = std::fs::read_to_string(&src)?;
2070        let (campos, exec_original) = leer_desktop(&texto);
2071
2072        let nombre_bin = exec_original
2073            .split_whitespace()
2074            .next()
2075            .map(|c| c.rsplit('/').next().unwrap_or(c).to_string())
2076            .unwrap_or_else(|| a.trim_end_matches(".desktop").to_string());
2077
2078        let mut salida = String::from("[Desktop Entry]\n");
2079        salida.push_str(&format!(
2080            "# GENERADO por `hammer qorpa export {id}` — {MARCA}. El Exec de abajo es NUESTRO:\n\
2081             # el original decía `{exec_original}` y no se copia (D5).\n"
2082        ));
2083        let mut icono_final = None;
2084        for (k, v) in &campos {
2085            if k.starts_with("Icon") {
2086                if let Some((ruta, dst)) = copiar_icono(&dir, &tree, v, &icon_dir) {
2087                    escritos.push(dst.display().to_string());
2088                    icono_final = Some(ruta);
2089                    continue; // se escribe abajo, ya resuelto a ruta del host
2090                }
2091            }
2092            salida.push_str(&format!("{k}={v}\n"));
2093        }
2094        if let Some(i) = icono_final {
2095            salida.push_str(&format!("Icon={i}\n"));
2096        }
2097        if !campos.iter().any(|(k, _)| k == "Type") {
2098            salida.push_str("Type=Application\n");
2099        }
2100        salida.push_str(&format!(
2101            "Exec={} qorpa run{raiz_flag} {id} -- {} %U\n",
2102            shell_quote(&hammer.display().to_string()),
2103            shell_quote(&nombre_bin),
2104        ));
2105        salida.push_str(&format!("X-Qorpa-Instance={id}\n"));
2106
2107        std::fs::create_dir_all(&app_dir)?;
2108        let dst = app_dir.join(src.file_name().unwrap());
2109        std::fs::write(&dst, salida)?;
2110        escritos.push(dst.display().to_string());
2111        println!("  desktop  {}", dst.display());
2112    }
2113
2114    std::fs::write(&ledger_path, serde_json::to_string_pretty(&escritos)?)?;
2115    println!(
2116        "{} fichero(s) generados desde {} — inspeccionables con `cat`, revocables con --remove",
2117        escritos.len(), src_label(&dir)
2118    );
2119    if !bin_dir.display().to_string().is_empty() {
2120        println!("  ⚠ que `{}` esté en tu PATH es cosa tuya", bin_dir.display());
2121    }
2122    Ok(())
2123}
2124
2125/// Busca el home del uid en el `/etc/passwd` de la instancia (vista merged). Cae a `/root` para el
2126/// 0 y a `/home/qorpa` si no hay entrada — nunca a nada, porque un HOME vacío rompe más que uno
2127/// inventado.
2128fn home_de(dir: &Path, tree: &Path, uid: u32) -> String {
2129    if let Some(p) = resolver_en_instancia(dir, tree, "etc/passwd") {
2130        if let Ok(txt) = std::fs::read_to_string(p) {
2131            for l in txt.lines() {
2132                let c: Vec<&str> = l.split(':').collect();
2133                if c.len() >= 6 && c[2].parse::<u32>() == Ok(uid) && !c[5].is_empty() {
2134                    return c[5].to_string();
2135                }
2136            }
2137        }
2138    }
2139    if uid == 0 { "/root".into() } else { "/home/qorpa".into() }
2140}
2141
2142/// (uid, gid, home) de un usuario del `/etc/passwd` de la instancia. Acepta nombre o uid.
2143fn usuario_de(dir: &Path, tree: &Path, quien: &str) -> Result<(u32, u32, String)> {
2144    let p = resolver_en_instancia(dir, tree, "etc/passwd")
2145        .context("la instancia no tiene /etc/passwd")?;
2146    for l in std::fs::read_to_string(p)?.lines() {
2147        let c: Vec<&str> = l.split(':').collect();
2148        if c.len() < 6 {
2149            continue;
2150        }
2151        if c[0] == quien || c[2] == quien {
2152            return Ok((c[2].parse()?, c[3].parse()?,
2153                       if c[5].is_empty() { "/".into() } else { c[5].to_string() }));
2154        }
2155    }
2156    bail!("no encuentro al usuario {quien:?}")
2157}
2158
2159fn src_label(dir: &Path) -> String {
2160    dir.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string())
2161}
2162
2163/// Parsea el `[Desktop Entry]` de un `.desktop` y devuelve (campos permitidos, el `Exec` original).
2164/// El `Exec` se devuelve SÓLO para nombrar el binario y para dejar dicho en el fichero generado qué
2165/// decía el ajeno — nunca se emite.
2166fn leer_desktop(texto: &str) -> (Vec<(String, String)>, String) {
2167    let mut campos = Vec::new();
2168    let mut exec = String::new();
2169    let mut dentro = false;
2170    for l in texto.lines() {
2171        let l = l.trim();
2172        if l.starts_with('[') {
2173            dentro = l == "[Desktop Entry]";
2174            continue;
2175        }
2176        if !dentro || l.is_empty() || l.starts_with('#') {
2177            continue;
2178        }
2179        let Some((k, v)) = l.split_once('=') else { continue };
2180        let (k, v) = (k.trim(), v.trim());
2181        if k == "Exec" && exec.is_empty() {
2182            exec = v.to_string();
2183            continue;
2184        }
2185        // La clave puede venir localizada (`Name[es]`): se compara la parte antes del corchete.
2186        let base = k.split('[').next().unwrap_or(k);
2187        if DESKTOP_OK.contains(&base) {
2188            campos.push((k.to_string(), v.to_string()));
2189        }
2190    }
2191    (campos, exec)
2192}
2193
2194/// Comillas para `sh`. Un shim se lee y se ejecuta: una ruta con espacios no puede romperlo.
2195fn shell_quote(s: &str) -> String {
2196    if !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"@%+=:,./-_".contains(&b)) {
2197        s.to_string()
2198    } else {
2199        format!("'{}'", s.replace('\'', r"'\''"))
2200    }
2201}
2202
2203// ── list ────────────────────────────────────────────────────────────────────────────────────────
2204
2205fn list(root: &Path, json: bool) -> Result<()> {
2206    let images = root.join("images");
2207    let mut rows: Vec<(String, Option<ImageManifest>, TreeState)> = Vec::new();
2208    if let Ok(rd) = std::fs::read_dir(&images) {
2209        for e in rd.flatten() {
2210            let name = e.file_name().to_string_lossy().into_owned();
2211            if name.starts_with('.') {
2212                continue; // restos de un pull a medias
2213            }
2214            let m = std::fs::read_to_string(e.path().join(IMAGE_MANIFEST))
2215                .ok()
2216                .and_then(|s| toml::from_str::<ImageManifest>(&s).ok());
2217            rows.push((name, m, tree_state(&e.path().join(TREE))));
2218        }
2219    }
2220    rows.sort_by(|a, b| a.0.cmp(&b.0));
2221
2222    let insts: Vec<(String, Instance)> = std::fs::read_dir(instances_dir(root))
2223        .into_iter()
2224        .flatten()
2225        .flatten()
2226        .filter_map(|e| {
2227            let id = e.file_name().to_string_lossy().into_owned();
2228            let body = std::fs::read_to_string(e.path().join(INSTANCE_MANIFEST)).ok()?;
2229            Some((id, toml::from_str::<Instance>(&body).ok()?))
2230        })
2231        .collect();
2232
2233    if json {
2234        let out: Vec<_> = rows.iter().map(|(sha, m, st)| {
2235            serde_json::json!({
2236                "sha256": sha,
2237                "label": m.as_ref().and_then(|m| m.label.clone()),
2238                "url": m.as_ref().map(|m| m.url.clone()),
2239                "tree_files": m.as_ref().map(|m| m.tree_files).unwrap_or(0),
2240                "tree_bytes": m.as_ref().map(|m| m.tree_bytes).unwrap_or(0),
2241                "empty": !matches!(st, TreeState::Populated),
2242            })
2243        }).collect();
2244        let insts_json: Vec<_> = insts.iter().map(|(id, i)| serde_json::json!({
2245            "id": id, "base": i.base, "distro": i.distro,
2246            "packages": i.packages, "network": i.grants.network,
2247            "sockets": i.grants.sockets, "devices": i.grants.devices,
2248        })).collect();
2249        println!("{}", serde_json::to_string_pretty(
2250            &serde_json::json!({"images": out, "instances": insts_json}))?);
2251        return Ok(());
2252    }
2253
2254    if rows.is_empty() && insts.is_empty() {
2255        println!("no hay imágenes en {} — `hammer qorpa pull <url> --sha256 <sha>`", images.display());
2256        return Ok(());
2257    }
2258    let mut vacias = 0;
2259    for (sha, m, st) in &rows {
2260        let etiqueta = m.as_ref().and_then(|m| m.label.clone()).unwrap_or_else(|| "—".into());
2261        match st {
2262            TreeState::Populated => println!(
2263                "  {}  {:<32} {:>7} ficheros  {}",
2264                &sha[..12],
2265                etiqueta,
2266                m.as_ref().map(|m| m.tree_files).unwrap_or(0),
2267                human(m.as_ref().map(|m| m.tree_bytes).unwrap_or(0)),
2268            ),
2269            _ => {
2270                vacias += 1;
2271                println!("  {}  {:<32} !! VACÍA — no es una imagen, es un nombre", &sha[..12], etiqueta);
2272            }
2273        }
2274    }
2275    if !insts.is_empty() {
2276        println!("\ninstancias:");
2277        for (id, i) in &insts {
2278            let sha = i.base.strip_prefix("sha256:").unwrap_or(&i.base);
2279            // Se enumeran las concesiones porque una instancia sin política declarada y una con la
2280            // pantalla abierta se ven IGUAL desde fuera, y no son lo mismo (ADR §NO-resuelve 1).
2281            let mut abre: Vec<String> = Vec::new();
2282            if i.grants.network { abre.push("red".into()); }
2283            abre.extend(i.grants.sockets.iter().cloned());
2284            abre.extend(i.grants.devices.iter().cloned());
2285            if !i.grants.dirs.is_empty() { abre.push(format!("{} dir(s)", i.grants.dirs.len())); }
2286            println!("  {:<16} sobre {}  abre: {}", id, &sha[..sha.len().min(12)],
2287                if abre.is_empty() { "nada".into() } else { abre.join(", ") });
2288        }
2289    }
2290    println!("\n{} imagen(es) en {}", rows.len(), images.display());
2291    if vacias > 0 {
2292        bail!("{vacias} imagen(es) VACÍA(s): un ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien");
2293    }
2294    Ok(())
2295}
2296
2297fn human(b: u64) -> String {
2298    const U: [&str; 5] = ["B", "K", "M", "G", "T"];
2299    let (mut v, mut i) = (b as f64, 0);
2300    while v >= 1024.0 && i < 4 { v /= 1024.0; i += 1; }
2301    if i == 0 { format!("{b} B") } else { format!("{v:.1} {}", U[i]) }
2302}
2303
2304#[cfg(test)]
2305mod tests {
2306    use super::*;
2307
2308    fn tar_gz(dir: &Path, dst: &Path, contenido: &[(&str, &str)]) {
2309        for (p, c) in contenido {
2310            let f = dir.join(p);
2311            std::fs::create_dir_all(f.parent().unwrap()).unwrap();
2312            std::fs::write(f, c).unwrap();
2313        }
2314        let st = Command::new("tar").args(["-czf"]).arg(dst).arg("-C").arg(dir)
2315            .args(contenido.iter().map(|(p, _)| p.split('/').next().unwrap()).collect::<std::collections::BTreeSet<_>>())
2316            .status().unwrap();
2317        assert!(st.success());
2318    }
2319
2320    fn sha_of(p: &Path) -> String {
2321        use sha2::{Digest, Sha256};
2322        let mut h = Sha256::new();
2323        std::io::copy(&mut std::fs::File::open(p).unwrap(), &mut h).unwrap();
2324        hex::encode(h.finalize())
2325    }
2326
2327    #[test]
2328    fn pull_verifica_desempaca_y_es_idempotente() {
2329        let d = tempfile::tempdir().unwrap();
2330        let src = d.path().join("src");
2331        std::fs::create_dir_all(&src).unwrap();
2332        let arch = d.path().join("img.tar.gz");
2333        tar_gz(&src, &arch, &[("etc/os-release", "ID=ajena\n"), ("bin/sh", "#!/bin/sh\n")]);
2334        let sha = sha_of(&arch);
2335        let root = d.path().join("qorpa");
2336        let url = format!("file://{}", arch.display());
2337
2338        pull(&root, &url, &sha, Some("prueba"), false, None).unwrap();
2339        let tree = root.join("images").join(&sha).join(TREE);
2340        assert_eq!(std::fs::read_to_string(tree.join("etc/os-release")).unwrap(), "ID=ajena\n");
2341        // Segundo pull: no vuelve a bajar ni rompe.
2342        pull(&root, &url, &sha, Some("prueba"), false, None).unwrap();
2343        assert!(root.join("images").join(&sha).join(IMAGE_MANIFEST).exists());
2344    }
2345
2346    #[test]
2347    fn pull_aborta_si_el_contenido_no_es_el_que_dice_ser() {
2348        let d = tempfile::tempdir().unwrap();
2349        let src = d.path().join("src");
2350        std::fs::create_dir_all(&src).unwrap();
2351        let arch = d.path().join("img.tar.gz");
2352        tar_gz(&src, &arch, &[("etc/hostname", "ajena\n")]);
2353        let root = d.path().join("qorpa");
2354        let mentira = "0".repeat(64);
2355        let err = pull(&root, &format!("file://{}", arch.display()), &mentira, None, false, None)
2356            .unwrap_err().to_string();
2357        assert!(err.contains("sha256 mismatch"), "mensaje inesperado: {err}");
2358        // Y no dejó nada a medias: contenido distinto ⇒ ABORTAR, no "avisar y seguir".
2359        assert!(!root.join("images").join(&mentira).exists());
2360    }
2361
2362    /// La cicatriz de Arch: el rootfs viene en `root.x86_64/` CON HERMANOS al lado (`version`,
2363    /// `pkglist`). La regla vieja ("un solo directorio arriba") no disparaba y dejaba el rootfs un
2364    /// nivel abajo, en verde. Se ancla por estructura.
2365    #[test]
2366    fn el_rootfs_anidado_se_eleva_aunque_tenga_hermanos() {
2367        let d = tempfile::tempdir().unwrap();
2368        let src = d.path().join("src");
2369        std::fs::create_dir_all(&src).unwrap();
2370        let arch = d.path().join("boot.tar.gz");
2371        tar_gz(&src, &arch, &[
2372            ("root.x86_64/etc/os-release", "ID=arch\n"),
2373            ("root.x86_64/usr/lib/keep", ""),
2374            ("version", "2026.09.01\n"),
2375        ]);
2376        let sha = sha_of(&arch);
2377        let root = d.path().join("qorpa");
2378        pull(&root, &format!("file://{}", arch.display()), &sha, None, false, None).unwrap();
2379        let tree = root.join("images").join(&sha).join(TREE);
2380        assert!(tree.join("etc/os-release").exists(), "no ancló el rootfs anidado");
2381        assert!(!tree.join("root.x86_64").exists());
2382        assert!(!tree.join("version").exists(), "el hermano no debía entrar al rootfs");
2383        let m: ImageManifest = toml::from_str(
2384            &std::fs::read_to_string(root.join("images").join(&sha).join(IMAGE_MANIFEST)).unwrap(),
2385        ).unwrap();
2386        assert_eq!(m.rootfs_subdir.as_deref(), Some("root.x86_64"));
2387        assert_eq!(m.dropped_siblings, vec!["version".to_string()]);
2388    }
2389
2390    #[test]
2391    fn un_arbol_vacio_no_pasa_por_imagen() {
2392        let d = tempfile::tempdir().unwrap();
2393        let vacio = d.path().join("vacio");
2394        std::fs::create_dir_all(&vacio).unwrap();
2395        let arch = d.path().join("nada.tar.gz");
2396        assert!(Command::new("tar").args(["-czf"]).arg(&arch).arg("-C").arg(&vacio)
2397            .args(["--files-from", "/dev/null"]).status().unwrap().success());
2398        let sha = sha_of(&arch);
2399        let root = d.path().join("qorpa");
2400        let err = pull(&root, &format!("file://{}", arch.display()), &sha, None, false, None)
2401            .unwrap_err().to_string();
2402        assert!(err.contains("VACÍO") || err.contains("no encuentro un rootfs"),
2403                "mensaje inesperado: {err}");
2404        assert!(!root.join("images").join(&sha).exists());
2405    }
2406
2407    /// Una imagen mínima que pasa por rootfs: `etc/` + `bin/`.
2408    fn imagen_falsa(root: &Path, sha: &str) {
2409        let t = root.join("images").join(sha).join(TREE);
2410        std::fs::create_dir_all(t.join("etc")).unwrap();
2411        std::fs::create_dir_all(t.join("bin")).unwrap();
2412        std::fs::write(t.join("etc/os-release"), "ID=falsa\n").unwrap();
2413    }
2414
2415    #[test]
2416    fn create_nace_sin_ninguna_concesion() {
2417        let d = tempfile::tempdir().unwrap();
2418        let root = d.path();
2419        let sha = "a".repeat(64);
2420        imagen_falsa(root, &sha);
2421        create(root, "prueba", "aaaa", Some("falsa")).unwrap();
2422        let (_, inst) = read_instance(root, "prueba").unwrap();
2423        assert_eq!(inst.base, format!("sha256:{sha}"));
2424        // D7: la política se autora, y arranca VACÍA.
2425        assert!(!inst.grants.network);
2426        assert!(inst.grants.sockets.is_empty() && inst.grants.devices.is_empty());
2427        // Y no se pisa a sí misma.
2428        assert!(create(root, "prueba", "aaaa", None).is_err());
2429    }
2430
2431    #[test]
2432    fn create_rechaza_una_imagen_vacia() {
2433        let d = tempfile::tempdir().unwrap();
2434        let root = d.path();
2435        let sha = "b".repeat(64);
2436        std::fs::create_dir_all(root.join("images").join(&sha).join(TREE)).unwrap();
2437        let err = create(root, "x", &sha, None).unwrap_err().to_string();
2438        assert!(err.contains("VACÍA"), "mensaje inesperado: {err}");
2439    }
2440
2441    #[test]
2442    fn recreate_tira_la_capa_mutable_y_conserva_el_manifiesto() {
2443        let d = tempfile::tempdir().unwrap();
2444        let root = d.path();
2445        let sha = "c".repeat(64);
2446        imagen_falsa(root, &sha);
2447        create(root, "i", &sha, None).unwrap();
2448        let upper = instance_dir(root, "i").join("upper");
2449        std::fs::write(upper.join("basura"), "x").unwrap();
2450        recreate(root, "i", true).unwrap();
2451        assert!(!upper.join("basura").exists(), "el upper no es caché si sobrevive");
2452        assert!(instance_dir(root, "i").join(INSTANCE_MANIFEST).exists(), "el manifiesto ES la verdad");
2453    }
2454
2455    /// El registro de lo provisionado vive FUERA del `upper`, así que `recreate` tiene que borrarlo
2456    /// a mano. Si sobreviviera, afirmaría que hay paquetes instalados sobre una capa recién
2457    /// vaciada: la forma más pura del error de la regla 3 —presencia declarada sin contenido—, y
2458    /// además haría que el siguiente `provision` no instalara nada.
2459    #[test]
2460    fn recreate_no_deja_vivo_el_registro_de_lo_provisionado() {
2461        let d = tempfile::tempdir().unwrap();
2462        let root = d.path();
2463        let sha = "e".repeat(64);
2464        imagen_falsa(root, &sha);
2465        create(root, "i", &sha, None).unwrap();
2466        let dir = instance_dir(root, "i");
2467        let (_, mut inst) = read_instance(root, "i").unwrap();
2468        inst.packages = vec!["strace".into()];
2469        write_instance(&dir, &inst).unwrap();
2470        std::fs::write(
2471            dir.join(PROVISION_LEDGER),
2472            toml::to_string(&Provisionado {
2473                manager: "apt".into(), packages: vec!["strace".into()], at_unix: 1,
2474            }).unwrap(),
2475        ).unwrap();
2476
2477        recreate(root, "i", true).unwrap();
2478        assert!(!dir.join(PROVISION_LEDGER).exists(), "el registro sobrevivió a la capa que describía");
2479    }
2480
2481    /// El gestor se DETECTA de la vista merged, no se configura: una imagen trae el suyo. Y el
2482    /// `upper` gana, porque el gestor puede haberse instalado después.
2483    #[test]
2484    fn el_gestor_sale_de_la_imagen_y_el_upper_manda() {
2485        let d = tempfile::tempdir().unwrap();
2486        let dir = d.path().join("inst");
2487        let tree = d.path().join("tree");
2488        std::fs::create_dir_all(dir.join("upper/usr/bin")).unwrap();
2489        std::fs::create_dir_all(tree.join("usr/bin")).unwrap();
2490
2491        std::fs::write(tree.join("usr/bin/apt-get"), "").unwrap();
2492        let g = gestor_de(&dir, &tree, &["strace".into()]).unwrap();
2493        assert_eq!(g.nombre, "apt");
2494        assert!(g.guion.contains("--no-install-recommends"));
2495
2496        std::fs::write(tree.join("usr/bin/dnf"), "").unwrap();
2497        assert_eq!(gestor_de(&dir, &tree, &["strace".into()]).unwrap().nombre, "apt",
2498                   "con dos, manda el orden declarado, no el azar del directorio");
2499
2500        // Sin ninguno: falla diciendo qué buscó, en vez de correr un comando que no existe.
2501        let vacio = d.path().join("pelada");
2502        std::fs::create_dir_all(&vacio).unwrap();
2503        let err = gestor_de(&vacio, &vacio, &["x".into()]).unwrap_err().to_string();
2504        assert!(err.contains("pacman") && err.contains("apk"), "mensaje inesperado: {err}");
2505    }
2506
2507    /// El nombre de paquete sale del manifiesto, que lo escribe una persona. Se comilla SIEMPRE, y
2508    /// además se rechaza lo que no parece un nombre: quotear evita la ejecución, pero un `;` en un
2509    /// nombre es un error de tipeo que conviene ver acá y no en la salida del gestor.
2510    #[test]
2511    fn un_nombre_de_paquete_raro_no_llega_al_guion() {
2512        let d = tempfile::tempdir().unwrap();
2513        let tree = d.path().join("tree");
2514        std::fs::create_dir_all(tree.join("usr/bin")).unwrap();
2515        std::fs::write(tree.join("usr/bin/apt-get"), "").unwrap();
2516
2517        for malo in ["strace; rm -rf /", "$(whoami)", "a b", "'", ""] {
2518            assert!(gestor_de(d.path(), &tree, &[malo.into()]).is_err(), "pasó {malo:?}");
2519        }
2520        assert_eq!(comillar("a'b"), r"'a'\''b'");
2521    }
2522
2523    #[test]
2524    fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() {
2525        let (a, w) = grants_to_args(&Grants::default(), "/run/user/0", None);
2526        assert!(a.contains(&"--unshare-net".to_string()));
2527        assert!(w.is_empty());
2528
2529        let g = Grants { network: true, ..Default::default() };
2530        let (a, _) = grants_to_args(&g, "/run/user/0", None);
2531        assert!(!a.contains(&"--unshare-net".to_string()));
2532        // El segundo fallo clásico de la primera corrida, según el ADR.
2533        assert!(a.iter().any(|x| x == "/etc/resolv.conf"));
2534    }
2535
2536    #[test]
2537    fn el_socket_de_wayland_avisa_que_es_un_borde_de_privilegio() {
2538        let g = Grants { sockets: vec!["wayland".into()], ..Default::default() };
2539        let (_, w) = grants_to_args(&g, "/run/user/0", None);
2540        assert_eq!(w.len(), 1);
2541        assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]);
2542    }
2543
2544    #[test]
2545    fn sin_sellar_la_politica_no_finge_confinar_el_fs() {
2546        let d = tempfile::tempdir().unwrap();
2547        imagen_falsa(d.path(), "d".repeat(64).as_str());
2548        let tree = d.path().join("images").join("d".repeat(64)).join(TREE);
2549        let dst = d.path().join("policy");
2550        compile_policy(&Grants::default(), &tree, &dst).unwrap();
2551        let pol = std::fs::read_to_string(&dst).unwrap();
2552        // El namespace de montaje de bwrap YA es lista blanca. Escribir reglas `ro` que no cambian
2553        // nada sería un sello de goma, así que la política se queda en una línea honesta.
2554        assert!(pol.lines().any(|l| l == "rw /"), "{pol}");
2555        assert!(!pol.contains("\nro "), "no debería haber reglas decorativas:\n{pol}");
2556    }
2557
2558    #[test]
2559    fn sellar_congela_la_imagen_y_deja_mutable_lo_de_la_instancia() {
2560        let d = tempfile::tempdir().unwrap();
2561        let sha = "e".repeat(64);
2562        imagen_falsa(d.path(), &sha);
2563        let tree = d.path().join("images").join(&sha).join(TREE);
2564        std::fs::create_dir_all(tree.join("var")).unwrap();
2565        std::fs::create_dir_all(tree.join("usr")).unwrap();
2566        let dst = d.path().join("policy");
2567        compile_policy(&Grants { seal_image: true, ..Default::default() }, &tree, &dst).unwrap();
2568        let pol = std::fs::read_to_string(&dst).unwrap();
2569        assert!(pol.lines().any(|l| l == "ro /usr"), "{pol}");
2570        assert!(pol.lines().any(|l| l == "rw /etc"), "{pol}");
2571        assert!(pol.lines().any(|l| l == "rw /var"), "{pol}");
2572        assert!(pol.lines().any(|l| l == "list /"), "{pol}");
2573        // Si `rw /` sobreviviera, taparía todas las `ro`: Landlock une derechos por ancestro.
2574        assert!(!pol.lines().any(|l| l == "rw /"), "el `rw /` anula el sellado:\n{pol}");
2575        // Sólo se nombra lo que EXISTE: harkaq-exec falla duro ante un path inexistente.
2576        assert!(!pol.contains("/boot"), "{pol}");
2577    }
2578
2579    #[test]
2580    fn root_es_lo_unico_que_nace_encendido() {
2581        let g = Grants::default();
2582        assert!(g.root, "sin esto un gestor de paquetes no arranca");
2583        assert!(!g.network && !g.nesting && !g.seal_image);
2584        assert!(g.sockets.is_empty() && g.devices.is_empty() && g.dirs.is_empty());
2585    }
2586
2587    #[test]
2588    fn el_rango_de_subuid_se_lee_por_usuario() {
2589        let d = tempfile::tempdir().unwrap();
2590        let f = d.path().join("subuid");
2591        std::fs::write(&f, "otro:100000:65536\nsergio:165536:65536\n").unwrap();
2592        let r = leer_subid(f.to_str().unwrap(), "sergio").unwrap();
2593        assert_eq!((r.inicio, r.cantidad), (165536, 65536));
2594        assert!(leer_subid(f.to_str().unwrap(), "nadie").is_none());
2595    }
2596
2597    #[test]
2598    fn las_caps_de_root_no_incluyen_la_de_montar() {
2599        // CAP_SYS_ADMIN es la que permite `mount`, y montar cuelga de la concesión `nesting`
2600        // declarada. Si se colara acá, `nesting` dejaría de ser una decisión y pasaría a ser el
2601        // comportamiento por defecto — que es justo lo que D7 prohíbe.
2602        assert!(!CAPS_ROOT.contains(&"CAP_SYS_ADMIN"), "{CAPS_ROOT:?}");
2603        // Y las que sí están son las que `apt` y `dpkg` necesitaron de verdad.
2604        for c in ["CAP_SETUID", "CAP_SETGID", "CAP_CHOWN"] {
2605            assert!(CAPS_ROOT.contains(&c), "falta {c}");
2606        }
2607    }
2608
2609    #[test]
2610    fn del_desktop_ajeno_no_sobrevive_nada_ejecutable() {
2611        let (campos, exec) = leer_desktop(
2612            "[Desktop Entry]\nName=Pinentry\nName[es]=Pinentry\nExec=/usr/bin/pinentry-qt --raro\n\
2613             TryExec=/usr/bin/pinentry-qt\nDBusActivatable=true\nPath=/tmp\nIcon=pinentry\n\
2614             X-KDE-Wayland-Interfaces=org_kde_kwin_keystate\n[Desktop Action Otra]\nExec=/mal\n",
2615        );
2616        let claves: Vec<&str> = campos.iter().map(|(k, _)| k.as_str()).collect();
2617        assert!(claves.contains(&"Name") && claves.contains(&"Name[es]") && claves.contains(&"Icon"));
2618        // Lista BLANCA: todo lo que ejecuta queda fuera por definición, no por enumeración.
2619        for prohibida in ["Exec", "TryExec", "Path", "DBusActivatable", "X-KDE-Wayland-Interfaces"] {
2620            assert!(!claves.contains(&prohibida), "se coló {prohibida}: {claves:?}");
2621        }
2622        // El Exec se lee sólo para nombrar el binario y citarlo; nunca se emite.
2623        assert_eq!(exec, "/usr/bin/pinentry-qt --raro");
2624    }
2625
2626    #[test]
2627    fn el_shim_no_se_rompe_con_rutas_raras() {
2628        assert_eq!(shell_quote("/usr/bin/pacman"), "/usr/bin/pacman");
2629        assert_eq!(shell_quote("/con espacio/x"), "'/con espacio/x'");
2630        assert_eq!(shell_quote("a'b"), "'a'\\''b'"); // la comilla se cierra, se escapa y se reabre
2631        assert_eq!(shell_quote("; rm -rf /"), "'; rm -rf /'");
2632    }
2633
2634    #[test]
2635    fn run_as_resuelve_uid_gid_y_home_del_passwd_de_la_imagen() {
2636        let d = tempfile::tempdir().unwrap();
2637        let sha = "f".repeat(64);
2638        imagen_falsa(d.path(), &sha);
2639        let tree = d.path().join("images").join(&sha).join(TREE);
2640        std::fs::write(tree.join("etc/passwd"),
2641            "root:x:0:0::/root:/bin/bash\njugador:x:1000:1000::/home/jugador:/bin/bash\n").unwrap();
2642        let dir = d.path().join("instances/x");
2643        std::fs::create_dir_all(&dir).unwrap();
2644
2645        assert_eq!(usuario_de(&dir, &tree, "jugador").unwrap(), (1000, 1000, "/home/jugador".into()));
2646        assert_eq!(usuario_de(&dir, &tree, "1000").unwrap().0, 1000);
2647        assert!(usuario_de(&dir, &tree, "nadie").is_err(), "un run_as inexistente NO puede caer a root");
2648        // El HOME sale del passwd, no de una constante.
2649        assert_eq!(home_de(&dir, &tree, 1000), "/home/jugador");
2650        assert_eq!(home_de(&dir, &tree, 0), "/root");
2651    }
2652
2653    #[test]
2654    fn la_poda_no_toca_una_imagen_con_instancia_y_si_los_restos() {
2655        let d = tempfile::tempdir().unwrap();
2656        let root = d.path();
2657        let usada = "1".repeat(64);
2658        let huerfana = "2".repeat(64);
2659        imagen_falsa(root, &usada);
2660        imagen_falsa(root, &huerfana);
2661        create(root, "viva", &usada, None).unwrap();
2662        // Un resto de pull a medias: lleva punto justo para no confundirse con una imagen.
2663        std::fs::create_dir_all(root.join("images/.3333.tmp")).unwrap();
2664
2665        // Simulacro: NO borra nada. Es la diferencia entre una poda y un accidente.
2666        prune(root, false, false).unwrap();
2667        assert!(root.join("images").join(&huerfana).exists(), "el simulacro borró");
2668
2669        prune(root, false, true).unwrap();
2670        assert!(root.join("images").join(&usada).exists(), "borró una imagen EN USO");
2671        assert!(!root.join("images").join(&huerfana).exists(), "no borró la huérfana");
2672        assert!(!root.join("images/.3333.tmp").exists(), "no borró el resto");
2673        // La instancia sigue entera: la poda no la toca sin --upper.
2674        assert!(instance_dir(root, "viva").join(INSTANCE_MANIFEST).exists());
2675        assert!(instance_dir(root, "viva").join("upper").exists());
2676    }
2677
2678    #[test]
2679    fn con_upper_la_capa_se_va_pero_la_instancia_queda_usable() {
2680        let d = tempfile::tempdir().unwrap();
2681        let root = d.path();
2682        let sha = "4".repeat(64);
2683        imagen_falsa(root, &sha);
2684        create(root, "i", &sha, None).unwrap();
2685        std::fs::write(instance_dir(root, "i").join("upper/estado"), "x").unwrap();
2686        prune(root, true, true).unwrap();
2687        assert!(!instance_dir(root, "i").join("upper/estado").exists(), "el upper no es caché");
2688        // Y queda utilizable: sin upper/ y work/ la instancia no vuelve a arrancar.
2689        assert!(instance_dir(root, "i").join("upper").is_dir());
2690        assert!(instance_dir(root, "i").join("work").is_dir());
2691    }
2692
2693    #[test]
2694    fn sha_mal_formado_falla_antes_de_tocar_la_red() {
2695        assert!(normalize_sha256("abc").is_err());
2696        assert_eq!(normalize_sha256(&format!("sha256:{}", "A".repeat(64))).unwrap(), "a".repeat(64));
2697    }
2698}