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

⎇ ba09fdb403c7e214d3d813e9ee1bc4130470a333 crates/hammer-cli/src/qorpa.rs Rust · 1019 siq'ikuna · 44.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

qorpa create/recreate/run: la instancia nace sin ver nada ba09fdb40 · 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        /// Comando dentro (default: el shell de la imagen). Va después de `--`.
106        #[arg(last = true)]
107        cmd: Vec<String>,
108    },
109    /// [ADR 0015 §Orden 5 — SIN IMPLEMENTAR] Genera los shims y `.desktop` de lo declarado en `[exporta]`.
110    Export { id: String },
111    /// [ADR 0015 §Orden 7 — SIN IMPLEMENTAR] Poda imágenes sin instancia y `upper` descartables.
112    Prune,
113}
114
115pub fn run(cmd: QorpaCmd) -> Result<()> {
116    match cmd {
117        QorpaCmd::Pull { url, sha256, label, verify_only, subdir, root } => {
118            pull(&resolve_root(root), &url, &sha256, label.as_deref(), verify_only, subdir.as_deref())
119        }
120        QorpaCmd::List { root, json } => list(&resolve_root(root), json),
121        QorpaCmd::Create { id, base, distro, root } => {
122            create(&resolve_root(root), &id, &base, distro.as_deref())
123        }
124        QorpaCmd::Recreate { id, root } => recreate(&resolve_root(root), &id),
125        QorpaCmd::Run { id, root, dry_run, cmd } => run_instance(&resolve_root(root), &id, &cmd, dry_run),
126        QorpaCmd::Export { .. } => bail!(
127            "sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \
128             `Exec=` de un .desktop ajeno."
129        ),
130        QorpaCmd::Prune => bail!(
131            "sin implementar: la poda es el paso 7 del ADR 0015. Nace con el subsistema y no \
132             después — este repo ya tuvo tres emergencias de disco."
133        ),
134    }
135}
136
137fn resolve_root(flag: Option<PathBuf>) -> PathBuf {
138    flag.or_else(|| std::env::var_os(ROOT_ENV).map(PathBuf::from))
139        .unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT))
140}
141
142/// Lo que queda escrito junto al árbol. Es el registro de PROCEDENCIA: sin él, un directorio con
143/// un rootfs adentro no sabe decir de dónde salió ni contra qué se verificó.
144#[derive(Serialize, Deserialize)]
145struct ImageManifest {
146    sha256: String,
147    url: String,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    label: Option<String>,
150    archive_bytes: u64,
151    tree_files: u64,
152    tree_bytes: u64,
153    /// El rootfs venía dentro de este subdirectorio del archivo (`root.x86_64` de Arch) y se elevó
154    /// a raíz. Ausente = el archivo YA era un rootfs plano (Ubuntu base).
155    #[serde(skip_serializing_if = "Option::is_none")]
156    rootfs_subdir: Option<String>,
157    /// Lo que había al lado del rootfs y NO entró (`version`, `pkglist.x86_64.txt` de Arch). Se
158    /// registra en vez de descartarse en silencio: si mañana falta algo, acá está qué se tiró.
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    dropped_siblings: Vec<String>,
161}
162
163// ── pull ────────────────────────────────────────────────────────────────────────────────────────
164
165fn pull(
166    root: &Path, url: &str, sha_in: &str, label: Option<&str>, verify_only: bool,
167    subdir: Option<&str>,
168) -> Result<()> {
169    let sha = normalize_sha256(sha_in)?;
170    let images = root.join("images");
171    let dest = images.join(&sha);
172
173    // Idempotencia con la regla 3 puesta delante: "ya está" sólo si TIENE CONTENIDO. Un directorio
174    // vacío de un pull interrumpido se leería como presencia y el error viajaría hasta el final.
175    if dest.exists() {
176        match tree_state(&dest.join(TREE)) {
177            TreeState::Populated => {
178                println!("ya presente y verificada: {}", dest.display());
179                return Ok(());
180            }
181            TreeState::Missing if verify_only => {
182                println!("archivo ya verificado antes (sin desempacar): {}", dest.display());
183                return Ok(());
184            }
185            TreeState::Missing | TreeState::Empty => {
186                eprintln!(
187                    "aviso: {} existe pero su árbol está vacío o ausente — pull anterior a medias, \
188                     se rehace",
189                    dest.display()
190                );
191                force_remove_dir_all(&dest)
192                    .with_context(|| format!("no pude limpiar {}", dest.display()))?;
193            }
194        }
195    }
196
197    std::fs::create_dir_all(&images)
198        .with_context(|| format!("no puedo crear {}", images.display()))?;
199
200    let archive = images.join(format!(".{sha}.part"));
201    println!("bajando {url}");
202    download_to_file(url, &archive)?;
203
204    // El corte. Si el contenido no es el que dice ser, se ABORTA — no se avisa y se sigue.
205    hammer_build::download::verify_sha256(&archive, &sha).inspect_err(|_| {
206        std::fs::remove_file(&archive).ok();
207    })?;
208    let archive_bytes = std::fs::metadata(&archive)?.len();
209    println!("sha256 verificado ({} bytes)", archive_bytes);
210
211    if verify_only {
212        std::fs::create_dir_all(&dest)?;
213        write_manifest(&dest, &ImageManifest {
214            sha256: sha.clone(), url: url.into(), label: label.map(Into::into),
215            archive_bytes, tree_files: 0, tree_bytes: 0, rootfs_subdir: None,
216            dropped_siblings: Vec::new(),
217        })?;
218        std::fs::rename(&archive, dest.join("archive"))?;
219        println!("guardada sin desempacar en {}", dest.display());
220        return Ok(());
221    }
222
223    // Desempacado atómico-ish: a un `.tmp` al lado y luego rotar. Extraer directo sobre el destino
224    // dejaría medio rootfs si algo falla, y medio rootfs es justo lo que la regla 3 castiga.
225    let staging = images.join(format!(".{sha}.tmp"));
226    force_remove_dir_all(&staging)
227        .with_context(|| format!("no pude limpiar el staging {}", staging.display()))?;
228    let tree_tmp = staging.join(TREE);
229    std::fs::create_dir_all(&tree_tmp)?;
230    println!("desempacando…");
231    // Cualquier fallo de acá en adelante barre el staging Y el archivo: "no dejar nada a medias" no
232    // vale sólo para el sha que no coincide — un desempacado a medias es la misma trampa.
233    let unpack = || -> Result<(Option<String>, Vec<String>, u64, u64)> {
234        untar(&archive, &tree_tmp)?;
235        let (sub, dropped) = locate_rootfs(&tree_tmp, subdir)?;
236        let (files, bytes) = walk_tree(&tree_tmp)?;
237        Ok((sub, dropped, files, bytes))
238    };
239    let (rootfs_subdir, dropped_siblings, tree_files, tree_bytes) = match unpack() {
240        Ok(v) => v,
241        Err(e) => {
242            force_remove_dir_all(&staging).ok();
243            std::fs::remove_file(&archive).ok();
244            return Err(e);
245        }
246    };
247    std::fs::remove_file(&archive).ok();
248
249    if let Some(d) = &rootfs_subdir {
250        println!("el rootfs venía en {d}/ — elevado a raíz");
251    }
252    if !dropped_siblings.is_empty() {
253        println!("fuera del rootfs, NO entran: {}", dropped_siblings.join(", "));
254    }
255    if tree_files == 0 {
256        force_remove_dir_all(&staging).ok();
257        bail!("el archivo desempacó VACÍO — eso no es una imagen, es un nombre (CLAUDE.md regla 3)");
258    }
259
260    write_manifest(&staging, &ImageManifest {
261        sha256: sha.clone(), url: url.into(), label: label.map(Into::into),
262        archive_bytes, tree_files, tree_bytes, rootfs_subdir, dropped_siblings,
263    })?;
264    std::fs::rename(&staging, &dest)
265        .with_context(|| format!("no puedo rotar {} a {}", staging.display(), dest.display()))?;
266
267    println!(
268        "imagen lista: {} · {tree_files} ficheros · {}",
269        dest.display(),
270        human(tree_bytes)
271    );
272    Ok(())
273}
274
275/// Descarga a fichero. NO se reusa `download::fetch_url_to_file` a propósito: ése trae el cuerpo
276/// entero a memoria (le sirve para un patch de KB) y acá hablamos de cientos de MB, con `--max-time`
277/// de 300 s que un rootfs no respeta.
278fn download_to_file(url: &str, dst: &Path) -> Result<()> {
279    if let Some(p) = dst.parent() {
280        std::fs::create_dir_all(p)?;
281    }
282    let st = Command::new("curl")
283        .args(["-fL", "--retry", "3", "--retry-delay", "2", "--connect-timeout", "30",
284               "--progress-bar", "-o"])
285        .arg(dst)
286        .arg(url)
287        .status()
288        .context("no pude ejecutar curl")?;
289    if !st.success() {
290        std::fs::remove_file(dst).ok();
291        bail!("curl falló bajando {url} (exit {:?})", st.code());
292    }
293    Ok(())
294}
295
296fn normalize_sha256(s: &str) -> Result<String> {
297    let s = s.strip_prefix("sha256:").unwrap_or(s).trim().to_ascii_lowercase();
298    if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
299        bail!("--sha256 debe ser 64 dígitos hex (llegó {:?}, {} chars)", s, s.len());
300    }
301    Ok(s)
302}
303
304/// Elige el descompresor por MAGIC, no por extensión: el nombre de un fichero es una sugerencia y
305/// las tres imágenes curadas usan tres compresores distintos (zstd en Arch, gzip en Ubuntu, xz en
306/// Fedora). `tar` extrae con `--no-same-owner` para que el resultado sea el MISMO lo corra root o
307/// no — la propiedad real la pone el mapeo del userns, no el tar.
308fn untar(archive: &Path, into: &Path) -> Result<()> {
309    let mut magic = [0u8; 6];
310    let n = std::fs::File::open(archive)?.read(&mut magic)?;
311    let flag = match &magic[..n.min(6)] {
312        [0x28, 0xb5, 0x2f, 0xfd, ..] => Some("--zstd"),
313        [0x1f, 0x8b, ..] => Some("-z"),
314        [0xfd, b'7', b'z', b'X', b'Z', 0x00] => Some("-J"),
315        [b'B', b'Z', b'h', ..] => Some("-j"),
316        _ => None, // tar plano
317    };
318    let mut c = Command::new("tar");
319    if let Some(f) = flag {
320        c.arg(f);
321    }
322    // `-p` conserva los modos exactos (el setuid de `sudo` adentro importa), y
323    // `--delay-directory-restore` es OBLIGATORIO junto con él: un rootfs real trae directorios de
324    // sólo-lectura (`/etc/ca-certificates/extracted/cadir` en Arch), y sin esto tar los crea con su
325    // modo final ANTES de meterles los ficheros ⇒ "Permission denied" al extraer sin ser root. Con
326    // esto, tar los deja escribibles y aplica los modos al final.
327    // tar ya rechaza rutas absolutas y `..` al extraer: ésa es la defensa contra un tarball ajeno
328    // malicioso. Los xattr `security.capability` se pierden sin privilegios (tar avisa): dentro de
329    // la jaula da igual salvo para binarios que dependan de capabilities de fichero — anotado.
330    c.args(["-x", "-p", "--delay-directory-restore", "--no-same-owner", "-f"])
331        .arg(archive).arg("-C").arg(into);
332    let st = c.status().context("no pude ejecutar tar")?;
333    if !st.success() {
334        bail!("tar falló desempacando {} (exit {:?})", archive.display(), st.code());
335    }
336    Ok(())
337}
338
339/// Dónde está el rootfs dentro de lo que se desempacó.
340///
341/// La primera versión de esto usaba «si hay UN solo directorio arriba, ése es el rootfs». Sonaba
342/// razonable y es FALSA: el bootstrap de Arch trae tres entradas arriba (`root.x86_64/`, `version`,
343/// `pkglist.x86_64.txt`) y la regla no disparaba, así que el rootfs quedaba un nivel abajo — con
344/// todo verde y sin un solo error. Se mide por ESTRUCTURA, que es lo que define a un rootfs:
345/// tiene `etc/` y tiene `usr/` o `bin/`.
346///
347/// Si no acierta, **falla y pide `--subdir`** en vez de adivinar: un rootfs mal anclado no rompe
348/// acá, rompe mucho después, cuando la instancia no encuentra su loader.
349fn looks_like_rootfs(d: &Path) -> bool {
350    d.join("etc").is_dir() && (d.join("usr").is_dir() || d.join("bin").exists())
351}
352
353/// Deja `tree` conteniendo el rootfs. Devuelve (subdir elevado, hermanos descartados).
354fn locate_rootfs(tree: &Path, forced: Option<&str>) -> Result<(Option<String>, Vec<String>)> {
355    if forced.is_none() && looks_like_rootfs(tree) {
356        return Ok((None, Vec::new())); // Ubuntu base: el archivo YA es el rootfs.
357    }
358    let entries: Vec<_> = std::fs::read_dir(tree)
359        .with_context(|| format!("leyendo la raíz desempacada {}", tree.display()))?
360        .collect::<std::io::Result<Vec<_>>>()?;
361    let name = match forced {
362        Some(f) => {
363            if !tree.join(f).is_dir() {
364                bail!("--subdir {f:?} no existe dentro del archivo");
365            }
366            f.to_string()
367        }
368        None => {
369            let mut cands: Vec<String> = entries
370                .iter()
371                .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
372                .filter(|e| looks_like_rootfs(&e.path()))
373                .map(|e| e.file_name().to_string_lossy().into_owned())
374                .collect();
375            cands.sort();
376            match cands.len() {
377                1 => cands.remove(0),
378                0 => bail!(
379                    "no encuentro un rootfs en el archivo (ni la raíz ni ningún subdirectorio tiene \
380                     `etc/` + `usr|bin`). Pasá `--subdir <dir>` si sabés dónde está."
381                ),
382                _ => bail!(
383                    "hay {} subdirectorios que parecen rootfs ({}) — elegí con `--subdir`",
384                    cands.len(), cands.join(", ")
385                ),
386            }
387        }
388    };
389    let dropped: Vec<String> = entries
390        .iter()
391        .map(|e| e.file_name().to_string_lossy().into_owned())
392        .filter(|n| *n != name)
393        .collect();
394
395    let top = tree.join(&name);
396    let parked = tree.with_file_name(format!(
397        "{}.lift",
398        tree.file_name().unwrap_or_default().to_string_lossy()
399    ));
400    // Renombrar un DIRECTORIO exige permiso de escritura sobre el directorio mismo (el kernel tiene
401    // que reescribir su `..`), y el `root.x86_64` de Arch viene `dr-xr-xr-x`. Se abre, se mueve y se
402    // le devuelve su modo exacto: la imagen tiene que quedar como venía, no como nos vino cómodo.
403    let mode = std::fs::metadata(&top)?.permissions().mode();
404    std::fs::set_permissions(&top, std::fs::Permissions::from_mode(mode | 0o700))?;
405    std::fs::rename(&top, &parked)
406        .with_context(|| format!("elevando {} a {}", top.display(), parked.display()))?;
407    force_remove_dir_all(tree)
408        .with_context(|| format!("descartando los hermanos en {}", tree.display()))?;
409    std::fs::rename(&parked, tree)
410        .with_context(|| format!("rotando {} a {}", parked.display(), tree.display()))?;
411    std::fs::set_permissions(tree, std::fs::Permissions::from_mode(mode))?;
412    Ok((Some(name), dropped))
413}
414
415/// `remove_dir_all` NO puede con un árbol que trae directorios sin permiso de escritura — y un
416/// rootfs real los trae (`/etc/ca-certificates/extracted/cadir` de Arch es 0555). Tragarse ese
417/// fallo con `.ok()` deja vivo el staging de un pull roto y el siguiente pull extrae ENCIMA: nos
418/// pasó, y el síntoma («Permission denied» en un directorio que acabás de crear) no se parece en
419/// nada a la causa. Abre los directorios a escritura de arriba abajo y después borra.
420fn force_remove_dir_all(p: &Path) -> std::io::Result<()> {
421    if !p.exists() {
422        return Ok(());
423    }
424    let mut stack = vec![p.to_path_buf()];
425    while let Some(d) = stack.pop() {
426        let _ = std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700));
427        if let Ok(rd) = std::fs::read_dir(&d) {
428            for e in rd.flatten() {
429                // `file_type` de read_dir es lstat: un symlink a directorio NO se sigue.
430                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
431                    stack.push(e.path());
432                }
433            }
434        }
435    }
436    std::fs::remove_dir_all(p)
437}
438
439enum TreeState { Missing, Empty, Populated }
440
441fn tree_state(tree: &Path) -> TreeState {
442    match std::fs::read_dir(tree) {
443        Err(_) => TreeState::Missing,
444        Ok(mut it) => {
445            if it.next().is_some() { TreeState::Populated } else { TreeState::Empty }
446        }
447    }
448}
449
450fn walk_tree(root: &Path) -> Result<(u64, u64)> {
451    let (mut files, mut bytes) = (0u64, 0u64);
452    let mut stack = vec![root.to_path_buf()];
453    while let Some(d) = stack.pop() {
454        // Un rootfs ajeno trae directorios que NI SU DUEÑO puede leer (`/etc/sudoers.d` es 0750,
455        // `/var/log/private` 0700, y alguno 0000). No es corrupción: es el rootfs siendo lo que es.
456        // Se cuentan como 0 y se sigue — abortar el pull por no poder contar sería absurdo.
457        let rd = match std::fs::read_dir(&d) {
458            Ok(rd) => rd,
459            Err(_) => continue,
460        };
461        for e in rd {
462            let Ok(e) = e else { continue };
463            let Ok(ft) = e.file_type() else { continue };
464            if ft.is_dir() {
465                stack.push(e.path());
466            } else {
467                files += 1;
468                bytes += e.metadata().map(|m| m.len()).unwrap_or(0);
469            }
470        }
471    }
472    Ok((files, bytes))
473}
474
475fn write_manifest(dir: &Path, m: &ImageManifest) -> Result<()> {
476    let body = format!(
477        "# Lo escribe `hammer qorpa pull`; no editar a mano.\n\
478         # La identidad de la imagen es el sha256 del ARCHIVO original, no de este árbol: por eso\n\
479         # la URL es informativa y espejar es gratis (ADR 0013).\n{}",
480        toml::to_string_pretty(m).context("serializando el manifiesto")?
481    );
482    std::fs::write(dir.join(IMAGE_MANIFEST), body)?;
483    Ok(())
484}
485
486// ── instancias ──────────────────────────────────────────────────────────────────────────────────
487
488/// El manifiesto de una instancia. **Es la verdad**: el `upper/` es caché y se puede tirar entero
489/// (D3). Los nombres de campo van en inglés como el resto de la superficie (`CLAUDE.md` regla 4);
490/// el ADR los escribió en castellano y se corrigieron aquí.
491#[derive(Serialize, Deserialize, Default)]
492struct Instance {
493    /// sha256 de la imagen base. Cambiarlo + `recreate` ES la actualización de versión (D8).
494    base: String,
495    #[serde(skip_serializing_if = "Option::is_none")]
496    distro: Option<String>,
497    /// Lo que debe estar instalado adentro. Declarativo: hoy se registra, instalarlo es el
498    /// siguiente paso. Sin esto, `recreate` no podría reconstruir nada.
499    #[serde(default, skip_serializing_if = "Vec::is_empty")]
500    packages: Vec<String>,
501    /// La política, autorada (D7). **Por defecto vacía**: una instancia nace sin ver nada.
502    #[serde(default)]
503    grants: Grants,
504}
505
506#[derive(Serialize, Deserialize, Default)]
507struct Grants {
508    /// Nombres cortos: `wayland`, `pipewire`, `dbus`. ⚠ `wayland` es un borde de PRIVILEGIO, no un
509    /// caño: quien lo tiene puede capturar pantalla y sintetizar teclas (ADR §NO-resuelve 1).
510    #[serde(default, skip_serializing_if = "Vec::is_empty")]
511    sockets: Vec<String>,
512    /// Nodos de dispositivo (`/dev/dri`). ABI de kernel, que es lo único que cruza el borde (D2).
513    #[serde(default, skip_serializing_if = "Vec::is_empty")]
514    devices: Vec<String>,
515    #[serde(default, skip_serializing_if = "Vec::is_empty")]
516    dirs: Vec<DirGrant>,
517    /// La concesión más peligrosa, y por eso va sola en su línea. `dnf`/`pacman` la necesitan.
518    #[serde(default)]
519    network: bool,
520}
521
522#[derive(Serialize, Deserialize)]
523struct DirGrant {
524    host: String,
525    inside: String,
526    /// `ro` o `rw`.
527    #[serde(default = "ro")]
528    mode: String,
529}
530fn ro() -> String { "ro".into() }
531
532fn instances_dir(root: &Path) -> PathBuf { root.join("instances") }
533fn instance_dir(root: &Path, id: &str) -> PathBuf { instances_dir(root).join(id) }
534
535/// Resuelve un prefijo de sha contra las imágenes presentes. Que exista y que TENGA CONTENIDO: una
536/// instancia sobre una imagen vacía es un directorio con nombre de rootfs (regla 3).
537fn resolve_image(root: &Path, prefix: &str) -> Result<(String, PathBuf)> {
538    let prefix = prefix.strip_prefix("sha256:").unwrap_or(prefix).to_ascii_lowercase();
539    let images = root.join("images");
540    let mut hits: Vec<String> = std::fs::read_dir(&images)
541        .with_context(|| format!("no hay imágenes en {}", images.display()))?
542        .flatten()
543        .map(|e| e.file_name().to_string_lossy().into_owned())
544        .filter(|n| !n.starts_with('.') && n.starts_with(&prefix))
545        .collect();
546    hits.sort();
547    match hits.len() {
548        1 => {
549            let sha = hits.remove(0);
550            let tree = images.join(&sha).join(TREE);
551            if !matches!(tree_state(&tree), TreeState::Populated) {
552                bail!("la imagen {sha} está VACÍA — no es una imagen, es un nombre");
553            }
554            Ok((sha, tree))
555        }
556        0 => bail!("ninguna imagen empieza por {prefix:?} — traela con `hammer qorpa pull`"),
557        n => bail!("{n} imágenes empiezan por {prefix:?}: dame más dígitos"),
558    }
559}
560
561fn create(root: &Path, id: &str, base: &str, distro: Option<&str>) -> Result<()> {
562    if id.is_empty() || id.contains('/') || id.starts_with('.') {
563        bail!("id de instancia inválido: {id:?}");
564    }
565    let dir = instance_dir(root, id);
566    if dir.exists() {
567        bail!("la instancia {id} ya existe en {} — `recreate` tira su capa mutable", dir.display());
568    }
569    let (sha, _tree) = resolve_image(root, base)?;
570
571    let inst = Instance {
572        base: format!("sha256:{sha}"),
573        distro: distro.map(Into::into),
574        ..Default::default()
575    };
576    std::fs::create_dir_all(dir.join("upper"))?;
577    std::fs::create_dir_all(dir.join("work"))?;
578    write_instance(&dir, &inst)?;
579    println!("instancia {id} creada en {}", dir.display());
580    println!("  base: sha256:{}", &sha[..12]);
581    println!("  concesiones: NINGUNA — se abren declarándolas en {}", INSTANCE_MANIFEST);
582    Ok(())
583}
584
585fn read_instance(root: &Path, id: &str) -> Result<(PathBuf, Instance)> {
586    let dir = instance_dir(root, id);
587    let f = dir.join(INSTANCE_MANIFEST);
588    let body = std::fs::read_to_string(&f)
589        .with_context(|| format!("no existe la instancia {id} ({})", f.display()))?;
590    let inst: Instance = toml::from_str(&body)
591        .with_context(|| format!("manifiesto ilegible: {}", f.display()))?;
592    Ok((dir, inst))
593}
594
595fn write_instance(dir: &Path, inst: &Instance) -> Result<()> {
596    let body = format!(
597        "# Manifiesto de la instancia — ESTO es la verdad, `upper/` es caché descartable (ADR 0015 D3).\n\
598         # `[grants]` es la política, y se AUTORA: es el único sitio del sistema donde no se deriva\n\
599         # de una clausura declarada (D7). Por defecto no hay ninguna.\n{}",
600        toml::to_string_pretty(inst).context("serializando la instancia")?
601    );
602    std::fs::write(dir.join(INSTANCE_MANIFEST), body)?;
603    Ok(())
604}
605
606fn recreate(root: &Path, id: &str) -> Result<()> {
607    let (dir, inst) = read_instance(root, id)?;
608    let (sha, _) = resolve_image(root, &inst.base)?;
609    for capa in ["upper", "work"] {
610        force_remove_dir_all(&dir.join(capa))
611            .with_context(|| format!("no pude tirar {capa}/ de {id}"))?;
612        std::fs::create_dir_all(dir.join(capa))?;
613    }
614    println!("instancia {id} recreada sobre sha256:{} — capa mutable tirada", &sha[..12]);
615    if !inst.packages.is_empty() {
616        println!(
617            "  declara {} paquete(s) ({}) — instalarlos todavía es a mano con `run`",
618            inst.packages.len(),
619            inst.packages.join(", ")
620        );
621    }
622    Ok(())
623}
624
625/// Traduce las concesiones a argumentos de bwrap. Devuelve también las advertencias que el usuario
626/// tiene que LEER, no descubrir: el ADR pide que el socket de Wayland se diga en la cara.
627fn grants_to_args(g: &Grants) -> (Vec<String>, Vec<String>) {
628    let mut a: Vec<String> = Vec::new();
629    let mut warn: Vec<String> = Vec::new();
630    // Sin dep de libc: el dueño de /proc/self ES el uid efectivo.
631    let uid = std::fs::metadata("/proc/self")
632        .map(|m| std::os::unix::fs::MetadataExt::uid(&m))
633        .unwrap_or(1000);
634    let xdg = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| format!("/run/user/{uid}"));
635
636    for s in &g.sockets {
637        let (host, dentro) = match s.as_str() {
638            "wayland" => {
639                warn.push(
640                    "wayland: la instancia podrá CAPTURAR LA PANTALLA y SINTETIZAR TECLAS — el \
641                     socket es un borde de privilegio y todavía no hay proxy filtrante \
642                     (ADR §NO-resuelve 1)".into(),
643                );
644                let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
645                (format!("{xdg}/{d}"), "/run/user/0/wayland-0".to_string())
646            }
647            "pipewire" => (format!("{xdg}/pipewire-0"), "/run/user/0/pipewire-0".into()),
648            "dbus" => (format!("{xdg}/bus"), "/run/user/0/bus".into()),
649            otro => {
650                warn.push(format!("socket desconocido {otro:?} — ignorado"));
651                continue;
652            }
653        };
654        a.push("--bind-try".into()); a.push(host); a.push(dentro);
655    }
656    for d in &g.devices {
657        a.push("--dev-bind-try".into()); a.push(d.clone()); a.push(d.clone());
658    }
659    for d in &g.dirs {
660        let host = if let Some(rest) = d.host.strip_prefix('~') {
661            format!("{}{}", std::env::var("HOME").unwrap_or_default(), rest)
662        } else { d.host.clone() };
663        a.push(if d.mode == "rw" { "--bind".into() } else { "--ro-bind".into() });
664        a.push(host); a.push(d.inside.clone());
665    }
666    if g.network {
667        // El segundo fallo clásico de la primera corrida, según el propio ADR.
668        a.push("--ro-bind-try".into());
669        a.push("/etc/resolv.conf".into());
670        a.push("/etc/resolv.conf".into());
671    } else {
672        a.push("--unshare-net".into());
673    }
674    (a, warn)
675}
676
677fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool) -> Result<()> {
678    let (dir, inst) = read_instance(root, id)?;
679    let (sha, tree) = resolve_image(root, &inst.base)?;
680    let (grant_args, warns) = grants_to_args(&inst.grants);
681
682    // El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo
683    // después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo
684    // que no se declara, no entra (D2, "por defecto NADA").
685    let mut args: Vec<String> = vec![
686        "--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try",
687        "--uid", "0", "--gid", "0",
688        "--hostname", "qorpa",
689        "--clearenv",
690        "--die-with-parent",
691    ].into_iter().map(String::from).collect();
692
693    args.push("--overlay-src".into()); args.push(tree.display().to_string());
694    args.push("--overlay".into());
695    args.push(dir.join("upper").display().to_string());
696    args.push(dir.join("work").display().to_string());
697    args.push("/".into());
698
699    // /proc y /dev van DESPUÉS del overlay: son del kernel, no de la imagen.
700    args.extend(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp",
701                 "--dir", "/run/user/0", "--chdir", "/"].map(String::from));
702    args.extend([
703        "--setenv", "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
704        "--setenv", "HOME", "/root",
705        "--setenv", "TERM", &std::env::var("TERM").unwrap_or_else(|_| "xterm".into()),
706        "--setenv", "XDG_RUNTIME_DIR", "/run/user/0",
707    ].map(String::from));
708    args.extend(grant_args);
709
710    let cmd: Vec<String> = if cmd.is_empty() {
711        vec!["/bin/sh".into()] // toda imagen trae uno; bash puede no estar.
712    } else { cmd.to_vec() };
713    args.extend(cmd.iter().cloned());
714
715    for w in &warns {
716        eprintln!("⚠ {w}");
717    }
718    if dry_run {
719        // Una línea por concesión, no una por token: el punto de `--dry-run` es que se LEA.
720        let mut linea = String::from("bwrap");
721        for a in &args {
722            if a.starts_with("--") { println!("{linea} \\"); linea = format!("  {a}"); }
723            else { linea.push(' '); linea.push_str(a); }
724        }
725        println!("{linea}");
726        return Ok(());
727    }
728    eprintln!("qorpa {id} · sha256:{} · red={} · {} socket(s), {} device(s), {} dir(s)",
729        &sha[..12], inst.grants.network, inst.grants.sockets.len(),
730        inst.grants.devices.len(), inst.grants.dirs.len());
731
732    let st = Command::new("bwrap").args(&args).status()
733        .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?;
734    match st.code() {
735        Some(0) => Ok(()),
736        Some(c) => std::process::exit(c),
737        None => bail!("la instancia murió por señal"),
738    }
739}
740
741// ── list ────────────────────────────────────────────────────────────────────────────────────────
742
743fn list(root: &Path, json: bool) -> Result<()> {
744    let images = root.join("images");
745    let mut rows: Vec<(String, Option<ImageManifest>, TreeState)> = Vec::new();
746    if let Ok(rd) = std::fs::read_dir(&images) {
747        for e in rd.flatten() {
748            let name = e.file_name().to_string_lossy().into_owned();
749            if name.starts_with('.') {
750                continue; // restos de un pull a medias
751            }
752            let m = std::fs::read_to_string(e.path().join(IMAGE_MANIFEST))
753                .ok()
754                .and_then(|s| toml::from_str::<ImageManifest>(&s).ok());
755            rows.push((name, m, tree_state(&e.path().join(TREE))));
756        }
757    }
758    rows.sort_by(|a, b| a.0.cmp(&b.0));
759
760    let insts: Vec<(String, Instance)> = std::fs::read_dir(instances_dir(root))
761        .into_iter()
762        .flatten()
763        .flatten()
764        .filter_map(|e| {
765            let id = e.file_name().to_string_lossy().into_owned();
766            let body = std::fs::read_to_string(e.path().join(INSTANCE_MANIFEST)).ok()?;
767            Some((id, toml::from_str::<Instance>(&body).ok()?))
768        })
769        .collect();
770
771    if json {
772        let out: Vec<_> = rows.iter().map(|(sha, m, st)| {
773            serde_json::json!({
774                "sha256": sha,
775                "label": m.as_ref().and_then(|m| m.label.clone()),
776                "url": m.as_ref().map(|m| m.url.clone()),
777                "tree_files": m.as_ref().map(|m| m.tree_files).unwrap_or(0),
778                "tree_bytes": m.as_ref().map(|m| m.tree_bytes).unwrap_or(0),
779                "empty": !matches!(st, TreeState::Populated),
780            })
781        }).collect();
782        let insts_json: Vec<_> = insts.iter().map(|(id, i)| serde_json::json!({
783            "id": id, "base": i.base, "distro": i.distro,
784            "packages": i.packages, "network": i.grants.network,
785            "sockets": i.grants.sockets, "devices": i.grants.devices,
786        })).collect();
787        println!("{}", serde_json::to_string_pretty(
788            &serde_json::json!({"images": out, "instances": insts_json}))?);
789        return Ok(());
790    }
791
792    if rows.is_empty() && insts.is_empty() {
793        println!("no hay imágenes en {} — `hammer qorpa pull <url> --sha256 <sha>`", images.display());
794        return Ok(());
795    }
796    let mut vacias = 0;
797    for (sha, m, st) in &rows {
798        let etiqueta = m.as_ref().and_then(|m| m.label.clone()).unwrap_or_else(|| "—".into());
799        match st {
800            TreeState::Populated => println!(
801                "  {}  {:<32} {:>7} ficheros  {}",
802                &sha[..12],
803                etiqueta,
804                m.as_ref().map(|m| m.tree_files).unwrap_or(0),
805                human(m.as_ref().map(|m| m.tree_bytes).unwrap_or(0)),
806            ),
807            _ => {
808                vacias += 1;
809                println!("  {}  {:<32} !! VACÍA — no es una imagen, es un nombre", &sha[..12], etiqueta);
810            }
811        }
812    }
813    if !insts.is_empty() {
814        println!("\ninstancias:");
815        for (id, i) in &insts {
816            let sha = i.base.strip_prefix("sha256:").unwrap_or(&i.base);
817            // Se enumeran las concesiones porque una instancia sin política declarada y una con la
818            // pantalla abierta se ven IGUAL desde fuera, y no son lo mismo (ADR §NO-resuelve 1).
819            let mut abre: Vec<String> = Vec::new();
820            if i.grants.network { abre.push("red".into()); }
821            abre.extend(i.grants.sockets.iter().cloned());
822            abre.extend(i.grants.devices.iter().cloned());
823            if !i.grants.dirs.is_empty() { abre.push(format!("{} dir(s)", i.grants.dirs.len())); }
824            println!("  {:<16} sobre {}  abre: {}", id, &sha[..sha.len().min(12)],
825                if abre.is_empty() { "nada".into() } else { abre.join(", ") });
826        }
827    }
828    println!("\n{} imagen(es) en {}", rows.len(), images.display());
829    if vacias > 0 {
830        bail!("{vacias} imagen(es) VACÍA(s): un ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien");
831    }
832    Ok(())
833}
834
835fn human(b: u64) -> String {
836    const U: [&str; 5] = ["B", "K", "M", "G", "T"];
837    let (mut v, mut i) = (b as f64, 0);
838    while v >= 1024.0 && i < 4 { v /= 1024.0; i += 1; }
839    if i == 0 { format!("{b} B") } else { format!("{v:.1} {}", U[i]) }
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845
846    fn tar_gz(dir: &Path, dst: &Path, contenido: &[(&str, &str)]) {
847        for (p, c) in contenido {
848            let f = dir.join(p);
849            std::fs::create_dir_all(f.parent().unwrap()).unwrap();
850            std::fs::write(f, c).unwrap();
851        }
852        let st = Command::new("tar").args(["-czf"]).arg(dst).arg("-C").arg(dir)
853            .args(contenido.iter().map(|(p, _)| p.split('/').next().unwrap()).collect::<std::collections::BTreeSet<_>>())
854            .status().unwrap();
855        assert!(st.success());
856    }
857
858    fn sha_of(p: &Path) -> String {
859        use sha2::{Digest, Sha256};
860        let mut h = Sha256::new();
861        std::io::copy(&mut std::fs::File::open(p).unwrap(), &mut h).unwrap();
862        hex::encode(h.finalize())
863    }
864
865    #[test]
866    fn pull_verifica_desempaca_y_es_idempotente() {
867        let d = tempfile::tempdir().unwrap();
868        let src = d.path().join("src");
869        std::fs::create_dir_all(&src).unwrap();
870        let arch = d.path().join("img.tar.gz");
871        tar_gz(&src, &arch, &[("etc/os-release", "ID=ajena\n"), ("bin/sh", "#!/bin/sh\n")]);
872        let sha = sha_of(&arch);
873        let root = d.path().join("qorpa");
874        let url = format!("file://{}", arch.display());
875
876        pull(&root, &url, &sha, Some("prueba"), false, None).unwrap();
877        let tree = root.join("images").join(&sha).join(TREE);
878        assert_eq!(std::fs::read_to_string(tree.join("etc/os-release")).unwrap(), "ID=ajena\n");
879        // Segundo pull: no vuelve a bajar ni rompe.
880        pull(&root, &url, &sha, Some("prueba"), false, None).unwrap();
881        assert!(root.join("images").join(&sha).join(IMAGE_MANIFEST).exists());
882    }
883
884    #[test]
885    fn pull_aborta_si_el_contenido_no_es_el_que_dice_ser() {
886        let d = tempfile::tempdir().unwrap();
887        let src = d.path().join("src");
888        std::fs::create_dir_all(&src).unwrap();
889        let arch = d.path().join("img.tar.gz");
890        tar_gz(&src, &arch, &[("etc/hostname", "ajena\n")]);
891        let root = d.path().join("qorpa");
892        let mentira = "0".repeat(64);
893        let err = pull(&root, &format!("file://{}", arch.display()), &mentira, None, false, None)
894            .unwrap_err().to_string();
895        assert!(err.contains("sha256 mismatch"), "mensaje inesperado: {err}");
896        // Y no dejó nada a medias: contenido distinto ⇒ ABORTAR, no "avisar y seguir".
897        assert!(!root.join("images").join(&mentira).exists());
898    }
899
900    /// La cicatriz de Arch: el rootfs viene en `root.x86_64/` CON HERMANOS al lado (`version`,
901    /// `pkglist`). La regla vieja ("un solo directorio arriba") no disparaba y dejaba el rootfs un
902    /// nivel abajo, en verde. Se ancla por estructura.
903    #[test]
904    fn el_rootfs_anidado_se_eleva_aunque_tenga_hermanos() {
905        let d = tempfile::tempdir().unwrap();
906        let src = d.path().join("src");
907        std::fs::create_dir_all(&src).unwrap();
908        let arch = d.path().join("boot.tar.gz");
909        tar_gz(&src, &arch, &[
910            ("root.x86_64/etc/os-release", "ID=arch\n"),
911            ("root.x86_64/usr/lib/keep", ""),
912            ("version", "2026.09.01\n"),
913        ]);
914        let sha = sha_of(&arch);
915        let root = d.path().join("qorpa");
916        pull(&root, &format!("file://{}", arch.display()), &sha, None, false, None).unwrap();
917        let tree = root.join("images").join(&sha).join(TREE);
918        assert!(tree.join("etc/os-release").exists(), "no ancló el rootfs anidado");
919        assert!(!tree.join("root.x86_64").exists());
920        assert!(!tree.join("version").exists(), "el hermano no debía entrar al rootfs");
921        let m: ImageManifest = toml::from_str(
922            &std::fs::read_to_string(root.join("images").join(&sha).join(IMAGE_MANIFEST)).unwrap(),
923        ).unwrap();
924        assert_eq!(m.rootfs_subdir.as_deref(), Some("root.x86_64"));
925        assert_eq!(m.dropped_siblings, vec!["version".to_string()]);
926    }
927
928    #[test]
929    fn un_arbol_vacio_no_pasa_por_imagen() {
930        let d = tempfile::tempdir().unwrap();
931        let vacio = d.path().join("vacio");
932        std::fs::create_dir_all(&vacio).unwrap();
933        let arch = d.path().join("nada.tar.gz");
934        assert!(Command::new("tar").args(["-czf"]).arg(&arch).arg("-C").arg(&vacio)
935            .args(["--files-from", "/dev/null"]).status().unwrap().success());
936        let sha = sha_of(&arch);
937        let root = d.path().join("qorpa");
938        let err = pull(&root, &format!("file://{}", arch.display()), &sha, None, false, None)
939            .unwrap_err().to_string();
940        assert!(err.contains("VACÍO") || err.contains("no encuentro un rootfs"),
941                "mensaje inesperado: {err}");
942        assert!(!root.join("images").join(&sha).exists());
943    }
944
945    /// Una imagen mínima que pasa por rootfs: `etc/` + `bin/`.
946    fn imagen_falsa(root: &Path, sha: &str) {
947        let t = root.join("images").join(sha).join(TREE);
948        std::fs::create_dir_all(t.join("etc")).unwrap();
949        std::fs::create_dir_all(t.join("bin")).unwrap();
950        std::fs::write(t.join("etc/os-release"), "ID=falsa\n").unwrap();
951    }
952
953    #[test]
954    fn create_nace_sin_ninguna_concesion() {
955        let d = tempfile::tempdir().unwrap();
956        let root = d.path();
957        let sha = "a".repeat(64);
958        imagen_falsa(root, &sha);
959        create(root, "prueba", "aaaa", Some("falsa")).unwrap();
960        let (_, inst) = read_instance(root, "prueba").unwrap();
961        assert_eq!(inst.base, format!("sha256:{sha}"));
962        // D7: la política se autora, y arranca VACÍA.
963        assert!(!inst.grants.network);
964        assert!(inst.grants.sockets.is_empty() && inst.grants.devices.is_empty());
965        // Y no se pisa a sí misma.
966        assert!(create(root, "prueba", "aaaa", None).is_err());
967    }
968
969    #[test]
970    fn create_rechaza_una_imagen_vacia() {
971        let d = tempfile::tempdir().unwrap();
972        let root = d.path();
973        let sha = "b".repeat(64);
974        std::fs::create_dir_all(root.join("images").join(&sha).join(TREE)).unwrap();
975        let err = create(root, "x", &sha, None).unwrap_err().to_string();
976        assert!(err.contains("VACÍA"), "mensaje inesperado: {err}");
977    }
978
979    #[test]
980    fn recreate_tira_la_capa_mutable_y_conserva_el_manifiesto() {
981        let d = tempfile::tempdir().unwrap();
982        let root = d.path();
983        let sha = "c".repeat(64);
984        imagen_falsa(root, &sha);
985        create(root, "i", &sha, None).unwrap();
986        let upper = instance_dir(root, "i").join("upper");
987        std::fs::write(upper.join("basura"), "x").unwrap();
988        recreate(root, "i").unwrap();
989        assert!(!upper.join("basura").exists(), "el upper no es caché si sobrevive");
990        assert!(instance_dir(root, "i").join(INSTANCE_MANIFEST).exists(), "el manifiesto ES la verdad");
991    }
992
993    #[test]
994    fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() {
995        let (a, w) = grants_to_args(&Grants::default());
996        assert!(a.contains(&"--unshare-net".to_string()));
997        assert!(w.is_empty());
998
999        let g = Grants { network: true, ..Default::default() };
1000        let (a, _) = grants_to_args(&g);
1001        assert!(!a.contains(&"--unshare-net".to_string()));
1002        // El segundo fallo clásico de la primera corrida, según el ADR.
1003        assert!(a.iter().any(|x| x == "/etc/resolv.conf"));
1004    }
1005
1006    #[test]
1007    fn el_socket_de_wayland_avisa_que_es_un_borde_de_privilegio() {
1008        let g = Grants { sockets: vec!["wayland".into()], ..Default::default() };
1009        let (_, w) = grants_to_args(&g);
1010        assert_eq!(w.len(), 1);
1011        assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]);
1012    }
1013
1014    #[test]
1015    fn sha_mal_formado_falla_antes_de_tocar_la_red() {
1016        assert!(normalize_sha256("abc").is_err());
1017        assert_eq!(normalize_sha256(&format!("sha256:{}", "A".repeat(64))).unwrap(), "a".repeat(64));
1018    }
1019}