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:
+399
-16
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user