|
|
|
@@ -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));
|
|
|
|
|
}
|
|
|
|
|
}
|