From ba09fdb403c7e214d3d813e9ee1bc4130470a333 Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 3 Sep 2026 04:38:25 +0000 Subject: [PATCH] qorpa create/recreate/run: la instancia nace sin ver nada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paso 3 del ADR 0015. El overlay lo monta bwrap dentro de su propio namespace (--overlay-src + --overlay), así que no hace falta root ni se monta nada en el host. `run` entra con --clearenv y con --unshare-net salvo que se declare `network`: el entorno del host TAMBIÉN es una concesión, y lo que no se declara no entra (D2/D7). `--dry-run` imprime el bwrap entero, una línea por concesión, porque una jaula que no se puede leer no se puede auditar. `list` ahora enumera también las instancias con lo que abre cada una — una instancia sin política y una con la pantalla abierta se ven IGUAL desde fuera y no son lo mismo. Los campos del manifiesto van en inglés (regla 4); el ADR los tenía en castellano y quedan corregidos, igual que las rutas images/ e instances/. D3 VALIDADO en la mano, no en el papel: la escritura va al upper, la imagen base no se toca, y `recreate` tira la capa y la instancia sigue siendo la misma. Y se midió la otra mitad del paso 1, que el ADR daba por «lo primero que va a fallar». Falla, sí, pero el veredicto es MEJOR de lo que decía: uid_map: 0 1001 1 · setgroups: deny - apt: el método http hace setgroups para bajar a _apt ⇒ para. Salteándolo con -o APT::Sandbox::User=root baja 34 MB, instala y corre los triggers de dpkg enteros; el único residuo es un AVISO de chown a root:adm. - pacman: chownea el directorio de descarga a `alpm` ⇒ para en duro. Con DownloadUser comentado sincroniza, y tras pacman-key --init/--populate instala y el binario corre. ⇒ subuid no es un muro, es un IMPUESTO: un solo id alcanza para instalar paquetes reales en los dos gestores, y lo que rompe es el chown/setgroups a OTRO id, que cada gestor hace en un sitio distinto. Y quitarlo pide algo que el ADR no decía: bwrap crea el userns con un solo id A PROPÓSITO y no llama a newuidmap, así que además del setcap hay que crear el namespace aparte, mapear el rango y pasárselo con --userns FD. 5 tests nuevos (nace sin concesiones, rechaza imagen vacía, el upper es caché, sin grants la red queda fuera, y que el aviso de wayland no sea tibio). 40/40. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2 --- crates/hammer-cli/src/qorpa.rs | 415 +++++++++++++++++++++++++++++-- docs/adr/0015-imagenes-ajenas.md | 73 +++--- 2 files changed, 444 insertions(+), 44 deletions(-) diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs index 68d806e4..68ae8b96 100644 --- a/crates/hammer-cli/src/qorpa.rs +++ b/crates/hammer-cli/src/qorpa.rs @@ -31,6 +31,8 @@ pub const ROOT_ENV: &str = "HAMMER_QORPA_ROOT"; const IMAGE_MANIFEST: &str = "image.toml"; /// El árbol desempacado dentro del directorio de la imagen. const TREE: &str = "rootfs"; +/// El manifiesto de una INSTANCIA: la verdad de la que se recrea (D3). +const INSTANCE_MANIFEST: &str = "instance.toml"; #[derive(Subcommand)] pub enum QorpaCmd { @@ -67,15 +69,43 @@ pub enum QorpaCmd { #[arg(long)] json: bool, }, - /// [ADR 0015 §Orden 3 — SIN IMPLEMENTAR] Crea una instancia: overlay sobre la imagen + manifiesto. + /// [ADR 0015 §Orden 3] Crea una instancia: capa mutable sobre una imagen + su manifiesto. + /// + /// Nace SIN NINGUNA concesión (D7): ni red, ni sockets, ni dispositivos, ni directorios del + /// host. Se abre lo que se declare en `instance.toml`, y sólo eso. Create { - /// Identificador de la instancia (`juegos`). + /// Identificador de la instancia (`games`). id: String, + /// sha256 de la imagen base, ya traída con `pull`. Acepta prefijos. + #[arg(long)] + base: String, + /// Nombre informativo de la distro (`arch`, `ubuntu-24.04`). + #[arg(long)] + distro: Option, + #[arg(long)] + root: Option, + }, + /// [ADR 0015 §Orden 3] Tira la capa mutable y la rehace desde el manifiesto. + /// + /// Es la prueba de D3: si esto duele, es que el `upper` se volvió el activo — y un blob + /// irreemplazable es justo lo que hammer existe para no tener. + Recreate { + id: String, + #[arg(long)] + root: Option, + }, + /// [ADR 0015 §Orden 3] Entra a la instancia con las concesiones declaradas y nada más. + Run { + id: String, + #[arg(long)] + root: Option, + /// Imprime el `bwrap` que se ejecutaría y sale. La jaula tiene que ser inspeccionable. + #[arg(long)] + dry_run: bool, + /// Comando dentro (default: el shell de la imagen). Va después de `--`. + #[arg(last = true)] + cmd: Vec, }, - /// [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. @@ -88,14 +118,11 @@ pub fn run(cmd: QorpaCmd) -> Result<()> { 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::Create { id, base, distro, root } => { + create(&resolve_root(root), &id, &base, distro.as_deref()) + } + QorpaCmd::Recreate { id, root } => recreate(&resolve_root(root), &id), + QorpaCmd::Run { id, root, dry_run, cmd } => run_instance(&resolve_root(root), &id, &cmd, dry_run), 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." @@ -456,6 +483,261 @@ fn write_manifest(dir: &Path, m: &ImageManifest) -> Result<()> { Ok(()) } +// ── instancias ────────────────────────────────────────────────────────────────────────────────── + +/// El manifiesto de una instancia. **Es la verdad**: el `upper/` es caché y se puede tirar entero +/// (D3). Los nombres de campo van en inglés como el resto de la superficie (`CLAUDE.md` regla 4); +/// el ADR los escribió en castellano y se corrigieron aquí. +#[derive(Serialize, Deserialize, Default)] +struct Instance { + /// sha256 de la imagen base. Cambiarlo + `recreate` ES la actualización de versión (D8). + base: String, + #[serde(skip_serializing_if = "Option::is_none")] + distro: Option, + /// Lo que debe estar instalado adentro. Declarativo: hoy se registra, instalarlo es el + /// siguiente paso. Sin esto, `recreate` no podría reconstruir nada. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + packages: Vec, + /// La política, autorada (D7). **Por defecto vacía**: una instancia nace sin ver nada. + #[serde(default)] + grants: Grants, +} + +#[derive(Serialize, Deserialize, Default)] +struct Grants { + /// Nombres cortos: `wayland`, `pipewire`, `dbus`. ⚠ `wayland` es un borde de PRIVILEGIO, no un + /// caño: quien lo tiene puede capturar pantalla y sintetizar teclas (ADR §NO-resuelve 1). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + sockets: Vec, + /// Nodos de dispositivo (`/dev/dri`). ABI de kernel, que es lo único que cruza el borde (D2). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + devices: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + dirs: Vec, + /// La concesión más peligrosa, y por eso va sola en su línea. `dnf`/`pacman` la necesitan. + #[serde(default)] + network: bool, +} + +#[derive(Serialize, Deserialize)] +struct DirGrant { + host: String, + inside: String, + /// `ro` o `rw`. + #[serde(default = "ro")] + mode: String, +} +fn ro() -> String { "ro".into() } + +fn instances_dir(root: &Path) -> PathBuf { root.join("instances") } +fn instance_dir(root: &Path, id: &str) -> PathBuf { instances_dir(root).join(id) } + +/// Resuelve un prefijo de sha contra las imágenes presentes. Que exista y que TENGA CONTENIDO: una +/// instancia sobre una imagen vacía es un directorio con nombre de rootfs (regla 3). +fn resolve_image(root: &Path, prefix: &str) -> Result<(String, PathBuf)> { + let prefix = prefix.strip_prefix("sha256:").unwrap_or(prefix).to_ascii_lowercase(); + let images = root.join("images"); + let mut hits: Vec = std::fs::read_dir(&images) + .with_context(|| format!("no hay imágenes en {}", images.display()))? + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| !n.starts_with('.') && n.starts_with(&prefix)) + .collect(); + hits.sort(); + match hits.len() { + 1 => { + let sha = hits.remove(0); + let tree = images.join(&sha).join(TREE); + if !matches!(tree_state(&tree), TreeState::Populated) { + bail!("la imagen {sha} está VACÍA — no es una imagen, es un nombre"); + } + Ok((sha, tree)) + } + 0 => bail!("ninguna imagen empieza por {prefix:?} — traela con `hammer qorpa pull`"), + n => bail!("{n} imágenes empiezan por {prefix:?}: dame más dígitos"), + } +} + +fn create(root: &Path, id: &str, base: &str, distro: Option<&str>) -> Result<()> { + if id.is_empty() || id.contains('/') || id.starts_with('.') { + bail!("id de instancia inválido: {id:?}"); + } + let dir = instance_dir(root, id); + if dir.exists() { + bail!("la instancia {id} ya existe en {} — `recreate` tira su capa mutable", dir.display()); + } + let (sha, _tree) = resolve_image(root, base)?; + + let inst = Instance { + base: format!("sha256:{sha}"), + distro: distro.map(Into::into), + ..Default::default() + }; + std::fs::create_dir_all(dir.join("upper"))?; + std::fs::create_dir_all(dir.join("work"))?; + write_instance(&dir, &inst)?; + println!("instancia {id} creada en {}", dir.display()); + println!(" base: sha256:{}", &sha[..12]); + println!(" concesiones: NINGUNA — se abren declarándolas en {}", INSTANCE_MANIFEST); + Ok(()) +} + +fn read_instance(root: &Path, id: &str) -> Result<(PathBuf, Instance)> { + let dir = instance_dir(root, id); + let f = dir.join(INSTANCE_MANIFEST); + let body = std::fs::read_to_string(&f) + .with_context(|| format!("no existe la instancia {id} ({})", f.display()))?; + let inst: Instance = toml::from_str(&body) + .with_context(|| format!("manifiesto ilegible: {}", f.display()))?; + Ok((dir, inst)) +} + +fn write_instance(dir: &Path, inst: &Instance) -> Result<()> { + let body = format!( + "# Manifiesto de la instancia — ESTO es la verdad, `upper/` es caché descartable (ADR 0015 D3).\n\ + # `[grants]` es la política, y se AUTORA: es el único sitio del sistema donde no se deriva\n\ + # de una clausura declarada (D7). Por defecto no hay ninguna.\n{}", + toml::to_string_pretty(inst).context("serializando la instancia")? + ); + std::fs::write(dir.join(INSTANCE_MANIFEST), body)?; + Ok(()) +} + +fn recreate(root: &Path, id: &str) -> Result<()> { + let (dir, inst) = read_instance(root, id)?; + let (sha, _) = resolve_image(root, &inst.base)?; + for capa in ["upper", "work"] { + force_remove_dir_all(&dir.join(capa)) + .with_context(|| format!("no pude tirar {capa}/ de {id}"))?; + std::fs::create_dir_all(dir.join(capa))?; + } + println!("instancia {id} recreada sobre sha256:{} — capa mutable tirada", &sha[..12]); + if !inst.packages.is_empty() { + println!( + " declara {} paquete(s) ({}) — instalarlos todavía es a mano con `run`", + inst.packages.len(), + inst.packages.join(", ") + ); + } + Ok(()) +} + +/// Traduce las concesiones a argumentos de bwrap. Devuelve también las advertencias que el usuario +/// tiene que LEER, no descubrir: el ADR pide que el socket de Wayland se diga en la cara. +fn grants_to_args(g: &Grants) -> (Vec, Vec) { + let mut a: Vec = Vec::new(); + let mut warn: Vec = Vec::new(); + // Sin dep de libc: el dueño de /proc/self ES el uid efectivo. + let uid = std::fs::metadata("/proc/self") + .map(|m| std::os::unix::fs::MetadataExt::uid(&m)) + .unwrap_or(1000); + let xdg = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| format!("/run/user/{uid}")); + + for s in &g.sockets { + let (host, dentro) = match s.as_str() { + "wayland" => { + warn.push( + "wayland: la instancia podrá CAPTURAR LA PANTALLA y SINTETIZAR TECLAS — el \ + socket es un borde de privilegio y todavía no hay proxy filtrante \ + (ADR §NO-resuelve 1)".into(), + ); + let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into()); + (format!("{xdg}/{d}"), "/run/user/0/wayland-0".to_string()) + } + "pipewire" => (format!("{xdg}/pipewire-0"), "/run/user/0/pipewire-0".into()), + "dbus" => (format!("{xdg}/bus"), "/run/user/0/bus".into()), + otro => { + warn.push(format!("socket desconocido {otro:?} — ignorado")); + continue; + } + }; + a.push("--bind-try".into()); a.push(host); a.push(dentro); + } + for d in &g.devices { + a.push("--dev-bind-try".into()); a.push(d.clone()); a.push(d.clone()); + } + for d in &g.dirs { + let host = if let Some(rest) = d.host.strip_prefix('~') { + format!("{}{}", std::env::var("HOME").unwrap_or_default(), rest) + } else { d.host.clone() }; + a.push(if d.mode == "rw" { "--bind".into() } else { "--ro-bind".into() }); + a.push(host); a.push(d.inside.clone()); + } + if g.network { + // El segundo fallo clásico de la primera corrida, según el propio ADR. + a.push("--ro-bind-try".into()); + a.push("/etc/resolv.conf".into()); + a.push("/etc/resolv.conf".into()); + } else { + a.push("--unshare-net".into()); + } + (a, warn) +} + +fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool) -> Result<()> { + let (dir, inst) = read_instance(root, id)?; + let (sha, tree) = resolve_image(root, &inst.base)?; + let (grant_args, warns) = grants_to_args(&inst.grants); + + // El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo + // después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo + // que no se declara, no entra (D2, "por defecto NADA"). + let mut args: Vec = vec![ + "--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try", + "--uid", "0", "--gid", "0", + "--hostname", "qorpa", + "--clearenv", + "--die-with-parent", + ].into_iter().map(String::from).collect(); + + args.push("--overlay-src".into()); args.push(tree.display().to_string()); + args.push("--overlay".into()); + args.push(dir.join("upper").display().to_string()); + args.push(dir.join("work").display().to_string()); + args.push("/".into()); + + // /proc y /dev van DESPUÉS del overlay: son del kernel, no de la imagen. + args.extend(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", + "--dir", "/run/user/0", "--chdir", "/"].map(String::from)); + args.extend([ + "--setenv", "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "--setenv", "HOME", "/root", + "--setenv", "TERM", &std::env::var("TERM").unwrap_or_else(|_| "xterm".into()), + "--setenv", "XDG_RUNTIME_DIR", "/run/user/0", + ].map(String::from)); + args.extend(grant_args); + + let cmd: Vec = if cmd.is_empty() { + vec!["/bin/sh".into()] // toda imagen trae uno; bash puede no estar. + } else { cmd.to_vec() }; + args.extend(cmd.iter().cloned()); + + for w in &warns { + eprintln!("⚠ {w}"); + } + if dry_run { + // Una línea por concesión, no una por token: el punto de `--dry-run` es que se LEA. + let mut linea = String::from("bwrap"); + for a in &args { + if a.starts_with("--") { println!("{linea} \\"); linea = format!(" {a}"); } + else { linea.push(' '); linea.push_str(a); } + } + println!("{linea}"); + return Ok(()); + } + eprintln!("qorpa {id} · sha256:{} · red={} · {} socket(s), {} device(s), {} dir(s)", + &sha[..12], inst.grants.network, inst.grants.sockets.len(), + inst.grants.devices.len(), inst.grants.dirs.len()); + + let st = Command::new("bwrap").args(&args).status() + .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?; + match st.code() { + Some(0) => Ok(()), + Some(c) => std::process::exit(c), + None => bail!("la instancia murió por señal"), + } +} + // ── list ──────────────────────────────────────────────────────────────────────────────────────── fn list(root: &Path, json: bool) -> Result<()> { @@ -475,6 +757,17 @@ fn list(root: &Path, json: bool) -> Result<()> { } rows.sort_by(|a, b| a.0.cmp(&b.0)); + let insts: Vec<(String, Instance)> = std::fs::read_dir(instances_dir(root)) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| { + let id = e.file_name().to_string_lossy().into_owned(); + let body = std::fs::read_to_string(e.path().join(INSTANCE_MANIFEST)).ok()?; + Some((id, toml::from_str::(&body).ok()?)) + }) + .collect(); + if json { let out: Vec<_> = rows.iter().map(|(sha, m, st)| { serde_json::json!({ @@ -486,11 +779,17 @@ fn list(root: &Path, json: bool) -> Result<()> { "empty": !matches!(st, TreeState::Populated), }) }).collect(); - println!("{}", serde_json::to_string_pretty(&out)?); + let insts_json: Vec<_> = insts.iter().map(|(id, i)| serde_json::json!({ + "id": id, "base": i.base, "distro": i.distro, + "packages": i.packages, "network": i.grants.network, + "sockets": i.grants.sockets, "devices": i.grants.devices, + })).collect(); + println!("{}", serde_json::to_string_pretty( + &serde_json::json!({"images": out, "instances": insts_json}))?); return Ok(()); } - if rows.is_empty() { + if rows.is_empty() && insts.is_empty() { println!("no hay imágenes en {} — `hammer qorpa pull --sha256 `", images.display()); return Ok(()); } @@ -511,6 +810,21 @@ fn list(root: &Path, json: bool) -> Result<()> { } } } + if !insts.is_empty() { + println!("\ninstancias:"); + for (id, i) in &insts { + let sha = i.base.strip_prefix("sha256:").unwrap_or(&i.base); + // Se enumeran las concesiones porque una instancia sin política declarada y una con la + // pantalla abierta se ven IGUAL desde fuera, y no son lo mismo (ADR §NO-resuelve 1). + let mut abre: Vec = Vec::new(); + if i.grants.network { abre.push("red".into()); } + abre.extend(i.grants.sockets.iter().cloned()); + abre.extend(i.grants.devices.iter().cloned()); + if !i.grants.dirs.is_empty() { abre.push(format!("{} dir(s)", i.grants.dirs.len())); } + println!(" {:<16} sobre {} abre: {}", id, &sha[..sha.len().min(12)], + if abre.is_empty() { "nada".into() } else { abre.join(", ") }); + } + } 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"); @@ -628,6 +942,75 @@ mod tests { assert!(!root.join("images").join(&sha).exists()); } + /// Una imagen mínima que pasa por rootfs: `etc/` + `bin/`. + fn imagen_falsa(root: &Path, sha: &str) { + let t = root.join("images").join(sha).join(TREE); + std::fs::create_dir_all(t.join("etc")).unwrap(); + std::fs::create_dir_all(t.join("bin")).unwrap(); + std::fs::write(t.join("etc/os-release"), "ID=falsa\n").unwrap(); + } + + #[test] + fn create_nace_sin_ninguna_concesion() { + let d = tempfile::tempdir().unwrap(); + let root = d.path(); + let sha = "a".repeat(64); + imagen_falsa(root, &sha); + create(root, "prueba", "aaaa", Some("falsa")).unwrap(); + let (_, inst) = read_instance(root, "prueba").unwrap(); + assert_eq!(inst.base, format!("sha256:{sha}")); + // D7: la política se autora, y arranca VACÍA. + assert!(!inst.grants.network); + assert!(inst.grants.sockets.is_empty() && inst.grants.devices.is_empty()); + // Y no se pisa a sí misma. + assert!(create(root, "prueba", "aaaa", None).is_err()); + } + + #[test] + fn create_rechaza_una_imagen_vacia() { + let d = tempfile::tempdir().unwrap(); + let root = d.path(); + let sha = "b".repeat(64); + std::fs::create_dir_all(root.join("images").join(&sha).join(TREE)).unwrap(); + let err = create(root, "x", &sha, None).unwrap_err().to_string(); + assert!(err.contains("VACÍA"), "mensaje inesperado: {err}"); + } + + #[test] + fn recreate_tira_la_capa_mutable_y_conserva_el_manifiesto() { + let d = tempfile::tempdir().unwrap(); + let root = d.path(); + let sha = "c".repeat(64); + imagen_falsa(root, &sha); + create(root, "i", &sha, None).unwrap(); + let upper = instance_dir(root, "i").join("upper"); + std::fs::write(upper.join("basura"), "x").unwrap(); + recreate(root, "i").unwrap(); + assert!(!upper.join("basura").exists(), "el upper no es caché si sobrevive"); + assert!(instance_dir(root, "i").join(INSTANCE_MANIFEST).exists(), "el manifiesto ES la verdad"); + } + + #[test] + fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() { + let (a, w) = grants_to_args(&Grants::default()); + assert!(a.contains(&"--unshare-net".to_string())); + assert!(w.is_empty()); + + let g = Grants { network: true, ..Default::default() }; + let (a, _) = grants_to_args(&g); + assert!(!a.contains(&"--unshare-net".to_string())); + // El segundo fallo clásico de la primera corrida, según el ADR. + assert!(a.iter().any(|x| x == "/etc/resolv.conf")); + } + + #[test] + fn el_socket_de_wayland_avisa_que_es_un_borde_de_privilegio() { + let g = Grants { sockets: vec!["wayland".into()], ..Default::default() }; + let (_, w) = grants_to_args(&g); + assert_eq!(w.len(), 1); + assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]); + } + #[test] fn sha_mal_formado_falla_antes_de_tocar_la_red() { assert!(normalize_sha256("abc").is_err()); diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index f48bbbc2..37e4d5e3 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -4,8 +4,8 @@ §Orden de trabajo. Este documento decide la frontera; el código viene después. - **Fecha:** 2026-09-03 - **Frontera (a crear):** `hammer qorpa {pull,create,run,export,list,prune}`, - `/var/lib/hammer/qorpa/imagenes//`, `/var/lib/hammer/qorpa/instancias//`, - `instancia.toml` (manifiesto), clase de nodo `ajeno` en `build-state.py`. + `/var/lib/hammer/qorpa/images//`, `/var/lib/hammer/qorpa/instances//`, + `instance.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), @@ -91,9 +91,9 @@ todo fue bien»). Espacio de nombres paralelo, direccionado por digest, **fuera de `hash_inputs` de todo**: ``` -/var/lib/hammer/qorpa/imagenes// imagen — INMUTABLE, verificada por digest -/var/lib/hammer/qorpa/instancias// - ├── instancia.toml manifiesto — LA VERDAD +/var/lib/hammer/qorpa/images// imagen — INMUTABLE, verificada por digest +/var/lib/hammer/qorpa/instances// + ├── instance.toml manifiesto — LA VERDAD ├── upper/ capa mutable — CACHÉ └── work/ overlayfs ``` @@ -129,20 +129,22 @@ host «para ahorrar espacio». Si el `upper` con 200 paquetes instalados a mano fuese el activo, tendríamos un blob irreemplazable: justo lo que hammer existe para no tener. La relación correcta es la misma que receta↔artefacto: +Los campos van en inglés como el resto de la superficie (`CLAUDE.md` regla 4): + ```toml -# /var/lib/hammer/qorpa/instancias/juegos/instancia.toml -base = "sha256:…" # digest de la imagen, inmutable -distro = "fedora-43" # informativo -paquetes = ["steam", "mesa-dri-drivers", "mesa-vulkan-drivers"] +# /var/lib/hammer/qorpa/instances/games/instance.toml +base = "sha256:…" # digest de la imagen, inmutable +distro = "arch" # informativo +packages = ["steam", "mesa"] -[concesiones] # POR DEFECTO NADA -sockets = ["wayland", "pipewire"] -devices = ["/dev/dri", "/dev/ntsync"] -directorios = [{ host = "~/Juegos", dentro = "/home/usuario/Juegos", modo = "rw" }] -red = true # lo necesita dnf y lo necesita Steam +[grants] # POR DEFECTO NADA +sockets = ["wayland", "pipewire"] +devices = ["/dev/dri", "/dev/ntsync"] +dirs = [{ host = "~/Juegos", inside = "/home/user/Juegos", mode = "rw" }] +network = true # lo necesita el gestor de paquetes y lo necesita Steam -[exporta] # capa de transparencia; ver D5 -binarios = ["steam"] +[export] # capa de transparencia; ver D5 +binaries = ["steam"] apps = ["steam.desktop"] ``` @@ -151,7 +153,7 @@ Consecuencias que se caen solas: 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 - `/var/lib/hammer/qorpa/instancias/*/upper` y eso es correcto, no un olvido. + `/var/lib/hammer/qorpa/instances/*/upper` y eso es correcto, no un olvido. - La poda tiene una regla trivial: **un `upper` siempre se puede borrar.** ### D4 — Granularidad: cuatro tipos, no uno @@ -191,7 +193,7 @@ una línea de comando que no escribimos en el menú del usuario. **Capa 2 — se exporta lo declarado.** Si se exporta todo, el `ls` y el `systemctl` de Fedora compiten con los nuestros. Ésa es la falla de Bedrock: **arbitra en tiempo de `exec`**, con heurísticas. Acá -la lista `[exporta]` del manifiesto resuelve la ambigüedad **al declarar**. Tiene precedente directo: +la lista `[export]` del manifiesto resuelve la ambigüedad **al declarar**. Tiene precedente directo: la hidratación del escritorio ya va por lista explícita (`TARGETS="…"`), nunca por glob. **Capa 3 — el grafo lo sabe.** Los nodos exportados entran en `build-state.json` con **clase @@ -232,7 +234,7 @@ la evidencia negativa que hace valioso al subproducto ([SDD 16](../16-harkaq-jau **Una imagen ajena no tiene clausura declarada.** Es el primer y único lugar del sistema donde la política hay que **autorarla**. No es un defecto: es una excepción que conviene nombrar y acotar en -vez de descubrir. El bloque `[concesiones]` del manifiesto es esa política, **por defecto vacío**, y +vez de descubrir. El bloque `[grants]` del manifiesto es esa política, **por defecto vacío**, y se compila al mismo `PolicySpec` que ya consume `harkaq-exec`. Un `dnf` que no alcanza `~/.ssh` es un argumento que ninguna distro ofrece. @@ -246,7 +248,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 recreate` | +| el rootfs base | **sí**, por sha256 | cambiar el digest en `instance.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 @@ -296,12 +298,23 @@ Se escriben acá para que no se descubran en producción. capturar la pantalla y sintetizar teclas**, y el manifiesto debe decirlo en la cara del usuario. Cerrarlo es tarea propia y probablemente el ticket más valioso que sale de este ADR. -2. **UID mapping.** Sin userns privilegiados, `root` adentro es tu UID afuera — que es la propiedad - de seguridad que queremos, pero rompe operaciones que hacen `chown` a UIDs distintos (rpm crea - usuarios de servicio). La salida son rangos subuid, y **las piezas ya están en el corpus**: - `recipes/shadow.toml` instala `newuidmap`/`newgidmap` y crea `/etc/subuid` vacío. Falta - provisionarlo (capacidad de fichero o setuid + rango en `/etc/subuid`) y probarlo con un `dnf - install` real. Es la primera cosa que va a fallar. +2. **UID mapping — MEDIDO 2026-09-03, y el veredicto es mejor de lo que decía este párrafo.** + Sin userns privilegiados, `root` adentro es tu UID afuera. La instancia ve `uid_map: 0 1001 1` y + `setgroups: deny`, y eso rompe todo lo que quiera *otro* id. Medido en las dos imágenes curadas: + + | gestor | qué pasa | severidad | + |---|---|---| + | `apt` (Ubuntu 24.04) | el método http hace `setgroups` para bajar a `_apt` ⇒ `Operation not permitted` | **para**, pero se saltea con `-o APT::Sandbox::User=root`, y entonces baja 34 MB, instala y corre los triggers de dpkg enteros. Único residuo: `W: chown to root:adm … failed`, un **aviso** | + | `pacman` (Arch) | chownea el directorio de descarga al usuario `alpm` ⇒ `Invalid argument` | **para en duro**; con `DownloadUser` comentado sincroniza, y tras `pacman-key --init && --populate` instala y el binario corre | + + ⇒ **subuid no es un muro, es un impuesto.** Un solo id alcanza para instalar paquetes reales en + los dos gestores; lo que rompe es el `chown`/`setgroups` a *otro* id, y cada gestor lo hace en un + sitio distinto. Lo que sigue faltando para quitar el impuesto: `newuidmap`/`newgidmap` con + `cap_setuid`/`cap_setgid` (`recipes/shadow.toml` los INSTALA pero no los provisiona — el mismo + hueco espera en la distro propia) **y** una vía para que bwrap use ese mapa: bwrap crea el userns + con un solo id a propósito y no llama a `newuidmap`, así que hay que crear el namespace aparte, + mapear el rango y pasárselo con `--userns FD`. `scripts/qorpa/qorpa-preflight.sh` ya mide las dos + mitades. 3. **Segunda cadena de suministro, sin ninguna garantía de hammer.** Sin repro, sin cierre firmado, sin escaneo de licencias. El riesgo no es técnico: es que se normalice. Mitigación = D5 capa 3 @@ -355,8 +368,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 pull --sha256` + verificación de digest. Reusa el patrón de `lab-image.sh`. -3. Instancia = overlay sobre la imagen + `instancia.toml`; `create` / `recreate` / `run`. +2. ✅ **HECHO 2026-09-03.** `hammer qorpa pull --sha256` + verificación de digest antes de + desempacar. El rootfs se ancla por estructura (`etc/` + `usr|bin`), con `--subdir` de escape. +3. ✅ **HECHO 2026-09-03.** Instancia = overlay sobre la imagen + `instance.toml`; `create` / + `recreate` / `run`. El overlay lo monta bwrap (`--overlay-src` + `--overlay`) dentro de su + propio namespace: sin root y sin montar nada en el host. `run` entra con `--clearenv` y con + `--unshare-net` salvo que se declare `network` — el entorno del host también es una concesión. 4. Concesiones → `PolicySpec` de harkaq. Empezar por **nada** y abrir sólo lo declarado. 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.