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