gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 188Rust 55Shell 30Markdown 281211 Sutichaykuna

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

fe155cf20db4e443ec1cc2fb06924be137aa0cd2 · ruwaq Sergio · (1 killa ñawpaq) · tayta 9a2739f85 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 33

crates/hammer-cli/src/main.rs

  • silq'uyninpi t'ikrakurqan enum Cmd
  • ukhunpi t'ikrakurqan fn main
  • musuq mod qorpa

crates/hammer-cli/src/qorpa.rs

4 qillqakuna · +656 −8

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