gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 188Rust 55Shell 30Markdown 281211 Sutichaykuna

⎇ 89da9c822fa58ce0bf48314d47a831a42067e239 crates/hammer-cli/src/qorpa.rs Rust · 1465 siq'ikuna · 66.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

qorpa: el mapeo por rango — subuid + --userns FD, y el impuesto se paga 89da9c822 · Sergio ·

1//! `hammer qorpa` — imágenes ajenas ([ADR 0015](../../../docs/adr/0015-imagenes-ajenas.md)).
2//!
3//! Superficie en inglés, mensajes en castellano (`CLAUDE.md` regla 4).
4//!
5//! ── LA INVARIANTE QUE ESTE MÓDULO PROTEGE ───────────────────────────────────────────────────────
6//! Una imagen ajena **no es un artefacto y no vive en el store** (ADR 0015 D1). El store promete que
7//! cada entrada se reconstruye desde fuente bit a bit, y un rootfs de Ubuntu no. Por eso todo lo de
8//! acá cuelga de un espacio de nombres paralelo (`/var/lib/hammer/qorpa/`), fuera de `hash_inputs`
9//! de todo, y nada de este fichero toca `Store`.
10//!
11//! ── EL ORDEN DE LAS OPERACIONES NO ES CASUAL ────────────────────────────────────────────────────
12//! Se verifica el sha256 **antes** de desempacar, nunca después. Un tar ajeno que no verificaste es
13//! código ajeno que ya escribió en tu disco: desempacar primero y comprobar luego deja el árbol
14//! sucio justo en el caso que importa. Y el veredicto sigue a ADR 0014: *ausencia ⇒ seguir;
15//! contenido distinto ⇒ ABORTAR*.
16
17use std::io::Read;
18use std::os::unix::fs::PermissionsExt;
19use std::path::{Path, PathBuf};
20use std::process::Command;
21
22use anyhow::{bail, Context, Result};
23use clap::Subcommand;
24use serde::{Deserialize, Serialize};
25
26/// Raíz del espacio de nombres de qorpa. Deliberadamente **fuera** del store.
27pub const DEFAULT_ROOT: &str = "/var/lib/hammer/qorpa";
28/// Para correr sin root (tests, laptop). Mismo patrón que `HAMMER_KCONFIG_ROOT`.
29pub const ROOT_ENV: &str = "HAMMER_QORPA_ROOT";
30/// El manifiesto de la imagen. No confundir con `instancia.toml`, que describe una INSTANCIA.
31const IMAGE_MANIFEST: &str = "image.toml";
32/// El árbol desempacado dentro del directorio de la imagen.
33const TREE: &str = "rootfs";
34/// El manifiesto de una INSTANCIA: la verdad de la que se recrea (D3).
35const INSTANCE_MANIFEST: &str = "instance.toml";
36
37#[derive(Subcommand)]
38pub enum QorpaCmd {
39    /// [ADR 0015 §Orden 2] Trae una imagen ajena y la verifica por sha256 ANTES de desempacarla.
40    ///
41    /// La identidad de la imagen es el sha256 del ARCHIVO original, no del árbol: así dos máquinas
42    /// que la traigan de orígenes distintos coinciden, que es lo que hace gratis el espejo
43    /// (ADR 0013: la URL no entra en la identidad) y posible el multi-origen (ADR 0014).
44    Pull {
45        /// De dónde bajarla. Cualquier esquema que entienda curl, `file://` incluido.
46        url: String,
47        /// sha256 esperado del archivo, en hex. Obligatorio: sin esto no hay identidad.
48        #[arg(long)]
49        sha256: String,
50        /// Etiqueta legible (`arch-bootstrap-2026.09.01`). Informativa; no entra en la identidad.
51        #[arg(long)]
52        label: Option<String>,
53        /// Baja y verifica, pero NO desempaca. Para poblar un espejo sin gastar el disco del árbol.
54        #[arg(long)]
55        verify_only: bool,
56        /// Dónde está el rootfs DENTRO del archivo, si la detección automática no acierta.
57        #[arg(long)]
58        subdir: Option<String>,
59        /// Raíz de qorpa (default: `$HAMMER_QORPA_ROOT` o `/var/lib/hammer/qorpa`).
60        #[arg(long)]
61        root: Option<PathBuf>,
62    },
63    /// Lista las imágenes presentes. Marca a gritos las que están VACÍAS (`CLAUDE.md` regla 3):
64    /// un directorio vacío no es una imagen, es un nombre.
65    List {
66        #[arg(long)]
67        root: Option<PathBuf>,
68        /// Salida JSON, para que un script no tenga que parsear la tabla.
69        #[arg(long)]
70        json: bool,
71    },
72    /// [ADR 0015 §Orden 3] Crea una instancia: capa mutable sobre una imagen + su manifiesto.
73    ///
74    /// Nace SIN NINGUNA concesión (D7): ni red, ni sockets, ni dispositivos, ni directorios del
75    /// host. Se abre lo que se declare en `instance.toml`, y sólo eso.
76    Create {
77        /// Identificador de la instancia (`games`).
78        id: String,
79        /// sha256 de la imagen base, ya traída con `pull`. Acepta prefijos.
80        #[arg(long)]
81        base: String,
82        /// Nombre informativo de la distro (`arch`, `ubuntu-24.04`).
83        #[arg(long)]
84        distro: Option<String>,
85        #[arg(long)]
86        root: Option<PathBuf>,
87    },
88    /// [ADR 0015 §Orden 3] Tira la capa mutable y la rehace desde el manifiesto.
89    ///
90    /// Es la prueba de D3: si esto duele, es que el `upper` se volvió el activo — y un blob
91    /// irreemplazable es justo lo que hammer existe para no tener.
92    Recreate {
93        id: String,
94        #[arg(long)]
95        root: Option<PathBuf>,
96    },
97    /// [ADR 0015 §Orden 3] Entra a la instancia con las concesiones declaradas y nada más.
98    Run {
99        id: String,
100        #[arg(long)]
101        root: Option<PathBuf>,
102        /// Imprime el `bwrap` que se ejecutaría y sale. La jaula tiene que ser inspeccionable.
103        #[arg(long)]
104        dry_run: bool,
105        /// Corre SIN harkaq (sin Landlock, sin seccomp, sin no_new_privs). Para diagnosticar
106        /// cuando algo muere y hay que saber si fue la jaula. Avisa a gritos.
107        #[arg(long)]
108        no_jail: bool,
109        /// Fuerza el userns de un solo id que crea bwrap, en vez del rango de `/etc/subuid`.
110        /// Para comparar: casi todo lo que "no anda adentro" es este mapeo.
111        #[arg(long)]
112        single_id: bool,
113        /// Comando dentro (default: el shell de la imagen). Va después de `--`.
114        #[arg(last = true)]
115        cmd: Vec<String>,
116    },
117    /// [ADR 0015 §Orden 5 — SIN IMPLEMENTAR] Genera los shims y `.desktop` de lo declarado en `[exporta]`.
118    Export { id: String },
119    /// [ADR 0015 §Orden 7 — SIN IMPLEMENTAR] Poda imágenes sin instancia y `upper` descartables.
120    Prune,
121}
122
123pub fn run(cmd: QorpaCmd) -> Result<()> {
124    match cmd {
125        QorpaCmd::Pull { url, sha256, label, verify_only, subdir, root } => {
126            pull(&resolve_root(root), &url, &sha256, label.as_deref(), verify_only, subdir.as_deref())
127        }
128        QorpaCmd::List { root, json } => list(&resolve_root(root), json),
129        QorpaCmd::Create { id, base, distro, root } => {
130            create(&resolve_root(root), &id, &base, distro.as_deref())
131        }
132        QorpaCmd::Recreate { id, root } => recreate(&resolve_root(root), &id),
133        QorpaCmd::Run { id, root, dry_run, no_jail, single_id, cmd } => {
134            run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail, single_id)
135        }
136        QorpaCmd::Export { .. } => bail!(
137            "sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \
138             `Exec=` de un .desktop ajeno."
139        ),
140        QorpaCmd::Prune => bail!(
141            "sin implementar: la poda es el paso 7 del ADR 0015. Nace con el subsistema y no \
142             después — este repo ya tuvo tres emergencias de disco."
143        ),
144    }
145}
146
147fn resolve_root(flag: Option<PathBuf>) -> PathBuf {
148    flag.or_else(|| std::env::var_os(ROOT_ENV).map(PathBuf::from))
149        .unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT))
150}
151
152/// Lo que queda escrito junto al árbol. Es el registro de PROCEDENCIA: sin él, un directorio con
153/// un rootfs adentro no sabe decir de dónde salió ni contra qué se verificó.
154#[derive(Serialize, Deserialize)]
155struct ImageManifest {
156    sha256: String,
157    url: String,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    label: Option<String>,
160    archive_bytes: u64,
161    tree_files: u64,
162    tree_bytes: u64,
163    /// El rootfs venía dentro de este subdirectorio del archivo (`root.x86_64` de Arch) y se elevó
164    /// a raíz. Ausente = el archivo YA era un rootfs plano (Ubuntu base).
165    #[serde(skip_serializing_if = "Option::is_none")]
166    rootfs_subdir: Option<String>,
167    /// Lo que había al lado del rootfs y NO entró (`version`, `pkglist.x86_64.txt` de Arch). Se
168    /// registra en vez de descartarse en silencio: si mañana falta algo, acá está qué se tiró.
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    dropped_siblings: Vec<String>,
171}
172
173// ── pull ────────────────────────────────────────────────────────────────────────────────────────
174
175fn pull(
176    root: &Path, url: &str, sha_in: &str, label: Option<&str>, verify_only: bool,
177    subdir: Option<&str>,
178) -> Result<()> {
179    let sha = normalize_sha256(sha_in)?;
180    let images = root.join("images");
181    let dest = images.join(&sha);
182
183    // Idempotencia con la regla 3 puesta delante: "ya está" sólo si TIENE CONTENIDO. Un directorio
184    // vacío de un pull interrumpido se leería como presencia y el error viajaría hasta el final.
185    if dest.exists() {
186        match tree_state(&dest.join(TREE)) {
187            TreeState::Populated => {
188                println!("ya presente y verificada: {}", dest.display());
189                return Ok(());
190            }
191            TreeState::Missing if verify_only => {
192                println!("archivo ya verificado antes (sin desempacar): {}", dest.display());
193                return Ok(());
194            }
195            TreeState::Missing | TreeState::Empty => {
196                eprintln!(
197                    "aviso: {} existe pero su árbol está vacío o ausente — pull anterior a medias, \
198                     se rehace",
199                    dest.display()
200                );
201                force_remove_dir_all(&dest)
202                    .with_context(|| format!("no pude limpiar {}", dest.display()))?;
203            }
204        }
205    }
206
207    std::fs::create_dir_all(&images)
208        .with_context(|| format!("no puedo crear {}", images.display()))?;
209
210    let archive = images.join(format!(".{sha}.part"));
211    println!("bajando {url}");
212    download_to_file(url, &archive)?;
213
214    // El corte. Si el contenido no es el que dice ser, se ABORTA — no se avisa y se sigue.
215    hammer_build::download::verify_sha256(&archive, &sha).inspect_err(|_| {
216        std::fs::remove_file(&archive).ok();
217    })?;
218    let archive_bytes = std::fs::metadata(&archive)?.len();
219    println!("sha256 verificado ({} bytes)", archive_bytes);
220
221    if verify_only {
222        std::fs::create_dir_all(&dest)?;
223        write_manifest(&dest, &ImageManifest {
224            sha256: sha.clone(), url: url.into(), label: label.map(Into::into),
225            archive_bytes, tree_files: 0, tree_bytes: 0, rootfs_subdir: None,
226            dropped_siblings: Vec::new(),
227        })?;
228        std::fs::rename(&archive, dest.join("archive"))?;
229        println!("guardada sin desempacar en {}", dest.display());
230        return Ok(());
231    }
232
233    // Desempacado atómico-ish: a un `.tmp` al lado y luego rotar. Extraer directo sobre el destino
234    // dejaría medio rootfs si algo falla, y medio rootfs es justo lo que la regla 3 castiga.
235    let staging = images.join(format!(".{sha}.tmp"));
236    force_remove_dir_all(&staging)
237        .with_context(|| format!("no pude limpiar el staging {}", staging.display()))?;
238    let tree_tmp = staging.join(TREE);
239    std::fs::create_dir_all(&tree_tmp)?;
240    println!("desempacando…");
241    // Cualquier fallo de acá en adelante barre el staging Y el archivo: "no dejar nada a medias" no
242    // vale sólo para el sha que no coincide — un desempacado a medias es la misma trampa.
243    let unpack = || -> Result<(Option<String>, Vec<String>, u64, u64)> {
244        untar(&archive, &tree_tmp)?;
245        let (sub, dropped) = locate_rootfs(&tree_tmp, subdir)?;
246        let (files, bytes) = walk_tree(&tree_tmp)?;
247        Ok((sub, dropped, files, bytes))
248    };
249    let (rootfs_subdir, dropped_siblings, tree_files, tree_bytes) = match unpack() {
250        Ok(v) => v,
251        Err(e) => {
252            force_remove_dir_all(&staging).ok();
253            std::fs::remove_file(&archive).ok();
254            return Err(e);
255        }
256    };
257    std::fs::remove_file(&archive).ok();
258
259    if let Some(d) = &rootfs_subdir {
260        println!("el rootfs venía en {d}/ — elevado a raíz");
261    }
262    if !dropped_siblings.is_empty() {
263        println!("fuera del rootfs, NO entran: {}", dropped_siblings.join(", "));
264    }
265    if tree_files == 0 {
266        force_remove_dir_all(&staging).ok();
267        bail!("el archivo desempacó VACÍO — eso no es una imagen, es un nombre (CLAUDE.md regla 3)");
268    }
269
270    write_manifest(&staging, &ImageManifest {
271        sha256: sha.clone(), url: url.into(), label: label.map(Into::into),
272        archive_bytes, tree_files, tree_bytes, rootfs_subdir, dropped_siblings,
273    })?;
274    std::fs::rename(&staging, &dest)
275        .with_context(|| format!("no puedo rotar {} a {}", staging.display(), dest.display()))?;
276
277    println!(
278        "imagen lista: {} · {tree_files} ficheros · {}",
279        dest.display(),
280        human(tree_bytes)
281    );
282    Ok(())
283}
284
285/// Descarga a fichero. NO se reusa `download::fetch_url_to_file` a propósito: ése trae el cuerpo
286/// entero a memoria (le sirve para un patch de KB) y acá hablamos de cientos de MB, con `--max-time`
287/// de 300 s que un rootfs no respeta.
288fn download_to_file(url: &str, dst: &Path) -> Result<()> {
289    if let Some(p) = dst.parent() {
290        std::fs::create_dir_all(p)?;
291    }
292    let st = Command::new("curl")
293        .args(["-fL", "--retry", "3", "--retry-delay", "2", "--connect-timeout", "30",
294               "--progress-bar", "-o"])
295        .arg(dst)
296        .arg(url)
297        .status()
298        .context("no pude ejecutar curl")?;
299    if !st.success() {
300        std::fs::remove_file(dst).ok();
301        bail!("curl falló bajando {url} (exit {:?})", st.code());
302    }
303    Ok(())
304}
305
306fn normalize_sha256(s: &str) -> Result<String> {
307    let s = s.strip_prefix("sha256:").unwrap_or(s).trim().to_ascii_lowercase();
308    if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
309        bail!("--sha256 debe ser 64 dígitos hex (llegó {:?}, {} chars)", s, s.len());
310    }
311    Ok(s)
312}
313
314/// Elige el descompresor por MAGIC, no por extensión: el nombre de un fichero es una sugerencia y
315/// las tres imágenes curadas usan tres compresores distintos (zstd en Arch, gzip en Ubuntu, xz en
316/// Fedora). `tar` extrae con `--no-same-owner` para que el resultado sea el MISMO lo corra root o
317/// no — la propiedad real la pone el mapeo del userns, no el tar.
318fn untar(archive: &Path, into: &Path) -> Result<()> {
319    let mut magic = [0u8; 6];
320    let n = std::fs::File::open(archive)?.read(&mut magic)?;
321    let flag = match &magic[..n.min(6)] {
322        [0x28, 0xb5, 0x2f, 0xfd, ..] => Some("--zstd"),
323        [0x1f, 0x8b, ..] => Some("-z"),
324        [0xfd, b'7', b'z', b'X', b'Z', 0x00] => Some("-J"),
325        [b'B', b'Z', b'h', ..] => Some("-j"),
326        _ => None, // tar plano
327    };
328    let mut c = Command::new("tar");
329    if let Some(f) = flag {
330        c.arg(f);
331    }
332    // `-p` conserva los modos exactos (el setuid de `sudo` adentro importa), y
333    // `--delay-directory-restore` es OBLIGATORIO junto con él: un rootfs real trae directorios de
334    // sólo-lectura (`/etc/ca-certificates/extracted/cadir` en Arch), y sin esto tar los crea con su
335    // modo final ANTES de meterles los ficheros ⇒ "Permission denied" al extraer sin ser root. Con
336    // esto, tar los deja escribibles y aplica los modos al final.
337    // tar ya rechaza rutas absolutas y `..` al extraer: ésa es la defensa contra un tarball ajeno
338    // malicioso. Los xattr `security.capability` se pierden sin privilegios (tar avisa): dentro de
339    // la jaula da igual salvo para binarios que dependan de capabilities de fichero — anotado.
340    c.args(["-x", "-p", "--delay-directory-restore", "--no-same-owner", "-f"])
341        .arg(archive).arg("-C").arg(into);
342    let st = c.status().context("no pude ejecutar tar")?;
343    if !st.success() {
344        bail!("tar falló desempacando {} (exit {:?})", archive.display(), st.code());
345    }
346    Ok(())
347}
348
349/// Dónde está el rootfs dentro de lo que se desempacó.
350///
351/// La primera versión de esto usaba «si hay UN solo directorio arriba, ése es el rootfs». Sonaba
352/// razonable y es FALSA: el bootstrap de Arch trae tres entradas arriba (`root.x86_64/`, `version`,
353/// `pkglist.x86_64.txt`) y la regla no disparaba, así que el rootfs quedaba un nivel abajo — con
354/// todo verde y sin un solo error. Se mide por ESTRUCTURA, que es lo que define a un rootfs:
355/// tiene `etc/` y tiene `usr/` o `bin/`.
356///
357/// Si no acierta, **falla y pide `--subdir`** en vez de adivinar: un rootfs mal anclado no rompe
358/// acá, rompe mucho después, cuando la instancia no encuentra su loader.
359fn looks_like_rootfs(d: &Path) -> bool {
360    d.join("etc").is_dir() && (d.join("usr").is_dir() || d.join("bin").exists())
361}
362
363/// Deja `tree` conteniendo el rootfs. Devuelve (subdir elevado, hermanos descartados).
364fn locate_rootfs(tree: &Path, forced: Option<&str>) -> Result<(Option<String>, Vec<String>)> {
365    if forced.is_none() && looks_like_rootfs(tree) {
366        return Ok((None, Vec::new())); // Ubuntu base: el archivo YA es el rootfs.
367    }
368    let entries: Vec<_> = std::fs::read_dir(tree)
369        .with_context(|| format!("leyendo la raíz desempacada {}", tree.display()))?
370        .collect::<std::io::Result<Vec<_>>>()?;
371    let name = match forced {
372        Some(f) => {
373            if !tree.join(f).is_dir() {
374                bail!("--subdir {f:?} no existe dentro del archivo");
375            }
376            f.to_string()
377        }
378        None => {
379            let mut cands: Vec<String> = entries
380                .iter()
381                .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
382                .filter(|e| looks_like_rootfs(&e.path()))
383                .map(|e| e.file_name().to_string_lossy().into_owned())
384                .collect();
385            cands.sort();
386            match cands.len() {
387                1 => cands.remove(0),
388                0 => bail!(
389                    "no encuentro un rootfs en el archivo (ni la raíz ni ningún subdirectorio tiene \
390                     `etc/` + `usr|bin`). Pasá `--subdir <dir>` si sabés dónde está."
391                ),
392                _ => bail!(
393                    "hay {} subdirectorios que parecen rootfs ({}) — elegí con `--subdir`",
394                    cands.len(), cands.join(", ")
395                ),
396            }
397        }
398    };
399    let dropped: Vec<String> = entries
400        .iter()
401        .map(|e| e.file_name().to_string_lossy().into_owned())
402        .filter(|n| *n != name)
403        .collect();
404
405    let top = tree.join(&name);
406    let parked = tree.with_file_name(format!(
407        "{}.lift",
408        tree.file_name().unwrap_or_default().to_string_lossy()
409    ));
410    // Renombrar un DIRECTORIO exige permiso de escritura sobre el directorio mismo (el kernel tiene
411    // que reescribir su `..`), y el `root.x86_64` de Arch viene `dr-xr-xr-x`. Se abre, se mueve y se
412    // le devuelve su modo exacto: la imagen tiene que quedar como venía, no como nos vino cómodo.
413    let mode = std::fs::metadata(&top)?.permissions().mode();
414    std::fs::set_permissions(&top, std::fs::Permissions::from_mode(mode | 0o700))?;
415    std::fs::rename(&top, &parked)
416        .with_context(|| format!("elevando {} a {}", top.display(), parked.display()))?;
417    force_remove_dir_all(tree)
418        .with_context(|| format!("descartando los hermanos en {}", tree.display()))?;
419    std::fs::rename(&parked, tree)
420        .with_context(|| format!("rotando {} a {}", parked.display(), tree.display()))?;
421    std::fs::set_permissions(tree, std::fs::Permissions::from_mode(mode))?;
422    Ok((Some(name), dropped))
423}
424
425/// `remove_dir_all` NO puede con un árbol que trae directorios sin permiso de escritura — y un
426/// rootfs real los trae (`/etc/ca-certificates/extracted/cadir` de Arch es 0555). Tragarse ese
427/// fallo con `.ok()` deja vivo el staging de un pull roto y el siguiente pull extrae ENCIMA: nos
428/// pasó, y el síntoma («Permission denied» en un directorio que acabás de crear) no se parece en
429/// nada a la causa. Abre los directorios a escritura de arriba abajo y después borra.
430fn force_remove_dir_all(p: &Path) -> std::io::Result<()> {
431    if !p.exists() {
432        return Ok(());
433    }
434    let mut stack = vec![p.to_path_buf()];
435    while let Some(d) = stack.pop() {
436        let _ = std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700));
437        if let Ok(rd) = std::fs::read_dir(&d) {
438            for e in rd.flatten() {
439                // `file_type` de read_dir es lstat: un symlink a directorio NO se sigue.
440                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
441                    stack.push(e.path());
442                }
443            }
444        }
445    }
446    match std::fs::remove_dir_all(p) {
447        Ok(()) => Ok(()),
448        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
449            // SEGUNDA causa, y aparece recién con el mapeo por rango: el `upper` de una instancia
450            // contiene ficheros de los SUBUID mapeados (apt deja los suyos con uid 165577), y
451            // nuestro uid normal no puede borrarlos ni chownearlos. La cura es la misma que los
452            // creó: entrar a un userns donde esos ids son nuestros. `--map-auto` usa newuidmap,
453            // así que esto sólo anda si subuid está provisionado — y si no, el error vuelve a
454            // salir tal cual, que es lo correcto.
455            let st = Command::new("unshare")
456                .args(["-U", "--map-auto", "-r", "rm", "-rf"])
457                .arg(p)
458                .status()?;
459            if st.success() && !p.exists() {
460                Ok(())
461            } else {
462                Err(e)
463            }
464        }
465        Err(e) => Err(e),
466    }
467}
468
469enum TreeState { Missing, Empty, Populated }
470
471fn tree_state(tree: &Path) -> TreeState {
472    match std::fs::read_dir(tree) {
473        Err(_) => TreeState::Missing,
474        Ok(mut it) => {
475            if it.next().is_some() { TreeState::Populated } else { TreeState::Empty }
476        }
477    }
478}
479
480fn walk_tree(root: &Path) -> Result<(u64, u64)> {
481    let (mut files, mut bytes) = (0u64, 0u64);
482    let mut stack = vec![root.to_path_buf()];
483    while let Some(d) = stack.pop() {
484        // Un rootfs ajeno trae directorios que NI SU DUEÑO puede leer (`/etc/sudoers.d` es 0750,
485        // `/var/log/private` 0700, y alguno 0000). No es corrupción: es el rootfs siendo lo que es.
486        // Se cuentan como 0 y se sigue — abortar el pull por no poder contar sería absurdo.
487        let rd = match std::fs::read_dir(&d) {
488            Ok(rd) => rd,
489            Err(_) => continue,
490        };
491        for e in rd {
492            let Ok(e) = e else { continue };
493            let Ok(ft) = e.file_type() else { continue };
494            if ft.is_dir() {
495                stack.push(e.path());
496            } else {
497                files += 1;
498                bytes += e.metadata().map(|m| m.len()).unwrap_or(0);
499            }
500        }
501    }
502    Ok((files, bytes))
503}
504
505fn write_manifest(dir: &Path, m: &ImageManifest) -> Result<()> {
506    let body = format!(
507        "# Lo escribe `hammer qorpa pull`; no editar a mano.\n\
508         # La identidad de la imagen es el sha256 del ARCHIVO original, no de este árbol: por eso\n\
509         # la URL es informativa y espejar es gratis (ADR 0013).\n{}",
510        toml::to_string_pretty(m).context("serializando el manifiesto")?
511    );
512    std::fs::write(dir.join(IMAGE_MANIFEST), body)?;
513    Ok(())
514}
515
516// ── instancias ──────────────────────────────────────────────────────────────────────────────────
517
518/// El manifiesto de una instancia. **Es la verdad**: el `upper/` es caché y se puede tirar entero
519/// (D3). Los nombres de campo van en inglés como el resto de la superficie (`CLAUDE.md` regla 4);
520/// el ADR los escribió en castellano y se corrigieron aquí.
521#[derive(Serialize, Deserialize, Default)]
522struct Instance {
523    /// sha256 de la imagen base. Cambiarlo + `recreate` ES la actualización de versión (D8).
524    base: String,
525    #[serde(skip_serializing_if = "Option::is_none")]
526    distro: Option<String>,
527    /// Lo que debe estar instalado adentro. Declarativo: hoy se registra, instalarlo es el
528    /// siguiente paso. Sin esto, `recreate` no podría reconstruir nada.
529    #[serde(default, skip_serializing_if = "Vec::is_empty")]
530    packages: Vec<String>,
531    /// La política, autorada (D7). **Por defecto vacía**: una instancia nace sin ver nada.
532    #[serde(default)]
533    grants: Grants,
534}
535
536#[derive(Serialize, Deserialize)]
537#[serde(default)]
538struct Grants {
539    /// Nombres cortos: `wayland`, `pipewire`, `dbus`. ⚠ `wayland` es un borde de PRIVILEGIO, no un
540    /// caño: quien lo tiene puede capturar pantalla y sintetizar teclas (ADR §NO-resuelve 1).
541    #[serde(default, skip_serializing_if = "Vec::is_empty")]
542    sockets: Vec<String>,
543    /// Nodos de dispositivo (`/dev/dri`). ABI de kernel, que es lo único que cruza el borde (D2).
544    #[serde(default, skip_serializing_if = "Vec::is_empty")]
545    devices: Vec<String>,
546    #[serde(default, skip_serializing_if = "Vec::is_empty")]
547    dirs: Vec<DirGrant>,
548    /// La concesión más peligrosa, y por eso va sola en su línea. `dnf`/`pacman` la necesitan.
549    #[serde(default)]
550    network: bool,
551    /// Deja que la instancia monte y cree namespaces adentro. **Steam ya es un contenedor**: su
552    /// pressure-vessel necesita esto para arrancar (D6). Es una concesión real — con ella la
553    /// instancia recupera la familia `mount`/`setns` que la jaula deniega por defecto.
554    #[serde(default)]
555    nesting: bool,
556    /// Mapear tu uid a **root adentro**. Encendido por defecto: es lo que quieren `dnf`/`pacman`.
557    ///
558    /// ⚠ MEDIDO 2026-09-03, y es un conflicto real con `nesting`: con `--uid 0`, un userns anidado
559    /// NO puede escribir su `uid_map` («Operation not permitted»), así que **pressure-vessel de
560    /// Valve no arranca**. Sin remapear a root, el anidamiento funciona pero el gestor de paquetes
561    /// se queja. Las dos mitades se arreglan con lo mismo: mapear un RANGO real de subuid con
562    /// `newuidmap` y pasarle el namespace a bwrap con `--userns FD` — el mismo impuesto que paga
563    /// hoy `apt` con su `setgroups`.
564    #[serde(default = "si")]
565    root: bool,
566    /// Sella el árbol de la IMAGEN (`/usr`, `/bin`, `/lib`, `/opt`…) contra escritura, aunque
567    /// adentro seas root. Apagado por defecto porque un gestor de paquetes necesita escribir en
568    /// `/usr`; encendido para una instancia que sólo CORRE algo, un exploit no puede persistir en
569    /// el `upper`. Es lo único que Landlock aporta acá que el namespace de montaje no da ya.
570    #[serde(default)]
571    seal_image: bool,
572}
573
574/// Dónde vive `harkaq-exec`. Misma convención que el harkaq del build (`HARKAQ_BIN`).
575fn harkaq_exec() -> PathBuf {
576    std::env::var("HARKAQ_BIN")
577        .map(PathBuf::from)
578        .unwrap_or_else(|_| {
579            PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into()))
580                .join(".cache/harkaq")
581        })
582        .join("harkaq-exec")
583}
584
585/// Las capabilities que hacen que el uid 0 de adentro sea root DE VERDAD.
586///
587/// MEDIDO: bwrap tira TODAS las capabilities, y en Linux ser root es tener `CAP_SETUID`, no tener
588/// uid 0. Sin ellas `apt` no puede `seteuid(42)` para bajar a `_apt` y `dpkg` no puede chownear —
589/// que es exactamente el síntoma que parecía de subuid y no lo era.
590///
591/// Son SEGURAS por construcción: una capability dentro de un user namespace sólo alcanza a lo que
592/// ese namespace posee, o sea los ids que le mapeamos (nuestros propios subuid). No dan nada en el
593/// host. Por eso se conceden con `root = true` sin pedir permiso aparte.
594///
595/// `CAP_SYS_ADMIN` NO está en la lista: es la que permite montar, y montar cuelga de la concesión
596/// `nesting` declarada. Abrir sólo lo declarado, también acá.
597const CAPS_ROOT: &[&str] = &[
598    "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_MKNOD",
599    "CAP_SETGID", "CAP_SETUID", "CAP_SETFCAP", "CAP_SETPCAP", "CAP_SYS_CHROOT",
600    "CAP_KILL", "CAP_AUDIT_WRITE",
601];
602
603/// Los directorios que pertenecen a la IMAGEN y que `seal_image` congela.
604const IMAGEN_INMUTABLE: &[&str] = &["usr", "bin", "sbin", "lib", "lib64", "lib32", "opt", "boot"];
605/// Lo que bwrap monta encima del overlay y siempre es de la instancia, no de la imagen.
606const DEL_SANDBOX: &[&str] = &["/proc", "/dev", "/tmp", "/run"];
607
608/// Compila las concesiones a la política que consume `harkaq-exec`.
609///
610/// ── QUÉ APORTA HARKAQ ACÁ, Y QUÉ NO ─────────────────────────────────────────────────────────────
611/// Honestidad primero: en el eje del SISTEMA DE FICHEROS, harkaq casi no agrega nada a una
612/// instancia, porque el namespace de montaje de bwrap ya es una lista blanca — lo que no se bindea
613/// no existe adentro, y un `--ro-bind` es de sólo lectura por el kernel, más fuerte que una regla.
614/// Escribir reglas `ro` que no cambian nada sería un sello de goma, así que no se escriben.
615///
616/// Lo que harkaq SÍ aporta, y es grande para un binario ajeno y opaco:
617///   1. **seccomp.** bwrap no instala filtro alguno: hoy una instancia puede `io_uring`, `bpf`,
618///      `ptrace`, `userfaultfd`, `keyctl`, `perf_event_open`, cargar módulos. Esa superficie
619///      importa MÁS con un prebuilt de terceros que con nuestros propios builds.
620///   2. **`no_new_privs`**: ningún setuid de la imagen escala adentro.
621///   3. **el canal de evidencia**: con ABI≥7 el kernel audita las denegaciones ⇒ se puede saber
622///      qué intentó tocar un binario cerrado. Es la única forma de auditar el montón B.
623///   4. **`seal_image`**: lo único del eje fs que el namespace no da — congelar el árbol de la
624///      imagen aunque adentro seas root.
625fn compile_policy(g: &Grants, image_tree: &Path, dst: &Path) -> Result<()> {
626    use std::fmt::Write as _;
627    let mut out = String::new();
628    out.push_str("# Política de una INSTANCIA qorpa (ADR 0015 D7).\n");
629    out.push_str("# A diferencia de la del build, ésta NO se deriva de una clausura: se AUTORA,\n");
630    out.push_str("# que es el único sitio del sistema donde eso pasa. Se compila del manifiesto.\n");
631
632    if !g.seal_image {
633        // El árbol entero es de la instancia y sus escrituras van al `upper`, que es caché.
634        out.push_str("rw /\n");
635    } else {
636        // Sin regla sobre `/` no se puede ni listar la raíz; listar no es leer ni escribir.
637        out.push_str("list /\n# la imagen, congelada:\n");
638        for d in IMAGEN_INMUTABLE {
639            if image_tree.join(d).exists() {
640                let _ = writeln!(out, "ro /{d}");
641            }
642        }
643        out.push_str("# lo mutable de la instancia:\n");
644        let mut mutables: Vec<String> = std::fs::read_dir(image_tree)
645            .with_context(|| format!("leyendo el árbol de la imagen {}", image_tree.display()))?
646            .flatten()
647            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
648            .map(|e| e.file_name().to_string_lossy().into_owned())
649            .filter(|n| !IMAGEN_INMUTABLE.contains(&n.as_str()))
650            .map(|n| format!("/{n}"))
651            .collect();
652        for d in DEL_SANDBOX {
653            if !mutables.iter().any(|m| m == d) {
654                mutables.push((*d).to_string());
655            }
656        }
657        mutables.sort();
658        mutables.dedup();
659        for m in &mutables {
660            let _ = writeln!(out, "rw {m}");
661        }
662    }
663    std::fs::write(dst, out)
664        .with_context(|| format!("escribiendo la política {}", dst.display()))?;
665    Ok(())
666}
667
668
669impl Default for Grants {
670    fn default() -> Self {
671        // Toda concesión arranca CERRADA menos `root`, que no abre nada del host: sólo decide
672        // quién sos adentro de tu propio rootfs.
673        Grants {
674            sockets: Vec::new(), devices: Vec::new(), dirs: Vec::new(),
675            network: false, nesting: false, root: true, seal_image: false,
676        }
677    }
678}
679
680#[derive(Serialize, Deserialize)]
681struct DirGrant {
682    host: String,
683    inside: String,
684    /// `ro` o `rw`.
685    #[serde(default = "ro")]
686    mode: String,
687}
688fn ro() -> String { "ro".into() }
689fn si() -> bool { true }
690
691fn instances_dir(root: &Path) -> PathBuf { root.join("instances") }
692fn instance_dir(root: &Path, id: &str) -> PathBuf { instances_dir(root).join(id) }
693
694/// Resuelve un prefijo de sha contra las imágenes presentes. Que exista y que TENGA CONTENIDO: una
695/// instancia sobre una imagen vacía es un directorio con nombre de rootfs (regla 3).
696fn resolve_image(root: &Path, prefix: &str) -> Result<(String, PathBuf)> {
697    let prefix = prefix.strip_prefix("sha256:").unwrap_or(prefix).to_ascii_lowercase();
698    let images = root.join("images");
699    let mut hits: Vec<String> = std::fs::read_dir(&images)
700        .with_context(|| format!("no hay imágenes en {}", images.display()))?
701        .flatten()
702        .map(|e| e.file_name().to_string_lossy().into_owned())
703        .filter(|n| !n.starts_with('.') && n.starts_with(&prefix))
704        .collect();
705    hits.sort();
706    match hits.len() {
707        1 => {
708            let sha = hits.remove(0);
709            let tree = images.join(&sha).join(TREE);
710            if !matches!(tree_state(&tree), TreeState::Populated) {
711                bail!("la imagen {sha} está VACÍA — no es una imagen, es un nombre");
712            }
713            Ok((sha, tree))
714        }
715        0 => bail!("ninguna imagen empieza por {prefix:?} — traela con `hammer qorpa pull`"),
716        n => bail!("{n} imágenes empiezan por {prefix:?}: dame más dígitos"),
717    }
718}
719
720fn create(root: &Path, id: &str, base: &str, distro: Option<&str>) -> Result<()> {
721    if id.is_empty() || id.contains('/') || id.starts_with('.') {
722        bail!("id de instancia inválido: {id:?}");
723    }
724    let dir = instance_dir(root, id);
725    if dir.exists() {
726        bail!("la instancia {id} ya existe en {} — `recreate` tira su capa mutable", dir.display());
727    }
728    let (sha, _tree) = resolve_image(root, base)?;
729
730    let inst = Instance {
731        base: format!("sha256:{sha}"),
732        distro: distro.map(Into::into),
733        ..Default::default()
734    };
735    std::fs::create_dir_all(dir.join("upper"))?;
736    std::fs::create_dir_all(dir.join("work"))?;
737    write_instance(&dir, &inst)?;
738    println!("instancia {id} creada en {}", dir.display());
739    println!("  base: sha256:{}", &sha[..12]);
740    println!("  concesiones: NINGUNA — se abren declarándolas en {}", INSTANCE_MANIFEST);
741    Ok(())
742}
743
744fn read_instance(root: &Path, id: &str) -> Result<(PathBuf, Instance)> {
745    let dir = instance_dir(root, id);
746    let f = dir.join(INSTANCE_MANIFEST);
747    let body = std::fs::read_to_string(&f)
748        .with_context(|| format!("no existe la instancia {id} ({})", f.display()))?;
749    let inst: Instance = toml::from_str(&body)
750        .with_context(|| format!("manifiesto ilegible: {}", f.display()))?;
751    Ok((dir, inst))
752}
753
754fn write_instance(dir: &Path, inst: &Instance) -> Result<()> {
755    let body = format!(
756        "# Manifiesto de la instancia — ESTO es la verdad, `upper/` es caché descartable (ADR 0015 D3).\n\
757         # `[grants]` es la política, y se AUTORA: es el único sitio del sistema donde no se deriva\n\
758         # de una clausura declarada (D7). Por defecto no hay ninguna.\n{}",
759        toml::to_string_pretty(inst).context("serializando la instancia")?
760    );
761    std::fs::write(dir.join(INSTANCE_MANIFEST), body)?;
762    Ok(())
763}
764
765fn recreate(root: &Path, id: &str) -> Result<()> {
766    let (dir, inst) = read_instance(root, id)?;
767    let (sha, _) = resolve_image(root, &inst.base)?;
768    for capa in ["upper", "work"] {
769        force_remove_dir_all(&dir.join(capa))
770            .with_context(|| format!("no pude tirar {capa}/ de {id}"))?;
771        std::fs::create_dir_all(dir.join(capa))?;
772    }
773    println!("instancia {id} recreada sobre sha256:{} — capa mutable tirada", &sha[..12]);
774    if !inst.packages.is_empty() {
775        println!(
776            "  declara {} paquete(s) ({}) — instalarlos todavía es a mano con `run`",
777            inst.packages.len(),
778            inst.packages.join(", ")
779        );
780    }
781    Ok(())
782}
783
784/// Traduce las concesiones a argumentos de bwrap. Devuelve también las advertencias que el usuario
785/// tiene que LEER, no descubrir: el ADR pide que el socket de Wayland se diga en la cara.
786fn grants_to_args(g: &Grants) -> (Vec<String>, Vec<String>) {
787    let mut a: Vec<String> = Vec::new();
788    let mut warn: Vec<String> = Vec::new();
789    // Sin dep de libc: el dueño de /proc/self ES el uid efectivo.
790    let uid = std::fs::metadata("/proc/self")
791        .map(|m| std::os::unix::fs::MetadataExt::uid(&m))
792        .unwrap_or(1000);
793    let xdg = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| format!("/run/user/{uid}"));
794
795    for s in &g.sockets {
796        let (host, dentro) = match s.as_str() {
797            "wayland" => {
798                warn.push(
799                    "wayland: la instancia podrá CAPTURAR LA PANTALLA y SINTETIZAR TECLAS — el \
800                     socket es un borde de privilegio y todavía no hay proxy filtrante \
801                     (ADR §NO-resuelve 1)".into(),
802                );
803                let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
804                (format!("{xdg}/{d}"), "/run/user/0/wayland-0".to_string())
805            }
806            "pipewire" => (format!("{xdg}/pipewire-0"), "/run/user/0/pipewire-0".into()),
807            "dbus" => (format!("{xdg}/bus"), "/run/user/0/bus".into()),
808            otro => {
809                warn.push(format!("socket desconocido {otro:?} — ignorado"));
810                continue;
811            }
812        };
813        a.push("--bind-try".into()); a.push(host); a.push(dentro);
814    }
815    for d in &g.devices {
816        a.push("--dev-bind-try".into()); a.push(d.clone()); a.push(d.clone());
817    }
818    for d in &g.dirs {
819        let host = if let Some(rest) = d.host.strip_prefix('~') {
820            format!("{}{}", std::env::var("HOME").unwrap_or_default(), rest)
821        } else { d.host.clone() };
822        a.push(if d.mode == "rw" { "--bind".into() } else { "--ro-bind".into() });
823        a.push(host); a.push(d.inside.clone());
824    }
825    if g.network {
826        // El segundo fallo clásico de la primera corrida, según el propio ADR.
827        a.push("--ro-bind-try".into());
828        a.push("/etc/resolv.conf".into());
829        a.push("/etc/resolv.conf".into());
830    } else {
831        a.push("--unshare-net".into());
832    }
833    (a, warn)
834}
835
836// ── el mapeo por rango (subuid) ──────────────────────────────────────────────────────────────────
837//
838// EL PROBLEMA, medido en los pasos anteriores: bwrap crea el user namespace con UN SOLO id mapeado
839// (`uid_map: 0 1001 1`, `setgroups: deny`). Con eso `apt` no puede bajar a `_apt`, `pacman` no
840// puede chownear su descarga a `alpm`, y un userns anidado —pressure-vessel— no puede escribir su
841// propio mapa. Tres síntomas distintos, una sola causa.
842//
843// LA CURA: crear el namespace NOSOTROS, mapearle un rango real de `/etc/subuid` con `newuidmap`, y
844// pasárselo a bwrap con `--userns FD`. bwrap no llama a `newuidmap` a propósito —es un binario con
845// capability y bwrap no quiere depender de él—, así que el trabajo es de quien lo invoca.
846//
847// El namespace lo sostiene un proceso «tenedor» que espera en un fifo: mientras viva, el fd
848// `/proc/<pid>/ns/user` es válido. La coreografía (hijo avisa por un fifo, padre mapea desde fuera,
849// hijo sigue) es la misma que ya verifica `scripts/qorpa/qorpa-preflight.sh`.
850
851struct Rango { inicio: u32, cantidad: u32 }
852
853fn leer_subid(fichero: &str, usuario: &str) -> Option<Rango> {
854    let txt = std::fs::read_to_string(fichero).ok()?;
855    txt.lines().find_map(|l| {
856        let mut c = l.split(':');
857        let (u, i, n) = (c.next()?, c.next()?, c.next()?);
858        if u != usuario {
859            return None;
860        }
861        Some(Rango { inicio: i.parse().ok()?, cantidad: n.parse().ok()? })
862    })
863}
864
865/// El namespace vivo, con su tenedor. Al soltarlo, se mata al tenedor y el namespace desaparece.
866struct UsernsMapeado {
867    tenedor: std::process::Child,
868    ns_path: String,
869    ids: u32,
870}
871
872impl Drop for UsernsMapeado {
873    fn drop(&mut self) {
874        // El tenedor sólo existe para sostener el namespace. Si sobreviviera al `run`, dejaríamos
875        // un proceso colgado por cada instancia arrancada.
876        let _ = self.tenedor.kill();
877        let _ = self.tenedor.wait();
878    }
879}
880
881/// Crea el namespace y le mapea el rango. Devuelve `Err` con la CAUSA exacta: hay tres formas de
882/// que esto no se pueda, y las tres se arreglan distinto, así que no se colapsan en un "no pude".
883fn userns_con_rango() -> Result<UsernsMapeado> {
884    let uid = std::fs::metadata("/proc/self")
885        .map(|m| std::os::unix::fs::MetadataExt::uid(&m))
886        .unwrap_or(1000);
887    let gid = std::fs::metadata("/proc/self")
888        .map(|m| std::os::unix::fs::MetadataExt::gid(&m))
889        .unwrap_or(1000);
890    let usuario = std::env::var("USER")
891        .or_else(|_| std::env::var("LOGNAME"))
892        .unwrap_or_default();
893    let ru = leer_subid("/etc/subuid", &usuario)
894        .with_context(|| format!("no hay rango para {usuario:?} en /etc/subuid"))?;
895    let rg = leer_subid("/etc/subgid", &usuario)
896        .with_context(|| format!("no hay rango para {usuario:?} en /etc/subgid"))?;
897
898    let tmp = std::env::temp_dir().join(format!("qorpa-userns-{}", std::process::id()));
899    std::fs::create_dir_all(&tmp)?;
900    let listo = tmp.join("listo");
901    let adelante = tmp.join("adelante");
902    for f in [&listo, &adelante] {
903        let _ = std::fs::remove_file(f);
904        let st = Command::new("mkfifo").arg(f).status().context("mkfifo")?;
905        if !st.success() {
906            bail!("no pude crear el fifo {}", f.display());
907        }
908    }
909
910    let tenedor = Command::new("unshare")
911        .arg("-U")
912        .arg("sh")
913        .arg("-c")
914        .arg(format!(
915            "echo r > {l}; read _ < {a}",
916            l = listo.display(), a = adelante.display()
917        ))
918        .spawn()
919        .context("no pude ejecutar `unshare -U` — ¿está util-linux?")?;
920    let pid = tenedor.id();
921    let mut guard = UsernsMapeado {
922        tenedor,
923        ns_path: format!("/proc/{pid}/ns/user"),
924        ids: 1 + ru.cantidad,
925    };
926
927    // Bloquea hasta que el hijo esté DENTRO del namespace. Sin este apretón de manos mapearíamos
928    // el namespace del padre, que es el del host.
929    std::fs::read_to_string(&listo).context("el tenedor no llegó al fifo")?;
930
931    let mapear = |bin: &str, id: u32, r: &Rango| -> Result<()> {
932        let out = Command::new(bin)
933            .args([pid.to_string(), "0".into(), id.to_string(), "1".into(),
934                   "1".into(), r.inicio.to_string(), r.cantidad.to_string()])
935            .output()
936            .with_context(|| format!("no pude ejecutar {bin}"))?;
937        if !out.status.success() {
938            bail!("{bin}: {}", String::from_utf8_lossy(&out.stderr).trim());
939        }
940        Ok(())
941    };
942    mapear("newuidmap", uid, &ru)?;
943    mapear("newgidmap", gid, &rg)?;
944
945    // Soltar al tenedor: ya mapeado, se queda esperando el fifo (que nadie escribirá) hasta que lo
946    // matemos. Lo que importa es que el proceso VIVA, no lo que haga.
947    let _ = std::fs::remove_file(&listo);
948    let _ = std::fs::remove_file(&adelante);
949    guard.ids = 1 + ru.cantidad;
950    Ok(guard)
951}
952
953fn run_instance(
954    root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: bool, single_id: bool,
955) -> Result<()> {
956    let (dir, inst) = read_instance(root, id)?;
957    let (sha, tree) = resolve_image(root, &inst.base)?;
958    let (grant_args, warns) = grants_to_args(&inst.grants);
959
960    // El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo
961    // después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo
962    // que no se declara, no entra (D2, "por defecto NADA").
963    // El mapeo por rango se intenta SIEMPRE que se pueda: es lo que quita el impuesto de
964    // `apt`/`pacman` y lo que permite anidar. `root = false` lo descarta porque ser otro-que-root
965    // adentro pide el `--uid` de bwrap, que exige `--unshare-user` y ése es incompatible con
966    // `--userns`. Cuando no se puede, se dice POR QUÉ y se sigue con un id: degradar en silencio
967    // haría que "no anda adentro" fuese un misterio en vez de una línea.
968    let mut mapeado: Option<UsernsMapeado> = None;
969    if !single_id && inst.grants.root {
970        match userns_con_rango() {
971            Ok(m) => mapeado = Some(m),
972            Err(e) => eprintln!(
973                "⚠ sin mapeo por rango ({e:#}) ⇒ un solo id adentro. Consecuencias MEDIDAS: `apt` \
974                 no baja a `_apt`, `pacman` no chownea su descarga, y un userns anidado no puede \
975                 escribir su mapa. Diagnóstico: scripts/qorpa/qorpa-preflight.sh"
976            ),
977        }
978    }
979
980    let mut args: Vec<String> = vec![
981        "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try",
982        "--hostname", "qorpa",
983        "--clearenv",
984        "--die-with-parent",
985    ].into_iter().map(String::from).collect();
986    if mapeado.is_none() {
987        // bwrap crea el suyo, con un solo id.
988        args.insert(0, "--unshare-user".into());
989        if inst.grants.root {
990            args.extend(["--uid", "0", "--gid", "0"].map(String::from));
991        }
992    }
993    if inst.grants.root {
994        for c in CAPS_ROOT {
995            args.push("--cap-add".into());
996            args.push((*c).to_string());
997        }
998        if inst.grants.nesting {
999            // La que permite montar. Sólo con la concesión declarada.
1000            args.push("--cap-add".into());
1001            args.push("CAP_SYS_ADMIN".into());
1002        }
1003    }
1004
1005    args.push("--overlay-src".into()); args.push(tree.display().to_string());
1006    args.push("--overlay".into());
1007    args.push(dir.join("upper").display().to_string());
1008    args.push(dir.join("work").display().to_string());
1009    args.push("/".into());
1010
1011    // /proc y /dev van DESPUÉS del overlay: son del kernel, no de la imagen.
1012    // `--perms 1777` antes del `--tmpfs /tmp` no es cosmético: bwrap lo crearía 0755 y entonces el
1013    // `_apt` (uid 42) al que apt baja para bajar paquetes no puede escribir su fichero temporal.
1014    // Un /tmp que no es 1777 no es /tmp.
1015    args.extend(["--proc", "/proc", "--dev", "/dev",
1016                 "--perms", "1777", "--tmpfs", "/tmp",
1017                 "--dir", "/run/user/0", "--chdir", "/"].map(String::from));
1018    args.extend([
1019        "--setenv", "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
1020        "--setenv", "HOME", "/root",
1021        "--setenv", "TERM", &std::env::var("TERM").unwrap_or_else(|_| "xterm".into()),
1022        "--setenv", "XDG_RUNTIME_DIR", "/run/user/0",
1023    ].map(String::from));
1024    args.extend(grant_args);
1025
1026    let mut cmd: Vec<String> = if cmd.is_empty() {
1027        vec!["/bin/sh".into()] // toda imagen trae uno; bash puede no estar.
1028    } else { cmd.to_vec() };
1029
1030    // harkaq va como ÚLTIMO eslabón antes del comando, igual que en el sandbox del build: bwrap ya
1031    // puso los namespaces y el overlay, y esto pone el grano fino. Cruza el borde un binario
1032    // ESTÁTICO — no una librería —, así que sigue valiendo D2: lo único que comparte con el host
1033    // es la ABI del kernel.
1034    if !no_jail {
1035        let exec = harkaq_exec();
1036        if !exec.exists() {
1037            bail!(
1038                "no encuentro harkaq-exec en {} — la instancia NO va a correr enjaulada y eso no se \
1039                 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.",
1040                exec.display(), exec.display()
1041            );
1042        }
1043        let policy = dir.join("policy");
1044        compile_policy(&inst.grants, &tree, &policy)?;
1045
1046        args.push("--ro-bind".into()); args.push(exec.display().to_string());
1047        args.push("/run/qorpa/harkaq-exec".into());
1048        args.push("--ro-bind".into()); args.push(policy.display().to_string());
1049        args.push("/run/qorpa/policy".into());
1050
1051        let mut envoltura: Vec<String> = vec![
1052            "/run/qorpa/harkaq-exec".into(), "--policy".into(), "/run/qorpa/policy".into(),
1053        ];
1054        if inst.grants.nesting {
1055            // MEDIDO: seccomp no alcanza. Un dominio Landlock activo hace fallar `mount` con
1056            // EACCES aunque el filtro lo permita — el kernel no admite montajes nuevos bajo un
1057            // dominio, porque escaparían de sus reglas por-ruta. ⇒ anidar cuesta la política de
1058            // ficheros. Seccomp y no_new_privs siguen puestos, que es lo que más pesa con un
1059            // binario ajeno, y el confinamiento de fs queda en el namespace de montaje de bwrap,
1060            // que ya es una lista blanca.
1061            envoltura.push("--allow-nesting".into());
1062            envoltura.push("--no-landlock".into());
1063        }
1064        envoltura.push("--".into());
1065        envoltura.extend(cmd);
1066        cmd = envoltura;
1067    } else {
1068        eprintln!("⚠ --no-jail: SIN Landlock, SIN seccomp, SIN no_new_privs. Sólo los namespaces \
1069                   de bwrap. No es el modo normal.");
1070    }
1071    args.extend(cmd.iter().cloned());
1072
1073    for w in &warns {
1074        eprintln!("⚠ {w}");
1075    }
1076    if dry_run {
1077        if let Some(m) = &mapeado {
1078            println!("# el userns lo creamos nosotros y le mapeamos {} ids:", m.ids);
1079            println!("#   exec 3<{}   ⇒   bwrap --userns 3 …", m.ns_path);
1080        }
1081        // Una línea por concesión, no una por token: el punto de `--dry-run` es que se LEA.
1082        let mut linea = String::from("bwrap");
1083        for a in &args {
1084            if a.starts_with("--") { println!("{linea} \\"); linea = format!("  {a}"); }
1085            else { linea.push(' '); linea.push_str(a); }
1086        }
1087        println!("{linea}");
1088        return Ok(());
1089    }
1090    eprintln!("qorpa {id} · sha256:{} · red={} · {} socket(s), {} device(s), {} dir(s) · \
1091               jaula={} · imagen={}",
1092        &sha[..12], inst.grants.network, inst.grants.sockets.len(),
1093        inst.grants.devices.len(), inst.grants.dirs.len(),
1094        if no_jail { "NO" } else if inst.grants.nesting { "sí (anidamiento permitido)" } else { "sí" },
1095        if inst.grants.seal_image { "sellada" } else { "escribible" });
1096    eprintln!("  ids mapeados: {}",
1097        match &mapeado { Some(m) => m.ids.to_string(), None => "1 (sin rango)".into() });
1098
1099    // Cuando el namespace es nuestro hay que pasarle a bwrap un fd ABIERTO, y un fd sólo cruza el
1100    // `exec` si no es CLOEXEC. En vez de arrastrar una dep de C para un `fcntl`, lo abre la shell:
1101    // sus redirecciones no son CLOEXEC por definición, y de paso el `--dry-run` puede mostrar
1102    // exactamente lo que pasa.
1103    let st = match &mapeado {
1104        Some(m) => Command::new("sh")
1105            .arg("-c")
1106            .arg(r#"exec 3<"$1"; shift; exec bwrap --userns 3 "$@""#)
1107            .arg("qorpa")
1108            .arg(&m.ns_path)
1109            .args(&args)
1110            .status()
1111            .context("no pude ejecutar sh/bwrap")?,
1112        None => Command::new("bwrap").args(&args).status()
1113            .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?,
1114    };
1115    match st.code() {
1116        Some(0) => Ok(()),
1117        Some(c) => std::process::exit(c),
1118        None => bail!("la instancia murió por señal"),
1119    }
1120}
1121
1122// ── list ────────────────────────────────────────────────────────────────────────────────────────
1123
1124fn list(root: &Path, json: bool) -> Result<()> {
1125    let images = root.join("images");
1126    let mut rows: Vec<(String, Option<ImageManifest>, TreeState)> = Vec::new();
1127    if let Ok(rd) = std::fs::read_dir(&images) {
1128        for e in rd.flatten() {
1129            let name = e.file_name().to_string_lossy().into_owned();
1130            if name.starts_with('.') {
1131                continue; // restos de un pull a medias
1132            }
1133            let m = std::fs::read_to_string(e.path().join(IMAGE_MANIFEST))
1134                .ok()
1135                .and_then(|s| toml::from_str::<ImageManifest>(&s).ok());
1136            rows.push((name, m, tree_state(&e.path().join(TREE))));
1137        }
1138    }
1139    rows.sort_by(|a, b| a.0.cmp(&b.0));
1140
1141    let insts: Vec<(String, Instance)> = std::fs::read_dir(instances_dir(root))
1142        .into_iter()
1143        .flatten()
1144        .flatten()
1145        .filter_map(|e| {
1146            let id = e.file_name().to_string_lossy().into_owned();
1147            let body = std::fs::read_to_string(e.path().join(INSTANCE_MANIFEST)).ok()?;
1148            Some((id, toml::from_str::<Instance>(&body).ok()?))
1149        })
1150        .collect();
1151
1152    if json {
1153        let out: Vec<_> = rows.iter().map(|(sha, m, st)| {
1154            serde_json::json!({
1155                "sha256": sha,
1156                "label": m.as_ref().and_then(|m| m.label.clone()),
1157                "url": m.as_ref().map(|m| m.url.clone()),
1158                "tree_files": m.as_ref().map(|m| m.tree_files).unwrap_or(0),
1159                "tree_bytes": m.as_ref().map(|m| m.tree_bytes).unwrap_or(0),
1160                "empty": !matches!(st, TreeState::Populated),
1161            })
1162        }).collect();
1163        let insts_json: Vec<_> = insts.iter().map(|(id, i)| serde_json::json!({
1164            "id": id, "base": i.base, "distro": i.distro,
1165            "packages": i.packages, "network": i.grants.network,
1166            "sockets": i.grants.sockets, "devices": i.grants.devices,
1167        })).collect();
1168        println!("{}", serde_json::to_string_pretty(
1169            &serde_json::json!({"images": out, "instances": insts_json}))?);
1170        return Ok(());
1171    }
1172
1173    if rows.is_empty() && insts.is_empty() {
1174        println!("no hay imágenes en {} — `hammer qorpa pull <url> --sha256 <sha>`", images.display());
1175        return Ok(());
1176    }
1177    let mut vacias = 0;
1178    for (sha, m, st) in &rows {
1179        let etiqueta = m.as_ref().and_then(|m| m.label.clone()).unwrap_or_else(|| "—".into());
1180        match st {
1181            TreeState::Populated => println!(
1182                "  {}  {:<32} {:>7} ficheros  {}",
1183                &sha[..12],
1184                etiqueta,
1185                m.as_ref().map(|m| m.tree_files).unwrap_or(0),
1186                human(m.as_ref().map(|m| m.tree_bytes).unwrap_or(0)),
1187            ),
1188            _ => {
1189                vacias += 1;
1190                println!("  {}  {:<32} !! VACÍA — no es una imagen, es un nombre", &sha[..12], etiqueta);
1191            }
1192        }
1193    }
1194    if !insts.is_empty() {
1195        println!("\ninstancias:");
1196        for (id, i) in &insts {
1197            let sha = i.base.strip_prefix("sha256:").unwrap_or(&i.base);
1198            // Se enumeran las concesiones porque una instancia sin política declarada y una con la
1199            // pantalla abierta se ven IGUAL desde fuera, y no son lo mismo (ADR §NO-resuelve 1).
1200            let mut abre: Vec<String> = Vec::new();
1201            if i.grants.network { abre.push("red".into()); }
1202            abre.extend(i.grants.sockets.iter().cloned());
1203            abre.extend(i.grants.devices.iter().cloned());
1204            if !i.grants.dirs.is_empty() { abre.push(format!("{} dir(s)", i.grants.dirs.len())); }
1205            println!("  {:<16} sobre {}  abre: {}", id, &sha[..sha.len().min(12)],
1206                if abre.is_empty() { "nada".into() } else { abre.join(", ") });
1207        }
1208    }
1209    println!("\n{} imagen(es) en {}", rows.len(), images.display());
1210    if vacias > 0 {
1211        bail!("{vacias} imagen(es) VACÍA(s): un ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien");
1212    }
1213    Ok(())
1214}
1215
1216fn human(b: u64) -> String {
1217    const U: [&str; 5] = ["B", "K", "M", "G", "T"];
1218    let (mut v, mut i) = (b as f64, 0);
1219    while v >= 1024.0 && i < 4 { v /= 1024.0; i += 1; }
1220    if i == 0 { format!("{b} B") } else { format!("{v:.1} {}", U[i]) }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225    use super::*;
1226
1227    fn tar_gz(dir: &Path, dst: &Path, contenido: &[(&str, &str)]) {
1228        for (p, c) in contenido {
1229            let f = dir.join(p);
1230            std::fs::create_dir_all(f.parent().unwrap()).unwrap();
1231            std::fs::write(f, c).unwrap();
1232        }
1233        let st = Command::new("tar").args(["-czf"]).arg(dst).arg("-C").arg(dir)
1234            .args(contenido.iter().map(|(p, _)| p.split('/').next().unwrap()).collect::<std::collections::BTreeSet<_>>())
1235            .status().unwrap();
1236        assert!(st.success());
1237    }
1238
1239    fn sha_of(p: &Path) -> String {
1240        use sha2::{Digest, Sha256};
1241        let mut h = Sha256::new();
1242        std::io::copy(&mut std::fs::File::open(p).unwrap(), &mut h).unwrap();
1243        hex::encode(h.finalize())
1244    }
1245
1246    #[test]
1247    fn pull_verifica_desempaca_y_es_idempotente() {
1248        let d = tempfile::tempdir().unwrap();
1249        let src = d.path().join("src");
1250        std::fs::create_dir_all(&src).unwrap();
1251        let arch = d.path().join("img.tar.gz");
1252        tar_gz(&src, &arch, &[("etc/os-release", "ID=ajena\n"), ("bin/sh", "#!/bin/sh\n")]);
1253        let sha = sha_of(&arch);
1254        let root = d.path().join("qorpa");
1255        let url = format!("file://{}", arch.display());
1256
1257        pull(&root, &url, &sha, Some("prueba"), false, None).unwrap();
1258        let tree = root.join("images").join(&sha).join(TREE);
1259        assert_eq!(std::fs::read_to_string(tree.join("etc/os-release")).unwrap(), "ID=ajena\n");
1260        // Segundo pull: no vuelve a bajar ni rompe.
1261        pull(&root, &url, &sha, Some("prueba"), false, None).unwrap();
1262        assert!(root.join("images").join(&sha).join(IMAGE_MANIFEST).exists());
1263    }
1264
1265    #[test]
1266    fn pull_aborta_si_el_contenido_no_es_el_que_dice_ser() {
1267        let d = tempfile::tempdir().unwrap();
1268        let src = d.path().join("src");
1269        std::fs::create_dir_all(&src).unwrap();
1270        let arch = d.path().join("img.tar.gz");
1271        tar_gz(&src, &arch, &[("etc/hostname", "ajena\n")]);
1272        let root = d.path().join("qorpa");
1273        let mentira = "0".repeat(64);
1274        let err = pull(&root, &format!("file://{}", arch.display()), &mentira, None, false, None)
1275            .unwrap_err().to_string();
1276        assert!(err.contains("sha256 mismatch"), "mensaje inesperado: {err}");
1277        // Y no dejó nada a medias: contenido distinto ⇒ ABORTAR, no "avisar y seguir".
1278        assert!(!root.join("images").join(&mentira).exists());
1279    }
1280
1281    /// La cicatriz de Arch: el rootfs viene en `root.x86_64/` CON HERMANOS al lado (`version`,
1282    /// `pkglist`). La regla vieja ("un solo directorio arriba") no disparaba y dejaba el rootfs un
1283    /// nivel abajo, en verde. Se ancla por estructura.
1284    #[test]
1285    fn el_rootfs_anidado_se_eleva_aunque_tenga_hermanos() {
1286        let d = tempfile::tempdir().unwrap();
1287        let src = d.path().join("src");
1288        std::fs::create_dir_all(&src).unwrap();
1289        let arch = d.path().join("boot.tar.gz");
1290        tar_gz(&src, &arch, &[
1291            ("root.x86_64/etc/os-release", "ID=arch\n"),
1292            ("root.x86_64/usr/lib/keep", ""),
1293            ("version", "2026.09.01\n"),
1294        ]);
1295        let sha = sha_of(&arch);
1296        let root = d.path().join("qorpa");
1297        pull(&root, &format!("file://{}", arch.display()), &sha, None, false, None).unwrap();
1298        let tree = root.join("images").join(&sha).join(TREE);
1299        assert!(tree.join("etc/os-release").exists(), "no ancló el rootfs anidado");
1300        assert!(!tree.join("root.x86_64").exists());
1301        assert!(!tree.join("version").exists(), "el hermano no debía entrar al rootfs");
1302        let m: ImageManifest = toml::from_str(
1303            &std::fs::read_to_string(root.join("images").join(&sha).join(IMAGE_MANIFEST)).unwrap(),
1304        ).unwrap();
1305        assert_eq!(m.rootfs_subdir.as_deref(), Some("root.x86_64"));
1306        assert_eq!(m.dropped_siblings, vec!["version".to_string()]);
1307    }
1308
1309    #[test]
1310    fn un_arbol_vacio_no_pasa_por_imagen() {
1311        let d = tempfile::tempdir().unwrap();
1312        let vacio = d.path().join("vacio");
1313        std::fs::create_dir_all(&vacio).unwrap();
1314        let arch = d.path().join("nada.tar.gz");
1315        assert!(Command::new("tar").args(["-czf"]).arg(&arch).arg("-C").arg(&vacio)
1316            .args(["--files-from", "/dev/null"]).status().unwrap().success());
1317        let sha = sha_of(&arch);
1318        let root = d.path().join("qorpa");
1319        let err = pull(&root, &format!("file://{}", arch.display()), &sha, None, false, None)
1320            .unwrap_err().to_string();
1321        assert!(err.contains("VACÍO") || err.contains("no encuentro un rootfs"),
1322                "mensaje inesperado: {err}");
1323        assert!(!root.join("images").join(&sha).exists());
1324    }
1325
1326    /// Una imagen mínima que pasa por rootfs: `etc/` + `bin/`.
1327    fn imagen_falsa(root: &Path, sha: &str) {
1328        let t = root.join("images").join(sha).join(TREE);
1329        std::fs::create_dir_all(t.join("etc")).unwrap();
1330        std::fs::create_dir_all(t.join("bin")).unwrap();
1331        std::fs::write(t.join("etc/os-release"), "ID=falsa\n").unwrap();
1332    }
1333
1334    #[test]
1335    fn create_nace_sin_ninguna_concesion() {
1336        let d = tempfile::tempdir().unwrap();
1337        let root = d.path();
1338        let sha = "a".repeat(64);
1339        imagen_falsa(root, &sha);
1340        create(root, "prueba", "aaaa", Some("falsa")).unwrap();
1341        let (_, inst) = read_instance(root, "prueba").unwrap();
1342        assert_eq!(inst.base, format!("sha256:{sha}"));
1343        // D7: la política se autora, y arranca VACÍA.
1344        assert!(!inst.grants.network);
1345        assert!(inst.grants.sockets.is_empty() && inst.grants.devices.is_empty());
1346        // Y no se pisa a sí misma.
1347        assert!(create(root, "prueba", "aaaa", None).is_err());
1348    }
1349
1350    #[test]
1351    fn create_rechaza_una_imagen_vacia() {
1352        let d = tempfile::tempdir().unwrap();
1353        let root = d.path();
1354        let sha = "b".repeat(64);
1355        std::fs::create_dir_all(root.join("images").join(&sha).join(TREE)).unwrap();
1356        let err = create(root, "x", &sha, None).unwrap_err().to_string();
1357        assert!(err.contains("VACÍA"), "mensaje inesperado: {err}");
1358    }
1359
1360    #[test]
1361    fn recreate_tira_la_capa_mutable_y_conserva_el_manifiesto() {
1362        let d = tempfile::tempdir().unwrap();
1363        let root = d.path();
1364        let sha = "c".repeat(64);
1365        imagen_falsa(root, &sha);
1366        create(root, "i", &sha, None).unwrap();
1367        let upper = instance_dir(root, "i").join("upper");
1368        std::fs::write(upper.join("basura"), "x").unwrap();
1369        recreate(root, "i").unwrap();
1370        assert!(!upper.join("basura").exists(), "el upper no es caché si sobrevive");
1371        assert!(instance_dir(root, "i").join(INSTANCE_MANIFEST).exists(), "el manifiesto ES la verdad");
1372    }
1373
1374    #[test]
1375    fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() {
1376        let (a, w) = grants_to_args(&Grants::default());
1377        assert!(a.contains(&"--unshare-net".to_string()));
1378        assert!(w.is_empty());
1379
1380        let g = Grants { network: true, ..Default::default() };
1381        let (a, _) = grants_to_args(&g);
1382        assert!(!a.contains(&"--unshare-net".to_string()));
1383        // El segundo fallo clásico de la primera corrida, según el ADR.
1384        assert!(a.iter().any(|x| x == "/etc/resolv.conf"));
1385    }
1386
1387    #[test]
1388    fn el_socket_de_wayland_avisa_que_es_un_borde_de_privilegio() {
1389        let g = Grants { sockets: vec!["wayland".into()], ..Default::default() };
1390        let (_, w) = grants_to_args(&g);
1391        assert_eq!(w.len(), 1);
1392        assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]);
1393    }
1394
1395    #[test]
1396    fn sin_sellar_la_politica_no_finge_confinar_el_fs() {
1397        let d = tempfile::tempdir().unwrap();
1398        imagen_falsa(d.path(), "d".repeat(64).as_str());
1399        let tree = d.path().join("images").join("d".repeat(64)).join(TREE);
1400        let dst = d.path().join("policy");
1401        compile_policy(&Grants::default(), &tree, &dst).unwrap();
1402        let pol = std::fs::read_to_string(&dst).unwrap();
1403        // El namespace de montaje de bwrap YA es lista blanca. Escribir reglas `ro` que no cambian
1404        // nada sería un sello de goma, así que la política se queda en una línea honesta.
1405        assert!(pol.lines().any(|l| l == "rw /"), "{pol}");
1406        assert!(!pol.contains("\nro "), "no debería haber reglas decorativas:\n{pol}");
1407    }
1408
1409    #[test]
1410    fn sellar_congela_la_imagen_y_deja_mutable_lo_de_la_instancia() {
1411        let d = tempfile::tempdir().unwrap();
1412        let sha = "e".repeat(64);
1413        imagen_falsa(d.path(), &sha);
1414        let tree = d.path().join("images").join(&sha).join(TREE);
1415        std::fs::create_dir_all(tree.join("var")).unwrap();
1416        std::fs::create_dir_all(tree.join("usr")).unwrap();
1417        let dst = d.path().join("policy");
1418        compile_policy(&Grants { seal_image: true, ..Default::default() }, &tree, &dst).unwrap();
1419        let pol = std::fs::read_to_string(&dst).unwrap();
1420        assert!(pol.lines().any(|l| l == "ro /usr"), "{pol}");
1421        assert!(pol.lines().any(|l| l == "rw /etc"), "{pol}");
1422        assert!(pol.lines().any(|l| l == "rw /var"), "{pol}");
1423        assert!(pol.lines().any(|l| l == "list /"), "{pol}");
1424        // Si `rw /` sobreviviera, taparía todas las `ro`: Landlock une derechos por ancestro.
1425        assert!(!pol.lines().any(|l| l == "rw /"), "el `rw /` anula el sellado:\n{pol}");
1426        // Sólo se nombra lo que EXISTE: harkaq-exec falla duro ante un path inexistente.
1427        assert!(!pol.contains("/boot"), "{pol}");
1428    }
1429
1430    #[test]
1431    fn root_es_lo_unico_que_nace_encendido() {
1432        let g = Grants::default();
1433        assert!(g.root, "sin esto un gestor de paquetes no arranca");
1434        assert!(!g.network && !g.nesting && !g.seal_image);
1435        assert!(g.sockets.is_empty() && g.devices.is_empty() && g.dirs.is_empty());
1436    }
1437
1438    #[test]
1439    fn el_rango_de_subuid_se_lee_por_usuario() {
1440        let d = tempfile::tempdir().unwrap();
1441        let f = d.path().join("subuid");
1442        std::fs::write(&f, "otro:100000:65536\nsergio:165536:65536\n").unwrap();
1443        let r = leer_subid(f.to_str().unwrap(), "sergio").unwrap();
1444        assert_eq!((r.inicio, r.cantidad), (165536, 65536));
1445        assert!(leer_subid(f.to_str().unwrap(), "nadie").is_none());
1446    }
1447
1448    #[test]
1449    fn las_caps_de_root_no_incluyen_la_de_montar() {
1450        // CAP_SYS_ADMIN es la que permite `mount`, y montar cuelga de la concesión `nesting`
1451        // declarada. Si se colara acá, `nesting` dejaría de ser una decisión y pasaría a ser el
1452        // comportamiento por defecto — que es justo lo que D7 prohíbe.
1453        assert!(!CAPS_ROOT.contains(&"CAP_SYS_ADMIN"), "{CAPS_ROOT:?}");
1454        // Y las que sí están son las que `apt` y `dpkg` necesitaron de verdad.
1455        for c in ["CAP_SETUID", "CAP_SETGID", "CAP_CHOWN"] {
1456            assert!(CAPS_ROOT.contains(&c), "falta {c}");
1457        }
1458    }
1459
1460    #[test]
1461    fn sha_mal_formado_falla_antes_de_tocar_la_red() {
1462        assert!(normalize_sha256("abc").is_err());
1463        assert_eq!(normalize_sha256(&format!("sha256:{}", "A".repeat(64))).unwrap(), "a".repeat(64));
1464    }
1465}