> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
qorpa pull/list: la imagen ajena entra verificada, o no entra
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 <url> --sha256 <sha>` 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
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<String>,
+ /// 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<String>,
+ /// Raíz de qorpa (default: `$HAMMER_QORPA_ROOT` o `/var/lib/hammer/qorpa`).
+ #[arg(long)]
+ root: Option<PathBuf>,
+ },
+ /// 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<PathBuf>,
+ /// 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>) -> 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<String>,
+ 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<String>,
+ /// 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<String>,
+}
+
+// ── 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<String>, Vec<String>, 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<String> {
+ 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<String>, Vec<String>)> {
+ 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::<std::io::Result<Vec<_>>>()?;
+ 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<String> = 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 <dir>` si sabés dónde está."
+ ),
+ _ => bail!(
+ "hay {} subdirectorios que parecen rootfs ({}) — elegí con `--subdir`",
+ cands.len(), cands.join(", ")
+ ),
+ }
+ }
+ };
+ let dropped: Vec<String> = 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<ImageManifest>, 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::<ImageManifest>(&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 <url> --sha256 <sha>`", 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::<std::collections::BTreeSet<_>>())
+ .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/<sha256>/`, `/var/lib/hammer/qorpa/instancias/<id>/`,
`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 <id>`** reconstruye la instancia desde el manifiesto. El `upper` es
+- **`hammer qorpa recreate <id>`** 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 <url> --sha256` acepta cualquier rootfs. Lo corto
+**b) Pinear ≠ lista cerrada.** `hammer qorpa pull <url> --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 <url> --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 <url> --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