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"; const IMAGE_MANIFEST: &str = "image.toml";
/// El árbol desempacado dentro del directorio de la imagen. /// El árbol desempacado dentro del directorio de la imagen.
const TREE: &str = "rootfs"; 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)] #[derive(Subcommand)]
pub enum QorpaCmd { pub enum QorpaCmd {
@@ -67,15 +69,43 @@ pub enum QorpaCmd {
#[arg(long)] #[arg(long)]
json: bool, 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 { Create {
/// Identificador de la instancia (`juegos`). /// Identificador de la instancia (`games`).
id: String, 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]`. /// [ADR 0015 §Orden 5 — SIN IMPLEMENTAR] Genera los shims y `.desktop` de lo declarado en `[exporta]`.
Export { id: String }, Export { id: String },
/// [ADR 0015 §Orden 7 — SIN IMPLEMENTAR] Poda imágenes sin instancia y `upper` descartables. /// [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()) pull(&resolve_root(root), &url, &sha256, label.as_deref(), verify_only, subdir.as_deref())
} }
QorpaCmd::List { root, json } => list(&resolve_root(root), json), QorpaCmd::List { root, json } => list(&resolve_root(root), json),
QorpaCmd::Create { .. } | QorpaCmd::Recreate { .. } => bail!( QorpaCmd::Create { id, base, distro, root } => {
"sin implementar: las instancias son el paso 3 del §Orden de trabajo del ADR 0015 \ create(&resolve_root(root), &id, &base, distro.as_deref())
(overlay sobre la imagen + instancia.toml). Hoy sólo hay `pull` y `list`." }
), QorpaCmd::Recreate { id, root } => recreate(&resolve_root(root), &id),
QorpaCmd::Run { .. } => bail!( QorpaCmd::Run { id, root, dry_run, cmd } => run_instance(&resolve_root(root), &id, &cmd, dry_run),
"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!( QorpaCmd::Export { .. } => bail!(
"sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \ "sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \
`Exec=` de un .desktop ajeno." `Exec=` de un .desktop ajeno."
@@ -456,6 +483,261 @@ fn write_manifest(dir: &Path, m: &ImageManifest) -> Result<()> {
Ok(()) 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 ──────────────────────────────────────────────────────────────────────────────────────── // ── list ────────────────────────────────────────────────────────────────────────────────────────
fn list(root: &Path, json: bool) -> Result<()> { 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)); 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 { if json {
let out: Vec<_> = rows.iter().map(|(sha, m, st)| { let out: Vec<_> = rows.iter().map(|(sha, m, st)| {
serde_json::json!({ serde_json::json!({
@@ -486,11 +779,17 @@ fn list(root: &Path, json: bool) -> Result<()> {
"empty": !matches!(st, TreeState::Populated), "empty": !matches!(st, TreeState::Populated),
}) })
}).collect(); }).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(()); 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()); println!("no hay imágenes en {} — `hammer qorpa pull <url> --sha256 <sha>`", images.display());
return Ok(()); 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()); println!("\n{} imagen(es) en {}", rows.len(), images.display());
if vacias > 0 { 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"); 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()); 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] #[test]
fn sha_mal_formado_falla_antes_de_tocar_la_red() { fn sha_mal_formado_falla_antes_de_tocar_la_red() {
assert!(normalize_sha256("abc").is_err()); assert!(normalize_sha256("abc").is_err());
+45 -28
View File
@@ -4,8 +4,8 @@
§Orden de trabajo. Este documento decide la frontera; el código viene después. §Orden de trabajo. Este documento decide la frontera; el código viene después.
- **Fecha:** 2026-09-03 - **Fecha:** 2026-09-03
- **Frontera (a crear):** `hammer qorpa {pull,create,run,export,list,prune}`, - **Frontera (a crear):** `hammer qorpa {pull,create,run,export,list,prune}`,
`/var/lib/hammer/qorpa/imagenes/<sha256>/`, `/var/lib/hammer/qorpa/instancias/<id>/`, `/var/lib/hammer/qorpa/images/<sha256>/`, `/var/lib/hammer/qorpa/instances/<id>/`,
`instancia.toml` (manifiesto), clase de nodo `ajeno` en `build-state.py`. `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ó - **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. 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), - **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**: 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/images/<sha256>/ imagen — INMUTABLE, verificada por digest
/var/lib/hammer/qorpa/instancias/<id>/ /var/lib/hammer/qorpa/instances/<id>/
├── instancia.toml manifiesto — LA VERDAD ├── instance.toml manifiesto — LA VERDAD
├── upper/ capa mutable — CACHÉ ├── upper/ capa mutable — CACHÉ
└── work/ overlayfs └── 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: 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: 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 ```toml
# /var/lib/hammer/qorpa/instancias/juegos/instancia.toml # /var/lib/hammer/qorpa/instances/games/instance.toml
base = "sha256:…" # digest de la imagen, inmutable base = "sha256:…" # digest de la imagen, inmutable
distro = "fedora-43" # informativo distro = "arch" # informativo
paquetes = ["steam", "mesa-dri-drivers", "mesa-vulkan-drivers"] packages = ["steam", "mesa"]
[concesiones] # POR DEFECTO NADA [grants] # POR DEFECTO NADA
sockets = ["wayland", "pipewire"] sockets = ["wayland", "pipewire"]
devices = ["/dev/dri", "/dev/ntsync"] devices = ["/dev/dri", "/dev/ntsync"]
directorios = [{ host = "~/Juegos", dentro = "/home/usuario/Juegos", modo = "rw" }] dirs = [{ host = "~/Juegos", inside = "/home/user/Juegos", mode = "rw" }]
red = true # lo necesita dnf y lo necesita Steam network = true # lo necesita el gestor de paquetes y lo necesita Steam
[exporta] # capa de transparencia; ver D5 [export] # capa de transparencia; ver D5
binarios = ["steam"] binaries = ["steam"]
apps = ["steam.desktop"] apps = ["steam.desktop"]
``` ```
@@ -151,7 +153,7 @@ Consecuencias que se caen solas:
descartable. descartable.
- **Actualizar la base** (Fedora 43 → 44) no es un rebase riesgoso: se cambia el digest y se recrea. - **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 - **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.** - La poda tiene una regla trivial: **un `upper` siempre se puede borrar.**
### D4 — Granularidad: cuatro tipos, no uno ### 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 **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á 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. 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 **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 **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 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`. se compila al mismo `PolicySpec` que ya consume `harkaq-exec`.
Un `dnf` que no alcanza `~/.ssh` es un argumento que ninguna distro ofrece. 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 | | 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 | | 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 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. 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. 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 2. **UID mapping — MEDIDO 2026-09-03, y el veredicto es mejor de lo que decía este párrafo.**
de seguridad que queremos, pero rompe operaciones que hacen `chown` a UIDs distintos (rpm crea Sin userns privilegiados, `root` adentro es tu UID afuera. La instancia ve `uid_map: 0 1001 1` y
usuarios de servicio). La salida son rangos subuid, y **las piezas ya están en el corpus**: `setgroups: deny`, y eso rompe todo lo que quiera *otro* id. Medido en las dos imágenes curadas:
`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 | gestor | qué pasa | severidad |
install` real. Es la primera cosa que va a fallar. |---|---|---|
| `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, 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 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 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. (§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`. 2.**HECHO 2026-09-03.** `hammer qorpa pull <url> --sha256` + verificación de digest antes de
3. Instancia = overlay sobre la imagen + `instancia.toml`; `create` / `recreate` / `run`. 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. 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`. 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. 6. **Steam de punta a punta**, con verificación explícita del bwrap anidado.