> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
qorpa create/recreate/run: la instancia nace sin ver nada
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
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<String>,
+ #[arg(long)]
+ root: Option<PathBuf>,
+ },
+ /// [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<PathBuf>,
+ },
+ /// [ADR 0015 §Orden 3] Entra a la instancia con las concesiones declaradas y nada más.
+ Run {
+ id: String,
+ #[arg(long)]
+ root: Option<PathBuf>,
+ /// 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<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.
@@ -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<String>,
+ /// 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<String>,
+ /// 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<String>,
+ /// 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<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ dirs: Vec<DirGrant>,
+ /// 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<String> = 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<String>, Vec<String>) {
+ let mut a: Vec<String> = Vec::new();
+ let mut warn: Vec<String> = 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<String> = 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<String> = 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::<Instance>(&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 <url> --sha256 <sha>`", 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<String> = 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/<sha256>/`, `/var/lib/hammer/qorpa/instancias/<id>/`,
- `instancia.toml` (manifiesto), clase de nodo `ajeno` en `build-state.py`.
+ `/var/lib/hammer/qorpa/images/<sha256>/`, `/var/lib/hammer/qorpa/instances/<id>/`,
+ `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/<sha256>/ imagen — INMUTABLE, verificada por digest
-/var/lib/hammer/qorpa/instancias/<id>/
- ├── instancia.toml manifiesto — LA VERDAD
+/var/lib/hammer/qorpa/images/<sha256>/ imagen — INMUTABLE, verificada por digest
+/var/lib/hammer/qorpa/instances/<id>/
+ ├── 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"]
-
-[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
-
-[exporta] # capa de transparencia; ver D5
-binarios = ["steam"]
+# /var/lib/hammer/qorpa/instances/games/instance.toml
+base = "sha256:…" # digest de la imagen, inmutable
+distro = "arch" # informativo
+packages = ["steam", "mesa"]
+
+[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
+
+[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 <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`.
+2. ✅ **HECHO 2026-09-03.** `hammer qorpa pull <url> --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.