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
This commit is contained in:
Sergio
2026-09-03 04:38:25 +00:00
co-authored by Claude Opus 5
parent fe155cf20d
commit ba09fdb403
2 changed files with 444 additions and 44 deletions
+399 -16
View File
@@ -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());