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
This commit is contained in:
Sergio
2026-09-03 04:02:46 +00:00
co-authored by Claude Opus 5
parent 9a2739f856
commit fe155cf20d
4 changed files with 656 additions and 8 deletions
+1
View File
@@ -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
+9
View File
@@ -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(())
}
+636
View File
@@ -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));
}
}
+10 -8
View File
@@ -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