//! `takana qorpa` — imágenes ajenas ([ADR 0015](../../../docs/adr/0015-imagenes-ajenas.md)). //! //! Superficie en inglés, mensajes en castellano (`CLAUDE.md` regla 4). //! //! ── LA INVARIANTE QUE ESTE MÓDULO PROTEGE ─────────────────────────────────────────────────────── //! Una imagen ajena **no es un artefacto y no vive en el store** (ADR 0015 D1). El store promete que //! cada entrada se reconstruye desde fuente bit a bit, y un rootfs de Ubuntu no. Por eso todo lo de //! acá cuelga de un espacio de nombres paralelo (`/var/lib/hammer/qorpa/`), fuera de `hash_inputs` //! de todo, y nada de este fichero toca `Store`. //! //! ── EL ORDEN DE LAS OPERACIONES NO ES CASUAL ──────────────────────────────────────────────────── //! Se verifica el sha256 **antes** de desempacar, nunca después. Un tar ajeno que no verificaste es //! código ajeno que ya escribió en tu disco: desempacar primero y comprobar luego deja el árbol //! sucio justo en el caso que importa. Y el veredicto sigue a ADR 0014: *ausencia ⇒ seguir; //! contenido distinto ⇒ ABORTAR*. use std::io::Read; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{bail, Context, Result}; use clap::Subcommand; use serde::{Deserialize, Serialize}; /// Raíz del espacio de nombres de qorpa. Deliberadamente **fuera** del store. pub const DEFAULT_ROOT: &str = "/var/lib/hammer/qorpa"; /// Para correr sin root (tests, laptop). Mismo patrón que `TAKANA_KCONFIG_ROOT`. pub const ROOT_ENV: &str = "TAKANA_QORPA_ROOT"; /// El manifiesto de la imagen. No confundir con `instancia.toml`, que describe una INSTANCIA. const IMAGE_MANIFEST: &str = "image.toml"; /// El árbol desempacado dentro del directorio de la imagen. const TREE: &str = "rootfs"; /// El manifiesto de una INSTANCIA: la verdad de la que se recrea (D3). const INSTANCE_MANIFEST: &str = "instance.toml"; #[derive(Subcommand)] pub enum QorpaCmd { /// [ADR 0015 §Orden 2] Trae una imagen ajena y la verifica por sha256 ANTES de desempacarla. /// /// La identidad de la imagen es el sha256 del ARCHIVO original, no del árbol: así dos máquinas /// que la traigan de orígenes distintos coinciden, que es lo que hace gratis el espejo /// (ADR 0013: la URL no entra en la identidad) y posible el multi-origen (ADR 0014). Pull { /// De dónde bajarla. Cualquier esquema que entienda curl, `file://` incluido. url: String, /// sha256 esperado del archivo, en hex. Obligatorio: sin esto no hay identidad. #[arg(long)] sha256: String, /// Etiqueta legible (`arch-bootstrap-2026.09.01`). Informativa; no entra en la identidad. #[arg(long)] label: Option, /// Baja y verifica, pero NO desempaca. Para poblar un espejo sin gastar el disco del árbol. #[arg(long)] verify_only: bool, /// Dónde está el rootfs DENTRO del archivo, si la detección automática no acierta. #[arg(long)] subdir: Option, /// Raíz de qorpa (default: `$TAKANA_QORPA_ROOT` o `/var/lib/hammer/qorpa`). #[arg(long)] root: Option, }, /// Lista las imágenes presentes. Marca a gritos las que están VACÍAS (`CLAUDE.md` regla 3): /// un directorio vacío no es una imagen, es un nombre. List { #[arg(long)] root: Option, /// Salida JSON, para que un script no tenga que parsear la tabla. #[arg(long)] json: bool, }, /// [ADR 0015 §Orden 3] Crea una instancia: capa mutable sobre una imagen + su manifiesto. /// /// Nace SIN NINGUNA concesión (D7): ni red, ni sockets, ni dispositivos, ni directorios del /// host. Se abre lo que se declare en `instance.toml`, y sólo eso. Create { /// Identificador de la instancia (`games`). id: String, /// sha256 de la imagen base, ya traída con `pull`. Acepta prefijos. #[arg(long)] base: String, /// Nombre informativo de la distro (`arch`, `ubuntu-24.04`). #[arg(long)] distro: Option, #[arg(long)] root: Option, }, /// [ADR 0015 §Orden 3] Tira la capa mutable y la rehace desde el manifiesto. /// /// Es la prueba de D3: si esto duele, es que el `upper` se volvió el activo — y un blob /// irreemplazable es justo lo que takana existe para no tener. Recreate { id: String, /// No reinstalar `packages` después de tirar la capa. Deja la instancia como recién creada. #[arg(long)] no_provision: bool, #[arg(long)] root: Option, }, /// [ADR 0015 §Orden 3] Entra a la instancia con las concesiones declaradas y nada más. Run { id: String, #[arg(long)] root: Option, /// Imprime el `bwrap` que se ejecutaría y sale. La jaula tiene que ser inspeccionable. #[arg(long)] dry_run: bool, /// Corre SIN harkaq (sin Landlock, sin seccomp, sin no_new_privs). Para diagnosticar /// cuando algo muere y hay que saber si fue la jaula. Avisa a gritos. #[arg(long)] no_jail: bool, /// Fuerza el userns de un solo id que crea bwrap, en vez del rango de `/etc/subuid`. /// Para comparar: casi todo lo que "no anda adentro" es este mapeo. #[arg(long)] single_id: bool, /// Abre el canal de evidencia: apunta qué intentó tocar la instancia y no pudo. /// /// **Es la única forma de auditar el montón B.** De un binario ajeno no hay fuente que /// leer: lo único que queda es lo que el kernel deniega y anota. Exige una política que /// pueda denegar algo (`seal_image`) y Landlock vivo (o sea, sin `nesting`); si falta /// alguna, FALLA diciendo cuál — un `denials=[]` sin evidencia posible sería el /// instrumento mintiendo. #[arg(long)] evidence: bool, /// Comando dentro (default: el shell de la imagen). Va después de `--`. #[arg(last = true)] cmd: Vec, }, /// [ADR 0015 D3] Instala en la instancia lo declarado en `packages`. /// /// **Es lo que vuelve cierta la frase «el manifiesto es la verdad».** Sin esto `packages` era /// una lista que nadie leía: `recreate` tiraba la capa y lo instalado a mano no volvía, así que /// el `upper` era el activo de verdad — exactamente el blob irreemplazable que takana existe /// para no tener. Provision { id: String, /// Reinstala aunque el registro diga que esos mismos paquetes ya se instalaron. #[arg(long)] force: bool, /// Imprime el guión que correría dentro de la instancia y sale. #[arg(long)] dry_run: bool, #[arg(long)] root: Option, }, /// [ADR 0015 D8] Registra como imagen un artefacto del store que trae una imagen ajena SELLADA. /// /// Es el consumidor de `recipes/steam-runtime-sniper.toml`: la imagen deja de venir de la CDN /// de Valve y viene de nuestro store, replicable con `takana mirror push` y cubrible por el /// índice firmado (ADR 0014). **La identidad no cambia**: la imagen se registra bajo el sha256 /// del ARCHIVO de upstream que el artefacto declara, no bajo su ArtifactHash, así que la que /// importa una máquina y la que otra trae con `pull` son la MISMA imagen. Import { /// Hash del artefacto en el store; acepta prefijo. #[arg(long)] from_store: String, /// Copia en vez de enlazar. Por defecto enlaza (`cp -al`), que es gratis y correcto: una /// imagen NUNCA se escribe —las escrituras van al `upper` de la instancia—, así que /// compartir inodos con el artefacto sellado no puede corromper nada. #[arg(long)] copy: bool, #[arg(long)] root: Option, }, /// [ADR 0015 §Orden 5] Genera los shims y `.desktop` de lo declarado en `[export]`. /// /// **Se GENERAN, no se copian.** El `Exec=` de un `.desktop` ajeno es texto ajeno: copiarlo /// pondría una línea de comando que no escribimos en el menú del usuario (D5). Export { id: String, /// Prefijo donde escribir (`/bin`, `/share/applications`). Default: `~/.local`. #[arg(long)] into: Option, /// Borra exactamente lo que se generó. Revocable: sin shims, la instancia desaparece del /// sistema sin desmontar nada. #[arg(long)] remove: bool, #[arg(long)] root: Option, }, /// [ADR 0015 §Orden 7] Poda: restos de descargas a medias, imágenes que ninguna instancia usa /// y —si se pide— las capas mutables, que por D3 siempre se pueden tirar. /// /// **Sin `--yes` no borra nada**: dice qué haría y cuánto liberaría. Este repo ya tuvo tres /// emergencias de disco y un `store-gc` que REPORTABA borrados que no ocurrían. Prune { /// Además, tira la capa mutable de cada instancia. El manifiesto es la verdad y el `upper` /// es caché (D3) — pero lo que instalaste a mano y no declaraste en `packages` se va. #[arg(long)] upper: bool, /// Ejecuta de verdad. Sin esto es un simulacro. #[arg(long)] yes: bool, #[arg(long)] root: Option, }, } pub fn run(cmd: QorpaCmd, store: &Path) -> Result<()> { match cmd { QorpaCmd::Pull { url, sha256, label, verify_only, subdir, root } => { pull(&resolve_root(root), &url, &sha256, label.as_deref(), verify_only, subdir.as_deref()) } QorpaCmd::List { root, json } => list(&resolve_root(root), json), QorpaCmd::Create { id, base, distro, root } => { create(&resolve_root(root), &id, &base, distro.as_deref()) } QorpaCmd::Recreate { id, no_provision, root } => { recreate(&resolve_root(root), &id, no_provision) } QorpaCmd::Run { id, root, dry_run, no_jail, single_id, evidence, cmd } => run_instance( &resolve_root(root), &id, &cmd, Ajustes { dry_run, no_jail, single_id, evidencia: evidence, ..Default::default() }, ), QorpaCmd::Provision { id, force, dry_run, root } => { provision(&resolve_root(root), &id, force, dry_run) } QorpaCmd::Export { id, into, remove, root } => { export(&resolve_root(root), &id, into.as_deref(), remove) } QorpaCmd::Prune { upper, yes, root } => prune(&resolve_root(root), upper, yes), QorpaCmd::Import { from_store, copy, root } => { import(&resolve_root(root), store, &from_store, copy) } } } fn resolve_root(flag: Option) -> PathBuf { flag.or_else(|| takana_core::env::var_os(ROOT_ENV).map(PathBuf::from)) .unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT)) } /// Lo que queda escrito junto al árbol. Es el registro de PROCEDENCIA: sin él, un directorio con /// un rootfs adentro no sabe decir de dónde salió ni contra qué se verificó. #[derive(Serialize, Deserialize)] struct ImageManifest { sha256: String, url: String, #[serde(skip_serializing_if = "Option::is_none")] label: Option, archive_bytes: u64, tree_files: u64, tree_bytes: u64, /// El rootfs venía dentro de este subdirectorio del archivo (`root.x86_64` de Arch) y se elevó /// a raíz. Ausente = el archivo YA era un rootfs plano (Ubuntu base). #[serde(skip_serializing_if = "Option::is_none")] rootfs_subdir: Option, /// Lo que había al lado del rootfs y NO entró (`version`, `pkglist.x86_64.txt` de Arch). Se /// registra en vez de descartarse en silencio: si mañana falta algo, acá está qué se tiró. #[serde(default, skip_serializing_if = "Vec::is_empty")] dropped_siblings: Vec, } // ── pull ──────────────────────────────────────────────────────────────────────────────────────── fn pull( root: &Path, url: &str, sha_in: &str, label: Option<&str>, verify_only: bool, subdir: Option<&str>, ) -> Result<()> { let sha = normalize_sha256(sha_in)?; let images = root.join("images"); let dest = images.join(&sha); // Idempotencia con la regla 3 puesta delante: "ya está" sólo si TIENE CONTENIDO. Un directorio // vacío de un pull interrumpido se leería como presencia y el error viajaría hasta el final. if dest.exists() { match tree_state(&dest.join(TREE)) { TreeState::Populated => { println!("ya presente y verificada: {}", dest.display()); return Ok(()); } TreeState::Missing if verify_only => { println!("archivo ya verificado antes (sin desempacar): {}", dest.display()); return Ok(()); } TreeState::Missing | TreeState::Empty => { eprintln!( "aviso: {} existe pero su árbol está vacío o ausente — pull anterior a medias, \ se rehace", dest.display() ); force_remove_dir_all(&dest) .with_context(|| format!("no pude limpiar {}", dest.display()))?; } } } std::fs::create_dir_all(&images) .with_context(|| format!("no puedo crear {}", images.display()))?; let archive = images.join(format!(".{sha}.part")); println!("bajando {url}"); download_to_file(url, &archive)?; // El corte. Si el contenido no es el que dice ser, se ABORTA — no se avisa y se sigue. takana_build::download::verify_sha256(&archive, &sha).inspect_err(|_| { std::fs::remove_file(&archive).ok(); })?; let archive_bytes = std::fs::metadata(&archive)?.len(); println!("sha256 verificado ({} bytes)", archive_bytes); if verify_only { std::fs::create_dir_all(&dest)?; write_manifest(&dest, &ImageManifest { sha256: sha.clone(), url: url.into(), label: label.map(Into::into), archive_bytes, tree_files: 0, tree_bytes: 0, rootfs_subdir: None, dropped_siblings: Vec::new(), })?; std::fs::rename(&archive, dest.join("archive"))?; println!("guardada sin desempacar en {}", dest.display()); return Ok(()); } // Desempacado atómico-ish: a un `.tmp` al lado y luego rotar. Extraer directo sobre el destino // dejaría medio rootfs si algo falla, y medio rootfs es justo lo que la regla 3 castiga. let staging = images.join(format!(".{sha}.tmp")); force_remove_dir_all(&staging) .with_context(|| format!("no pude limpiar el staging {}", staging.display()))?; let tree_tmp = staging.join(TREE); std::fs::create_dir_all(&tree_tmp)?; println!("desempacando…"); // Cualquier fallo de acá en adelante barre el staging Y el archivo: "no dejar nada a medias" no // vale sólo para el sha que no coincide — un desempacado a medias es la misma trampa. let unpack = || -> Result<(Option, Vec, u64, u64)> { untar(&archive, &tree_tmp)?; let (sub, dropped) = locate_rootfs(&tree_tmp, subdir)?; let (files, bytes) = walk_tree(&tree_tmp)?; Ok((sub, dropped, files, bytes)) }; let (rootfs_subdir, dropped_siblings, tree_files, tree_bytes) = match unpack() { Ok(v) => v, Err(e) => { force_remove_dir_all(&staging).ok(); std::fs::remove_file(&archive).ok(); return Err(e); } }; std::fs::remove_file(&archive).ok(); if let Some(d) = &rootfs_subdir { println!("el rootfs venía en {d}/ — elevado a raíz"); } if !dropped_siblings.is_empty() { println!("fuera del rootfs, NO entran: {}", dropped_siblings.join(", ")); } if tree_files == 0 { force_remove_dir_all(&staging).ok(); bail!("el archivo desempacó VACÍO — eso no es una imagen, es un nombre (CLAUDE.md regla 3)"); } write_manifest(&staging, &ImageManifest { sha256: sha.clone(), url: url.into(), label: label.map(Into::into), archive_bytes, tree_files, tree_bytes, rootfs_subdir, dropped_siblings, })?; std::fs::rename(&staging, &dest) .with_context(|| format!("no puedo rotar {} a {}", staging.display(), dest.display()))?; println!( "imagen lista: {} · {tree_files} ficheros · {}", dest.display(), human(tree_bytes) ); Ok(()) } /// Descarga a fichero. NO se reusa `download::fetch_url_to_file` a propósito: ése trae el cuerpo /// entero a memoria (le sirve para un patch de KB) y acá hablamos de cientos de MB, con `--max-time` /// de 300 s que un rootfs no respeta. /// /// **`--retry` a secas no cubre el fallo que de verdad pasa.** Medido trayendo el runtime *sniper* /// (302 MB): la CDN de Valve cortó el stream al 73% con `HTTP/2 INTERNAL_ERROR` (exit 92) y curl /// **no reintentó**, porque sin `--retry-all-errors` sólo considera transitorios los timeouts y los /// 5xx — un stream roto a media transferencia no entra en esa lista. Y aun reintentando, sin /// `-C -` cada intento vuelve a empezar de cero: en un fichero de cientos de MB sobre una conexión /// que se corta, eso no converge nunca. /// /// Por eso el parcial **NO se borra al fallar**: es lo que hace que el siguiente intento reanude en /// vez de rebajar todo. Es seguro porque quien decide no es el fichero sino el `sha256` que se /// verifica después —un parcial corrupto no puede pasar por bueno— y porque el nombre lleva punto /// delante, así que `list` no lo ve como imagen y `prune` ya lo barre como «resto de un pull a /// medias». fn download_to_file(url: &str, dst: &Path) -> Result<()> { if let Some(p) = dst.parent() { std::fs::create_dir_all(p)?; } let st = Command::new("curl") .args(["-fL", "--retry", "5", "--retry-delay", "2", "--retry-all-errors", "--connect-timeout", "30", "-C", "-", "--progress-bar", "-o"]) .arg(dst) .arg(url) .status() .context("no pude ejecutar curl")?; if !st.success() { let reanudable = std::fs::metadata(dst).map(|m| m.len()).unwrap_or(0); if reanudable > 0 { eprintln!( " (quedaron {} bajados en {}: el siguiente intento reanuda desde ahí; \ `qorpa prune` los barre)", human(reanudable), dst.display() ); } bail!("curl falló bajando {url} (exit {:?})", st.code()); } Ok(()) } fn normalize_sha256(s: &str) -> Result { let s = s.strip_prefix("sha256:").unwrap_or(s).trim().to_ascii_lowercase(); if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) { bail!("--sha256 debe ser 64 dígitos hex (llegó {:?}, {} chars)", s, s.len()); } Ok(s) } /// Elige el descompresor por MAGIC, no por extensión: el nombre de un fichero es una sugerencia y /// las tres imágenes curadas usan tres compresores distintos (zstd en Arch, gzip en Ubuntu, xz en /// Fedora). `tar` extrae con `--no-same-owner` para que el resultado sea el MISMO lo corra root o /// no — la propiedad real la pone el mapeo del userns, no el tar. fn untar(archive: &Path, into: &Path) -> Result<()> { let mut magic = [0u8; 6]; let n = std::fs::File::open(archive)?.read(&mut magic)?; let flag = match &magic[..n.min(6)] { [0x28, 0xb5, 0x2f, 0xfd, ..] => Some("--zstd"), [0x1f, 0x8b, ..] => Some("-z"), [0xfd, b'7', b'z', b'X', b'Z', 0x00] => Some("-J"), [b'B', b'Z', b'h', ..] => Some("-j"), _ => None, // tar plano }; // ⚠ EL `tar` DE BUSYBOX NO SIRVE ACÁ, Y SU FALLO NO LO DICE. Medido en la caja de producción: // `qorpa pull` de un rootfs `.tar.zst` moría imprimiendo **la ayuda de busybox** y un // `exit 1` — ningún mensaje sobre `--zstd`, que es la opción que no entiende. Comprobarlo acá // convierte diez minutos de diagnóstico en una línea, y nombra el arreglo: `tar` (GNU) está // sellado en el corpus desde hace meses; lo que faltaba era declararlo en el perfil. let es_gnu = Command::new("tar") .arg("--version") .output() .map(|o| String::from_utf8_lossy(&o.stdout).contains("GNU tar")) .unwrap_or(false); if !es_gnu { bail!( "el `tar` de este sistema no es GNU tar (parece busybox): no entiende `--zstd`, \n\ `-p` ni `--delay-directory-restore`, que hacen falta para desempacar un rootfs ajeno.\n\ Instalá el paquete `tar` — está sellado en el corpus; si esta es una imagen de takana, \n\ declaralo en el perfil." ); } let mut c = Command::new("tar"); if let Some(f) = flag { c.arg(f); } // `-p` conserva los modos exactos (el setuid de `sudo` adentro importa), y // `--delay-directory-restore` es OBLIGATORIO junto con él: un rootfs real trae directorios de // sólo-lectura (`/etc/ca-certificates/extracted/cadir` en Arch), y sin esto tar los crea con su // modo final ANTES de meterles los ficheros ⇒ "Permission denied" al extraer sin ser root. Con // esto, tar los deja escribibles y aplica los modos al final. // tar ya rechaza rutas absolutas y `..` al extraer: ésa es la defensa contra un tarball ajeno // malicioso. Los xattr `security.capability` se pierden sin privilegios (tar avisa): dentro de // la jaula da igual salvo para binarios que dependan de capabilities de fichero — anotado. c.args(["-x", "-p", "--delay-directory-restore", "--no-same-owner", "-f"]) .arg(archive).arg("-C").arg(into); let st = c.status().context("no pude ejecutar tar")?; if !st.success() { bail!("tar falló desempacando {} (exit {:?})", archive.display(), st.code()); } Ok(()) } /// Dónde está el rootfs dentro de lo que se desempacó. /// /// La primera versión de esto usaba «si hay UN solo directorio arriba, ése es el rootfs». Sonaba /// razonable y es FALSA: el bootstrap de Arch trae tres entradas arriba (`root.x86_64/`, `version`, /// `pkglist.x86_64.txt`) y la regla no disparaba, así que el rootfs quedaba un nivel abajo — con /// todo verde y sin un solo error. Se mide por ESTRUCTURA, que es lo que define a un rootfs: /// tiene `etc/` y tiene `usr/` o `bin/`. /// /// Si no acierta, **falla y pide `--subdir`** en vez de adivinar: un rootfs mal anclado no rompe /// acá, rompe mucho después, cuando la instancia no encuentra su loader. fn looks_like_rootfs(d: &Path) -> bool { d.join("etc").is_dir() && (d.join("usr").is_dir() || d.join("bin").exists()) } /// Deja `tree` conteniendo el rootfs. Devuelve (subdir elevado, hermanos descartados). fn locate_rootfs(tree: &Path, forced: Option<&str>) -> Result<(Option, Vec)> { if forced.is_none() && looks_like_rootfs(tree) { return Ok((None, Vec::new())); // Ubuntu base: el archivo YA es el rootfs. } let entries: Vec<_> = std::fs::read_dir(tree) .with_context(|| format!("leyendo la raíz desempacada {}", tree.display()))? .collect::>>()?; let name = match forced { Some(f) => { if !tree.join(f).is_dir() { bail!("--subdir {f:?} no existe dentro del archivo"); } f.to_string() } None => { let mut cands: Vec = entries .iter() .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) .filter(|e| looks_like_rootfs(&e.path())) .map(|e| e.file_name().to_string_lossy().into_owned()) .collect(); cands.sort(); match cands.len() { 1 => cands.remove(0), 0 => bail!( "no encuentro un rootfs en el archivo (ni la raíz ni ningún subdirectorio tiene \ `etc/` + `usr|bin`). Pasá `--subdir ` si sabés dónde está." ), _ => bail!( "hay {} subdirectorios que parecen rootfs ({}) — elegí con `--subdir`", cands.len(), cands.join(", ") ), } } }; let dropped: Vec = entries .iter() .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| *n != name) .collect(); let top = tree.join(&name); let parked = tree.with_file_name(format!( "{}.lift", tree.file_name().unwrap_or_default().to_string_lossy() )); // Renombrar un DIRECTORIO exige permiso de escritura sobre el directorio mismo (el kernel tiene // que reescribir su `..`), y el `root.x86_64` de Arch viene `dr-xr-xr-x`. Se abre, se mueve y se // le devuelve su modo exacto: la imagen tiene que quedar como venía, no como nos vino cómodo. let mode = std::fs::metadata(&top)?.permissions().mode(); std::fs::set_permissions(&top, std::fs::Permissions::from_mode(mode | 0o700))?; std::fs::rename(&top, &parked) .with_context(|| format!("elevando {} a {}", top.display(), parked.display()))?; force_remove_dir_all(tree) .with_context(|| format!("descartando los hermanos en {}", tree.display()))?; std::fs::rename(&parked, tree) .with_context(|| format!("rotando {} a {}", parked.display(), tree.display()))?; std::fs::set_permissions(tree, std::fs::Permissions::from_mode(mode))?; Ok((Some(name), dropped)) } /// `remove_dir_all` NO puede con un árbol que trae directorios sin permiso de escritura — y un /// rootfs real los trae (`/etc/ca-certificates/extracted/cadir` de Arch es 0555). Tragarse ese /// fallo con `.ok()` deja vivo el staging de un pull roto y el siguiente pull extrae ENCIMA: nos /// pasó, y el síntoma («Permission denied» en un directorio que acabás de crear) no se parece en /// nada a la causa. Abre los directorios a escritura de arriba abajo y después borra. fn force_remove_dir_all(p: &Path) -> std::io::Result<()> { if !p.exists() { return Ok(()); } let mut stack = vec![p.to_path_buf()]; while let Some(d) = stack.pop() { let _ = std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700)); if let Ok(rd) = std::fs::read_dir(&d) { for e in rd.flatten() { // `file_type` de read_dir es lstat: un symlink a directorio NO se sigue. if e.file_type().map(|t| t.is_dir()).unwrap_or(false) { stack.push(e.path()); } } } } match std::fs::remove_dir_all(p) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { // SEGUNDA causa, y aparece recién con el mapeo por rango: el `upper` de una instancia // contiene ficheros de los SUBUID mapeados (apt deja los suyos con uid 165577), y // nuestro uid normal no puede borrarlos ni chownearlos. La cura es la misma que los // creó: entrar a un userns donde esos ids son nuestros. `--map-auto` usa newuidmap, // así que esto sólo anda si subuid está provisionado — y si no, el error vuelve a // salir tal cual, que es lo correcto. let st = Command::new("unshare") .args(["-U", "--map-auto", "-r", "rm", "-rf"]) .arg(p) .status()?; if st.success() && !p.exists() { Ok(()) } else { Err(e) } } Err(e) => Err(e), } } enum TreeState { Missing, Empty, Populated } fn tree_state(tree: &Path) -> TreeState { match std::fs::read_dir(tree) { Err(_) => TreeState::Missing, Ok(mut it) => { if it.next().is_some() { TreeState::Populated } else { TreeState::Empty } } } } fn walk_tree(root: &Path) -> Result<(u64, u64)> { let (f, b, _) = walk_tree_ciegos(root)?; Ok((f, b)) } /// Igual, pero además cuenta los directorios que NO se pudieron leer. Sin ese tercer número, el /// tamaño de un `upper` con ficheros de los subuid sale MENOR de lo que es y nadie lo nota: un /// número silenciosamente bajo es peor que ninguno cuando lo que se decide con él es borrar. fn walk_tree_ciegos(root: &Path) -> Result<(u64, u64, u64)> { let (mut files, mut bytes, mut ciegos) = (0u64, 0u64, 0u64); let mut stack = vec![root.to_path_buf()]; while let Some(d) = stack.pop() { // Un rootfs ajeno trae directorios que NI SU DUEÑO puede leer (`/etc/sudoers.d` es 0750, // `/var/log/private` 0700, y alguno 0000). No es corrupción: es el rootfs siendo lo que es. // Se cuentan como 0 y se sigue — abortar el pull por no poder contar sería absurdo. let rd = match std::fs::read_dir(&d) { Ok(rd) => rd, Err(_) => { ciegos += 1; continue } }; for e in rd { let Ok(e) = e else { continue }; let Ok(ft) = e.file_type() else { continue }; if ft.is_dir() { stack.push(e.path()); } else { files += 1; bytes += e.metadata().map(|m| m.len()).unwrap_or(0); } } } Ok((files, bytes, ciegos)) } fn write_manifest(dir: &Path, m: &ImageManifest) -> Result<()> { let body = format!( "# Lo escribe `hammer qorpa pull`; no editar a mano.\n\ # La identidad de la imagen es el sha256 del ARCHIVO original, no de este árbol: por eso\n\ # la URL es informativa y espejar es gratis (ADR 0013).\n{}", toml::to_string_pretty(m).context("serializando el manifiesto")? ); std::fs::write(dir.join(IMAGE_MANIFEST), body)?; Ok(()) } // ── import: consumir una imagen SELLADA al store (D8) ─────────────────────────────────────────── // // D1 dice que las imágenes ajenas no entran al store, y el motivo es que un rootfs MUTABLE no // reproduce. Sniper no muta —nadie le instala nada adentro— así que sellarlo es una afirmación // verdadera, y con eso se gana cadena de custodia nuestra: se replica con `takana mirror push`, su // digest puede ir al índice firmado (ADR 0014) y una instancia se arma sin depender de la CDN de // Valve. Esto es lo que convierte ese artefacto en una imagen usable. // // LO QUE NO CAMBIA: la identidad. La imagen se registra bajo el **sha256 del archivo de upstream** // que el artefacto declara, no bajo su ArtifactHash. Si se registrara por ArtifactHash, la imagen // importada del store y la traída con `pull` serían dos imágenes distintas con los mismos bytes, y // las instancias de dos máquinas dejarían de coincidir — justo lo que el pin existe para evitar. fn import(root: &Path, store: &Path, hash: &str, copiar: bool) -> Result<()> { let store = takana_core::store::Store::open(store) .map_err(|e| anyhow::anyhow!("abriendo el store: {e}"))?; let art = store.find_by_hash(hash) .map_err(|e| anyhow::anyhow!("{e}"))?; // La imagen vive bajo un prefijo namespaceado: un rootfs glibc entero en la raíz del artefacto // pisaría el sistema si alguien lo compusiera por error. let base = art.join("usr/share/qorpa/images"); let mut candidatos: Vec = std::fs::read_dir(&base) .with_context(|| format!( "el artefacto {} no trae `usr/share/qorpa/images/` — ¿es una receta de imagen ajena? \ (hoy sólo `steam-runtime-sniper`)", art.display()))? .flatten() .map(|e| e.path()) .filter(|p| p.join(IMAGE_MANIFEST).is_file()) .collect(); candidatos.sort(); let src = match candidatos.len() { 1 => candidatos.remove(0), 0 => bail!("no hay ninguna imagen con `{IMAGE_MANIFEST}` en {}", base.display()), n => bail!("{n} imágenes en un mismo artefacto: no adivino cuál ({:?})", candidatos), }; let m: ImageManifest = toml::from_str(&std::fs::read_to_string(src.join(IMAGE_MANIFEST))?) .with_context(|| format!("leyendo {}", src.join(IMAGE_MANIFEST).display()))?; let sha = normalize_sha256(&m.sha256)?; let arbol_src = src.join(TREE); if !matches!(tree_state(&arbol_src), TreeState::Populated) { bail!("el artefacto declara la imagen pero su `{TREE}/` está vacío o no existe — un nombre \ no es una imagen (CLAUDE.md regla 3)"); } let dest = root.join("images").join(&sha); if let TreeState::Populated = tree_state(&dest.join(TREE)) { println!("ya presente: {} (sha256:{}…)", dest.display(), &sha[..12]); return Ok(()); } force_remove_dir_all(&dest).ok(); std::fs::create_dir_all(&dest)?; // ENLAZAR, no copiar, y no es un ahorro cualquiera: una imagen NUNCA se escribe —lo que escribe // la instancia va a su `upper`—, así que compartir inodos con un artefacto sellado (y de sólo // lectura) es correcto por construcción y hace que la imagen cueste ~0 bytes. // ⚠ La contracara, que ya nos mordió con `.dmerge`: mientras el artefacto siga en el store, // borrar la imagen NO libera disco. Es una propiedad, no un fallo; `--copy` la evita. let mut modo = "enlazada"; let arbol_dst = dest.join(TREE); let cp = |args: &[&str]| -> Result { Ok(Command::new("cp").args(args) .arg(&arbol_src).arg(&arbol_dst).status()?.success()) }; if copiar || !cp(&["-a", "--link", "-T"])? { if !copiar { // EXDEV: el store puede estar en OTRO mount (bind-mount del volumen), y `link()` no // cruza mounts aunque sea el mismo filesystem. Se dice y se copia, en vez de fallar. eprintln!(" (no pude enlazar —¿store en otro mount?—, copio)"); force_remove_dir_all(&arbol_dst).ok(); } modo = "copiada"; if !cp(&["-a", "-T"])? { bail!("no pude copiar el árbol de la imagen desde {}", arbol_src.display()); } } // Se MIDE lo que quedó, no se cree lo que el manifiesto del artefacto decía: los contadores del // artefacto podrían venir de otra máquina y este árbol es el que la instancia va a montar. let (tree_files, tree_bytes) = walk_tree(&arbol_dst)?; if tree_files == 0 { bail!("el árbol importado quedó vacío — se aborta antes de escribir el manifiesto"); } write_manifest(&dest, &ImageManifest { sha256: sha.clone(), url: m.url, label: m.label, archive_bytes: m.archive_bytes, tree_files, tree_bytes, rootfs_subdir: m.rootfs_subdir, dropped_siblings: m.dropped_siblings, })?; println!( "imagen {modo} desde el store: {} · {tree_files} ficheros · {}", dest.display(), human(tree_bytes) ); println!(" identidad = sha256 del archivo de upstream, no el ArtifactHash: coincide con la \ que otra máquina traiga con `pull`"); Ok(()) } // ── instancias ────────────────────────────────────────────────────────────────────────────────── /// El manifiesto de una instancia. **Es la verdad**: el `upper/` es caché y se puede tirar entero /// (D3). Los nombres de campo van en inglés como el resto de la superficie (`CLAUDE.md` regla 4); /// el ADR los escribió en castellano y se corrigieron aquí. #[derive(Serialize, Deserialize, Default)] struct Instance { /// sha256 de la imagen base. Cambiarlo + `recreate` ES la actualización de versión (D8). base: String, #[serde(skip_serializing_if = "Option::is_none")] distro: Option, /// Lo que debe estar instalado adentro. **Se instala solo**: `takana qorpa provision` lo pone /// con el gestor de la imagen, y `recreate` lo llama después de tirar la capa. Sin esto el /// `upper` sería el activo y el manifiesto un adorno. #[serde(default, skip_serializing_if = "Vec::is_empty")] packages: Vec, /// La política, autorada (D7). **Por defecto vacía**: una instancia nace sin ver nada. #[serde(default)] grants: Grants, /// Correr el comando como este usuario de la imagen, en vez de como root. /// /// ── POR QUÉ ESTO Y NO `root = false` ─────────────────────────────────────────────────────── /// MEDIDO con Steam: el cliente **se niega a correr como root** (`bin_steam.sh: Error: Cannot /// run as root user`), así que la instancia de juegos necesita un usuario normal. La vía /// tentadora —cambiar el mapa para caer en el uid 1000— **corrompe la instancia**: un fichero /// creado bajo un mapa aparece con OTRO uid bajo el otro, y el `useradd` de la preparación deja /// un `/home` que después su propio dueño no puede escribir. /// /// La vía correcta es la de cualquier runtime de contenedores: **un solo mapa, y se BAJA de /// privilegio adentro**. Tenemos `CAP_SETUID` en el namespace, así que es un `setpriv` y ya. #[serde(default, skip_serializing_if = "Option::is_none")] run_as: Option, /// La capa de transparencia (D5). Vacía por defecto: **se exporta lo DECLARADO**, nunca todo. /// Exportar todo es la falla de Bedrock — su `ls` compitiendo con el nuestro, arbitrado por /// heurística en tiempo de `exec`. Acá la ambigüedad se resuelve al declarar. #[serde(default)] export: Export, } #[derive(Serialize, Deserialize, Default)] struct Export { /// Binarios que aparecen en el `PATH` del host como shims. #[serde(default, skip_serializing_if = "Vec::is_empty")] binaries: Vec, /// Ficheros `.desktop` de la imagen cuyo equivalente se GENERA para el menú del host. #[serde(default, skip_serializing_if = "Vec::is_empty")] apps: Vec, } #[derive(Serialize, Deserialize)] #[serde(default)] struct Grants { /// Nombres cortos: `wayland`, `pipewire`, `dbus`. ⚠ `wayland` es un borde de PRIVILEGIO, no un /// caño: quien lo tiene puede capturar pantalla y sintetizar teclas (ADR §NO-resuelve 1). #[serde(default, skip_serializing_if = "Vec::is_empty")] sockets: Vec, /// Nodos de dispositivo (`/dev/dri`). ABI de kernel, que es lo único que cruza el borde (D2). #[serde(default, skip_serializing_if = "Vec::is_empty")] devices: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] dirs: Vec, /// La concesión más peligrosa, y por eso va sola en su línea. `dnf`/`pacman` la necesitan. #[serde(default)] network: bool, /// Pasar el socket de Wayland **crudo**, sin el proxy filtrante. Apagado por defecto. /// /// Con esto la instancia recupera TODOS los globals del compositor, incluidos los que capturan /// pantalla, sintetizan teclas y leen el portapapeles. Existe para diagnosticar, no para usar. #[serde(default)] wayland_raw: bool, /// Globals de Wayland que el proxy deja ver. Vacío = la lista curada de `qorpa_wl`. #[serde(default, skip_serializing_if = "Vec::is_empty")] wayland_globals: Vec, /// Deja que la instancia monte y cree namespaces adentro. **Steam ya es un contenedor**: su /// pressure-vessel necesita esto para arrancar (D6). Es una concesión real — con ella la /// instancia recupera la familia `mount`/`setns` que la jaula deniega por defecto. #[serde(default)] nesting: bool, /// Mapear tu uid a **root adentro**. Encendido por defecto: es lo que quieren `dnf`/`pacman`. /// /// ⚠ MEDIDO 2026-09-03, y es un conflicto real con `nesting`: con `--uid 0`, un userns anidado /// NO puede escribir su `uid_map` («Operation not permitted»), así que **pressure-vessel de /// Valve no arranca**. Sin remapear a root, el anidamiento funciona pero el gestor de paquetes /// se queja. Las dos mitades se arreglan con lo mismo: mapear un RANGO real de subuid con /// `newuidmap` y pasarle el namespace a bwrap con `--userns FD` — el mismo impuesto que paga /// hoy `apt` con su `setgroups`. #[serde(default = "si")] root: bool, /// Sella el árbol de la IMAGEN (`/usr`, `/bin`, `/lib`, `/opt`…) contra escritura, aunque /// adentro seas root. Apagado por defecto porque un gestor de paquetes necesita escribir en /// `/usr`; encendido para una instancia que sólo CORRE algo, un exploit no puede persistir en /// el `upper`. Es lo único que Landlock aporta acá que el namespace de montaje no da ya. #[serde(default)] seal_image: bool, } /// Dónde vive `harkaq-exec`. Misma convención que el harkaq del build (`HARKAQ_BIN`), **más el /// binario instalado**, que es el caso de cualquier máquina que no sea el hub de desarrollo. /// /// ⚠ MEDIDO el 2026-09-15 en la caja de producción: sólo se buscaba en `~/.cache/harkaq`, o sea en /// la caché de quien lo hubiera compilado a mano. En una caja instalada no hay `gcc`, ni `scripts/`, /// ni caché — así que `qorpa` abortaba con una instrucción imposible de seguir y la jaula del ADR /// 0015 funcionaba únicamente en la máquina donde alguien la había construido. Ahora /// `recipes/harkaq-exec.toml` lo publica en `/usr/bin` y va declarado en `perfil.base`. /// /// El orden es deliberado: lo que el operador declara (`HARKAQ_BIN`) gana, después el árbol de /// desarrollo —para que un cambio en la jaula se pruebe sin instalar nada—, y el paquete al final. /// Si no está ninguno se devuelve el primer candidato, que es el que nombra el error. fn harkaq_exec() -> PathBuf { if let Ok(dir) = std::env::var("HARKAQ_BIN") { return PathBuf::from(dir).join("harkaq-exec"); } let cache = PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into())) .join(".cache/harkaq") .join("harkaq-exec"); if cache.exists() { return cache; } let instalado = PathBuf::from("/usr/bin/harkaq-exec"); if instalado.exists() { return instalado; } cache } /// Las capabilities que hacen que el uid 0 de adentro sea root DE VERDAD. /// /// MEDIDO: bwrap tira TODAS las capabilities, y en Linux ser root es tener `CAP_SETUID`, no tener /// uid 0. Sin ellas `apt` no puede `seteuid(42)` para bajar a `_apt` y `dpkg` no puede chownear — /// que es exactamente el síntoma que parecía de subuid y no lo era. /// /// Son SEGURAS por construcción: una capability dentro de un user namespace sólo alcanza a lo que /// ese namespace posee, o sea los ids que le mapeamos (nuestros propios subuid). No dan nada en el /// host. Por eso se conceden con `root = true` sin pedir permiso aparte. /// /// `CAP_SYS_ADMIN` NO está en la lista: es la que permite montar, y montar cuelga de la concesión /// `nesting` declarada. Abrir sólo lo declarado, también acá. const CAPS_ROOT: &[&str] = &[ "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_MKNOD", "CAP_SETGID", "CAP_SETUID", "CAP_SETFCAP", "CAP_SETPCAP", "CAP_SYS_CHROOT", "CAP_KILL", "CAP_AUDIT_WRITE", ]; /// Los directorios que pertenecen a la IMAGEN y que `seal_image` congela. const IMAGEN_INMUTABLE: &[&str] = &["usr", "bin", "sbin", "lib", "lib64", "lib32", "opt", "boot"]; /// Lo que bwrap monta encima del overlay y siempre es de la instancia, no de la imagen. const DEL_SANDBOX: &[&str] = &["/proc", "/dev", "/tmp", "/run"]; /// Compila las concesiones a la política que consume `harkaq-exec`. /// /// ── QUÉ APORTA HARKAQ ACÁ, Y QUÉ NO ───────────────────────────────────────────────────────────── /// Honestidad primero: en el eje del SISTEMA DE FICHEROS, harkaq casi no agrega nada a una /// instancia, porque el namespace de montaje de bwrap ya es una lista blanca — lo que no se bindea /// no existe adentro, y un `--ro-bind` es de sólo lectura por el kernel, más fuerte que una regla. /// Escribir reglas `ro` que no cambian nada sería un sello de goma, así que no se escriben. /// /// Lo que harkaq SÍ aporta, y es grande para un binario ajeno y opaco: /// 1. **seccomp.** bwrap no instala filtro alguno: hoy una instancia puede `io_uring`, `bpf`, /// `ptrace`, `userfaultfd`, `keyctl`, `perf_event_open`, cargar módulos. Esa superficie /// importa MÁS con un prebuilt de terceros que con nuestros propios builds. /// 2. **`no_new_privs`**: ningún setuid de la imagen escala adentro. /// 3. **el canal de evidencia**: con ABI≥7 el kernel audita las denegaciones ⇒ se puede saber /// qué intentó tocar un binario cerrado. Es la única forma de auditar el montón B. /// 4. **`seal_image`**: lo único del eje fs que el namespace no da — congelar el árbol de la /// imagen aunque adentro seas root. fn compile_policy(g: &Grants, image_tree: &Path, dst: &Path) -> Result<()> { use std::fmt::Write as _; let mut out = String::new(); out.push_str("# Política de una INSTANCIA qorpa (ADR 0015 D7).\n"); out.push_str("# A diferencia de la del build, ésta NO se deriva de una clausura: se AUTORA,\n"); out.push_str("# que es el único sitio del sistema donde eso pasa. Se compila del manifiesto.\n"); if !g.seal_image { // El árbol entero es de la instancia y sus escrituras van al `upper`, que es caché. out.push_str("rw /\n"); } else { // Sin regla sobre `/` no se puede ni listar la raíz; listar no es leer ni escribir. out.push_str("list /\n# la imagen, congelada:\n"); for d in IMAGEN_INMUTABLE { if image_tree.join(d).exists() { let _ = writeln!(out, "ro /{d}"); } } out.push_str("# lo mutable de la instancia:\n"); let mut mutables: Vec = std::fs::read_dir(image_tree) .with_context(|| format!("leyendo el árbol de la imagen {}", image_tree.display()))? .flatten() .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| !IMAGEN_INMUTABLE.contains(&n.as_str())) .map(|n| format!("/{n}")) .collect(); for d in DEL_SANDBOX { if !mutables.iter().any(|m| m == d) { mutables.push((*d).to_string()); } } mutables.sort(); mutables.dedup(); for m in &mutables { let _ = writeln!(out, "rw {m}"); } } std::fs::write(dst, out) .with_context(|| format!("escribiendo la política {}", dst.display()))?; Ok(()) } impl Default for Grants { fn default() -> Self { // Toda concesión arranca CERRADA menos `root`, que no abre nada del host: sólo decide // quién sos adentro de tu propio rootfs. Grants { sockets: Vec::new(), devices: Vec::new(), dirs: Vec::new(), network: false, nesting: false, root: true, seal_image: false, wayland_raw: false, wayland_globals: Vec::new(), } } } #[derive(Serialize, Deserialize)] struct DirGrant { host: String, inside: String, /// `ro` o `rw`. #[serde(default = "ro")] mode: String, } fn ro() -> String { "ro".into() } fn si() -> bool { true } fn instances_dir(root: &Path) -> PathBuf { root.join("instances") } fn instance_dir(root: &Path, id: &str) -> PathBuf { instances_dir(root).join(id) } /// Resuelve un prefijo de sha contra las imágenes presentes. Que exista y que TENGA CONTENIDO: una /// instancia sobre una imagen vacía es un directorio con nombre de rootfs (regla 3). fn resolve_image(root: &Path, prefix: &str) -> Result<(String, PathBuf)> { let prefix = prefix.strip_prefix("sha256:").unwrap_or(prefix).to_ascii_lowercase(); let images = root.join("images"); let mut hits: Vec = std::fs::read_dir(&images) .with_context(|| format!("no hay imágenes en {}", images.display()))? .flatten() .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| !n.starts_with('.') && n.starts_with(&prefix)) .collect(); hits.sort(); match hits.len() { 1 => { let sha = hits.remove(0); let tree = images.join(&sha).join(TREE); if !matches!(tree_state(&tree), TreeState::Populated) { bail!("la imagen {sha} está VACÍA — no es una imagen, es un nombre"); } Ok((sha, tree)) } 0 => bail!("ninguna imagen empieza por {prefix:?} — traela con `hammer qorpa pull`"), n => bail!("{n} imágenes empiezan por {prefix:?}: dame más dígitos"), } } fn create(root: &Path, id: &str, base: &str, distro: Option<&str>) -> Result<()> { if id.is_empty() || id.contains('/') || id.starts_with('.') { bail!("id de instancia inválido: {id:?}"); } let dir = instance_dir(root, id); if dir.exists() { bail!("la instancia {id} ya existe en {} — `recreate` tira su capa mutable", dir.display()); } let (sha, _tree) = resolve_image(root, base)?; let inst = Instance { base: format!("sha256:{sha}"), distro: distro.map(Into::into), ..Default::default() }; std::fs::create_dir_all(dir.join("upper"))?; std::fs::create_dir_all(dir.join("work"))?; write_instance(&dir, &inst)?; println!("instancia {id} creada en {}", dir.display()); println!(" base: sha256:{}", &sha[..12]); println!(" concesiones: NINGUNA — se abren declarándolas en {}", INSTANCE_MANIFEST); Ok(()) } fn read_instance(root: &Path, id: &str) -> Result<(PathBuf, Instance)> { let dir = instance_dir(root, id); let f = dir.join(INSTANCE_MANIFEST); let body = std::fs::read_to_string(&f) .with_context(|| format!("no existe la instancia {id} ({})", f.display()))?; let inst: Instance = toml::from_str(&body) .with_context(|| format!("manifiesto ilegible: {}", f.display()))?; Ok((dir, inst)) } fn write_instance(dir: &Path, inst: &Instance) -> Result<()> { let body = format!( "# Manifiesto de la instancia — ESTO es la verdad, `upper/` es caché descartable (ADR 0015 D3).\n\ # `[grants]` es la política, y se AUTORA: es el único sitio del sistema donde no se deriva\n\ # de una clausura declarada (D7). Por defecto no hay ninguna.\n{}", toml::to_string_pretty(inst).context("serializando la instancia")? ); std::fs::write(dir.join(INSTANCE_MANIFEST), body)?; Ok(()) } fn recreate(root: &Path, id: &str, no_provision: bool) -> Result<()> { let (dir, inst) = read_instance(root, id)?; let (sha, _) = resolve_image(root, &inst.base)?; for capa in ["upper", "work"] { force_remove_dir_all(&dir.join(capa)) .with_context(|| format!("no pude tirar {capa}/ de {id}"))?; std::fs::create_dir_all(dir.join(capa))?; } // El registro vive FUERA del `upper` (si viviera dentro se iría con él y nunca sabríamos qué // había), así que hay que borrarlo a mano: la capa se fue, así que no hay nada instalado. // Dejarlo sería la forma más pura del error de la regla 3 — un registro que afirma presencia // sobre un árbol vacío. let _ = std::fs::remove_file(dir.join(PROVISION_LEDGER)); println!("instancia {id} recreada sobre sha256:{} — capa mutable tirada", &sha[..12]); if inst.packages.is_empty() { return Ok(()); } if no_provision { println!( " declara {} paquete(s) y NO se reinstalaron (--no-provision): `hammer qorpa \ provision {id}`", inst.packages.len() ); return Ok(()); } // Acá es donde D3 deja de ser una aspiración: si `recreate` no reinstala, el `upper` es el // activo y el manifiesto un adorno. println!(" reinstalando lo declarado ({} paquete(s))…", inst.packages.len()); provision(root, id, false, false) } // ── provision: `packages` deja de ser una lista que nadie lee (D3) ────────────────────────────── const PROVISION_LEDGER: &str = "provisioned.toml"; /// Lo que quedó instalado, para no reinstalar por gusto y para que `recreate` sepa qué rehacer. /// Vive junto al manifiesto y NO dentro del `upper`: si viviera dentro se iría con la capa. #[derive(Serialize, Deserialize, Default)] struct Provisionado { manager: String, packages: Vec, /// Segundos desde epoch. Sin dep de fechas: se lee con `date -u -d @`. at_unix: u64, } /// El guión que instala, y de qué gestor sale. /// /// **Un gestor por imagen, entrando explícitamente** — el ADR rechaza multiplexar `dnf`/`pacman` /// detrás de un comando único (el `pmm` de Bedrock): eso es menos mágico y no miente. Acá no se /// multiplexa nada: se DETECTA cuál trae la imagen y se corre ése, con sus manías. #[derive(Debug)] struct Gestor { nombre: &'static str, guion: String, } /// Comillas simples de shell a prueba de todo: el contenido sale del manifiesto, que lo escribe una /// persona, y un nombre de paquete no tiene por qué ser un token seguro. fn comillar(s: &str) -> String { format!("'{}'", s.replace('\'', r"'\''")) } fn gestor_de(dir: &Path, tree: &Path, paquetes: &[String]) -> Result { // Nombres de paquete: se validan ANTES de armar el guión. Quotear alcanza para que no se // ejecute nada, pero un nombre raro casi siempre es un error de tipeo, y fallar temprano dice // dónde está mejor que un gestor quejándose de un paquete inexistente. for p in paquetes { if p.is_empty() || !p.chars().all(|c| c.is_ascii_alphanumeric() || "._+-:@/".contains(c)) { bail!("nombre de paquete raro en `packages`: {p:?} — se aborta antes de armar el guión"); } } let lista = paquetes.iter().map(|p| comillar(p)).collect::>().join(" "); let hay = |rel: &str| resolver_en_instancia(dir, tree, rel).is_some(); if hay("usr/bin/apt-get") { return Ok(Gestor { nombre: "apt", guion: format!( "set -e\n\ export DEBIAN_FRONTEND=noninteractive\n\ apt-get update\n\ apt-get install -y --no-install-recommends {lista}\n")}); } if hay("usr/bin/pacman") { // Dos manías del bootstrap de Arch, y ninguna se ve hasta que pacman falla: // · la mirrorlist viene ENTERA comentada ⇒ «no servers configured for repository»; // · el llavero no está inicializado ⇒ toda firma es inválida. // El mirror se elige acá y se dice cuál: es el geo oficial de Arch. Que lo elija la // herramienta es peor que que lo elija el usuario, pero **no elegir** deja una instancia // que no instala nada, y el fichero queda a la vista en el `upper` para cambiarlo. return Ok(Gestor { nombre: "pacman", guion: format!( "set -e\n\ grep -q '^Server' /etc/pacman.d/mirrorlist 2>/dev/null || {{\n\ \x20 echo 'aviso: mirrorlist vacía — pongo el mirror geo oficial de Arch' >&2\n\ \x20 echo 'Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' >> /etc/pacman.d/mirrorlist\n\ }}\n\ [ -d /etc/pacman.d/gnupg ] || {{ pacman-key --init; pacman-key --populate archlinux; }}\n\ pacman -Sy --noconfirm --needed {lista}\n")}); } if hay("usr/bin/dnf") { return Ok(Gestor { nombre: "dnf", guion: format!( "set -e\ndnf install -y --setopt=install_weak_deps=False {lista}\n")}); } if hay("sbin/apk") || hay("usr/bin/apk") { return Ok(Gestor { nombre: "apk", guion: format!( "set -e\napk add --no-cache {lista}\n")}); } bail!( "no encuentro gestor de paquetes en la instancia (busqué apt-get, pacman, dnf y apk).\n \ Si la imagen usa otro, instalá a mano con `run` — pero entonces `recreate` no lo va a \n \ poder rehacer, y eso es justo lo que D3 dice que no puede pasar." ); } fn provision(root: &Path, id: &str, force: bool, dry_run: bool) -> Result<()> { let (dir, inst) = read_instance(root, id)?; let (_, tree) = resolve_image(root, &inst.base)?; if inst.packages.is_empty() { println!( "la instancia {id} no declara `packages`. Se instala lo DECLARADO: es lo que hace que \ la capa mutable sea caché y no el activo." ); return Ok(()); } let previo: Option = std::fs::read_to_string(dir.join(PROVISION_LEDGER)) .ok() .and_then(|s| toml::from_str(&s).ok()); if let Some(p) = &previo { if !force && p.packages == inst.packages { println!( "ya provisionada con {} ({} paquete(s)) — `--force` para reinstalar", p.manager, p.packages.len() ); return Ok(()); } } let g = gestor_de(&dir, &tree, &inst.packages)?; if dry_run { println!("# gestor detectado: {}", g.nombre); print!("{}", g.guion); return Ok(()); } println!("provisionando {id} con {} — {} paquete(s)", g.nombre, inst.packages.len()); run_instance(root, id, &["/bin/sh".into(), "-c".into(), g.guion.clone()], Ajustes { provisionando: true, ..Default::default() })?; let reg = Provisionado { manager: g.nombre.into(), packages: inst.packages.clone(), at_unix: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0), }; std::fs::write( dir.join(PROVISION_LEDGER), format!( "# Lo escribe `hammer qorpa provision`. Es un REGISTRO, no la verdad: la verdad es\n\ # `packages` en instance.toml. Si los dos difieren, gana el manifiesto y se reinstala.\n\ # `at_unix` se lee con `date -u -d @`.\n{}", toml::to_string_pretty(®).context("serializando el registro")? ), )?; println!("listo: {} paquete(s) instalados con {}", reg.packages.len(), reg.manager); Ok(()) } /// Traduce las concesiones a argumentos de bwrap. Devuelve también las advertencias que el usuario /// tiene que LEER, no descubrir: el ADR pide que el socket de Wayland se diga en la cara. fn grants_to_args(g: &Grants, destino_xdg: &str, wl: Option<&Path>) -> (Vec, Vec) { let mut a: Vec = Vec::new(); let mut warn: Vec = Vec::new(); // Sin dep de libc: el dueño de /proc/self ES el uid efectivo. let uid = std::fs::metadata("/proc/self") .map(|m| std::os::unix::fs::MetadataExt::uid(&m)) .unwrap_or(1000); let xdg = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| format!("/run/user/{uid}")); for s in &g.sockets { let (host, dentro) = match s.as_str() { "wayland" => { let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into()); match wl { Some(proxy) => (proxy.display().to_string(), format!("{destino_xdg}/wayland-0")), None => { warn.push( "wayland CRUDO (`wayland_raw`): la instancia ve TODOS los globals del \ compositor y podrá CAPTURAR LA PANTALLA, SINTETIZAR TECLAS y LEER EL \ PORTAPAPELES. El proxy filtrante existe; esto lo saltea a propósito." .into(), ); (format!("{xdg}/{d}"), format!("{destino_xdg}/wayland-0")) } } } "pipewire" => (format!("{xdg}/pipewire-0"), format!("{destino_xdg}/pipewire-0")), "dbus" => (format!("{xdg}/bus"), format!("{destino_xdg}/bus")), otro => { warn.push(format!("socket desconocido {otro:?} — ignorado")); continue; } }; a.push("--bind-try".into()); a.push(host); a.push(dentro); } for d in &g.devices { a.push("--dev-bind-try".into()); a.push(d.clone()); a.push(d.clone()); } for d in &g.dirs { let host = if let Some(rest) = d.host.strip_prefix('~') { format!("{}{}", std::env::var("HOME").unwrap_or_default(), rest) } else { d.host.clone() }; a.push(if d.mode == "rw" { "--bind".into() } else { "--ro-bind".into() }); a.push(host); a.push(d.inside.clone()); } if g.network { // El segundo fallo clásico de la primera corrida, según el propio ADR. a.push("--ro-bind-try".into()); a.push("/etc/resolv.conf".into()); a.push("/etc/resolv.conf".into()); } else { a.push("--unshare-net".into()); } (a, warn) } // ── el mapeo por rango (subuid) ────────────────────────────────────────────────────────────────── // // EL PROBLEMA, medido en los pasos anteriores: bwrap crea el user namespace con UN SOLO id mapeado // (`uid_map: 0 1001 1`, `setgroups: deny`). Con eso `apt` no puede bajar a `_apt`, `pacman` no // puede chownear su descarga a `alpm`, y un userns anidado —pressure-vessel— no puede escribir su // propio mapa. Tres síntomas distintos, una sola causa. // // LA CURA: crear el namespace NOSOTROS, mapearle un rango real de `/etc/subuid` con `newuidmap`, y // pasárselo a bwrap con `--userns FD`. bwrap no llama a `newuidmap` a propósito —es un binario con // capability y bwrap no quiere depender de él—, así que el trabajo es de quien lo invoca. // // El namespace lo sostiene un proceso «tenedor» que espera en un fifo: mientras viva, el fd // `/proc//ns/user` es válido. La coreografía (hijo avisa por un fifo, padre mapea desde fuera, // hijo sigue) es la misma que ya verifica `scripts/qorpa/qorpa-preflight.sh`. struct Rango { inicio: u32, cantidad: u32 } fn leer_subid(fichero: &str, usuario: &str) -> Option { let txt = std::fs::read_to_string(fichero).ok()?; txt.lines().find_map(|l| { let mut c = l.split(':'); let (u, i, n) = (c.next()?, c.next()?, c.next()?); if u != usuario { return None; } Some(Rango { inicio: i.parse().ok()?, cantidad: n.parse().ok()? }) }) } /// El namespace vivo, con su tenedor. Al soltarlo, se mata al tenedor y el namespace desaparece. struct UsernsMapeado { tenedor: std::process::Child, ns_path: String, ids: u32, } impl Drop for UsernsMapeado { fn drop(&mut self) { // El tenedor sólo existe para sostener el namespace. Si sobreviviera al `run`, dejaríamos // un proceso colgado por cada instancia arrancada. let _ = self.tenedor.kill(); let _ = self.tenedor.wait(); } } /// Crea el namespace y le mapea el rango. Devuelve `Err` con la CAUSA exacta: hay tres formas de /// que esto no se pueda, y las tres se arreglan distinto, así que no se colapsan en un "no pude". /// /// `uid_dentro` decide quién sos adentro, y **eso lo hace el MAPA, no un flag de bwrap** — que es /// justo lo que permite ser un usuario normal sin renunciar al rango. Hizo falta por Steam: el /// cliente se niega a correr como root (`bin_steam.sh: Error: Cannot run as root user`), así que la /// instancia de juegos NECESITA no ser root, y antes eso costaba el rango entero. fn userns_con_rango(uid_dentro: u32) -> Result { let uid = std::fs::metadata("/proc/self") .map(|m| std::os::unix::fs::MetadataExt::uid(&m)) .unwrap_or(1000); let gid = std::fs::metadata("/proc/self") .map(|m| std::os::unix::fs::MetadataExt::gid(&m)) .unwrap_or(1000); let usuario = std::env::var("USER") .or_else(|_| std::env::var("LOGNAME")) .unwrap_or_default(); let ru = leer_subid("/etc/subuid", &usuario) .with_context(|| format!("no hay rango para {usuario:?} en /etc/subuid"))?; let rg = leer_subid("/etc/subgid", &usuario) .with_context(|| format!("no hay rango para {usuario:?} en /etc/subgid"))?; let tmp = std::env::temp_dir().join(format!("qorpa-userns-{}", std::process::id())); std::fs::create_dir_all(&tmp)?; let listo = tmp.join("listo"); let adelante = tmp.join("adelante"); for f in [&listo, &adelante] { let _ = std::fs::remove_file(f); let st = Command::new("mkfifo").arg(f).status().context("mkfifo")?; if !st.success() { bail!("no pude crear el fifo {}", f.display()); } } let tenedor = Command::new("unshare") .arg("-U") .arg("sh") .arg("-c") .arg(format!( "echo r > {l}; read _ < {a}", l = listo.display(), a = adelante.display() )) .spawn() .context("no pude ejecutar `unshare -U` — ¿está util-linux?")?; let pid = tenedor.id(); let mut guard = UsernsMapeado { tenedor, ns_path: format!("/proc/{pid}/ns/user"), ids: 1 + ru.cantidad, }; // Bloquea hasta que el hijo esté DENTRO del namespace. Sin este apretón de manos mapearíamos // el namespace del padre, que es el del host. std::fs::read_to_string(&listo).context("el tenedor no llegó al fifo")?; // Los tramos del mapa. Con `uid_dentro = 0` es el caso simple (somos root adentro y el resto // del rango va detrás). Con cualquier otro, hay que partir el rango en tres para dejar hueco: // los ids de abajo (incluido el 0, que tiene que EXISTIR para que la imagen tenga dueño de // ficheros) salen del subuid, nosotros caemos en `uid_dentro`, y el resto sigue arriba. let tramos = move |id_afuera: u32, r: &Rango| -> Vec { if uid_dentro == 0 { vec!["0".into(), id_afuera.to_string(), "1".into(), "1".into(), r.inicio.to_string(), r.cantidad.to_string()] } else { let bajos = uid_dentro.min(r.cantidad); let altos = r.cantidad.saturating_sub(bajos + 1); let mut v = vec![ "0".into(), r.inicio.to_string(), bajos.to_string(), uid_dentro.to_string(), id_afuera.to_string(), "1".into(), ]; if altos > 0 { v.extend([(uid_dentro + 1).to_string(), (r.inicio + bajos).to_string(), altos.to_string()]); } v } }; let mapear = |bin: &str, id: u32, r: &Rango| -> Result<()> { let mut argv = vec![pid.to_string()]; argv.extend(tramos(id, r)); let out = Command::new(bin) .args(&argv) .output() .with_context(|| format!("no pude ejecutar {bin}"))?; if !out.status.success() { bail!("{bin}: {}", String::from_utf8_lossy(&out.stderr).trim()); } Ok(()) }; mapear("newuidmap", uid, &ru)?; mapear("newgidmap", gid, &rg)?; // Soltar al tenedor: ya mapeado, se queda esperando el fifo (que nadie escribirá) hasta que lo // matemos. Lo que importa es que el proceso VIVA, no lo que haga. let _ = std::fs::remove_file(&listo); let _ = std::fs::remove_file(&adelante); guard.ids = 1 + ru.cantidad; Ok(guard) } /// Lo que NO viene del manifiesto: banderas de la invocación. Van juntas para que agregar una no /// mueva la firma de media docena de llamadas. #[derive(Default, Clone, Copy)] struct Ajustes { dry_run: bool, no_jail: bool, single_id: bool, /// Modo `provision`: la política declarada se ENSANCHA para poder instalar, y sólo mientras /// dura la instalación. Ver `provision()` — lo que se ensancha y por qué se dice en voz alta. provisionando: bool, /// Levanta el lector de audit y recoge el veredicto. Ver `preparar_evidencia`. evidencia: bool, } fn run_instance( root: &Path, id: &str, cmd: &[String], aj: Ajustes, ) -> Result<()> { let Ajustes { dry_run, no_jail, single_id, provisionando, evidencia: _ } = aj; let (dir, mut inst) = read_instance(root, id)?; let (sha, tree) = resolve_image(root, &inst.base)?; // ── LA POLÍTICA SE ENSANCHA PARA PROVISIONAR, Y SE DICE ──────────────────────────────────── // Instalar paquetes pide exactamente las tres cosas que una instancia bien declarada NO tiene: // red (los paquetes se bajan), root (el gestor chownea y escribe en /usr) y la imagen sin // sellar (lo que instala va a /usr por definición). Ensancharlas en silencio sería la falla // que el ADR llama por su nombre en D7: la política se AUTORA. Así que se ensancha sólo aquí, // sólo mientras dura el `provision`, y se imprime en la cara — el manifiesto NO se toca, y el // siguiente `run` vuelve a la política escrita. if provisionando { let mut abierto: Vec<&str> = Vec::new(); if !inst.grants.network { abierto.push("red"); inst.grants.network = true; } if !inst.grants.root { abierto.push("root"); inst.grants.root = true; } if inst.grants.seal_image { abierto.push("imagen sin sellar"); inst.grants.seal_image = false; } if inst.run_as.is_some() { abierto.push("como root, no `run_as`"); inst.run_as = None; } if !abierto.is_empty() { eprintln!( "⚠ provision ENSANCHA la política sólo para esta operación: {}. El manifiesto no \n\ \x20 cambia y el próximo `run` vuelve a lo declarado.", abierto.join(", ") ); } } // Quién sos adentro. Con el mapeo por rango esto NO cuesta el rango: lo decide el propio mapa. // ⚠ Cambiarlo en una instancia YA USADA la corrompe: los ficheros del `upper` quedaron con los // uids del mapa anterior. Para correr como usuario normal está `run_as`, que no toca el mapa. let uid_dentro: u32 = if inst.grants.root { 0 } else { 1000 }; // El XDG_RUNTIME_DIR es del usuario que CORRE, no del uid del mapa: con `run_as`, apuntarlo al // de root deja a las herramientas del Steam Runtime sin poder crear su temporal // (`srt-logger: g_mkdtemp: Permission denied`), y eso se lee como un aviso menor hasta que algo // deja de andar sin explicar por qué. let uid_corre = match &inst.run_as { Some(u) => usuario_de(&dir, &tree, u).map(|(x, _, _)| x).unwrap_or(uid_dentro), None => uid_dentro, }; let runtime_dir = format!("/run/user/{uid_corre}"); // El proxy vive mientras viva este proceso, que es exactamente lo que dura la instancia. // `_proxy` se mantiene en scope a propósito. let mut proxy_sock: Option = None; let _proxy = if inst.grants.sockets.iter().any(|s| s == "wayland") && !inst.grants.wayland_raw { let xdg = std::env::var("XDG_RUNTIME_DIR") .unwrap_or_else(|_| format!("/run/user/{}", uid_corre)); let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into()); let real = PathBuf::from(format!("{xdg}/{d}")); if !real.exists() { bail!("la instancia pide `wayland` pero no hay compositor en {}", real.display()); } let permitidos: std::collections::HashSet = if inst.grants.wayland_globals.is_empty() { crate::qorpa_wl::GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect() } else { inst.grants.wayland_globals.iter().cloned().collect() }; let n = permitidos.len(); let p = crate::qorpa_wl::arrancar(&dir.join("wl"), &real, permitidos) .context("no pude arrancar el proxy de Wayland")?; eprintln!(" wayland: proxy filtrante puesto — {n} global(s) visibles, el resto no existe \ para la instancia"); proxy_sock = Some(p.socket.clone()); Some(p) } else { None }; let (grant_args, warns) = grants_to_args(&inst.grants, &runtime_dir, proxy_sock.as_deref()); // El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo // después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo // que no se declara, no entra (D2, "por defecto NADA"). // El mapeo por rango se intenta SIEMPRE que se pueda: es lo que quita el impuesto de // `apt`/`pacman` y lo que permite anidar. `root = false` lo descarta porque ser otro-que-root // adentro pide el `--uid` de bwrap, que exige `--unshare-user` y ése es incompatible con // `--userns`. Cuando no se puede, se dice POR QUÉ y se sigue con un id: degradar en silencio // haría que "no anda adentro" fuese un misterio en vez de una línea. let mut mapeado: Option = None; if !single_id { match userns_con_rango(uid_dentro) { Ok(m) => mapeado = Some(m), Err(e) => eprintln!( "⚠ sin mapeo por rango ({e:#}) ⇒ un solo id adentro. Consecuencias MEDIDAS: `apt` \ no baja a `_apt`, `pacman` no chownea su descarga, y un userns anidado no puede \ escribir su mapa. Diagnóstico: scripts/qorpa/qorpa-preflight.sh" ), } } let mut args: Vec = vec![ "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try", "--hostname", "qorpa", "--clearenv", "--die-with-parent", ].into_iter().map(String::from).collect(); if mapeado.is_none() { // bwrap crea el suyo, con un solo id. args.insert(0, "--unshare-user".into()); if inst.grants.root { args.extend(["--uid", "0", "--gid", "0"].map(String::from)); } } if inst.grants.root { for c in CAPS_ROOT { args.push("--cap-add".into()); args.push((*c).to_string()); } if inst.grants.nesting { // La que permite montar. Sólo con la concesión declarada. args.push("--cap-add".into()); args.push("CAP_SYS_ADMIN".into()); } } args.push("--overlay-src".into()); args.push(tree.display().to_string()); args.push("--overlay".into()); args.push(dir.join("upper").display().to_string()); args.push(dir.join("work").display().to_string()); args.push("/".into()); // /proc y /dev van DESPUÉS del overlay: son del kernel, no de la imagen. // `--perms 1777` antes del `--tmpfs /tmp` no es cosmético: bwrap lo crearía 0755 y entonces el // `_apt` (uid 42) al que apt baja para bajar paquetes no puede escribir su fichero temporal. // Un /tmp que no es 1777 no es /tmp. args.extend(["--proc", "/proc", "--dev", "/dev", "--perms", "1777", "--tmpfs", "/tmp"].map(String::from)); args.extend(["--dir".to_string(), runtime_dir.clone(), "--chdir".into(), "/".into()]); // El HOME sale del /etc/passwd de la IMAGEN, no de una constante: si la instancia creó un // usuario, su home es el que ese fichero dice. Un HOME que no existe hace que media app se // comporte raro sin decir por qué. let home = home_de(&dir, &tree, uid_dentro); args.extend([ "--setenv", "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "--setenv", "HOME", &home, "--setenv", "TERM", &std::env::var("TERM").unwrap_or_else(|_| "xterm".into()), ].map(String::from)); args.extend(["--setenv".to_string(), "XDG_RUNTIME_DIR".into(), runtime_dir.clone()]); args.extend(grant_args); let mut cmd: Vec = if cmd.is_empty() { vec!["/bin/sh".into()] // toda imagen trae uno; bash puede no estar. } else { cmd.to_vec() }; // Bajar de privilegio ADENTRO, con el mapa intacto. `setpriv` viene en util-linux, que trae // toda imagen de distro; si faltara, se dice en vez de correr como root en silencio — que sería // exactamente lo que el manifiesto pidió no hacer. if let Some(usuario) = &inst.run_as { let (uid_u, gid_u, home_u) = usuario_de(&dir, &tree, usuario).with_context(|| { format!("`run_as = {usuario:?}` pero no existe en el /etc/passwd de la instancia") })?; // Un prelude MÍNIMO como root para darle su runtime dir al usuario, y después `exec`: el // `"$@"` evita entrecomillar el comando del usuario, que es donde estos wrappers se rompen. let mut env = vec![ "/bin/sh".to_string(), "-c".into(), "chown \"$1\":\"$2\" \"$3\" 2>/dev/null; shift 3; exec \"$@\"".into(), "qorpa".into(), uid_u.to_string(), gid_u.to_string(), runtime_dir.clone(), "setpriv".into(), "--reuid".into(), uid_u.to_string(), "--regid".into(), gid_u.to_string(), "--init-groups".into(), "--".into(), ]; env.extend(cmd); cmd = env; // El HOME tiene que ser el SUYO, no el de root: media app se comporta raro sin decir por qué. if let Some(i) = args.iter().position(|a| a == "HOME") { args[i + 1] = home_u; } } // ── EL CANAL DE EVIDENCIA ────────────────────────────────────────────────────────────────── // De un binario ajeno no hay fuente que leer. Lo único que queda para saber qué intentó hacer // es lo que el kernel le NEGÓ y anotó — por eso el ADR lo llama «la única forma de auditar el // montón B». El lector (`harkaq-audit`) vive en el HOST, fuera de bwrap, porque el audit no // está namespaceado; se levanta ANTES para no perderse las primeras denegaciones. let mut ev: Option = None; if aj.evidencia && !dry_run { let e = preparar_evidencia(&dir, id, no_jail, &inst.grants)?; args.push("--ro-bind".into()); args.push(e.canario_src.display().to_string()); args.push(e.canario.clone()); // El canario se lee ADENTRO y su denegación es lo que revela el `domain=` del dominio // Landlock, que es un número que nadie puede adivinar desde fuera. Sin esto el lector no // sabe cuáles de todas las denegaciones del sistema son de ESTA instancia. let mut w = vec![ "/bin/sh".to_string(), "-c".into(), // El `{ …; } 2>/dev/null` NO es lo mismo que `read _ < "$1" 2>/dev/null`: las // redirecciones se aplican de izquierda a derecha, así que en la segunda forma el // fallo de la entrada se reporta ANTES de silenciar stderr y el usuario ve un // «Permission denied» que parece un error suyo y es el canario funcionando. "{ read _ < \"$1\"; } 2>/dev/null || true; shift; exec \"$@\"".into(), "qorpa".into(), e.canario.clone(), ]; w.extend(cmd); cmd = w; ev = Some(e); } // harkaq va como ÚLTIMO eslabón antes del comando, igual que en el sandbox del build: bwrap ya // puso los namespaces y el overlay, y esto pone el grano fino. Cruza el borde un binario // ESTÁTICO — no una librería —, así que sigue valiendo D2: lo único que comparte con el host // es la ABI del kernel. if !no_jail { let exec = harkaq_exec(); if !exec.exists() { bail!( "no encuentro harkaq-exec en {} ni en /usr/bin — la instancia NO va a correr \ enjaulada y eso no se hace en silencio.\n instalalo: está en `perfil.base` como \ receta `harkaq-exec`; en una caja instalada, `takana upgrade`.\n o construilo: \ gcc -O1 -Wall -static -o {} scripts/harkaq/harkaq-exec.c (sólo en el árbol de \ desarrollo)\n o corré con --no-jail si sabés lo que estás haciendo.", exec.display(), exec.display() ); } let policy = dir.join("policy"); compile_policy(&inst.grants, &tree, &policy)?; args.push("--ro-bind".into()); args.push(exec.display().to_string()); args.push("/run/qorpa/harkaq-exec".into()); args.push("--ro-bind".into()); args.push(policy.display().to_string()); args.push("/run/qorpa/policy".into()); let mut envoltura: Vec = vec![ "/run/qorpa/harkaq-exec".into(), "--policy".into(), "/run/qorpa/policy".into(), ]; if inst.grants.nesting { // MEDIDO: seccomp no alcanza. Un dominio Landlock activo hace fallar `mount` con // EACCES aunque el filtro lo permita — el kernel no admite montajes nuevos bajo un // dominio, porque escaparían de sus reglas por-ruta. ⇒ anidar cuesta la política de // ficheros. Seccomp y no_new_privs siguen puestos, que es lo que más pesa con un // binario ajeno, y el confinamiento de fs queda en el namespace de montaje de bwrap, // que ya es una lista blanca. envoltura.push("--allow-nesting".into()); envoltura.push("--no-landlock".into()); } envoltura.push("--".into()); envoltura.extend(cmd); cmd = envoltura; } else { eprintln!("⚠ --no-jail: SIN Landlock, SIN seccomp, SIN no_new_privs. Sólo los namespaces \ de bwrap. No es el modo normal."); } args.extend(cmd.iter().cloned()); for w in &warns { eprintln!("⚠ {w}"); } if dry_run { if let Some(m) = &mapeado { println!("# el userns lo creamos nosotros y le mapeamos {} ids:", m.ids); println!("# exec 3<{} ⇒ bwrap --userns 3 …", m.ns_path); } // Una línea por concesión, no una por token: el punto de `--dry-run` es que se LEA. let mut linea = String::from("bwrap"); for a in &args { if a.starts_with("--") { println!("{linea} \\"); linea = format!(" {a}"); } else { linea.push(' '); linea.push_str(a); } } println!("{linea}"); return Ok(()); } eprintln!("qorpa {id} · sha256:{} · red={} · {} socket(s), {} device(s), {} dir(s) · \ jaula={} · imagen={}", &sha[..12], inst.grants.network, inst.grants.sockets.len(), inst.grants.devices.len(), inst.grants.dirs.len(), if no_jail { "NO" } else if inst.grants.nesting { "sí (anidamiento permitido)" } else { "sí" }, if inst.grants.seal_image { "sellada" } else { "escribible" }); eprintln!(" ids mapeados: {}", match &mapeado { Some(m) => m.ids.to_string(), None => "1 (sin rango)".into() }); // Cuando el namespace es nuestro hay que pasarle a bwrap un fd ABIERTO, y un fd sólo cruza el // `exec` si no es CLOEXEC. En vez de arrastrar una dep de C para un `fcntl`, lo abre la shell: // sus redirecciones no son CLOEXEC por definición, y de paso el `--dry-run` puede mostrar // exactamente lo que pasa. // ── EL OVERLAY PUEDE SEGUIR TOMADO POR LA CORRIDA ANTERIOR ───────────────────────────────── // MEDIDO: dos `run` seguidos sobre la MISMA instancia y el segundo muere con // `bwrap: Can't make overlay mount … Device or resource busy`; el tercero anda. // // No es un capricho de overlayfs: el kernel se NIEGA a montar dos overlays vivos que compartan // `upperdir`/`workdir` porque eso corrompe la capa. O sea que el EBUSY es un GUARDIÁN, no una // molestia — lo que está mal es el momento: la corrida anterior ya devolvió el prompt y su // namespace todavía no terminó de reaparse, así que el usuario ve una denegación correcta por // una razón que ya no existe. Se reintenta acotado; si de verdad hay otra `run` viva, los // reintentos se agotan y se dice cuál es la causa en vez de dejar el mensaje crudo de bwrap. const INTENTOS: u32 = 6; let mut st = None; for intento in 1..=INTENTOS { let (status, overlay_tomado) = lanzar_bwrap(&mapeado, &args)?; if status.success() || !overlay_tomado { st = Some(status); break; } if intento == INTENTOS { bail!( "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." ); } eprintln!(" el overlay lo tiene todavía la corrida anterior — reintento {intento}/{INTENTOS}"); std::thread::sleep(std::time::Duration::from_millis(200 * u64::from(intento))); } let st = st.expect("el bucle sale con estado o con bail"); if let Some(e) = ev { recoger_evidencia(&dir, e)?; } match st.code() { Some(0) => Ok(()), Some(c) => std::process::exit(c), None => bail!("la instancia murió por señal"), } } // ── el canal de evidencia (D9 punto 3) ────────────────────────────────────────────────────────── // // LO QUE HACE ÚNICO A ESTE CANAL: con nuestros propios builds, si algo toca lo que no debe, está // la receta y la fuente para mirar. Con un binario ajeno no hay nada que leer. Lo que el kernel // deniega y anota es LA ÚNICA superficie observable — el ADR lo dice así en D9. // // Tres piezas, y ninguna sobra: // · el LECTOR vive en el host, fuera de bwrap: el audit no está namespaceado y los registros // cruzan igual. Se levanta ANTES de la instancia o se pierde el principio. // · el CANARIO es un fichero en un sitio que la política NO alcanza. Al leerlo, el kernel emite // una denegación con el `domain=` de este dominio Landlock — un número que desde fuera no se // puede adivinar. Sin canario, el lector no sabe cuáles de todas las denegaciones del sistema // son de esta instancia. // · el VEREDICTO se gana: `denials=[]` sólo vale si el canario se vio Y el contador del kernel // cuadra. Si no, el estado es `SinEvidencia`, que es distinto de «limpio». const EVIDENCE_FILE: &str = "evidence.json"; struct Evidencia { canario: String, canario_src: PathBuf, veredicto: PathBuf, lector: std::process::Child, } fn preparar_evidencia(dir: &Path, id: &str, no_jail: bool, g: &Grants) -> Result { // ── LAS TRES CONDICIONES, Y POR QUÉ SE FALLA EN VEZ DE SEGUIR ────────────────────────────── // Si alguna falta, el lector devolvería `denials=[]` y eso se leería como «la instancia no // intentó nada raro», cuando la verdad sería «no había forma de enterarse». Un instrumento que // no puede medir tiene que decirlo, no marcar cero. if no_jail { bail!("`--evidence` con `--no-jail` no tiene sentido: sin jaula no hay dominio Landlock y \ no hay nada que el kernel pueda anotar"); } if g.nesting { bail!( "`nesting` apaga Landlock (D9, conflicto 1: el kernel no admite montajes nuevos bajo \n \ un dominio) ⇒ o la instancia anida o se audita, no las dos. Para auditar Steam hay \n \ que mirar la instancia SIN pressure-vessel." ); } if !g.seal_image { bail!( "sin `seal_image` la política de una instancia es UNA línea, `rw /` ⇒ no hay NADA que \n \ el kernel pueda denegar, y el veredicto diría `denials=[]` por construcción y no por \n \ mérito. Encendé `seal_image = true` en el manifiesto y volvé a correr." ); } let bin = harkaq_exec().parent().unwrap_or(Path::new(".")).join("harkaq-audit"); if !bin.exists() { bail!( "no encuentro harkaq-audit en {}\n \ construilo: gcc -O1 -Wall -o {} scripts/harkaq/harkaq-audit.c\n \ y dale las capabilities: sudo setcap cap_audit_read,cap_audit_control+ep {}", bin.display(), bin.display(), bin.display() ); } // El canario va en la RAÍZ. Con `seal_image` la política concede `list /` y nada más sobre la // raíz misma, así que un fichero ahí se puede LISTAR pero no LEER: la denegación es exacta. // En cualquier directorio con `rw` —/etc, /var, /tmp— se leería sin ruido y el canario sería // mudo, que es la misma trampa que ya documenta el canario del build. let canario = format!("/harkaq-canary-{id}-{}", std::process::id()); let canario_src = dir.join("canary"); std::fs::write(&canario_src, b"harkaq\n") .with_context(|| format!("escribiendo el canario {}", canario_src.display()))?; let veredicto = dir.join(EVIDENCE_FILE); let vf = std::fs::File::create(&veredicto) .with_context(|| format!("creando {}", veredicto.display()))?; let lector = Command::new(&bin) .arg("--canary").arg(&canario) .arg("--timeout").arg(std::env::var("HARKAQ_TIMEOUT").unwrap_or_else(|_| "1800".into())) .stdout(std::process::Stdio::from(vf)) .stderr(std::process::Stdio::inherit()) .spawn() .with_context(|| format!("no pude lanzar {}", bin.display()))?; // El lector tiene que estar en el multicast antes de que arranque la instancia: si no, sus // primeras denegaciones —el canario entre ellas— se emiten sin nadie del otro lado y el // veredicto sale `SinEvidencia` por una carrera y no por un problema real. std::thread::sleep(std::time::Duration::from_millis(300)); eprintln!(" evidencia: lector puesto, canario {canario}"); Ok(Evidencia { canario, canario_src, veredicto, lector }) } fn recoger_evidencia(dir: &Path, mut e: Evidencia) -> Result<()> { // Gracia para que llegue el `deallocated` del dominio: el kernel lo emite con retraso (RCU), // y es lo que le permite al lector comparar su conteo contra el del kernel. std::thread::sleep(std::time::Duration::from_millis(1500)); let _ = nix::sys::signal::kill( nix::unistd::Pid::from_raw(e.lector.id() as i32), nix::sys::signal::Signal::SIGTERM, ); let salida = e.lector.wait().ok().and_then(|s| s.code()); let _ = std::fs::remove_file(&e.canario_src); let txt = std::fs::read_to_string(&e.veredicto).unwrap_or_default(); // Un veredicto VACÍO no es un veredicto: es el lector que no llegó a emitir. Se dice con su // código de salida, que es el único dato que queda — un `SinEvidencia` mudo manda a buscar el // problema en el sitio equivocado. if txt.trim().is_empty() { println!( "evidencia: SIN EVIDENCIA — el lector no emitió veredicto (salió con {}). NO quiere \ decir «limpio».\n Comprobá: getcap {}", salida.map(|c| c.to_string()).unwrap_or_else(|| "señal".into()), harkaq_exec().parent().unwrap_or(Path::new(".")).join("harkaq-audit").display(), ); return Ok(()); } let v: serde_json::Value = serde_json::from_str(txt.trim()).unwrap_or(serde_json::Value::Null); let estado = v.get("estado").and_then(|x| x.as_str()).unwrap_or("SinEvidencia"); let denials = v.get("denials").and_then(|x| x.as_array()).map(|a| a.len()).unwrap_or(0); match estado { "Hermetico" => println!("evidencia: HERMÉTICO — 0 denegaciones, y el canario las respalda"), "Impuro" => { println!("evidencia: IMPURO — {denials} denegación(es); esto es el diagnóstico, no el fallo"); for d in v["denials"].as_array().into_iter().flatten().take(12) { println!(" {} · {}", d.get("blockers").and_then(|x| x.as_str()).unwrap_or("?"), d.get("path").and_then(|x| x.as_str()).unwrap_or("?")); } if denials > 12 { println!(" … y {} más en {}", denials - 12, e.veredicto.display()); } } _ => println!( "evidencia: SIN EVIDENCIA — {}. NO quiere decir «limpio»: quiere decir que no se pudo \ saber.", v.get("motivo").and_then(|x| x.as_str()).unwrap_or("el lector no dejó motivo") ), } println!(" veredicto completo: {}", e.veredicto.display()); let _ = dir; Ok(()) } /// Lanza bwrap y devuelve su estado junto con **si el fallo fue el overlay tomado**. /// /// El stderr se PASA A TRAVÉS mientras se mira, byte a byte y sin esperar renglones: hay que leerlo /// para distinguir ese fallo de cualquier otro, pero tragárselo sería peor que el bug —adentro /// corre un shell interactivo y su prompt sale por stderr sin salto de línea—. Sólo se guardan los /// primeros KB para buscar la firma; el resto se reenvía y se olvida. fn lanzar_bwrap( mapeado: &Option, args: &[String], ) -> Result<(std::process::ExitStatus, bool)> { let mut c = match mapeado { Some(m) => { let mut c = Command::new("sh"); c.arg("-c") .arg(r#"exec 3<"$1"; shift; exec bwrap --userns 3 "$@""#) .arg("qorpa") .arg(&m.ns_path) .args(args); c } None => { let mut c = Command::new("bwrap"); c.args(args); c } }; c.stderr(std::process::Stdio::piped()); let mut hijo = c .spawn() .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?; let mut err = hijo.stderr.take().expect("stderr pedido arriba"); let hilo = std::thread::spawn(move || { use std::io::Write; let mut principio = Vec::new(); let mut buf = [0u8; 4096]; loop { match err.read(&mut buf) { Ok(0) | Err(_) => break, Ok(n) => { if principio.len() < 8192 { principio.extend_from_slice(&buf[..n]); } let _ = std::io::stderr().write_all(&buf[..n]); let _ = std::io::stderr().flush(); } } } let t = String::from_utf8_lossy(&principio).into_owned(); t.contains("Can't make overlay mount") && t.contains("busy") }); let st = hijo.wait().context("esperando a bwrap")?; let overlay_tomado = hilo.join().unwrap_or(false); Ok((st, overlay_tomado)) } // ── prune: la poda (§Orden 7) ─────────────────────────────────────────────────────────────────── // // Nace CON el subsistema y no después, a propósito: un rootfs son cientos de MB o varios GB, y no // lo alcanzan ni `store-gc` ni la caché `.dmerge` — es un tercer montón sin dueño, como ya lo fue // `work/sources`. La regla que la hace trivial es D3: **un `upper` siempre se puede borrar**. // // Dos cicatrices del repo, cableadas acá: // · `store-gc` REPORTABA borrados que no ocurrían (corriendo en segundo plano). Por eso esto // comprueba DESPUÉS de borrar que el directorio se fue, y si sigue ahí lo dice. // · Un tamaño calculado sobre un árbol que no se puede leer entero sale MENOR de lo que es. Por // eso se cuentan los directorios ciegos y la cifra se marca como cota inferior. struct Candidato { que: String, ruta: PathBuf, bytes: u64, cota: bool } fn prune(root: &Path, con_upper: bool, de_verdad: bool) -> Result<()> { let images = root.join("images"); let mut cands: Vec = Vec::new(); // 1. Restos de descargas y desempacados a medias. Basura por definición: llevan el prefijo con // punto justo para que `list` no los confunda con una imagen. if let Ok(rd) = std::fs::read_dir(&images) { for e in rd.flatten() { let n = e.file_name().to_string_lossy().into_owned(); if !n.starts_with('.') { continue; } let (bytes, cota) = medir(&e.path()); cands.push(Candidato { que: format!("resto de un pull a medias ({n})"), ruta: e.path(), bytes, cota }); } } // 2. Imágenes que ninguna instancia usa. Se pueden volver a traer por digest, así que borrarlas // no pierde nada que no se pueda recuperar: es exactamente la propiedad que da el pin. let usadas: std::collections::BTreeSet = std::fs::read_dir(instances_dir(root)) .into_iter().flatten().flatten() .filter_map(|e| { let body = std::fs::read_to_string(e.path().join(INSTANCE_MANIFEST)).ok()?; let i: Instance = toml::from_str(&body).ok()?; Some(i.base.strip_prefix("sha256:").unwrap_or(&i.base).to_string()) }) .collect(); if let Ok(rd) = std::fs::read_dir(&images) { for e in rd.flatten() { let n = e.file_name().to_string_lossy().into_owned(); if n.starts_with('.') || usadas.contains(&n) { continue; } let m: Option = std::fs::read_to_string(e.path().join(IMAGE_MANIFEST)) .ok().and_then(|s| toml::from_str(&s).ok()); // El tamaño ya está en el manifiesto (lo midió `pull` sobre el árbol recién // desempacado, cuando todavía era legible entero): mejor dato y gratis. let (bytes, cota) = match &m { Some(m) if m.tree_bytes > 0 => (m.tree_bytes, false), _ => medir(&e.path()), }; let etiqueta = m.and_then(|m| m.label).unwrap_or_else(|| n[..12].to_string()); cands.push(Candidato { que: format!("imagen sin instancia ({etiqueta})"), ruta: e.path(), bytes, cota }); } } // 3. Capas mutables. Sólo si se piden: son caché (D3), pero lo que instalaste a mano y no // declaraste en `packages` no vuelve solo. if con_upper { for e in std::fs::read_dir(instances_dir(root)).into_iter().flatten().flatten() { let id = e.file_name().to_string_lossy().into_owned(); let up = e.path().join("upper"); if !up.exists() { continue; } let (bytes, cota) = medir(&up); cands.push(Candidato { que: format!("capa mutable de {id}"), ruta: up, bytes, cota }); } } if cands.is_empty() { println!("nada que podar en {}", root.display()); return Ok(()); } let total: u64 = cands.iter().map(|c| c.bytes).sum(); let alguna_cota = cands.iter().any(|c| c.cota); for c in &cands { println!(" {}{:<9} {}", if c.cota { "≥" } else { " " }, human(c.bytes), c.que); } println!("{}{} en {} elemento(s)", if alguna_cota { "≥ " } else { "" }, human(total), cands.len()); if !de_verdad { println!("\nsimulacro: no se borró nada. Repetí con --yes."); if !con_upper { println!("(`--upper` añade las capas mutables, que por D3 siempre se pueden tirar)"); } return Ok(()); } let (mut ok, mut fallos) = (0u64, 0u64); for c in &cands { // `force_remove_dir_all` sabe abrir los directorios de sólo-lectura de un rootfs y, si el // árbol tiene ficheros de los subuid, entrar al userns mapeado para borrarlos. let r = if c.ruta.is_dir() { force_remove_dir_all(&c.ruta) } else { std::fs::remove_file(&c.ruta) }; // La cicatriz de store-gc: no basta con que la llamada devuelva Ok — se COMPRUEBA que se fue. match (r, c.ruta.exists()) { (Ok(()), false) => { ok += 1; } (Ok(()), true) => { fallos += 1; eprintln!(" !! {} dijo que sí y SIGUE AHÍ", c.ruta.display()); } (Err(e), _) => { fallos += 1; eprintln!(" !! {}: {e}", c.ruta.display()); } } } // La capa mutable es parte de la instancia: si se tiró, hay que dejarla utilizable. if con_upper { for e in std::fs::read_dir(instances_dir(root)).into_iter().flatten().flatten() { let _ = std::fs::create_dir_all(e.path().join("upper")); let _ = force_remove_dir_all(&e.path().join("work")); let _ = std::fs::create_dir_all(e.path().join("work")); } } println!("podado: {ok} de {} elemento(s)", cands.len()); if fallos > 0 { bail!("{fallos} no se pudieron borrar — el número de arriba NO es lo que se liberó"); } Ok(()) } /// Tamaño de un árbol y si la cifra es cota inferior (hubo directorios ilegibles). fn medir(p: &Path) -> (u64, bool) { if p.is_file() { return (std::fs::metadata(p).map(|m| m.len()).unwrap_or(0), false); } match walk_tree_ciegos(p) { Ok((_, bytes, ciegos)) => (bytes, ciegos > 0), Err(_) => (0, true), } } // ── export: la capa de transparencia (D5) ─────────────────────────────────────────────────────── // // El objetivo es el poder de Bedrock Linux —apps de cualquier «stratum» disponibles en todos // lados— SIN sus formas: nada de un FUSE global en el camino de cada `exec`, nada de arbitrar por // heurística qué binario gana. Acá no hace falta, porque **el FHS de esta distro ya es una // proyección**: `takana hydrate` proyecta artefactos a un árbol, y extender la proyección a // instancias es idiomático. // // Tres propiedades que un FUSE no da: **cero costo en runtime** (no hay proceso en el camino // crítico), **inspeccionable** (`cat` al shim y ves qué hace) y **revocable** (se borran los shims // y la instancia desaparece del sistema sin desmontar nada). /// Registro de lo escrito. Sin esto, `--remove` tendría que ADIVINAR qué ficheros son nuestros, y /// borrar por patrón en el `~/.local/bin` de alguien es exactamente lo que no se hace. const EXPORT_LEDGER: &str = "exported.json"; /// Marca dentro de cada fichero generado. Redundante con el registro a propósito: si alguien mueve /// el shim, sigue diciendo de dónde salió. const MARCA: &str = "hammer-qorpa"; /// Claves del `.desktop` ajeno que se copian: TEXTO DE PRESENTACIÓN y nada más. /// /// Lista blanca, no negra. `Exec`, `TryExec`, `Path`, `DBusActivatable` y `Actions` quedan fuera /// **por definición**: son ejecución, y la ejecución la escribimos nosotros. Una lista negra /// dejaría entrar la próxima clave ejecutable que invente el estándar. const DESKTOP_OK: &[&str] = &[ "Name", "GenericName", "Comment", "Keywords", "Icon", "Categories", "Terminal", "StartupWMClass", "StartupNotify", "MimeType", "NoDisplay", "Type", ]; /// Busca un fichero en la vista MERGED de la instancia: primero la capa mutable, después la imagen. /// Es el mismo orden que ve el proceso adentro; mirar sólo la imagen se perdería lo que instaló el /// gestor de paquetes, que es justo lo que se quiere exportar. fn resolver_en_instancia(dir: &Path, tree: &Path, rel: &str) -> Option { let rel = rel.trim_start_matches('/'); for base in [dir.join("upper"), tree.to_path_buf()] { let p = base.join(rel); if p.exists() { return Some(p); } } None } fn buscar_desktop(dir: &Path, tree: &Path, nombre: &str) -> Option { let nombre = if nombre.ends_with(".desktop") { nombre.to_string() } else { format!("{nombre}.desktop") }; for d in ["usr/share/applications", "usr/local/share/applications", "share/applications"] { if let Some(p) = resolver_en_instancia(dir, tree, &format!("{d}/{nombre}")) { return Some(p); } } None } /// El icono referenciado por un `.desktop` puede ser una ruta o un NOMBRE de tema. Si es nombre, se /// busca en los sitios estándar de la imagen y se copia al host: un `.desktop` con un icono que el /// host no puede resolver se ve como un cuadrito gris, que es peor que no exportarlo. fn copiar_icono(dir: &Path, tree: &Path, icono: &str, destino_base: &Path) -> Option<(String, PathBuf)> { if icono.starts_with('/') { let src = resolver_en_instancia(dir, tree, icono)?; let ext = src.extension().and_then(|e| e.to_str()).unwrap_or("png").to_string(); let nombre = format!("{MARCA}-{}", src.file_name()?.to_string_lossy()); let dst = destino_base.join(&nombre); std::fs::create_dir_all(destino_base).ok()?; std::fs::copy(&src, &dst).ok()?; let _ = ext; return Some((dst.display().to_string(), dst)); } for tamano in ["scalable", "256x256", "128x128", "64x64", "48x48"] { for ext in ["svg", "png"] { let rel = format!("usr/share/icons/hicolor/{tamano}/apps/{icono}.{ext}"); if let Some(src) = resolver_en_instancia(dir, tree, &rel) { let dst = destino_base.join(format!("{MARCA}-{icono}.{ext}")); std::fs::create_dir_all(destino_base).ok()?; if std::fs::copy(&src, &dst).is_ok() { return Some((dst.display().to_string(), dst)); } } } } for rel in [format!("usr/share/pixmaps/{icono}.png"), format!("usr/share/pixmaps/{icono}.svg")] { if let Some(src) = resolver_en_instancia(dir, tree, &rel) { let ext = if rel.ends_with("svg") { "svg" } else { "png" }; let dst = destino_base.join(format!("{MARCA}-{icono}.{ext}")); std::fs::create_dir_all(destino_base).ok()?; if std::fs::copy(&src, &dst).is_ok() { return Some((dst.display().to_string(), dst)); } } } None } fn export(root: &Path, id: &str, into: Option<&Path>, remove: bool) -> Result<()> { let (dir, inst) = read_instance(root, id)?; let ledger_path = dir.join(EXPORT_LEDGER); if remove { let previos: Vec = std::fs::read_to_string(&ledger_path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); if previos.is_empty() { println!("la instancia {id} no tiene nada exportado"); return Ok(()); } for f in &previos { match std::fs::remove_file(f) { Ok(()) => println!(" borrado {f}"), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => eprintln!(" no pude borrar {f}: {e}"), } } let _ = std::fs::remove_file(&ledger_path); println!("{} fichero(s) revocados — la instancia sigue existiendo, ya no se ve", previos.len()); return Ok(()); } if inst.export.binaries.is_empty() && inst.export.apps.is_empty() { bail!( "la instancia {id} no declara nada en `[export]`. Se exporta lo DECLARADO: exportar \ todo haría que el `ls` de la imagen compita con el nuestro, que es la falla de Bedrock." ); } let (_sha, tree) = resolve_image(root, &inst.base)?; let prefijo = into.map(PathBuf::from).unwrap_or_else(|| { PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into())).join(".local") }); let bin_dir = prefijo.join("bin"); let app_dir = prefijo.join("share/applications"); let icon_dir = prefijo.join("share/icons/qorpa"); std::fs::create_dir_all(&bin_dir)?; // La ruta ABSOLUTA de este mismo binario: un shim que dependiera del `PATH` del usuario haría // que la app arranque o no según quién la lance. let hammer = std::env::current_exe().context("no sé cuál es mi propia ruta")?; let raiz_flag = if root == Path::new(DEFAULT_ROOT) { String::new() } else { format!(" --root {}", shell_quote(&root.display().to_string())) }; let mut escritos: Vec = Vec::new(); for b in &inst.export.binaries { let dst = bin_dir.join(b); let cuerpo = format!( "#!/bin/sh\n\ # GENERADO por `takana qorpa export {id}` — {MARCA}. No editar: se reescribe.\n\ # Borralo (o corré `takana qorpa export {id} --remove`) y la instancia deja de verse.\n\ exec {hammer} qorpa run{raiz_flag} {id} -- {b} \"$@\"\n", hammer = shell_quote(&hammer.display().to_string()), b = shell_quote(b), ); std::fs::write(&dst, cuerpo)?; std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755))?; escritos.push(dst.display().to_string()); println!(" bin {}", dst.display()); } for a in &inst.export.apps { let src = buscar_desktop(&dir, &tree, a) .with_context(|| format!("no encuentro {a} dentro de la instancia {id}"))?; let texto = std::fs::read_to_string(&src)?; let (campos, exec_original) = leer_desktop(&texto); let nombre_bin = exec_original .split_whitespace() .next() .map(|c| c.rsplit('/').next().unwrap_or(c).to_string()) .unwrap_or_else(|| a.trim_end_matches(".desktop").to_string()); let mut salida = String::from("[Desktop Entry]\n"); salida.push_str(&format!( "# GENERADO por `hammer qorpa export {id}` — {MARCA}. El Exec de abajo es NUESTRO:\n\ # el original decía `{exec_original}` y no se copia (D5).\n" )); let mut icono_final = None; for (k, v) in &campos { if k.starts_with("Icon") { if let Some((ruta, dst)) = copiar_icono(&dir, &tree, v, &icon_dir) { escritos.push(dst.display().to_string()); icono_final = Some(ruta); continue; // se escribe abajo, ya resuelto a ruta del host } } salida.push_str(&format!("{k}={v}\n")); } if let Some(i) = icono_final { salida.push_str(&format!("Icon={i}\n")); } if !campos.iter().any(|(k, _)| k == "Type") { salida.push_str("Type=Application\n"); } salida.push_str(&format!( "Exec={} qorpa run{raiz_flag} {id} -- {} %U\n", shell_quote(&hammer.display().to_string()), shell_quote(&nombre_bin), )); salida.push_str(&format!("X-Qorpa-Instance={id}\n")); std::fs::create_dir_all(&app_dir)?; let dst = app_dir.join(src.file_name().unwrap()); std::fs::write(&dst, salida)?; escritos.push(dst.display().to_string()); println!(" desktop {}", dst.display()); } std::fs::write(&ledger_path, serde_json::to_string_pretty(&escritos)?)?; println!( "{} fichero(s) generados desde {} — inspeccionables con `cat`, revocables con --remove", escritos.len(), src_label(&dir) ); if !bin_dir.display().to_string().is_empty() { println!(" ⚠ que `{}` esté en tu PATH es cosa tuya", bin_dir.display()); } Ok(()) } /// Busca el home del uid en el `/etc/passwd` de la instancia (vista merged). Cae a `/root` para el /// 0 y a `/home/qorpa` si no hay entrada — nunca a nada, porque un HOME vacío rompe más que uno /// inventado. fn home_de(dir: &Path, tree: &Path, uid: u32) -> String { if let Some(p) = resolver_en_instancia(dir, tree, "etc/passwd") { if let Ok(txt) = std::fs::read_to_string(p) { for l in txt.lines() { let c: Vec<&str> = l.split(':').collect(); if c.len() >= 6 && c[2].parse::() == Ok(uid) && !c[5].is_empty() { return c[5].to_string(); } } } } if uid == 0 { "/root".into() } else { "/home/qorpa".into() } } /// (uid, gid, home) de un usuario del `/etc/passwd` de la instancia. Acepta nombre o uid. fn usuario_de(dir: &Path, tree: &Path, quien: &str) -> Result<(u32, u32, String)> { let p = resolver_en_instancia(dir, tree, "etc/passwd") .context("la instancia no tiene /etc/passwd")?; for l in std::fs::read_to_string(p)?.lines() { let c: Vec<&str> = l.split(':').collect(); if c.len() < 6 { continue; } if c[0] == quien || c[2] == quien { return Ok((c[2].parse()?, c[3].parse()?, if c[5].is_empty() { "/".into() } else { c[5].to_string() })); } } bail!("no encuentro al usuario {quien:?}") } fn src_label(dir: &Path) -> String { dir.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string()) } /// Parsea el `[Desktop Entry]` de un `.desktop` y devuelve (campos permitidos, el `Exec` original). /// El `Exec` se devuelve SÓLO para nombrar el binario y para dejar dicho en el fichero generado qué /// decía el ajeno — nunca se emite. fn leer_desktop(texto: &str) -> (Vec<(String, String)>, String) { let mut campos = Vec::new(); let mut exec = String::new(); let mut dentro = false; for l in texto.lines() { let l = l.trim(); if l.starts_with('[') { dentro = l == "[Desktop Entry]"; continue; } if !dentro || l.is_empty() || l.starts_with('#') { continue; } let Some((k, v)) = l.split_once('=') else { continue }; let (k, v) = (k.trim(), v.trim()); if k == "Exec" && exec.is_empty() { exec = v.to_string(); continue; } // La clave puede venir localizada (`Name[es]`): se compara la parte antes del corchete. let base = k.split('[').next().unwrap_or(k); if DESKTOP_OK.contains(&base) { campos.push((k.to_string(), v.to_string())); } } (campos, exec) } /// Comillas para `sh`. Un shim se lee y se ejecuta: una ruta con espacios no puede romperlo. fn shell_quote(s: &str) -> String { if !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"@%+=:,./-_".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) } } // ── list ──────────────────────────────────────────────────────────────────────────────────────── fn list(root: &Path, json: bool) -> Result<()> { let images = root.join("images"); let mut rows: Vec<(String, Option, TreeState)> = Vec::new(); if let Ok(rd) = std::fs::read_dir(&images) { for e in rd.flatten() { let name = e.file_name().to_string_lossy().into_owned(); if name.starts_with('.') { continue; // restos de un pull a medias } let m = std::fs::read_to_string(e.path().join(IMAGE_MANIFEST)) .ok() .and_then(|s| toml::from_str::(&s).ok()); rows.push((name, m, tree_state(&e.path().join(TREE)))); } } rows.sort_by(|a, b| a.0.cmp(&b.0)); let insts: Vec<(String, Instance)> = std::fs::read_dir(instances_dir(root)) .into_iter() .flatten() .flatten() .filter_map(|e| { let id = e.file_name().to_string_lossy().into_owned(); let body = std::fs::read_to_string(e.path().join(INSTANCE_MANIFEST)).ok()?; Some((id, toml::from_str::(&body).ok()?)) }) .collect(); if json { let out: Vec<_> = rows.iter().map(|(sha, m, st)| { serde_json::json!({ "sha256": sha, "label": m.as_ref().and_then(|m| m.label.clone()), "url": m.as_ref().map(|m| m.url.clone()), "tree_files": m.as_ref().map(|m| m.tree_files).unwrap_or(0), "tree_bytes": m.as_ref().map(|m| m.tree_bytes).unwrap_or(0), "empty": !matches!(st, TreeState::Populated), }) }).collect(); let insts_json: Vec<_> = insts.iter().map(|(id, i)| serde_json::json!({ "id": id, "base": i.base, "distro": i.distro, "packages": i.packages, "network": i.grants.network, "sockets": i.grants.sockets, "devices": i.grants.devices, })).collect(); println!("{}", serde_json::to_string_pretty( &serde_json::json!({"images": out, "instances": insts_json}))?); return Ok(()); } if rows.is_empty() && insts.is_empty() { println!("no hay imágenes en {} — `hammer qorpa pull --sha256 `", images.display()); return Ok(()); } let mut vacias = 0; for (sha, m, st) in &rows { let etiqueta = m.as_ref().and_then(|m| m.label.clone()).unwrap_or_else(|| "—".into()); match st { TreeState::Populated => println!( " {} {:<32} {:>7} ficheros {}", &sha[..12], etiqueta, m.as_ref().map(|m| m.tree_files).unwrap_or(0), human(m.as_ref().map(|m| m.tree_bytes).unwrap_or(0)), ), _ => { vacias += 1; println!(" {} {:<32} !! VACÍA — no es una imagen, es un nombre", &sha[..12], etiqueta); } } } if !insts.is_empty() { println!("\ninstancias:"); for (id, i) in &insts { let sha = i.base.strip_prefix("sha256:").unwrap_or(&i.base); // Se enumeran las concesiones porque una instancia sin política declarada y una con la // pantalla abierta se ven IGUAL desde fuera, y no son lo mismo (ADR §NO-resuelve 1). let mut abre: Vec = Vec::new(); if i.grants.network { abre.push("red".into()); } abre.extend(i.grants.sockets.iter().cloned()); abre.extend(i.grants.devices.iter().cloned()); if !i.grants.dirs.is_empty() { abre.push(format!("{} dir(s)", i.grants.dirs.len())); } println!(" {:<16} sobre {} abre: {}", id, &sha[..sha.len().min(12)], if abre.is_empty() { "nada".into() } else { abre.join(", ") }); } } println!("\n{} imagen(es) en {}", rows.len(), images.display()); if vacias > 0 { bail!("{vacias} imagen(es) VACÍA(s): un ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien"); } Ok(()) } fn human(b: u64) -> String { const U: [&str; 5] = ["B", "K", "M", "G", "T"]; let (mut v, mut i) = (b as f64, 0); while v >= 1024.0 && i < 4 { v /= 1024.0; i += 1; } if i == 0 { format!("{b} B") } else { format!("{v:.1} {}", U[i]) } } #[cfg(test)] mod tests { use super::*; fn tar_gz(dir: &Path, dst: &Path, contenido: &[(&str, &str)]) { for (p, c) in contenido { let f = dir.join(p); std::fs::create_dir_all(f.parent().unwrap()).unwrap(); std::fs::write(f, c).unwrap(); } let st = Command::new("tar").args(["-czf"]).arg(dst).arg("-C").arg(dir) .args(contenido.iter().map(|(p, _)| p.split('/').next().unwrap()).collect::>()) .status().unwrap(); assert!(st.success()); } fn sha_of(p: &Path) -> String { use sha2::{Digest, Sha256}; let mut h = Sha256::new(); std::io::copy(&mut std::fs::File::open(p).unwrap(), &mut h).unwrap(); hex::encode(h.finalize()) } #[test] fn pull_verifica_desempaca_y_es_idempotente() { let d = tempfile::tempdir().unwrap(); let src = d.path().join("src"); std::fs::create_dir_all(&src).unwrap(); let arch = d.path().join("img.tar.gz"); tar_gz(&src, &arch, &[("etc/os-release", "ID=ajena\n"), ("bin/sh", "#!/bin/sh\n")]); let sha = sha_of(&arch); let root = d.path().join("qorpa"); let url = format!("file://{}", arch.display()); pull(&root, &url, &sha, Some("prueba"), false, None).unwrap(); let tree = root.join("images").join(&sha).join(TREE); assert_eq!(std::fs::read_to_string(tree.join("etc/os-release")).unwrap(), "ID=ajena\n"); // Segundo pull: no vuelve a bajar ni rompe. pull(&root, &url, &sha, Some("prueba"), false, None).unwrap(); assert!(root.join("images").join(&sha).join(IMAGE_MANIFEST).exists()); } #[test] fn pull_aborta_si_el_contenido_no_es_el_que_dice_ser() { let d = tempfile::tempdir().unwrap(); let src = d.path().join("src"); std::fs::create_dir_all(&src).unwrap(); let arch = d.path().join("img.tar.gz"); tar_gz(&src, &arch, &[("etc/hostname", "ajena\n")]); let root = d.path().join("qorpa"); let mentira = "0".repeat(64); let err = pull(&root, &format!("file://{}", arch.display()), &mentira, None, false, None) .unwrap_err().to_string(); assert!(err.contains("sha256 mismatch"), "mensaje inesperado: {err}"); // Y no dejó nada a medias: contenido distinto ⇒ ABORTAR, no "avisar y seguir". assert!(!root.join("images").join(&mentira).exists()); } /// La cicatriz de Arch: el rootfs viene en `root.x86_64/` CON HERMANOS al lado (`version`, /// `pkglist`). La regla vieja ("un solo directorio arriba") no disparaba y dejaba el rootfs un /// nivel abajo, en verde. Se ancla por estructura. #[test] fn el_rootfs_anidado_se_eleva_aunque_tenga_hermanos() { let d = tempfile::tempdir().unwrap(); let src = d.path().join("src"); std::fs::create_dir_all(&src).unwrap(); let arch = d.path().join("boot.tar.gz"); tar_gz(&src, &arch, &[ ("root.x86_64/etc/os-release", "ID=arch\n"), ("root.x86_64/usr/lib/keep", ""), ("version", "2026.09.01\n"), ]); let sha = sha_of(&arch); let root = d.path().join("qorpa"); pull(&root, &format!("file://{}", arch.display()), &sha, None, false, None).unwrap(); let tree = root.join("images").join(&sha).join(TREE); assert!(tree.join("etc/os-release").exists(), "no ancló el rootfs anidado"); assert!(!tree.join("root.x86_64").exists()); assert!(!tree.join("version").exists(), "el hermano no debía entrar al rootfs"); let m: ImageManifest = toml::from_str( &std::fs::read_to_string(root.join("images").join(&sha).join(IMAGE_MANIFEST)).unwrap(), ).unwrap(); assert_eq!(m.rootfs_subdir.as_deref(), Some("root.x86_64")); assert_eq!(m.dropped_siblings, vec!["version".to_string()]); } #[test] fn un_arbol_vacio_no_pasa_por_imagen() { let d = tempfile::tempdir().unwrap(); let vacio = d.path().join("vacio"); std::fs::create_dir_all(&vacio).unwrap(); let arch = d.path().join("nada.tar.gz"); assert!(Command::new("tar").args(["-czf"]).arg(&arch).arg("-C").arg(&vacio) .args(["--files-from", "/dev/null"]).status().unwrap().success()); let sha = sha_of(&arch); let root = d.path().join("qorpa"); let err = pull(&root, &format!("file://{}", arch.display()), &sha, None, false, None) .unwrap_err().to_string(); assert!(err.contains("VACÍO") || err.contains("no encuentro un rootfs"), "mensaje inesperado: {err}"); assert!(!root.join("images").join(&sha).exists()); } /// Una imagen mínima que pasa por rootfs: `etc/` + `bin/`. fn imagen_falsa(root: &Path, sha: &str) { let t = root.join("images").join(sha).join(TREE); std::fs::create_dir_all(t.join("etc")).unwrap(); std::fs::create_dir_all(t.join("bin")).unwrap(); std::fs::write(t.join("etc/os-release"), "ID=falsa\n").unwrap(); } /// Un artefacto del store con una imagen ajena sellada dentro. Réplica de lo que produce /// `recipes/steam-runtime-sniper.toml`. fn artefacto_con_imagen(store: &Path, hex: &str, sha_upstream: &str, con_arbol: bool) -> PathBuf { let img = store.join(format!("{hex}-imagen-ajena")).join("usr/share/qorpa/images/ajena"); std::fs::create_dir_all(&img).unwrap(); if con_arbol { std::fs::create_dir_all(img.join(TREE).join("etc")).unwrap(); std::fs::write(img.join(TREE).join("etc/os-release"), "ID=ajena\n").unwrap(); } else { std::fs::create_dir_all(img.join(TREE)).unwrap(); } std::fs::write(img.join(IMAGE_MANIFEST), format!( "sha256 = \"{sha_upstream}\"\nurl = \"https://ejemplo/x.tar.gz\"\n\ label = \"ajena-1\"\narchive_bytes = 42\ntree_files = 0\ntree_bytes = 0\n" )).unwrap(); img } /// LA propiedad que hace que sellar valga la pena: la imagen importada del store queda bajo el /// **sha256 de upstream**, no bajo el hash del artefacto. Si fuera al revés, la imagen del store /// y la traída con `pull` serían dos imágenes distintas con los mismos bytes y las instancias de /// dos máquinas dejarían de coincidir — justo lo que el pin existe para evitar. #[test] fn import_registra_la_imagen_por_el_sha_de_upstream_no_por_el_del_artefacto() { let d = tempfile::tempdir().unwrap(); let store = d.path().join("store"); let root = d.path().join("qorpa"); let hex = "f".repeat(64); let up = "1".repeat(64); artefacto_con_imagen(&store, &hex, &up, true); import(&root, &store, "ffffffff", true).unwrap(); let dest = root.join("images").join(&up); assert_eq!(std::fs::read_to_string(dest.join(TREE).join("etc/os-release")).unwrap(), "ID=ajena\n"); assert!(!root.join("images").join(&hex).exists(), "se registró por el hash del artefacto"); let m: ImageManifest = toml::from_str( &std::fs::read_to_string(dest.join(IMAGE_MANIFEST)).unwrap()).unwrap(); assert_eq!(m.sha256, up); // Los contadores se MIDEN sobre lo importado, no se copian del artefacto (que traía 0). assert_eq!(m.tree_files, 1); assert!(m.tree_bytes > 0); // Idempotente: importar dos veces no rompe ni duplica. import(&root, &store, "ffffffff", true).unwrap(); } /// Regla 3 en el eslabón nuevo: un árbol vacío dentro del artefacto no puede pasar por imagen. /// Es donde más barato sale equivocarse — el manifiesto está, el directorio está, y lo único /// que falta es el contenido. #[test] fn import_rechaza_un_artefacto_con_el_arbol_vacio() { let d = tempfile::tempdir().unwrap(); let store = d.path().join("store"); let root = d.path().join("qorpa"); artefacto_con_imagen(&store, &"e".repeat(64), &"2".repeat(64), false); let err = import(&root, &store, "eeeeeeee", true).unwrap_err().to_string(); assert!(err.contains("vacío"), "mensaje inesperado: {err}"); assert!(!root.join("images").join("2".repeat(64)).exists()); } #[test] fn create_nace_sin_ninguna_concesion() { let d = tempfile::tempdir().unwrap(); let root = d.path(); let sha = "a".repeat(64); imagen_falsa(root, &sha); create(root, "prueba", "aaaa", Some("falsa")).unwrap(); let (_, inst) = read_instance(root, "prueba").unwrap(); assert_eq!(inst.base, format!("sha256:{sha}")); // D7: la política se autora, y arranca VACÍA. assert!(!inst.grants.network); assert!(inst.grants.sockets.is_empty() && inst.grants.devices.is_empty()); // Y no se pisa a sí misma. assert!(create(root, "prueba", "aaaa", None).is_err()); } #[test] fn create_rechaza_una_imagen_vacia() { let d = tempfile::tempdir().unwrap(); let root = d.path(); let sha = "b".repeat(64); std::fs::create_dir_all(root.join("images").join(&sha).join(TREE)).unwrap(); let err = create(root, "x", &sha, None).unwrap_err().to_string(); assert!(err.contains("VACÍA"), "mensaje inesperado: {err}"); } #[test] fn recreate_tira_la_capa_mutable_y_conserva_el_manifiesto() { let d = tempfile::tempdir().unwrap(); let root = d.path(); let sha = "c".repeat(64); imagen_falsa(root, &sha); create(root, "i", &sha, None).unwrap(); let upper = instance_dir(root, "i").join("upper"); std::fs::write(upper.join("basura"), "x").unwrap(); recreate(root, "i", true).unwrap(); assert!(!upper.join("basura").exists(), "el upper no es caché si sobrevive"); assert!(instance_dir(root, "i").join(INSTANCE_MANIFEST).exists(), "el manifiesto ES la verdad"); } /// El registro de lo provisionado vive FUERA del `upper`, así que `recreate` tiene que borrarlo /// a mano. Si sobreviviera, afirmaría que hay paquetes instalados sobre una capa recién /// vaciada: la forma más pura del error de la regla 3 —presencia declarada sin contenido—, y /// además haría que el siguiente `provision` no instalara nada. #[test] fn recreate_no_deja_vivo_el_registro_de_lo_provisionado() { let d = tempfile::tempdir().unwrap(); let root = d.path(); let sha = "e".repeat(64); imagen_falsa(root, &sha); create(root, "i", &sha, None).unwrap(); let dir = instance_dir(root, "i"); let (_, mut inst) = read_instance(root, "i").unwrap(); inst.packages = vec!["strace".into()]; write_instance(&dir, &inst).unwrap(); std::fs::write( dir.join(PROVISION_LEDGER), toml::to_string(&Provisionado { manager: "apt".into(), packages: vec!["strace".into()], at_unix: 1, }).unwrap(), ).unwrap(); recreate(root, "i", true).unwrap(); assert!(!dir.join(PROVISION_LEDGER).exists(), "el registro sobrevivió a la capa que describía"); } /// El gestor se DETECTA de la vista merged, no se configura: una imagen trae el suyo. Y el /// `upper` gana, porque el gestor puede haberse instalado después. #[test] fn el_gestor_sale_de_la_imagen_y_el_upper_manda() { let d = tempfile::tempdir().unwrap(); let dir = d.path().join("inst"); let tree = d.path().join("tree"); std::fs::create_dir_all(dir.join("upper/usr/bin")).unwrap(); std::fs::create_dir_all(tree.join("usr/bin")).unwrap(); std::fs::write(tree.join("usr/bin/apt-get"), "").unwrap(); let g = gestor_de(&dir, &tree, &["strace".into()]).unwrap(); assert_eq!(g.nombre, "apt"); assert!(g.guion.contains("--no-install-recommends")); std::fs::write(tree.join("usr/bin/dnf"), "").unwrap(); assert_eq!(gestor_de(&dir, &tree, &["strace".into()]).unwrap().nombre, "apt", "con dos, manda el orden declarado, no el azar del directorio"); // Sin ninguno: falla diciendo qué buscó, en vez de correr un comando que no existe. let vacio = d.path().join("pelada"); std::fs::create_dir_all(&vacio).unwrap(); let err = gestor_de(&vacio, &vacio, &["x".into()]).unwrap_err().to_string(); assert!(err.contains("pacman") && err.contains("apk"), "mensaje inesperado: {err}"); } /// El nombre de paquete sale del manifiesto, que lo escribe una persona. Se comilla SIEMPRE, y /// además se rechaza lo que no parece un nombre: quotear evita la ejecución, pero un `;` en un /// nombre es un error de tipeo que conviene ver acá y no en la salida del gestor. #[test] fn un_nombre_de_paquete_raro_no_llega_al_guion() { let d = tempfile::tempdir().unwrap(); let tree = d.path().join("tree"); std::fs::create_dir_all(tree.join("usr/bin")).unwrap(); std::fs::write(tree.join("usr/bin/apt-get"), "").unwrap(); for malo in ["strace; rm -rf /", "$(whoami)", "a b", "'", ""] { assert!(gestor_de(d.path(), &tree, &[malo.into()]).is_err(), "pasó {malo:?}"); } assert_eq!(comillar("a'b"), r"'a'\''b'"); } #[test] fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() { let (a, w) = grants_to_args(&Grants::default(), "/run/user/0", None); assert!(a.contains(&"--unshare-net".to_string())); assert!(w.is_empty()); let g = Grants { network: true, ..Default::default() }; let (a, _) = grants_to_args(&g, "/run/user/0", None); assert!(!a.contains(&"--unshare-net".to_string())); // El segundo fallo clásico de la primera corrida, según el ADR. assert!(a.iter().any(|x| x == "/etc/resolv.conf")); } #[test] fn el_socket_de_wayland_avisa_que_es_un_borde_de_privilegio() { let g = Grants { sockets: vec!["wayland".into()], ..Default::default() }; let (_, w) = grants_to_args(&g, "/run/user/0", None); assert_eq!(w.len(), 1); assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]); } #[test] fn sin_sellar_la_politica_no_finge_confinar_el_fs() { let d = tempfile::tempdir().unwrap(); imagen_falsa(d.path(), "d".repeat(64).as_str()); let tree = d.path().join("images").join("d".repeat(64)).join(TREE); let dst = d.path().join("policy"); compile_policy(&Grants::default(), &tree, &dst).unwrap(); let pol = std::fs::read_to_string(&dst).unwrap(); // El namespace de montaje de bwrap YA es lista blanca. Escribir reglas `ro` que no cambian // nada sería un sello de goma, así que la política se queda en una línea honesta. assert!(pol.lines().any(|l| l == "rw /"), "{pol}"); assert!(!pol.contains("\nro "), "no debería haber reglas decorativas:\n{pol}"); } #[test] fn sellar_congela_la_imagen_y_deja_mutable_lo_de_la_instancia() { let d = tempfile::tempdir().unwrap(); let sha = "e".repeat(64); imagen_falsa(d.path(), &sha); let tree = d.path().join("images").join(&sha).join(TREE); std::fs::create_dir_all(tree.join("var")).unwrap(); std::fs::create_dir_all(tree.join("usr")).unwrap(); let dst = d.path().join("policy"); compile_policy(&Grants { seal_image: true, ..Default::default() }, &tree, &dst).unwrap(); let pol = std::fs::read_to_string(&dst).unwrap(); assert!(pol.lines().any(|l| l == "ro /usr"), "{pol}"); assert!(pol.lines().any(|l| l == "rw /etc"), "{pol}"); assert!(pol.lines().any(|l| l == "rw /var"), "{pol}"); assert!(pol.lines().any(|l| l == "list /"), "{pol}"); // Si `rw /` sobreviviera, taparía todas las `ro`: Landlock une derechos por ancestro. assert!(!pol.lines().any(|l| l == "rw /"), "el `rw /` anula el sellado:\n{pol}"); // Sólo se nombra lo que EXISTE: harkaq-exec falla duro ante un path inexistente. assert!(!pol.contains("/boot"), "{pol}"); } #[test] fn root_es_lo_unico_que_nace_encendido() { let g = Grants::default(); assert!(g.root, "sin esto un gestor de paquetes no arranca"); assert!(!g.network && !g.nesting && !g.seal_image); assert!(g.sockets.is_empty() && g.devices.is_empty() && g.dirs.is_empty()); } #[test] fn el_rango_de_subuid_se_lee_por_usuario() { let d = tempfile::tempdir().unwrap(); let f = d.path().join("subuid"); std::fs::write(&f, "otro:100000:65536\nsergio:165536:65536\n").unwrap(); let r = leer_subid(f.to_str().unwrap(), "sergio").unwrap(); assert_eq!((r.inicio, r.cantidad), (165536, 65536)); assert!(leer_subid(f.to_str().unwrap(), "nadie").is_none()); } #[test] fn las_caps_de_root_no_incluyen_la_de_montar() { // CAP_SYS_ADMIN es la que permite `mount`, y montar cuelga de la concesión `nesting` // declarada. Si se colara acá, `nesting` dejaría de ser una decisión y pasaría a ser el // comportamiento por defecto — que es justo lo que D7 prohíbe. assert!(!CAPS_ROOT.contains(&"CAP_SYS_ADMIN"), "{CAPS_ROOT:?}"); // Y las que sí están son las que `apt` y `dpkg` necesitaron de verdad. for c in ["CAP_SETUID", "CAP_SETGID", "CAP_CHOWN"] { assert!(CAPS_ROOT.contains(&c), "falta {c}"); } } #[test] fn del_desktop_ajeno_no_sobrevive_nada_ejecutable() { let (campos, exec) = leer_desktop( "[Desktop Entry]\nName=Pinentry\nName[es]=Pinentry\nExec=/usr/bin/pinentry-qt --raro\n\ TryExec=/usr/bin/pinentry-qt\nDBusActivatable=true\nPath=/tmp\nIcon=pinentry\n\ X-KDE-Wayland-Interfaces=org_kde_kwin_keystate\n[Desktop Action Otra]\nExec=/mal\n", ); let claves: Vec<&str> = campos.iter().map(|(k, _)| k.as_str()).collect(); assert!(claves.contains(&"Name") && claves.contains(&"Name[es]") && claves.contains(&"Icon")); // Lista BLANCA: todo lo que ejecuta queda fuera por definición, no por enumeración. for prohibida in ["Exec", "TryExec", "Path", "DBusActivatable", "X-KDE-Wayland-Interfaces"] { assert!(!claves.contains(&prohibida), "se coló {prohibida}: {claves:?}"); } // El Exec se lee sólo para nombrar el binario y citarlo; nunca se emite. assert_eq!(exec, "/usr/bin/pinentry-qt --raro"); } #[test] fn el_shim_no_se_rompe_con_rutas_raras() { assert_eq!(shell_quote("/usr/bin/pacman"), "/usr/bin/pacman"); assert_eq!(shell_quote("/con espacio/x"), "'/con espacio/x'"); assert_eq!(shell_quote("a'b"), "'a'\\''b'"); // la comilla se cierra, se escapa y se reabre assert_eq!(shell_quote("; rm -rf /"), "'; rm -rf /'"); } #[test] fn run_as_resuelve_uid_gid_y_home_del_passwd_de_la_imagen() { let d = tempfile::tempdir().unwrap(); let sha = "f".repeat(64); imagen_falsa(d.path(), &sha); let tree = d.path().join("images").join(&sha).join(TREE); std::fs::write(tree.join("etc/passwd"), "root:x:0:0::/root:/bin/bash\njugador:x:1000:1000::/home/jugador:/bin/bash\n").unwrap(); let dir = d.path().join("instances/x"); std::fs::create_dir_all(&dir).unwrap(); assert_eq!(usuario_de(&dir, &tree, "jugador").unwrap(), (1000, 1000, "/home/jugador".into())); assert_eq!(usuario_de(&dir, &tree, "1000").unwrap().0, 1000); assert!(usuario_de(&dir, &tree, "nadie").is_err(), "un run_as inexistente NO puede caer a root"); // El HOME sale del passwd, no de una constante. assert_eq!(home_de(&dir, &tree, 1000), "/home/jugador"); assert_eq!(home_de(&dir, &tree, 0), "/root"); } #[test] fn la_poda_no_toca_una_imagen_con_instancia_y_si_los_restos() { let d = tempfile::tempdir().unwrap(); let root = d.path(); let usada = "1".repeat(64); let huerfana = "2".repeat(64); imagen_falsa(root, &usada); imagen_falsa(root, &huerfana); create(root, "viva", &usada, None).unwrap(); // Un resto de pull a medias: lleva punto justo para no confundirse con una imagen. std::fs::create_dir_all(root.join("images/.3333.tmp")).unwrap(); // Simulacro: NO borra nada. Es la diferencia entre una poda y un accidente. prune(root, false, false).unwrap(); assert!(root.join("images").join(&huerfana).exists(), "el simulacro borró"); prune(root, false, true).unwrap(); assert!(root.join("images").join(&usada).exists(), "borró una imagen EN USO"); assert!(!root.join("images").join(&huerfana).exists(), "no borró la huérfana"); assert!(!root.join("images/.3333.tmp").exists(), "no borró el resto"); // La instancia sigue entera: la poda no la toca sin --upper. assert!(instance_dir(root, "viva").join(INSTANCE_MANIFEST).exists()); assert!(instance_dir(root, "viva").join("upper").exists()); } #[test] fn con_upper_la_capa_se_va_pero_la_instancia_queda_usable() { let d = tempfile::tempdir().unwrap(); let root = d.path(); let sha = "4".repeat(64); imagen_falsa(root, &sha); create(root, "i", &sha, None).unwrap(); std::fs::write(instance_dir(root, "i").join("upper/estado"), "x").unwrap(); prune(root, true, true).unwrap(); assert!(!instance_dir(root, "i").join("upper/estado").exists(), "el upper no es caché"); // Y queda utilizable: sin upper/ y work/ la instancia no vuelve a arrancar. assert!(instance_dir(root, "i").join("upper").is_dir()); assert!(instance_dir(root, "i").join("work").is_dir()); } #[test] fn sha_mal_formado_falla_antes_de_tocar_la_red() { assert!(normalize_sha256("abc").is_err()); assert_eq!(normalize_sha256(&format!("sha256:{}", "A".repeat(64))).unwrap(), "a".repeat(64)); } }