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…
⎇ ba09fdb403c7e214d3d813e9ee1bc4130470a333
crates/hammer-cli/src/qorpa.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
qorpa create/recreate/run: la instancia nace sin ver nada
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}