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 Definiciones

⎇ fe155cf20db4e443ec1cc2fb06924be137aa0cd2 crates/hammer-cli/src/qorpa.rs Rust · 636 líneas · 28.4 KB Historia Quién tocó qué crudo

qorpa pull/list: la imagen ajena entra verificada, o no entra fe155cf20 · Sergio ·

1//! `hammer qorpa` — imágenes ajenas ([ADR 0015](../../../docs/adr/0015-imagenes-ajenas.md)).
2//!
3//! Superficie en inglés, mensajes en castellano (`CLAUDE.md` regla 4).
4//!
5//! ── LA INVARIANTE QUE ESTE MÓDULO PROTEGE ───────────────────────────────────────────────────────
6//! Una imagen ajena **no es un artefacto y no vive en el store** (ADR 0015 D1). El store promete que
7//! cada entrada se reconstruye desde fuente bit a bit, y un rootfs de Ubuntu no. Por eso todo lo de
8//! acá cuelga de un espacio de nombres paralelo (`/var/lib/hammer/qorpa/`), fuera de `hash_inputs`
9//! de todo, y nada de este fichero toca `Store`.
10//!
11//! ── EL ORDEN DE LAS OPERACIONES NO ES CASUAL ────────────────────────────────────────────────────
12//! Se verifica el sha256 **antes** de desempacar, nunca después. Un tar ajeno que no verificaste es
13//! código ajeno que ya escribió en tu disco: desempacar primero y comprobar luego deja el árbol
14//! sucio justo en el caso que importa. Y el veredicto sigue a ADR 0014: *ausencia ⇒ seguir;
15//! contenido distinto ⇒ ABORTAR*.
16
17use std::io::Read;
18use std::os::unix::fs::PermissionsExt;
19use std::path::{Path, PathBuf};
20use std::process::Command;
21
22use anyhow::{bail, Context, Result};
23use clap::Subcommand;
24use serde::{Deserialize, Serialize};
25
26/// Raíz del espacio de nombres de qorpa. Deliberadamente **fuera** del store.
27pub const DEFAULT_ROOT: &str = "/var/lib/hammer/qorpa";
28/// Para correr sin root (tests, laptop). Mismo patrón que `HAMMER_KCONFIG_ROOT`.
29pub const ROOT_ENV: &str = "HAMMER_QORPA_ROOT";
30/// El manifiesto de la imagen. No confundir con `instancia.toml`, que describe una INSTANCIA.
31const IMAGE_MANIFEST: &str = "image.toml";
32/// El árbol desempacado dentro del directorio de la imagen.
33const TREE: &str = "rootfs";
34
35#[derive(Subcommand)]
36pub enum QorpaCmd {
37    /// [ADR 0015 §Orden 2] Trae una imagen ajena y la verifica por sha256 ANTES de desempacarla.
38    ///
39    /// La identidad de la imagen es el sha256 del ARCHIVO original, no del árbol: así dos máquinas
40    /// que la traigan de orígenes distintos coinciden, que es lo que hace gratis el espejo
41    /// (ADR 0013: la URL no entra en la identidad) y posible el multi-origen (ADR 0014).
42    Pull {
43        /// De dónde bajarla. Cualquier esquema que entienda curl, `file://` incluido.
44        url: String,
45        /// sha256 esperado del archivo, en hex. Obligatorio: sin esto no hay identidad.
46        #[arg(long)]
47        sha256: String,
48        /// Etiqueta legible (`arch-bootstrap-2026.09.01`). Informativa; no entra en la identidad.
49        #[arg(long)]
50        label: Option<String>,
51        /// Baja y verifica, pero NO desempaca. Para poblar un espejo sin gastar el disco del árbol.
52        #[arg(long)]
53        verify_only: bool,
54        /// Dónde está el rootfs DENTRO del archivo, si la detección automática no acierta.
55        #[arg(long)]
56        subdir: Option<String>,
57        /// Raíz de qorpa (default: `$HAMMER_QORPA_ROOT` o `/var/lib/hammer/qorpa`).
58        #[arg(long)]
59        root: Option<PathBuf>,
60    },
61    /// Lista las imágenes presentes. Marca a gritos las que están VACÍAS (`CLAUDE.md` regla 3):
62    /// un directorio vacío no es una imagen, es un nombre.
63    List {
64        #[arg(long)]
65        root: Option<PathBuf>,
66        /// Salida JSON, para que un script no tenga que parsear la tabla.
67        #[arg(long)]
68        json: bool,
69    },
70    /// [ADR 0015 §Orden 3 — SIN IMPLEMENTAR] Crea una instancia: overlay sobre la imagen + manifiesto.
71    Create {
72        /// Identificador de la instancia (`juegos`).
73        id: String,
74    },
75    /// [ADR 0015 §Orden 3 — SIN IMPLEMENTAR] Recrea una instancia desde su manifiesto, tirando el `upper`.
76    Recreate { id: String },
77    /// [ADR 0015 §Orden 3/4 — SIN IMPLEMENTAR] Entra a una instancia con las concesiones declaradas.
78    Run { id: String },
79    /// [ADR 0015 §Orden 5 — SIN IMPLEMENTAR] Genera los shims y `.desktop` de lo declarado en `[exporta]`.
80    Export { id: String },
81    /// [ADR 0015 §Orden 7 — SIN IMPLEMENTAR] Poda imágenes sin instancia y `upper` descartables.
82    Prune,
83}
84
85pub fn run(cmd: QorpaCmd) -> Result<()> {
86    match cmd {
87        QorpaCmd::Pull { url, sha256, label, verify_only, subdir, root } => {
88            pull(&resolve_root(root), &url, &sha256, label.as_deref(), verify_only, subdir.as_deref())
89        }
90        QorpaCmd::List { root, json } => list(&resolve_root(root), json),
91        QorpaCmd::Create { .. } | QorpaCmd::Recreate { .. } => bail!(
92            "sin implementar: las instancias son el paso 3 del §Orden de trabajo del ADR 0015 \
93             (overlay sobre la imagen + instancia.toml). Hoy sólo hay `pull` y `list`."
94        ),
95        QorpaCmd::Run { .. } => bail!(
96            "sin implementar: `run` necesita las instancias (paso 3) y las concesiones traducidas \
97             al PolicySpec de harkaq (paso 4) — abrir NADA por defecto es parte del contrato."
98        ),
99        QorpaCmd::Export { .. } => bail!(
100            "sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \
101             `Exec=` de un .desktop ajeno."
102        ),
103        QorpaCmd::Prune => bail!(
104            "sin implementar: la poda es el paso 7 del ADR 0015. Nace con el subsistema y no \
105             después — este repo ya tuvo tres emergencias de disco."
106        ),
107    }
108}
109
110fn resolve_root(flag: Option<PathBuf>) -> PathBuf {
111    flag.or_else(|| std::env::var_os(ROOT_ENV).map(PathBuf::from))
112        .unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT))
113}
114
115/// Lo que queda escrito junto al árbol. Es el registro de PROCEDENCIA: sin él, un directorio con
116/// un rootfs adentro no sabe decir de dónde salió ni contra qué se verificó.
117#[derive(Serialize, Deserialize)]
118struct ImageManifest {
119    sha256: String,
120    url: String,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    label: Option<String>,
123    archive_bytes: u64,
124    tree_files: u64,
125    tree_bytes: u64,
126    /// El rootfs venía dentro de este subdirectorio del archivo (`root.x86_64` de Arch) y se elevó
127    /// a raíz. Ausente = el archivo YA era un rootfs plano (Ubuntu base).
128    #[serde(skip_serializing_if = "Option::is_none")]
129    rootfs_subdir: Option<String>,
130    /// Lo que había al lado del rootfs y NO entró (`version`, `pkglist.x86_64.txt` de Arch). Se
131    /// registra en vez de descartarse en silencio: si mañana falta algo, acá está qué se tiró.
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    dropped_siblings: Vec<String>,
134}
135
136// ── pull ────────────────────────────────────────────────────────────────────────────────────────
137
138fn pull(
139    root: &Path, url: &str, sha_in: &str, label: Option<&str>, verify_only: bool,
140    subdir: Option<&str>,
141) -> Result<()> {
142    let sha = normalize_sha256(sha_in)?;
143    let images = root.join("images");
144    let dest = images.join(&sha);
145
146    // Idempotencia con la regla 3 puesta delante: "ya está" sólo si TIENE CONTENIDO. Un directorio
147    // vacío de un pull interrumpido se leería como presencia y el error viajaría hasta el final.
148    if dest.exists() {
149        match tree_state(&dest.join(TREE)) {
150            TreeState::Populated => {
151                println!("ya presente y verificada: {}", dest.display());
152                return Ok(());
153            }
154            TreeState::Missing if verify_only => {
155                println!("archivo ya verificado antes (sin desempacar): {}", dest.display());
156                return Ok(());
157            }
158            TreeState::Missing | TreeState::Empty => {
159                eprintln!(
160                    "aviso: {} existe pero su árbol está vacío o ausente — pull anterior a medias, \
161                     se rehace",
162                    dest.display()
163                );
164                force_remove_dir_all(&dest)
165                    .with_context(|| format!("no pude limpiar {}", dest.display()))?;
166            }
167        }
168    }
169
170    std::fs::create_dir_all(&images)
171        .with_context(|| format!("no puedo crear {}", images.display()))?;
172
173    let archive = images.join(format!(".{sha}.part"));
174    println!("bajando {url}");
175    download_to_file(url, &archive)?;
176
177    // El corte. Si el contenido no es el que dice ser, se ABORTA — no se avisa y se sigue.
178    hammer_build::download::verify_sha256(&archive, &sha).inspect_err(|_| {
179        std::fs::remove_file(&archive).ok();
180    })?;
181    let archive_bytes = std::fs::metadata(&archive)?.len();
182    println!("sha256 verificado ({} bytes)", archive_bytes);
183
184    if verify_only {
185        std::fs::create_dir_all(&dest)?;
186        write_manifest(&dest, &ImageManifest {
187            sha256: sha.clone(), url: url.into(), label: label.map(Into::into),
188            archive_bytes, tree_files: 0, tree_bytes: 0, rootfs_subdir: None,
189            dropped_siblings: Vec::new(),
190        })?;
191        std::fs::rename(&archive, dest.join("archive"))?;
192        println!("guardada sin desempacar en {}", dest.display());
193        return Ok(());
194    }
195
196    // Desempacado atómico-ish: a un `.tmp` al lado y luego rotar. Extraer directo sobre el destino
197    // dejaría medio rootfs si algo falla, y medio rootfs es justo lo que la regla 3 castiga.
198    let staging = images.join(format!(".{sha}.tmp"));
199    force_remove_dir_all(&staging)
200        .with_context(|| format!("no pude limpiar el staging {}", staging.display()))?;
201    let tree_tmp = staging.join(TREE);
202    std::fs::create_dir_all(&tree_tmp)?;
203    println!("desempacando…");
204    // Cualquier fallo de acá en adelante barre el staging Y el archivo: "no dejar nada a medias" no
205    // vale sólo para el sha que no coincide — un desempacado a medias es la misma trampa.
206    let unpack = || -> Result<(Option<String>, Vec<String>, u64, u64)> {
207        untar(&archive, &tree_tmp)?;
208        let (sub, dropped) = locate_rootfs(&tree_tmp, subdir)?;
209        let (files, bytes) = walk_tree(&tree_tmp)?;
210        Ok((sub, dropped, files, bytes))
211    };
212    let (rootfs_subdir, dropped_siblings, tree_files, tree_bytes) = match unpack() {
213        Ok(v) => v,
214        Err(e) => {
215            force_remove_dir_all(&staging).ok();
216            std::fs::remove_file(&archive).ok();
217            return Err(e);
218        }
219    };
220    std::fs::remove_file(&archive).ok();
221
222    if let Some(d) = &rootfs_subdir {
223        println!("el rootfs venía en {d}/ — elevado a raíz");
224    }
225    if !dropped_siblings.is_empty() {
226        println!("fuera del rootfs, NO entran: {}", dropped_siblings.join(", "));
227    }
228    if tree_files == 0 {
229        force_remove_dir_all(&staging).ok();
230        bail!("el archivo desempacó VACÍO — eso no es una imagen, es un nombre (CLAUDE.md regla 3)");
231    }
232
233    write_manifest(&staging, &ImageManifest {
234        sha256: sha.clone(), url: url.into(), label: label.map(Into::into),
235        archive_bytes, tree_files, tree_bytes, rootfs_subdir, dropped_siblings,
236    })?;
237    std::fs::rename(&staging, &dest)
238        .with_context(|| format!("no puedo rotar {} a {}", staging.display(), dest.display()))?;
239
240    println!(
241        "imagen lista: {} · {tree_files} ficheros · {}",
242        dest.display(),
243        human(tree_bytes)
244    );
245    Ok(())
246}
247
248/// Descarga a fichero. NO se reusa `download::fetch_url_to_file` a propósito: ése trae el cuerpo
249/// entero a memoria (le sirve para un patch de KB) y acá hablamos de cientos de MB, con `--max-time`
250/// de 300 s que un rootfs no respeta.
251fn download_to_file(url: &str, dst: &Path) -> Result<()> {
252    if let Some(p) = dst.parent() {
253        std::fs::create_dir_all(p)?;
254    }
255    let st = Command::new("curl")
256        .args(["-fL", "--retry", "3", "--retry-delay", "2", "--connect-timeout", "30",
257               "--progress-bar", "-o"])
258        .arg(dst)
259        .arg(url)
260        .status()
261        .context("no pude ejecutar curl")?;
262    if !st.success() {
263        std::fs::remove_file(dst).ok();
264        bail!("curl falló bajando {url} (exit {:?})", st.code());
265    }
266    Ok(())
267}
268
269fn normalize_sha256(s: &str) -> Result<String> {
270    let s = s.strip_prefix("sha256:").unwrap_or(s).trim().to_ascii_lowercase();
271    if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
272        bail!("--sha256 debe ser 64 dígitos hex (llegó {:?}, {} chars)", s, s.len());
273    }
274    Ok(s)
275}
276
277/// Elige el descompresor por MAGIC, no por extensión: el nombre de un fichero es una sugerencia y
278/// las tres imágenes curadas usan tres compresores distintos (zstd en Arch, gzip en Ubuntu, xz en
279/// Fedora). `tar` extrae con `--no-same-owner` para que el resultado sea el MISMO lo corra root o
280/// no — la propiedad real la pone el mapeo del userns, no el tar.
281fn untar(archive: &Path, into: &Path) -> Result<()> {
282    let mut magic = [0u8; 6];
283    let n = std::fs::File::open(archive)?.read(&mut magic)?;
284    let flag = match &magic[..n.min(6)] {
285        [0x28, 0xb5, 0x2f, 0xfd, ..] => Some("--zstd"),
286        [0x1f, 0x8b, ..] => Some("-z"),
287        [0xfd, b'7', b'z', b'X', b'Z', 0x00] => Some("-J"),
288        [b'B', b'Z', b'h', ..] => Some("-j"),
289        _ => None, // tar plano
290    };
291    let mut c = Command::new("tar");
292    if let Some(f) = flag {
293        c.arg(f);
294    }
295    // `-p` conserva los modos exactos (el setuid de `sudo` adentro importa), y
296    // `--delay-directory-restore` es OBLIGATORIO junto con él: un rootfs real trae directorios de
297    // sólo-lectura (`/etc/ca-certificates/extracted/cadir` en Arch), y sin esto tar los crea con su
298    // modo final ANTES de meterles los ficheros ⇒ "Permission denied" al extraer sin ser root. Con
299    // esto, tar los deja escribibles y aplica los modos al final.
300    // tar ya rechaza rutas absolutas y `..` al extraer: ésa es la defensa contra un tarball ajeno
301    // malicioso. Los xattr `security.capability` se pierden sin privilegios (tar avisa): dentro de
302    // la jaula da igual salvo para binarios que dependan de capabilities de fichero — anotado.
303    c.args(["-x", "-p", "--delay-directory-restore", "--no-same-owner", "-f"])
304        .arg(archive).arg("-C").arg(into);
305    let st = c.status().context("no pude ejecutar tar")?;
306    if !st.success() {
307        bail!("tar falló desempacando {} (exit {:?})", archive.display(), st.code());
308    }
309    Ok(())
310}
311
312/// Dónde está el rootfs dentro de lo que se desempacó.
313///
314/// La primera versión de esto usaba «si hay UN solo directorio arriba, ése es el rootfs». Sonaba
315/// razonable y es FALSA: el bootstrap de Arch trae tres entradas arriba (`root.x86_64/`, `version`,
316/// `pkglist.x86_64.txt`) y la regla no disparaba, así que el rootfs quedaba un nivel abajo — con
317/// todo verde y sin un solo error. Se mide por ESTRUCTURA, que es lo que define a un rootfs:
318/// tiene `etc/` y tiene `usr/` o `bin/`.
319///
320/// Si no acierta, **falla y pide `--subdir`** en vez de adivinar: un rootfs mal anclado no rompe
321/// acá, rompe mucho después, cuando la instancia no encuentra su loader.
322fn looks_like_rootfs(d: &Path) -> bool {
323    d.join("etc").is_dir() && (d.join("usr").is_dir() || d.join("bin").exists())
324}
325
326/// Deja `tree` conteniendo el rootfs. Devuelve (subdir elevado, hermanos descartados).
327fn locate_rootfs(tree: &Path, forced: Option<&str>) -> Result<(Option<String>, Vec<String>)> {
328    if forced.is_none() && looks_like_rootfs(tree) {
329        return Ok((None, Vec::new())); // Ubuntu base: el archivo YA es el rootfs.
330    }
331    let entries: Vec<_> = std::fs::read_dir(tree)
332        .with_context(|| format!("leyendo la raíz desempacada {}", tree.display()))?
333        .collect::<std::io::Result<Vec<_>>>()?;
334    let name = match forced {
335        Some(f) => {
336            if !tree.join(f).is_dir() {
337                bail!("--subdir {f:?} no existe dentro del archivo");
338            }
339            f.to_string()
340        }
341        None => {
342            let mut cands: Vec<String> = entries
343                .iter()
344                .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
345                .filter(|e| looks_like_rootfs(&e.path()))
346                .map(|e| e.file_name().to_string_lossy().into_owned())
347                .collect();
348            cands.sort();
349            match cands.len() {
350                1 => cands.remove(0),
351                0 => bail!(
352                    "no encuentro un rootfs en el archivo (ni la raíz ni ningún subdirectorio tiene \
353                     `etc/` + `usr|bin`). Pasá `--subdir <dir>` si sabés dónde está."
354                ),
355                _ => bail!(
356                    "hay {} subdirectorios que parecen rootfs ({}) — elegí con `--subdir`",
357                    cands.len(), cands.join(", ")
358                ),
359            }
360        }
361    };
362    let dropped: Vec<String> = entries
363        .iter()
364        .map(|e| e.file_name().to_string_lossy().into_owned())
365        .filter(|n| *n != name)
366        .collect();
367
368    let top = tree.join(&name);
369    let parked = tree.with_file_name(format!(
370        "{}.lift",
371        tree.file_name().unwrap_or_default().to_string_lossy()
372    ));
373    // Renombrar un DIRECTORIO exige permiso de escritura sobre el directorio mismo (el kernel tiene
374    // que reescribir su `..`), y el `root.x86_64` de Arch viene `dr-xr-xr-x`. Se abre, se mueve y se
375    // le devuelve su modo exacto: la imagen tiene que quedar como venía, no como nos vino cómodo.
376    let mode = std::fs::metadata(&top)?.permissions().mode();
377    std::fs::set_permissions(&top, std::fs::Permissions::from_mode(mode | 0o700))?;
378    std::fs::rename(&top, &parked)
379        .with_context(|| format!("elevando {} a {}", top.display(), parked.display()))?;
380    force_remove_dir_all(tree)
381        .with_context(|| format!("descartando los hermanos en {}", tree.display()))?;
382    std::fs::rename(&parked, tree)
383        .with_context(|| format!("rotando {} a {}", parked.display(), tree.display()))?;
384    std::fs::set_permissions(tree, std::fs::Permissions::from_mode(mode))?;
385    Ok((Some(name), dropped))
386}
387
388/// `remove_dir_all` NO puede con un árbol que trae directorios sin permiso de escritura — y un
389/// rootfs real los trae (`/etc/ca-certificates/extracted/cadir` de Arch es 0555). Tragarse ese
390/// fallo con `.ok()` deja vivo el staging de un pull roto y el siguiente pull extrae ENCIMA: nos
391/// pasó, y el síntoma («Permission denied» en un directorio que acabás de crear) no se parece en
392/// nada a la causa. Abre los directorios a escritura de arriba abajo y después borra.
393fn force_remove_dir_all(p: &Path) -> std::io::Result<()> {
394    if !p.exists() {
395        return Ok(());
396    }
397    let mut stack = vec![p.to_path_buf()];
398    while let Some(d) = stack.pop() {
399        let _ = std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700));
400        if let Ok(rd) = std::fs::read_dir(&d) {
401            for e in rd.flatten() {
402                // `file_type` de read_dir es lstat: un symlink a directorio NO se sigue.
403                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
404                    stack.push(e.path());
405                }
406            }
407        }
408    }
409    std::fs::remove_dir_all(p)
410}
411
412enum TreeState { Missing, Empty, Populated }
413
414fn tree_state(tree: &Path) -> TreeState {
415    match std::fs::read_dir(tree) {
416        Err(_) => TreeState::Missing,
417        Ok(mut it) => {
418            if it.next().is_some() { TreeState::Populated } else { TreeState::Empty }
419        }
420    }
421}
422
423fn walk_tree(root: &Path) -> Result<(u64, u64)> {
424    let (mut files, mut bytes) = (0u64, 0u64);
425    let mut stack = vec![root.to_path_buf()];
426    while let Some(d) = stack.pop() {
427        // Un rootfs ajeno trae directorios que NI SU DUEÑO puede leer (`/etc/sudoers.d` es 0750,
428        // `/var/log/private` 0700, y alguno 0000). No es corrupción: es el rootfs siendo lo que es.
429        // Se cuentan como 0 y se sigue — abortar el pull por no poder contar sería absurdo.
430        let rd = match std::fs::read_dir(&d) {
431            Ok(rd) => rd,
432            Err(_) => continue,
433        };
434        for e in rd {
435            let Ok(e) = e else { continue };
436            let Ok(ft) = e.file_type() else { continue };
437            if ft.is_dir() {
438                stack.push(e.path());
439            } else {
440                files += 1;
441                bytes += e.metadata().map(|m| m.len()).unwrap_or(0);
442            }
443        }
444    }
445    Ok((files, bytes))
446}
447
448fn write_manifest(dir: &Path, m: &ImageManifest) -> Result<()> {
449    let body = format!(
450        "# Lo escribe `hammer qorpa pull`; no editar a mano.\n\
451         # La identidad de la imagen es el sha256 del ARCHIVO original, no de este árbol: por eso\n\
452         # la URL es informativa y espejar es gratis (ADR 0013).\n{}",
453        toml::to_string_pretty(m).context("serializando el manifiesto")?
454    );
455    std::fs::write(dir.join(IMAGE_MANIFEST), body)?;
456    Ok(())
457}
458
459// ── list ────────────────────────────────────────────────────────────────────────────────────────
460
461fn list(root: &Path, json: bool) -> Result<()> {
462    let images = root.join("images");
463    let mut rows: Vec<(String, Option<ImageManifest>, TreeState)> = Vec::new();
464    if let Ok(rd) = std::fs::read_dir(&images) {
465        for e in rd.flatten() {
466            let name = e.file_name().to_string_lossy().into_owned();
467            if name.starts_with('.') {
468                continue; // restos de un pull a medias
469            }
470            let m = std::fs::read_to_string(e.path().join(IMAGE_MANIFEST))
471                .ok()
472                .and_then(|s| toml::from_str::<ImageManifest>(&s).ok());
473            rows.push((name, m, tree_state(&e.path().join(TREE))));
474        }
475    }
476    rows.sort_by(|a, b| a.0.cmp(&b.0));
477
478    if json {
479        let out: Vec<_> = rows.iter().map(|(sha, m, st)| {
480            serde_json::json!({
481                "sha256": sha,
482                "label": m.as_ref().and_then(|m| m.label.clone()),
483                "url": m.as_ref().map(|m| m.url.clone()),
484                "tree_files": m.as_ref().map(|m| m.tree_files).unwrap_or(0),
485                "tree_bytes": m.as_ref().map(|m| m.tree_bytes).unwrap_or(0),
486                "empty": !matches!(st, TreeState::Populated),
487            })
488        }).collect();
489        println!("{}", serde_json::to_string_pretty(&out)?);
490        return Ok(());
491    }
492
493    if rows.is_empty() {
494        println!("no hay imágenes en {} — `hammer qorpa pull <url> --sha256 <sha>`", images.display());
495        return Ok(());
496    }
497    let mut vacias = 0;
498    for (sha, m, st) in &rows {
499        let etiqueta = m.as_ref().and_then(|m| m.label.clone()).unwrap_or_else(|| "—".into());
500        match st {
501            TreeState::Populated => println!(
502                "  {}  {:<32} {:>7} ficheros  {}",
503                &sha[..12],
504                etiqueta,
505                m.as_ref().map(|m| m.tree_files).unwrap_or(0),
506                human(m.as_ref().map(|m| m.tree_bytes).unwrap_or(0)),
507            ),
508            _ => {
509                vacias += 1;
510                println!("  {}  {:<32} !! VACÍA — no es una imagen, es un nombre", &sha[..12], etiqueta);
511            }
512        }
513    }
514    println!("\n{} imagen(es) en {}", rows.len(), images.display());
515    if vacias > 0 {
516        bail!("{vacias} imagen(es) VACÍA(s): un ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien");
517    }
518    Ok(())
519}
520
521fn human(b: u64) -> String {
522    const U: [&str; 5] = ["B", "K", "M", "G", "T"];
523    let (mut v, mut i) = (b as f64, 0);
524    while v >= 1024.0 && i < 4 { v /= 1024.0; i += 1; }
525    if i == 0 { format!("{b} B") } else { format!("{v:.1} {}", U[i]) }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    fn tar_gz(dir: &Path, dst: &Path, contenido: &[(&str, &str)]) {
533        for (p, c) in contenido {
534            let f = dir.join(p);
535            std::fs::create_dir_all(f.parent().unwrap()).unwrap();
536            std::fs::write(f, c).unwrap();
537        }
538        let st = Command::new("tar").args(["-czf"]).arg(dst).arg("-C").arg(dir)
539            .args(contenido.iter().map(|(p, _)| p.split('/').next().unwrap()).collect::<std::collections::BTreeSet<_>>())
540            .status().unwrap();
541        assert!(st.success());
542    }
543
544    fn sha_of(p: &Path) -> String {
545        use sha2::{Digest, Sha256};
546        let mut h = Sha256::new();
547        std::io::copy(&mut std::fs::File::open(p).unwrap(), &mut h).unwrap();
548        hex::encode(h.finalize())
549    }
550
551    #[test]
552    fn pull_verifica_desempaca_y_es_idempotente() {
553        let d = tempfile::tempdir().unwrap();
554        let src = d.path().join("src");
555        std::fs::create_dir_all(&src).unwrap();
556        let arch = d.path().join("img.tar.gz");
557        tar_gz(&src, &arch, &[("etc/os-release", "ID=ajena\n"), ("bin/sh", "#!/bin/sh\n")]);
558        let sha = sha_of(&arch);
559        let root = d.path().join("qorpa");
560        let url = format!("file://{}", arch.display());
561
562        pull(&root, &url, &sha, Some("prueba"), false, None).unwrap();
563        let tree = root.join("images").join(&sha).join(TREE);
564        assert_eq!(std::fs::read_to_string(tree.join("etc/os-release")).unwrap(), "ID=ajena\n");
565        // Segundo pull: no vuelve a bajar ni rompe.
566        pull(&root, &url, &sha, Some("prueba"), false, None).unwrap();
567        assert!(root.join("images").join(&sha).join(IMAGE_MANIFEST).exists());
568    }
569
570    #[test]
571    fn pull_aborta_si_el_contenido_no_es_el_que_dice_ser() {
572        let d = tempfile::tempdir().unwrap();
573        let src = d.path().join("src");
574        std::fs::create_dir_all(&src).unwrap();
575        let arch = d.path().join("img.tar.gz");
576        tar_gz(&src, &arch, &[("etc/hostname", "ajena\n")]);
577        let root = d.path().join("qorpa");
578        let mentira = "0".repeat(64);
579        let err = pull(&root, &format!("file://{}", arch.display()), &mentira, None, false, None)
580            .unwrap_err().to_string();
581        assert!(err.contains("sha256 mismatch"), "mensaje inesperado: {err}");
582        // Y no dejó nada a medias: contenido distinto ⇒ ABORTAR, no "avisar y seguir".
583        assert!(!root.join("images").join(&mentira).exists());
584    }
585
586    /// La cicatriz de Arch: el rootfs viene en `root.x86_64/` CON HERMANOS al lado (`version`,
587    /// `pkglist`). La regla vieja ("un solo directorio arriba") no disparaba y dejaba el rootfs un
588    /// nivel abajo, en verde. Se ancla por estructura.
589    #[test]
590    fn el_rootfs_anidado_se_eleva_aunque_tenga_hermanos() {
591        let d = tempfile::tempdir().unwrap();
592        let src = d.path().join("src");
593        std::fs::create_dir_all(&src).unwrap();
594        let arch = d.path().join("boot.tar.gz");
595        tar_gz(&src, &arch, &[
596            ("root.x86_64/etc/os-release", "ID=arch\n"),
597            ("root.x86_64/usr/lib/keep", ""),
598            ("version", "2026.09.01\n"),
599        ]);
600        let sha = sha_of(&arch);
601        let root = d.path().join("qorpa");
602        pull(&root, &format!("file://{}", arch.display()), &sha, None, false, None).unwrap();
603        let tree = root.join("images").join(&sha).join(TREE);
604        assert!(tree.join("etc/os-release").exists(), "no ancló el rootfs anidado");
605        assert!(!tree.join("root.x86_64").exists());
606        assert!(!tree.join("version").exists(), "el hermano no debía entrar al rootfs");
607        let m: ImageManifest = toml::from_str(
608            &std::fs::read_to_string(root.join("images").join(&sha).join(IMAGE_MANIFEST)).unwrap(),
609        ).unwrap();
610        assert_eq!(m.rootfs_subdir.as_deref(), Some("root.x86_64"));
611        assert_eq!(m.dropped_siblings, vec!["version".to_string()]);
612    }
613
614    #[test]
615    fn un_arbol_vacio_no_pasa_por_imagen() {
616        let d = tempfile::tempdir().unwrap();
617        let vacio = d.path().join("vacio");
618        std::fs::create_dir_all(&vacio).unwrap();
619        let arch = d.path().join("nada.tar.gz");
620        assert!(Command::new("tar").args(["-czf"]).arg(&arch).arg("-C").arg(&vacio)
621            .args(["--files-from", "/dev/null"]).status().unwrap().success());
622        let sha = sha_of(&arch);
623        let root = d.path().join("qorpa");
624        let err = pull(&root, &format!("file://{}", arch.display()), &sha, None, false, None)
625            .unwrap_err().to_string();
626        assert!(err.contains("VACÍO") || err.contains("no encuentro un rootfs"),
627                "mensaje inesperado: {err}");
628        assert!(!root.join("images").join(&sha).exists());
629    }
630
631    #[test]
632    fn sha_mal_formado_falla_antes_de_tocar_la_red() {
633        assert!(normalize_sha256("abc").is_err());
634        assert_eq!(normalize_sha256(&format!("sha256:{}", "A".repeat(64))).unwrap(), "a".repeat(64));
635    }
636}