qorpa export: shims generados, y la clase ajeno para que nadie los cuente mal
Paso 5 del ADR 0015, sus dos mitades. SHIMS. `hammer qorpa export <id>` genera lanzadores finos y `.desktop` en el espacio del host, desde lo DECLARADO en `[export]` — nunca todo: exportar todo haría que el `ls` de la imagen compita con el nuestro, que es la falla de Bedrock (arbitra en tiempo de exec, por heurística). Gana en tres cosas contra un FUSE: cero costo en runtime, `cat` al shim y ves qué hace, y se revoca borrándolos. Se GENERAN, no se copian. El `.desktop` se arma con lista BLANCA de claves, así que `Exec`, `TryExec`, `Path` y `DBusActivatable` quedan fuera por definición y no por enumeración — una lista negra dejaría entrar la próxima clave ejecutable que invente el estándar. El Exec original se cita en un comentario del fichero generado, para que se vea qué decía y qué no se copió. El icono se busca en la vista merged (upper primero, imagen después: si no, se perdería lo que instaló el gestor de paquetes) y se copia al host, porque un icono que el host no resuelve se ve como un cuadrito gris. Y `exported.json` registra cada fichero escrito, para que `--remove` borre EXACTAMENTE eso y no por patrón sobre el ~/.local/bin de alguien. Probado de punta a punta con un .desktop ajeno real de la imagen de Arch: el shim corre `pacman -Q` del huésped desde el host, el X-KDE-Wayland-Interfaces quedó fuera, el icono viajó, y --remove dejó 0 ficheros con la instancia intacta. CLASE `ajeno`. build-state.py inyecta los nodos declarados en el nuevo docs/state/qorpa-ajenos.toml ANTES que los `wanted`, y ese orden es la mitad del punto: un nodo que provee una imagen ajena no es una receta por escribir. Con eso el `xwayland` de escritorio-kde deja de ser deuda y pasa a contarse aparte: escritorio-kde 187/188 listo falta 1 (raíces 14, + 1 ajenas) Dos decisiones que sostienen esa cifra: los ajenos se RESTAN del denominador (si entraran, el número que se lee como "cuánto construimos" crecería solo cada vez que alguien enjaula una app), y la declaración vive en el REPO y no se lee de /var/lib/hammer — build-state.json se commitea y lo regenera el cron en dos máquinas; si la clase saliera de las instancias instaladas, cada una diría algo distinto y se pisarían en cada cosecha. Es el error que ya se cometió con sealed_remoto. Qué provee una imagen ajena es diseño; qué tenés instalado, no. Un ajeno tampoco se hashea, y no por comodidad: no tiene procedencia de fuente, así que un hash afirmaría que lo reproducimos. 2 tests nuevos (que del .desktop ajeno no sobreviva nada ejecutable; que el shim no se rompa con rutas raras). 47/47. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
This commit is contained in:
@@ -114,8 +114,22 @@ pub enum QorpaCmd {
|
||||
#[arg(last = true)]
|
||||
cmd: Vec<String>,
|
||||
},
|
||||
/// [ADR 0015 §Orden 5 — SIN IMPLEMENTAR] Genera los shims y `.desktop` de lo declarado en `[exporta]`.
|
||||
Export { id: String },
|
||||
/// [ADR 0015 §Orden 5] Genera los shims y `.desktop` de lo declarado en `[export]`.
|
||||
///
|
||||
/// **Se GENERAN, no se copian.** El `Exec=` de un `.desktop` ajeno es texto ajeno: copiarlo
|
||||
/// pondría una línea de comando que no escribimos en el menú del usuario (D5).
|
||||
Export {
|
||||
id: String,
|
||||
/// Prefijo donde escribir (`<into>/bin`, `<into>/share/applications`). Default: `~/.local`.
|
||||
#[arg(long)]
|
||||
into: Option<PathBuf>,
|
||||
/// Borra exactamente lo que se generó. Revocable: sin shims, la instancia desaparece del
|
||||
/// sistema sin desmontar nada.
|
||||
#[arg(long)]
|
||||
remove: bool,
|
||||
#[arg(long)]
|
||||
root: Option<PathBuf>,
|
||||
},
|
||||
/// [ADR 0015 §Orden 7 — SIN IMPLEMENTAR] Poda imágenes sin instancia y `upper` descartables.
|
||||
Prune,
|
||||
}
|
||||
@@ -133,10 +147,9 @@ pub fn run(cmd: QorpaCmd) -> Result<()> {
|
||||
QorpaCmd::Run { id, root, dry_run, no_jail, single_id, cmd } => {
|
||||
run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail, single_id)
|
||||
}
|
||||
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."
|
||||
),
|
||||
QorpaCmd::Export { id, into, remove, root } => {
|
||||
export(&resolve_root(root), &id, into.as_deref(), remove)
|
||||
}
|
||||
QorpaCmd::Prune => bail!(
|
||||
"sin implementar: la poda es el paso 7 del ADR 0015. Nace con el subsistema y no \
|
||||
después — este repo ya tuvo tres emergencias de disco."
|
||||
@@ -531,6 +544,21 @@ struct Instance {
|
||||
/// La política, autorada (D7). **Por defecto vacía**: una instancia nace sin ver nada.
|
||||
#[serde(default)]
|
||||
grants: Grants,
|
||||
/// La capa de transparencia (D5). Vacía por defecto: **se exporta lo DECLARADO**, nunca todo.
|
||||
/// Exportar todo es la falla de Bedrock — su `ls` compitiendo con el nuestro, arbitrado por
|
||||
/// heurística en tiempo de `exec`. Acá la ambigüedad se resuelve al declarar.
|
||||
#[serde(default)]
|
||||
export: Export,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
struct Export {
|
||||
/// Binarios que aparecen en el `PATH` del host como shims.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
binaries: Vec<String>,
|
||||
/// Ficheros `.desktop` de la imagen cuyo equivalente se GENERA para el menú del host.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
apps: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -1119,6 +1147,269 @@ fn run_instance(
|
||||
}
|
||||
}
|
||||
|
||||
// ── export: la capa de transparencia (D5) ───────────────────────────────────────────────────────
|
||||
//
|
||||
// El objetivo es el poder de Bedrock Linux —apps de cualquier «stratum» disponibles en todos
|
||||
// lados— SIN sus formas: nada de un FUSE global en el camino de cada `exec`, nada de arbitrar por
|
||||
// heurística qué binario gana. Acá no hace falta, porque **el FHS de esta distro ya es una
|
||||
// proyección**: `hammer hydrate` proyecta artefactos a un árbol, y extender la proyección a
|
||||
// instancias es idiomático.
|
||||
//
|
||||
// Tres propiedades que un FUSE no da: **cero costo en runtime** (no hay proceso en el camino
|
||||
// crítico), **inspeccionable** (`cat` al shim y ves qué hace) y **revocable** (se borran los shims
|
||||
// y la instancia desaparece del sistema sin desmontar nada).
|
||||
|
||||
/// Registro de lo escrito. Sin esto, `--remove` tendría que ADIVINAR qué ficheros son nuestros, y
|
||||
/// borrar por patrón en el `~/.local/bin` de alguien es exactamente lo que no se hace.
|
||||
const EXPORT_LEDGER: &str = "exported.json";
|
||||
/// Marca dentro de cada fichero generado. Redundante con el registro a propósito: si alguien mueve
|
||||
/// el shim, sigue diciendo de dónde salió.
|
||||
const MARCA: &str = "hammer-qorpa";
|
||||
|
||||
/// Claves del `.desktop` ajeno que se copian: TEXTO DE PRESENTACIÓN y nada más.
|
||||
///
|
||||
/// Lista blanca, no negra. `Exec`, `TryExec`, `Path`, `DBusActivatable` y `Actions` quedan fuera
|
||||
/// **por definición**: son ejecución, y la ejecución la escribimos nosotros. Una lista negra
|
||||
/// dejaría entrar la próxima clave ejecutable que invente el estándar.
|
||||
const DESKTOP_OK: &[&str] = &[
|
||||
"Name", "GenericName", "Comment", "Keywords", "Icon", "Categories",
|
||||
"Terminal", "StartupWMClass", "StartupNotify", "MimeType", "NoDisplay", "Type",
|
||||
];
|
||||
|
||||
/// Busca un fichero en la vista MERGED de la instancia: primero la capa mutable, después la imagen.
|
||||
/// Es el mismo orden que ve el proceso adentro; mirar sólo la imagen se perdería lo que instaló el
|
||||
/// gestor de paquetes, que es justo lo que se quiere exportar.
|
||||
fn resolver_en_instancia(dir: &Path, tree: &Path, rel: &str) -> Option<PathBuf> {
|
||||
let rel = rel.trim_start_matches('/');
|
||||
for base in [dir.join("upper"), tree.to_path_buf()] {
|
||||
let p = base.join(rel);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn buscar_desktop(dir: &Path, tree: &Path, nombre: &str) -> Option<PathBuf> {
|
||||
let nombre = if nombre.ends_with(".desktop") { nombre.to_string() } else { format!("{nombre}.desktop") };
|
||||
for d in ["usr/share/applications", "usr/local/share/applications", "share/applications"] {
|
||||
if let Some(p) = resolver_en_instancia(dir, tree, &format!("{d}/{nombre}")) {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// El icono referenciado por un `.desktop` puede ser una ruta o un NOMBRE de tema. Si es nombre, se
|
||||
/// busca en los sitios estándar de la imagen y se copia al host: un `.desktop` con un icono que el
|
||||
/// host no puede resolver se ve como un cuadrito gris, que es peor que no exportarlo.
|
||||
fn copiar_icono(dir: &Path, tree: &Path, icono: &str, destino_base: &Path) -> Option<(String, PathBuf)> {
|
||||
if icono.starts_with('/') {
|
||||
let src = resolver_en_instancia(dir, tree, icono)?;
|
||||
let ext = src.extension().and_then(|e| e.to_str()).unwrap_or("png").to_string();
|
||||
let nombre = format!("{MARCA}-{}", src.file_name()?.to_string_lossy());
|
||||
let dst = destino_base.join(&nombre);
|
||||
std::fs::create_dir_all(destino_base).ok()?;
|
||||
std::fs::copy(&src, &dst).ok()?;
|
||||
let _ = ext;
|
||||
return Some((dst.display().to_string(), dst));
|
||||
}
|
||||
for tamano in ["scalable", "256x256", "128x128", "64x64", "48x48"] {
|
||||
for ext in ["svg", "png"] {
|
||||
let rel = format!("usr/share/icons/hicolor/{tamano}/apps/{icono}.{ext}");
|
||||
if let Some(src) = resolver_en_instancia(dir, tree, &rel) {
|
||||
let dst = destino_base.join(format!("{MARCA}-{icono}.{ext}"));
|
||||
std::fs::create_dir_all(destino_base).ok()?;
|
||||
if std::fs::copy(&src, &dst).is_ok() {
|
||||
return Some((dst.display().to_string(), dst));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for rel in [format!("usr/share/pixmaps/{icono}.png"), format!("usr/share/pixmaps/{icono}.svg")] {
|
||||
if let Some(src) = resolver_en_instancia(dir, tree, &rel) {
|
||||
let ext = if rel.ends_with("svg") { "svg" } else { "png" };
|
||||
let dst = destino_base.join(format!("{MARCA}-{icono}.{ext}"));
|
||||
std::fs::create_dir_all(destino_base).ok()?;
|
||||
if std::fs::copy(&src, &dst).is_ok() {
|
||||
return Some((dst.display().to_string(), dst));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn export(root: &Path, id: &str, into: Option<&Path>, remove: bool) -> Result<()> {
|
||||
let (dir, inst) = read_instance(root, id)?;
|
||||
let ledger_path = dir.join(EXPORT_LEDGER);
|
||||
|
||||
if remove {
|
||||
let previos: Vec<String> = std::fs::read_to_string(&ledger_path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
if previos.is_empty() {
|
||||
println!("la instancia {id} no tiene nada exportado");
|
||||
return Ok(());
|
||||
}
|
||||
for f in &previos {
|
||||
match std::fs::remove_file(f) {
|
||||
Ok(()) => println!(" borrado {f}"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => eprintln!(" no pude borrar {f}: {e}"),
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&ledger_path);
|
||||
println!("{} fichero(s) revocados — la instancia sigue existiendo, ya no se ve", previos.len());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if inst.export.binaries.is_empty() && inst.export.apps.is_empty() {
|
||||
bail!(
|
||||
"la instancia {id} no declara nada en `[export]`. Se exporta lo DECLARADO: exportar \
|
||||
todo haría que el `ls` de la imagen compita con el nuestro, que es la falla de Bedrock."
|
||||
);
|
||||
}
|
||||
let (_sha, tree) = resolve_image(root, &inst.base)?;
|
||||
|
||||
let prefijo = into.map(PathBuf::from).unwrap_or_else(|| {
|
||||
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into())).join(".local")
|
||||
});
|
||||
let bin_dir = prefijo.join("bin");
|
||||
let app_dir = prefijo.join("share/applications");
|
||||
let icon_dir = prefijo.join("share/icons/qorpa");
|
||||
std::fs::create_dir_all(&bin_dir)?;
|
||||
|
||||
// La ruta ABSOLUTA de este mismo binario: un shim que dependiera del `PATH` del usuario haría
|
||||
// que la app arranque o no según quién la lance.
|
||||
let hammer = std::env::current_exe().context("no sé cuál es mi propia ruta")?;
|
||||
let raiz_flag = if root == Path::new(DEFAULT_ROOT) {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" --root {}", shell_quote(&root.display().to_string()))
|
||||
};
|
||||
|
||||
let mut escritos: Vec<String> = Vec::new();
|
||||
|
||||
for b in &inst.export.binaries {
|
||||
let dst = bin_dir.join(b);
|
||||
let cuerpo = format!(
|
||||
"#!/bin/sh\n\
|
||||
# GENERADO por `hammer qorpa export {id}` — {MARCA}. No editar: se reescribe.\n\
|
||||
# Borralo (o corré `hammer qorpa export {id} --remove`) y la instancia deja de verse.\n\
|
||||
exec {hammer} qorpa run{raiz_flag} {id} -- {b} \"$@\"\n",
|
||||
hammer = shell_quote(&hammer.display().to_string()),
|
||||
b = shell_quote(b),
|
||||
);
|
||||
std::fs::write(&dst, cuerpo)?;
|
||||
std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755))?;
|
||||
escritos.push(dst.display().to_string());
|
||||
println!(" bin {}", dst.display());
|
||||
}
|
||||
|
||||
for a in &inst.export.apps {
|
||||
let src = buscar_desktop(&dir, &tree, a)
|
||||
.with_context(|| format!("no encuentro {a} dentro de la instancia {id}"))?;
|
||||
let texto = std::fs::read_to_string(&src)?;
|
||||
let (campos, exec_original) = leer_desktop(&texto);
|
||||
|
||||
let nombre_bin = exec_original
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(|c| c.rsplit('/').next().unwrap_or(c).to_string())
|
||||
.unwrap_or_else(|| a.trim_end_matches(".desktop").to_string());
|
||||
|
||||
let mut salida = String::from("[Desktop Entry]\n");
|
||||
salida.push_str(&format!(
|
||||
"# GENERADO por `hammer qorpa export {id}` — {MARCA}. El Exec de abajo es NUESTRO:\n\
|
||||
# el original decía `{exec_original}` y no se copia (D5).\n"
|
||||
));
|
||||
let mut icono_final = None;
|
||||
for (k, v) in &campos {
|
||||
if k.starts_with("Icon") {
|
||||
if let Some((ruta, dst)) = copiar_icono(&dir, &tree, v, &icon_dir) {
|
||||
escritos.push(dst.display().to_string());
|
||||
icono_final = Some(ruta);
|
||||
continue; // se escribe abajo, ya resuelto a ruta del host
|
||||
}
|
||||
}
|
||||
salida.push_str(&format!("{k}={v}\n"));
|
||||
}
|
||||
if let Some(i) = icono_final {
|
||||
salida.push_str(&format!("Icon={i}\n"));
|
||||
}
|
||||
if !campos.iter().any(|(k, _)| k == "Type") {
|
||||
salida.push_str("Type=Application\n");
|
||||
}
|
||||
salida.push_str(&format!(
|
||||
"Exec={} qorpa run{raiz_flag} {id} -- {} %U\n",
|
||||
shell_quote(&hammer.display().to_string()),
|
||||
shell_quote(&nombre_bin),
|
||||
));
|
||||
salida.push_str(&format!("X-Qorpa-Instance={id}\n"));
|
||||
|
||||
std::fs::create_dir_all(&app_dir)?;
|
||||
let dst = app_dir.join(src.file_name().unwrap());
|
||||
std::fs::write(&dst, salida)?;
|
||||
escritos.push(dst.display().to_string());
|
||||
println!(" desktop {}", dst.display());
|
||||
}
|
||||
|
||||
std::fs::write(&ledger_path, serde_json::to_string_pretty(&escritos)?)?;
|
||||
println!(
|
||||
"{} fichero(s) generados desde {} — inspeccionables con `cat`, revocables con --remove",
|
||||
escritos.len(), src_label(&dir)
|
||||
);
|
||||
if !bin_dir.display().to_string().is_empty() {
|
||||
println!(" ⚠ que `{}` esté en tu PATH es cosa tuya", bin_dir.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn src_label(dir: &Path) -> String {
|
||||
dir.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string())
|
||||
}
|
||||
|
||||
/// Parsea el `[Desktop Entry]` de un `.desktop` y devuelve (campos permitidos, el `Exec` original).
|
||||
/// El `Exec` se devuelve SÓLO para nombrar el binario y para dejar dicho en el fichero generado qué
|
||||
/// decía el ajeno — nunca se emite.
|
||||
fn leer_desktop(texto: &str) -> (Vec<(String, String)>, String) {
|
||||
let mut campos = Vec::new();
|
||||
let mut exec = String::new();
|
||||
let mut dentro = false;
|
||||
for l in texto.lines() {
|
||||
let l = l.trim();
|
||||
if l.starts_with('[') {
|
||||
dentro = l == "[Desktop Entry]";
|
||||
continue;
|
||||
}
|
||||
if !dentro || l.is_empty() || l.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some((k, v)) = l.split_once('=') else { continue };
|
||||
let (k, v) = (k.trim(), v.trim());
|
||||
if k == "Exec" && exec.is_empty() {
|
||||
exec = v.to_string();
|
||||
continue;
|
||||
}
|
||||
// La clave puede venir localizada (`Name[es]`): se compara la parte antes del corchete.
|
||||
let base = k.split('[').next().unwrap_or(k);
|
||||
if DESKTOP_OK.contains(&base) {
|
||||
campos.push((k.to_string(), v.to_string()));
|
||||
}
|
||||
}
|
||||
(campos, exec)
|
||||
}
|
||||
|
||||
/// Comillas para `sh`. Un shim se lee y se ejecuta: una ruta con espacios no puede romperlo.
|
||||
fn shell_quote(s: &str) -> String {
|
||||
if !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"@%+=:,./-_".contains(&b)) {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── list ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn list(root: &Path, json: bool) -> Result<()> {
|
||||
@@ -1457,6 +1748,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn del_desktop_ajeno_no_sobrevive_nada_ejecutable() {
|
||||
let (campos, exec) = leer_desktop(
|
||||
"[Desktop Entry]\nName=Pinentry\nName[es]=Pinentry\nExec=/usr/bin/pinentry-qt --raro\n\
|
||||
TryExec=/usr/bin/pinentry-qt\nDBusActivatable=true\nPath=/tmp\nIcon=pinentry\n\
|
||||
X-KDE-Wayland-Interfaces=org_kde_kwin_keystate\n[Desktop Action Otra]\nExec=/mal\n",
|
||||
);
|
||||
let claves: Vec<&str> = campos.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert!(claves.contains(&"Name") && claves.contains(&"Name[es]") && claves.contains(&"Icon"));
|
||||
// Lista BLANCA: todo lo que ejecuta queda fuera por definición, no por enumeración.
|
||||
for prohibida in ["Exec", "TryExec", "Path", "DBusActivatable", "X-KDE-Wayland-Interfaces"] {
|
||||
assert!(!claves.contains(&prohibida), "se coló {prohibida}: {claves:?}");
|
||||
}
|
||||
// El Exec se lee sólo para nombrar el binario y citarlo; nunca se emite.
|
||||
assert_eq!(exec, "/usr/bin/pinentry-qt --raro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn el_shim_no_se_rompe_con_rutas_raras() {
|
||||
assert_eq!(shell_quote("/usr/bin/pacman"), "/usr/bin/pacman");
|
||||
assert_eq!(shell_quote("/con espacio/x"), "'/con espacio/x'");
|
||||
assert_eq!(shell_quote("a'b"), "'a'\\''b'"); // la comilla se cierra, se escapa y se reabre
|
||||
assert_eq!(shell_quote("; rm -rf /"), "'; rm -rf /'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha_mal_formado_falla_antes_de_tocar_la_red() {
|
||||
assert!(normalize_sha256("abc").is_err());
|
||||
|
||||
Reference in New Issue
Block a user