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…
⎇ fe155cf20db4e443ec1cc2fb06924be137aa0cd2
crates/hammer-cli/src/qorpa.rs
History
Who touched what
raw
qorpa pull/list: la imagen ajena entra verificada, o no entra
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}