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