From fe155cf20db4e443ec1cc2fb06924be137aa0cd2 Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 3 Sep 2026 04:02:46 +0000 Subject: [PATCH] qorpa pull/list: la imagen ajena entra verificada, o no entra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paso 2 del §Orden de trabajo del ADR 0015. Verbos en inglés (regla 4); el ADR decía traer/crear/correr y queda corregido, con una línea que dice por qué para que no se vuelva a proponer. `hammer qorpa pull --sha256 ` baja, VERIFICA y recién entonces desempaca — nunca al revés: un tar ajeno sin verificar es código ajeno que ya escribió en tu disco. Veredicto de ADR 0014: contenido distinto ⇒ ABORTAR, y no queda nada a medias. La identidad es el sha256 del ARCHIVO, no del árbol, así que la URL es informativa y espejar sale gratis (ADR 0013). `list` marca a gritos las imágenes vacías y sale ≠0 (regla 3). Los pasos 3-7 están declarados en la superficie y fallan diciendo a qué paso del ADR pertenecen. Nada de esto toca el store: es el espacio paralelo /var/lib/hammer/qorpa (D1). PROBADO de punta a punta contra las dos imágenes curadas — Ubuntu base 24.04.3 (2760 ficheros, 78 M) y Arch bootstrap 2026.09.01 (31748, 534 M), las dos con su glibc adentro, que es el montón B entero. Y probarlo de verdad destapó tres cosas que en verde no se ven: 1. `-p` sin `--delay-directory-restore` NO extrae un rootfs real sin ser root: /etc/ca-certificates/extracted/cadir es 0555 y tar lo crea con su modo final ANTES de llenarlo. 2. Mi limpieza mentía: `remove_dir_all().ok()` no puede con un árbol que trae directorios de sólo-lectura, así que el staging de un pull roto SOBREVIVÍA y el siguiente pull extraía encima. El síntoma («Permission denied» en un directorio recién creado) no se parece en nada a la causa. 3. Renombrar un DIRECTORIO exige escritura sobre el directorio mismo, y el root.x86_64 de Arch viene dr-xr-xr-x. Se abre, se mueve y se le devuelve su modo exacto. Y una regla que sonaba razonable y era falsa: «si hay un solo directorio arriba, ése es el rootfs». El bootstrap de Arch trae TRES entradas arriba (root.x86_64, version, pkglist) ⇒ no disparaba y el rootfs quedaba un nivel abajo, con todo verde y sin un error. Ahora se ancla por ESTRUCTURA (tiene etc/ y usr|bin), con --subdir como escape, y si no acierta FALLA en vez de adivinar: un rootfs mal anclado no rompe acá, rompe cuando la instancia no encuentra su loader. Los hermanos descartados quedan escritos en el manifiesto, no tirados en silencio. 5 tests nuevos, incluida la cicatriz de Arch. 35/35 en hammer-cli. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2 --- crates/hammer-cli/Cargo.toml | 1 + crates/hammer-cli/src/main.rs | 9 + crates/hammer-cli/src/qorpa.rs | 636 +++++++++++++++++++++++++++++++ docs/adr/0015-imagenes-ajenas.md | 18 +- 4 files changed, 656 insertions(+), 8 deletions(-) create mode 100644 crates/hammer-cli/src/qorpa.rs diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml index 1b1bb867..4616266d 100644 --- a/crates/hammer-cli/Cargo.toml +++ b/crates/hammer-cli/Cargo.toml @@ -28,6 +28,7 @@ hammer-journal.workspace = true hammer-agent.workspace = true serde.workspace = true serde_json.workspace = true +toml.workspace = true base64.workspace = true anyhow.workspace = true clap.workspace = true diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 3cbe1a14..83053b99 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -10,6 +10,7 @@ use clap::{Parser, Subcommand}; mod alpine_import; mod kernel_cmd; +mod qorpa; mod nix_import; const DEFAULT_STORE: &str = "/store"; @@ -456,6 +457,13 @@ enum Cmd { #[command(subcommand)] sub: kernel_cmd::KernelCmd, }, + /// [ADR 0015] Imágenes ajenas: rootfs de otras distros, pineados por digest, que corren + /// enjaulados sobre el mismo kernel. **No entran al store** — no reproducen desde fuente, y + /// meterlos ahí haría que el store mienta. + Qorpa { + #[command(subcommand)] + sub: qorpa::QorpaCmd, + }, } #[derive(Subcommand)] @@ -1429,6 +1437,7 @@ fn main() -> anyhow::Result<()> { } } Cmd::Kernel { sub } => kernel_cmd::run(sub, &cli.store)?, + Cmd::Qorpa { sub } => qorpa::run(sub)?, } Ok(()) } diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs new file mode 100644 index 00000000..68d806e4 --- /dev/null +++ b/crates/hammer-cli/src/qorpa.rs @@ -0,0 +1,636 @@ +//! `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"; + +#[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 — SIN IMPLEMENTAR] Crea una instancia: overlay sobre la imagen + manifiesto. + Create { + /// Identificador de la instancia (`juegos`). + id: String, + }, + /// [ADR 0015 §Orden 3 — SIN IMPLEMENTAR] Recrea una instancia desde su manifiesto, tirando el `upper`. + Recreate { id: String }, + /// [ADR 0015 §Orden 3/4 — SIN IMPLEMENTAR] Entra a una instancia con las concesiones declaradas. + Run { id: String }, + /// [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 { .. } | QorpaCmd::Recreate { .. } => bail!( + "sin implementar: las instancias son el paso 3 del §Orden de trabajo del ADR 0015 \ + (overlay sobre la imagen + instancia.toml). Hoy sólo hay `pull` y `list`." + ), + QorpaCmd::Run { .. } => bail!( + "sin implementar: `run` necesita las instancias (paso 3) y las concesiones traducidas \ + al PolicySpec de harkaq (paso 4) — abrir NADA por defecto es parte del contrato." + ), + 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(()) +} + +// ── 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)); + + 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(); + println!("{}", serde_json::to_string_pretty(&out)?); + return Ok(()); + } + + if rows.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); + } + } + } + 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()); + } + + #[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)); + } +} diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index 3e60969a..f48bbbc2 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -3,9 +3,11 @@ - **Estado:** PROPUESTO — nombre ADOPTADO (`qorpa`, 2026-09-03); implementación EN CURSO por el §Orden de trabajo. Este documento decide la frontera; el código viene después. - **Fecha:** 2026-09-03 -- **Frontera (a crear):** `hammer qorpa {traer,crear,correr,exportar,listar,podar}`, +- **Frontera (a crear):** `hammer qorpa {pull,create,run,export,list,prune}`, `/var/lib/hammer/qorpa/imagenes//`, `/var/lib/hammer/qorpa/instancias//`, `instancia.toml` (manifiesto), clase de nodo `ajeno` en `build-state.py`. +- **Superficie:** verbos en inglés, mensajes en castellano — `CLAUDE.md` regla 4. Este ADR nació + proponiendo `traer/crear/correr` y se corrigió el 2026-09-03; el nombre `qorpa` sí es quechua. - **Continúa:** [SDD 04](../04-overlay.md) (overlay), [SDD 16](../16-harkaq-jaula.md) (harkaq), [ADR 0004](0004-no-custom-nix.md) (hammer no usa nix), [SDD 20](../20-catalogo-publicable-y-completa.md) (catálogo publicable). @@ -145,7 +147,7 @@ apps = ["steam.desktop"] ``` Consecuencias que se caen solas: -- **`hammer qorpa recrear `** reconstruye la instancia desde el manifiesto. El `upper` es +- **`hammer qorpa recreate `** reconstruye la instancia desde el manifiesto. El `upper` es descartable. - **Actualizar la base** (Fedora 43 → 44) no es un rebase riesgoso: se cambia el digest y se recrea. - **El respaldo** es el manifiesto (KB), no el `upper` (GB). `respaldo-storagebox.sh` no toca @@ -244,7 +246,7 @@ Dos preguntas que se confunden y tienen respuestas distintas. | qué | ¿pineado? | cómo se actualiza | |---|---|---| -| el rootfs base | **sí**, por sha256 | cambiar el digest en `instancia.toml` + `hammer qorpa recrear` | +| el rootfs base | **sí**, por sha256 | cambiar el digest en `instancia.toml` + `hammer qorpa recreate` | | lo que instalás adentro (`dnf install steam`, `pacman -Syu`) | **no, y no puede estarlo** | con el gestor de la imagen, cuando quieras | Subir la base de versión es barato **precisamente por D3**: como el manifiesto es la verdad y el @@ -255,7 +257,7 @@ El riesgo real del pin —que upstream borre el tarball— **ya está resuelto p [ADR 0013](0013-mirror-de-fuentes.md)**: la URL no entra en la identidad, sólo el sha256, así que espejar una imagen es gratis y una imagen pineada no se puede perder. -**b) Pinear ≠ lista cerrada.** `hammer qorpa traer --sha256` acepta cualquier rootfs. Lo corto +**b) Pinear ≠ lista cerrada.** `hammer qorpa pull --sha256` acepta cualquier rootfs. Lo corto no es lo que *se puede* traer, sino lo que **nosotros probamos, espejamos y publicamos**, porque cada imagen curada es una segunda cadena de suministro que hay que sostener (§NO-resuelve 3). Se curan **tres**, y cada una entra por un trabajo distinto — no por sabor: @@ -353,12 +355,12 @@ Se escriben acá para que no se descubran en producción. 1. **Provisionar subuid** y probar `dnf install` en una instancia mínima. Es lo primero que falla (§NO-resuelve 2) y define si el resto es fácil o difícil. -2. `hammer qorpa traer --sha256` + verificación de digest. Reusa el patrón de `lab-image.sh`. -3. Instancia = overlay sobre la imagen + `instancia.toml`; `crear` / `recrear` / `correr`. +2. `hammer qorpa pull --sha256` + verificación de digest. Reusa el patrón de `lab-image.sh`. +3. Instancia = overlay sobre la imagen + `instancia.toml`; `create` / `recreate` / `run`. 4. Concesiones → `PolicySpec` de harkaq. Empezar por **nada** y abrir sólo lo declarado. -5. Shims + `.desktop` generados (`exportar`), y clase `ajeno` en `build-state.py`. +5. Shims + `.desktop` generados (`export`), y clase `ajeno` en `build-state.py`. 6. **Steam de punta a punta**, con verificación explícita del bwrap anidado. -7. Poda (`hammer qorpa podar`) y renglón en SDD 20 sobre licencias. +7. Poda (`hammer qorpa prune`) y renglón en SDD 20 sobre licencias. 8. Proxy filtrante de Wayland — ticket propio, el más valioso de la lista. ## Nombre — ADOPTADO 2026-09-03