//! `hammer 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 `HAMMER_KCONFIG_ROOT`. pub const ROOT_ENV: &str = "HAMMER_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: `$HAMMER_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 hammer existe para no tener. Recreate { id: String, #[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, /// Comando dentro (default: el shell de la imagen). Va después de `--`. #[arg(last = true)] cmd: Vec, }, /// [ADR 0015 §Orden 5 — SIN IMPLEMENTAR] Genera los shims y `.desktop` de lo declarado en `[exporta]`. Export { id: String }, /// [ADR 0015 §Orden 7 — SIN IMPLEMENTAR] Poda imágenes sin instancia y `upper` descartables. Prune, } pub fn run(cmd: QorpaCmd) -> 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, root } => recreate(&resolve_root(root), &id), QorpaCmd::Run { id, root, dry_run, cmd } => run_instance(&resolve_root(root), &id, &cmd, dry_run), QorpaCmd::Export { .. } => bail!( "sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \ `Exec=` de un .desktop ajeno." ), QorpaCmd::Prune => bail!( "sin implementar: la poda es el paso 7 del ADR 0015. Nace con el subsistema y no \ después — este repo ya tuvo tres emergencias de disco." ), } } fn resolve_root(flag: Option) -> PathBuf { flag.or_else(|| std::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. hammer_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. 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", "3", "--retry-delay", "2", "--connect-timeout", "30", "--progress-bar", "-o"]) .arg(dst) .arg(url) .status() .context("no pude ejecutar curl")?; if !st.success() { std::fs::remove_file(dst).ok(); 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 }; 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()); } } } } std::fs::remove_dir_all(p) } 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 (mut files, mut bytes) = (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(_) => 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)) } 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(()) } // ── 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. Declarativo: hoy se registra, instalarlo es el /// siguiente paso. Sin esto, `recreate` no podría reconstruir nada. #[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, } #[derive(Serialize, Deserialize, 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, } #[derive(Serialize, Deserialize)] struct DirGrant { host: String, inside: String, /// `ro` o `rw`. #[serde(default = "ro")] mode: String, } fn ro() -> String { "ro".into() } 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) -> 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))?; } println!("instancia {id} recreada sobre sha256:{} — capa mutable tirada", &sha[..12]); if !inst.packages.is_empty() { println!( " declara {} paquete(s) ({}) — instalarlos todavía es a mano con `run`", inst.packages.len(), inst.packages.join(", ") ); } 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) -> (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" => { warn.push( "wayland: la instancia podrá CAPTURAR LA PANTALLA y SINTETIZAR TECLAS — el \ socket es un borde de privilegio y todavía no hay proxy filtrante \ (ADR §NO-resuelve 1)".into(), ); let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into()); (format!("{xdg}/{d}"), "/run/user/0/wayland-0".to_string()) } "pipewire" => (format!("{xdg}/pipewire-0"), "/run/user/0/pipewire-0".into()), "dbus" => (format!("{xdg}/bus"), "/run/user/0/bus".into()), 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) } fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool) -> Result<()> { let (dir, inst) = read_instance(root, id)?; let (sha, tree) = resolve_image(root, &inst.base)?; let (grant_args, warns) = grants_to_args(&inst.grants); // 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"). let mut args: Vec = vec![ "--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try", "--uid", "0", "--gid", "0", "--hostname", "qorpa", "--clearenv", "--die-with-parent", ].into_iter().map(String::from).collect(); 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. args.extend(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/run/user/0", "--chdir", "/"].map(String::from)); args.extend([ "--setenv", "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "--setenv", "HOME", "/root", "--setenv", "TERM", &std::env::var("TERM").unwrap_or_else(|_| "xterm".into()), "--setenv", "XDG_RUNTIME_DIR", "/run/user/0", ].map(String::from)); args.extend(grant_args); let cmd: Vec = if cmd.is_empty() { vec!["/bin/sh".into()] // toda imagen trae uno; bash puede no estar. } else { cmd.to_vec() }; args.extend(cmd.iter().cloned()); for w in &warns { eprintln!("⚠ {w}"); } if dry_run { // 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)", &sha[..12], inst.grants.network, inst.grants.sockets.len(), inst.grants.devices.len(), inst.grants.dirs.len()); let st = Command::new("bwrap").args(&args).status() .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?; match st.code() { Some(0) => Ok(()), Some(c) => std::process::exit(c), None => bail!("la instancia murió por señal"), } } // ── 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(); } #[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").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"); } #[test] fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() { let (a, w) = grants_to_args(&Grants::default()); assert!(a.contains(&"--unshare-net".to_string())); assert!(w.is_empty()); let g = Grants { network: true, ..Default::default() }; let (a, _) = grants_to_args(&g); 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); assert_eq!(w.len(), 1); assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]); } #[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)); } }