diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index e63c427f..df3c2e14 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -1438,7 +1438,7 @@ fn main() -> anyhow::Result<()> { } } Cmd::Kernel { sub } => kernel_cmd::run(sub, &cli.store)?, - Cmd::Qorpa { sub } => qorpa::run(sub)?, + Cmd::Qorpa { sub } => qorpa::run(sub, std::path::Path::new(&cli.store))?, } Ok(()) } diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs index cde91169..27a52e76 100644 --- a/crates/hammer-cli/src/qorpa.rs +++ b/crates/hammer-cli/src/qorpa.rs @@ -143,6 +143,25 @@ pub enum QorpaCmd { #[arg(long)] root: Option, }, + /// [ADR 0015 D8] Registra como imagen un artefacto del store que trae una imagen ajena SELLADA. + /// + /// Es el consumidor de `recipes/steam-runtime-sniper.toml`: la imagen deja de venir de la CDN + /// de Valve y viene de nuestro store, replicable con `hammer mirror push` y cubrible por el + /// índice firmado (ADR 0014). **La identidad no cambia**: la imagen se registra bajo el sha256 + /// del ARCHIVO de upstream que el artefacto declara, no bajo su ArtifactHash, así que la que + /// importa una máquina y la que otra trae con `pull` son la MISMA imagen. + Import { + /// Hash del artefacto en el store; acepta prefijo. + #[arg(long)] + from_store: String, + /// Copia en vez de enlazar. Por defecto enlaza (`cp -al`), que es gratis y correcto: una + /// imagen NUNCA se escribe —las escrituras van al `upper` de la instancia—, así que + /// compartir inodos con el artefacto sellado no puede corromper nada. + #[arg(long)] + copy: bool, + #[arg(long)] + root: Option, + }, /// [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 @@ -177,7 +196,7 @@ pub enum QorpaCmd { }, } -pub fn run(cmd: QorpaCmd) -> Result<()> { +pub fn run(cmd: QorpaCmd, store: &Path) -> Result<()> { match cmd { QorpaCmd::Pull { url, sha256, label, verify_only, subdir, root } => { pull(&resolve_root(root), &url, &sha256, label.as_deref(), verify_only, subdir.as_deref()) @@ -200,6 +219,9 @@ pub fn run(cmd: QorpaCmd) -> Result<()> { export(&resolve_root(root), &id, into.as_deref(), remove) } QorpaCmd::Prune { upper, yes, root } => prune(&resolve_root(root), upper, yes), + QorpaCmd::Import { from_store, copy, root } => { + import(&resolve_root(root), store, &from_store, copy) + } } } @@ -601,6 +623,103 @@ fn write_manifest(dir: &Path, m: &ImageManifest) -> Result<()> { Ok(()) } +// ── import: consumir una imagen SELLADA al store (D8) ─────────────────────────────────────────── +// +// D1 dice que las imágenes ajenas no entran al store, y el motivo es que un rootfs MUTABLE no +// reproduce. Sniper no muta —nadie le instala nada adentro— así que sellarlo es una afirmación +// verdadera, y con eso se gana cadena de custodia nuestra: se replica con `hammer mirror push`, su +// digest puede ir al índice firmado (ADR 0014) y una instancia se arma sin depender de la CDN de +// Valve. Esto es lo que convierte ese artefacto en una imagen usable. +// +// LO QUE NO CAMBIA: la identidad. La imagen se registra bajo el **sha256 del archivo de upstream** +// que el artefacto declara, no bajo su ArtifactHash. Si se registrara por ArtifactHash, la imagen +// importada del store y la traída con `pull` serían dos imágenes distintas con los mismos bytes, y +// las instancias de dos máquinas dejarían de coincidir — justo lo que el pin existe para evitar. +fn import(root: &Path, store: &Path, hash: &str, copiar: bool) -> Result<()> { + let store = hammer_core::store::Store::open(store) + .map_err(|e| anyhow::anyhow!("abriendo el store: {e}"))?; + let art = store.find_by_hash(hash) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + // La imagen vive bajo un prefijo namespaceado: un rootfs glibc entero en la raíz del artefacto + // pisaría el sistema si alguien lo compusiera por error. + let base = art.join("usr/share/qorpa/images"); + let mut candidatos: Vec = std::fs::read_dir(&base) + .with_context(|| format!( + "el artefacto {} no trae `usr/share/qorpa/images/` — ¿es una receta de imagen ajena? \ + (hoy sólo `steam-runtime-sniper`)", art.display()))? + .flatten() + .map(|e| e.path()) + .filter(|p| p.join(IMAGE_MANIFEST).is_file()) + .collect(); + candidatos.sort(); + let src = match candidatos.len() { + 1 => candidatos.remove(0), + 0 => bail!("no hay ninguna imagen con `{IMAGE_MANIFEST}` en {}", base.display()), + n => bail!("{n} imágenes en un mismo artefacto: no adivino cuál ({:?})", candidatos), + }; + + let m: ImageManifest = toml::from_str(&std::fs::read_to_string(src.join(IMAGE_MANIFEST))?) + .with_context(|| format!("leyendo {}", src.join(IMAGE_MANIFEST).display()))?; + let sha = normalize_sha256(&m.sha256)?; + let arbol_src = src.join(TREE); + if !matches!(tree_state(&arbol_src), TreeState::Populated) { + bail!("el artefacto declara la imagen pero su `{TREE}/` está vacío o no existe — un nombre \ + no es una imagen (CLAUDE.md regla 3)"); + } + + let dest = root.join("images").join(&sha); + if let TreeState::Populated = tree_state(&dest.join(TREE)) { + println!("ya presente: {} (sha256:{}…)", dest.display(), &sha[..12]); + return Ok(()); + } + force_remove_dir_all(&dest).ok(); + std::fs::create_dir_all(&dest)?; + + // ENLAZAR, no copiar, y no es un ahorro cualquiera: una imagen NUNCA se escribe —lo que escribe + // la instancia va a su `upper`—, así que compartir inodos con un artefacto sellado (y de sólo + // lectura) es correcto por construcción y hace que la imagen cueste ~0 bytes. + // ⚠ La contracara, que ya nos mordió con `.dmerge`: mientras el artefacto siga en el store, + // borrar la imagen NO libera disco. Es una propiedad, no un fallo; `--copy` la evita. + let mut modo = "enlazada"; + let arbol_dst = dest.join(TREE); + let cp = |args: &[&str]| -> Result { + Ok(Command::new("cp").args(args) + .arg(&arbol_src).arg(&arbol_dst).status()?.success()) + }; + if copiar || !cp(&["-a", "--link", "-T"])? { + if !copiar { + // EXDEV: el store puede estar en OTRO mount (bind-mount del volumen), y `link()` no + // cruza mounts aunque sea el mismo filesystem. Se dice y se copia, en vez de fallar. + eprintln!(" (no pude enlazar —¿store en otro mount?—, copio)"); + force_remove_dir_all(&arbol_dst).ok(); + } + modo = "copiada"; + if !cp(&["-a", "-T"])? { + bail!("no pude copiar el árbol de la imagen desde {}", arbol_src.display()); + } + } + + // Se MIDE lo que quedó, no se cree lo que el manifiesto del artefacto decía: los contadores del + // artefacto podrían venir de otra máquina y este árbol es el que la instancia va a montar. + let (tree_files, tree_bytes) = walk_tree(&arbol_dst)?; + if tree_files == 0 { + bail!("el árbol importado quedó vacío — se aborta antes de escribir el manifiesto"); + } + write_manifest(&dest, &ImageManifest { + sha256: sha.clone(), url: m.url, label: m.label, + archive_bytes: m.archive_bytes, tree_files, tree_bytes, + rootfs_subdir: m.rootfs_subdir, dropped_siblings: m.dropped_siblings, + })?; + println!( + "imagen {modo} desde el store: {} · {tree_files} ficheros · {}", + dest.display(), human(tree_bytes) + ); + println!(" identidad = sha256 del archivo de upstream, no el ArtifactHash: coincide con la \ + que otra máquina traiga con `pull`"); + Ok(()) +} + // ── instancias ────────────────────────────────────────────────────────────────────────────────── /// El manifiesto de una instancia. **Es la verdad**: el `upper/` es caché y se puede tirar entero @@ -2412,6 +2531,68 @@ mod tests { std::fs::write(t.join("etc/os-release"), "ID=falsa\n").unwrap(); } + /// Un artefacto del store con una imagen ajena sellada dentro. Réplica de lo que produce + /// `recipes/steam-runtime-sniper.toml`. + fn artefacto_con_imagen(store: &Path, hex: &str, sha_upstream: &str, con_arbol: bool) -> PathBuf { + let img = store.join(format!("{hex}-imagen-ajena")).join("usr/share/qorpa/images/ajena"); + std::fs::create_dir_all(&img).unwrap(); + if con_arbol { + std::fs::create_dir_all(img.join(TREE).join("etc")).unwrap(); + std::fs::write(img.join(TREE).join("etc/os-release"), "ID=ajena\n").unwrap(); + } else { + std::fs::create_dir_all(img.join(TREE)).unwrap(); + } + std::fs::write(img.join(IMAGE_MANIFEST), format!( + "sha256 = \"{sha_upstream}\"\nurl = \"https://ejemplo/x.tar.gz\"\n\ + label = \"ajena-1\"\narchive_bytes = 42\ntree_files = 0\ntree_bytes = 0\n" + )).unwrap(); + img + } + + /// LA propiedad que hace que sellar valga la pena: la imagen importada del store queda bajo el + /// **sha256 de upstream**, no bajo el hash del artefacto. Si fuera al revés, la imagen del store + /// y la traída con `pull` serían dos imágenes distintas con los mismos bytes y las instancias de + /// dos máquinas dejarían de coincidir — justo lo que el pin existe para evitar. + #[test] + fn import_registra_la_imagen_por_el_sha_de_upstream_no_por_el_del_artefacto() { + let d = tempfile::tempdir().unwrap(); + let store = d.path().join("store"); + let root = d.path().join("qorpa"); + let hex = "f".repeat(64); + let up = "1".repeat(64); + artefacto_con_imagen(&store, &hex, &up, true); + + import(&root, &store, "ffffffff", true).unwrap(); + let dest = root.join("images").join(&up); + assert_eq!(std::fs::read_to_string(dest.join(TREE).join("etc/os-release")).unwrap(), + "ID=ajena\n"); + assert!(!root.join("images").join(&hex).exists(), "se registró por el hash del artefacto"); + + let m: ImageManifest = toml::from_str( + &std::fs::read_to_string(dest.join(IMAGE_MANIFEST)).unwrap()).unwrap(); + assert_eq!(m.sha256, up); + // Los contadores se MIDEN sobre lo importado, no se copian del artefacto (que traía 0). + assert_eq!(m.tree_files, 1); + assert!(m.tree_bytes > 0); + + // Idempotente: importar dos veces no rompe ni duplica. + import(&root, &store, "ffffffff", true).unwrap(); + } + + /// Regla 3 en el eslabón nuevo: un árbol vacío dentro del artefacto no puede pasar por imagen. + /// Es donde más barato sale equivocarse — el manifiesto está, el directorio está, y lo único + /// que falta es el contenido. + #[test] + fn import_rechaza_un_artefacto_con_el_arbol_vacio() { + let d = tempfile::tempdir().unwrap(); + let store = d.path().join("store"); + let root = d.path().join("qorpa"); + artefacto_con_imagen(&store, &"e".repeat(64), &"2".repeat(64), false); + let err = import(&root, &store, "eeeeeeee", true).unwrap_err().to_string(); + assert!(err.contains("vacío"), "mensaje inesperado: {err}"); + assert!(!root.join("images").join("2".repeat(64)).exists()); + } + #[test] fn create_nace_sin_ninguna_concesion() { let d = tempfile::tempdir().unwrap(); diff --git a/crates/hammer-core/src/recipe.rs b/crates/hammer-core/src/recipe.rs index ab11c70e..74e0512f 100644 --- a/crates/hammer-core/src/recipe.rs +++ b/crates/hammer-core/src/recipe.rs @@ -44,6 +44,23 @@ pub struct Recipe { /// `None` = deuda declarada, no «sin licencia». `scripts/licencias.sh` mide la cobertura. #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option, + /// La receta **sella bytes ajenos ya compilados**, no construye desde fuente (ADR 0015 D8). + /// + /// Hoy la usa una sola: el runtime *sniper* de Valve, que es la única imagen ajena que puede + /// entrar al store porque **no muta** — nadie le instala nada adentro, así que el mismo tarball + /// pineado da siempre el mismo árbol y sellarlo es una afirmación verdadera. Un rootfs mutable + /// (Arch, Ubuntu) no la cumple y por eso se queda fuera, en `/var/lib/hammer/qorpa`. + /// + /// **Lo que hace la marca es contable, no técnica.** El build no cambia en un solo byte: lo que + /// cambia es que `scripts/build-state.py` la clasifica como `ajeno`, la resta del denominador + /// de las imágenes y la deja fuera del recuento de recetas. Sin esto, sellar un prebuilt + /// inflaría la cifra que todo el mundo lee como «cuánto construimos» — el riesgo que el ADR + /// 0015 escribió antes de que existiera la primera instancia. + /// + /// **NO entra en `hash_inputs`**, igual que `license`: describe procedencia, no identidad del + /// build. Añadirla no mueve un solo `ArtifactHash`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub foreign: bool, /// Directorio base contra el que se resuelven rutas relativas de la receta /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío. #[serde(skip, default)] @@ -706,6 +723,19 @@ commit = "deadbeef" assert_eq!(r1.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), r2.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap()); } + /// `foreign` marca PROCEDENCIA, no identidad del build: una receta que sella bytes ajenos + /// produce exactamente el mismo artefacto con la marca puesta o quitada. Si entrara al hash, + /// marcar la primera receta ajena habría movido su `ArtifactHash` — y peor, habría invitado a + /// no marcarla para no re-sellar. La marca tiene que ser gratis para que se use. + #[test] + fn foreign_no_entra_al_hash() { + let sin = Recipe::from_toml(SAMPLE).unwrap(); + let con = Recipe::from_toml(&format!("foreign = true\n{SAMPLE}")).unwrap(); + assert!(con.foreign && !sin.foreign, "la marca no se parseó"); + let lab = crate::lab::LabFingerprint::for_tests(); + assert_eq!(sin.hash_inputs(&[], &lab).unwrap(), con.hash_inputs(&[], &lab).unwrap()); + } + #[test] fn hash_changes_when_patch_content_changes() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/20-catalogo-publicable-y-completa.md b/docs/20-catalogo-publicable-y-completa.md index 4bb5646d..fc0448fb 100644 --- a/docs/20-catalogo-publicable-y-completa.md +++ b/docs/20-catalogo-publicable-y-completa.md @@ -168,6 +168,17 @@ Lo que sí se hace, y alcanza: con Firefox: redistribuir un rootfs de Ubuntu sin modificar suele estar permitido, pero la política de marca es cosa aparte y **no es una pregunta técnica**. +**Novedad 2026-09-04, y no rompe nada de lo anterior: una de ellas YA está sellada al store.** El +runtime *sniper* de Valve entra por `recipes/steam-runtime-sniper.toml` porque **no muta** —nadie le +instala nada adentro—, así que el mismo tarball pineado da siempre el mismo árbol. Sigue **fuera** +del catálogo publicable y del reporte de licencias: su receta lleva `foreign = true`, el grafo la +clasifica `ajeno` y su `license` es un `LicenseRef-…-no-enumerable` explícito, que dice lo que hay +—cientos de paquetes Debian que no podemos enumerar— en vez de dejar el campo vacío, que se leería +como «todavía no la poblamos». Y la distinción del punto anterior sigue en pie con más filo: +**replicarla a nuestras propias máquinas** con `hammer mirror push` es lo mismo que ya hace el ADR +0013 con las fuentes; **publicarla a terceros** sigue pidiendo mirar licencia y marca antes, y eso +no es una pregunta técnica. + ⚠️ Y el corolario que hay que escribir donde se lea: **el claim «hammer reproduce bit a bit» hay que acotarlo** desde el día que exista una instancia — *el sistema base reproduce; las instancias ajenas no, y se declaran como tales*. Sin eso, la cultura de números honestos se erosiona sola. diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md index 1ea782b3..a979fb02 100644 --- a/docs/adr/0015-imagenes-ajenas.md +++ b/docs/adr/0015-imagenes-ajenas.md @@ -184,7 +184,7 @@ tira la capa y la deja igual — que es la prueba de esta sección entera. | tipo | qué es | mutable | ¿sellable? | para qué | |---|---|---|---|---| | **1** | rootfs completo con gestor (Fedora+`dnf`, Arch+`pacman`) | sí | no | «literalmente lo que sea» | -| **2** | runtime curado congelado (Steam Linux Runtime *sniper*, runtime freedesktop) | **no** | **sí**, `file_drop` por sha256 | cadena de custodia nuestra | +| **2** | runtime curado congelado (Steam Linux Runtime *sniper*, runtime freedesktop) | **no** | **sí**, receta `foreign` pineada por sha256 | cadena de custodia nuestra | | **3** | bundle por app (AppImage) | no | sí | una app suelta | | **4** | glibc en un prefijo, sin jaula | — | — | **rechazado**: frágil y ensucia justo lo que se protege | @@ -192,6 +192,13 @@ El tipo 2 es la F1 del plan de juegos y **sigue siendo el preferido cuando alcan se sella y su digest entra en el índice firmado. El tipo 1 existe porque `dnf install` es precisamente lo que el tipo 2 no permite. +**⚠ Corrección de vocabulario (2026-09-04).** Este ADR decía «entra al store por `file_drop`», y +`file_drop` en hammer es **otra cosa**: una operación de `hammer apply` que coloca un fichero en el +sistema instalado verificando su hash (`hammer-core::apply`). No tenía nada que ver con sellar. Lo +que sella de verdad es lo de siempre —**una receta**—, con una marca nueva: `foreign = true`. Se +deja escrito porque un término inventado que suena a mecanismo existente es peor que un hueco: manda +a buscar el código donde no está. + ### D5 — Transparencia: shims generados, no un sistema de ficheros El objetivo es el poder de Bedrock Linux —un espacio de nombres unificado, apps de cualquier @@ -302,7 +309,7 @@ imagen curada es una segunda cadena de suministro que hay que sostener (§NO-res |---|---|---|---| | **Arch bootstrap** | 1 | juegos: `multilib` de 32 bits de primera clase, y SteamOS *es* Arch ⇒ extiende el argumento de D6 («la configuración exacta que Valve prueba») | `archlinux-bootstrap-2026.09.01-x86_64.tar.zst`, en `archive.archlinux.org` (archivado para siempre) | | **Ubuntu base LTS** | 1 | binarios comerciales: Zoom, Slack, Discord, Spotify, Chrome, JetBrains se compilan y prueban contra esto | `ubuntu-base-24.04.3-base-amd64.tar.gz`, `cdimage.ubuntu.com` + `old-releases` | -| **Steam Runtime «sniper»** | 2 | la única **sellable**: al no mutar entra al store por `file_drop` y su digest va al índice firmado — cadena de custodia nuestra | ✅ **verificado 2026-09-03**: snapshot `3.0.20260805.254768` (el que despliega el cliente, no «el último»), imagen y depot | +| **Steam Runtime «sniper»** | 2 | la única **sellable**: al no mutar, su árbol entra al store y su digest va al índice firmado — cadena de custodia nuestra | ✅ **SELLADA 2026-09-04** por `recipes/steam-runtime-sniper.toml`; snapshot `3.0.20260805.254768`, el que despliega el cliente y no «el último» | **Los digests enteros están en [`docs/state/qorpa-imagenes.toml`](../state/qorpa-imagenes.toml), no acá.** Estaban acá, abreviados —`895661bd…`— y cuando la poda se llevó las imágenes del disco no @@ -312,6 +319,31 @@ salió cada uno y **si esa lista viene firmada**, que es la única forma honesta cadena de suministro ajena: Ubuntu firma su `SHA256SUMS`, Arch no firma la lista pero sí el tarball, y **Valve no firma nada** — su garantía entera es TLS más el pin. +**Cómo se sella, y la marca que evita que la cifra mienta (2026-09-04).** +`recipes/steam-runtime-sniper.toml` es una receta normal salvo en una cosa: no construye nada, sella +bytes ajenos ya compilados. Por eso lleva **`foreign = true`**, un campo nuevo que **no entra en +`hash_inputs`** —describe procedencia, no identidad, así que no movió un solo `ArtifactHash`— y cuyo +efecto es **contable**: `build-state.py` la clasifica `ajeno`, la resta del denominador de las +imágenes y la deja fuera del recuento de recetas. Sin esa marca, sellar un prebuilt habría subido la +cifra que todo el mundo lee como «cuánto construimos» — el riesgo que este ADR se puso por escrito +antes de que existiera la primera instancia. El grafo lo dice con nombre y apellido: +`de las ajenas, 1 selladas al store (prebuilt pineado, sin procedencia de fuente)`. + +**Y una diferencia con el otro tipo de ajeno:** el declarado en `qorpa-ajenos.toml` (`xwayland`) no +se hashea, porque no hay receta y un hash afirmaría que lo reproducimos. El sellado **sí conserva su +hash**: está en el store, y que un artefacto exista mientras el grafo lo niega sería otra forma de +mentir. Lo que comparten es el estado —`ajeno`, fuera del corpus—, que es lo que protege la cifra. + +**Se consume con `hammer qorpa import --from-store `**, y ahí está el detalle que hace que +todo esto valga: la imagen se registra bajo el **sha256 del archivo de upstream** que el artefacto +declara, **no** bajo su `ArtifactHash`. Si se registrara por `ArtifactHash`, la imagen importada del +store y la traída con `pull` serían dos imágenes distintas con los mismos bytes y las instancias de +dos máquinas dejarían de coincidir — justo lo que el pin existe para evitar. El árbol se **enlaza** +en vez de copiarse: una imagen nunca se escribe (lo que escribe la instancia va a su `upper`), así +que compartir inodos con un artefacto sellado y de sólo lectura es correcto por construcción y hace +que la imagen cueste ~0 bytes. La contracara ya conocida: mientras el artefacto siga en el store, +borrar la imagen no libera disco — `--copy` lo evita. + **Del sniper se pinean dos cosas, y la segunda es la que destraba el paso 6.** La imagen (`Platform-…-runtime.tar.gz`, un rootfs) y el **depot** `SteamLinuxRuntime_sniper.tar.xz`, que trae pressure-vessel más la imagen y es lo que Steam despliega en `steamapps/common/`. Ese runtime diff --git a/recipes/steam-runtime-sniper.toml b/recipes/steam-runtime-sniper.toml new file mode 100644 index 00000000..a98e15b7 --- /dev/null +++ b/recipes/steam-runtime-sniper.toml @@ -0,0 +1,90 @@ +# steam-runtime-sniper — el runtime en el que Valve corre los juegos, SELLADO al store. +# ADR 0015 (qorpa) D8: es la única imagen ajena de la terna que puede entrar acá, y esta receta +# es lo que hace cierto ese renglón. +# +# ══ ESTO NO ES UNA RECETA NORMAL, Y LA DIFERENCIA IMPORTA ══════════════════════════════════════ +# No construye nada: **sella bytes ajenos ya compilados**. Por eso lleva `foreign = true`, que no +# cambia el build en nada y sí cambia cómo la cuenta el grafo — pasa a clase `ajeno`, se resta del +# denominador de las imágenes y NO engorda «cuántas recetas construimos». Sin esa marca, sellar un +# prebuilt inflaría la cifra que todo el mundo lee como corpus, que es exactamente el riesgo que el +# ADR 0015 se puso por escrito para no cometer. +# +# ══ POR QUÉ ESTA SÍ Y LAS OTRAS DOS NO ═════════════════════════════════════════════════════════ +# La invariante D1 dice que las imágenes ajenas NO entran al store, y el motivo es que un rootfs +# mutable —Arch, Ubuntu, al que le instalás cosas— no reproduce: meterlo haría que el store mienta. +# Sniper es distinto por una propiedad, no por simpatía: **no muta**. Nadie le instala nada adentro; +# pressure-vessel lo monta de sólo lectura. Así que el mismo tarball pineado produce siempre el +# mismo árbol, y sellarlo es una afirmación verdadera. +# +# ══ QUÉ SE GANA SELLÁNDOLO ═════════════════════════════════════════════════════════════════════ +# Cadena de custodia NUESTRA: el artefacto se replica con `hammer mirror push`, su digest puede ir +# al índice firmado (ADR 0014) y una instancia se arma sin depender de que la CDN de Valve siga +# viva. Se consume con: +# +# hammer qorpa import --from-store # ⇒ images//{image.toml,rootfs} +# +# La identidad de la imagen sigue siendo **el sha256 del ARCHIVO de Valve**, no el ArtifactHash: es +# lo que hace que dos máquinas coincidan aunque una lo traiga del store y la otra de upstream. Por +# eso el `image.toml` viaja DENTRO del artefacto, escrito acá y no adivinado por el importador. +# +# ══ POR QUÉ EL ÁRBOL VA A /usr/share/qorpa Y NO A LA RAÍZ ══════════════════════════════════════ +# Un rootfs glibc entero volcado en `/` pisaría el sistema; y aunque la tentación sea dejarlo en la +# raíz del artefacto para que `hydrate` lo escupa con la forma exacta de una imagen qorpa, un +# artefacto tiene que ser inofensivo si alguien lo compone en un rootfs por error. Namespaceado no +# colisiona con nada. **Esta receta NO va en ningún perfil de `targets.toml`**: no es parte de +# ninguna imagen nuestra, es carga para qorpa. +# +# ══ LICENCIA ══════════════════════════════════════════════════════════════════════════════════ +# `LicenseRef-…-no-enumerable` a propósito: adentro hay cientos de paquetes Debian con sus términos +# y no podemos enumerarlos. Poner "MIT" o dejarlo vacío sería peor — vacío se lee como «todavía no +# la poblamos». SDD 20 ya dice que las ajenas no entran al reporte de licencias; esto lo hace +# visible desde la receta. +name = "steam-runtime-sniper" +version = "3.0.20260805.254768" +license = "LicenseRef-qorpa-ajena-no-enumerable" +foreign = true + +[source] +# El snapshot NO es «el último»: es el que `latest-container-runtime-depot.txt` dice que despliega +# el cliente de Steam. Pinear otro sería pinear algo que nadie corre. Digest verificado contra el +# SHA256SUMS del propio snapshot el 2026-09-03 (ver docs/state/qorpa-imagenes.toml). +tarball = "https://repo.steampowered.com/steamrt-images-sniper/snapshots/3.0.20260805.254768/com.valvesoftware.SteamRuntime.Platform-amd64%2Ci386-sniper-runtime.tar.gz" +sha256 = "1a293c312215c59e4e5348ce210594cd7967d1a1fcaec6248a024e0878776f2b" +# CERO, no el 1 de siempre: el archivo trae DOS entradas arriba —`files/` (el rootfs) y `metadata` +# (la ficha de build de Valve)—. Con strip 1 el rootfs se fundiría con su hermano y el árbol +# quedaría mal anclado, que es el fallo que no rompe al traerlo sino después, cuando la instancia +# no encuentra su loader. +strip_components = 0 + +[build] +compiler = "zig-cc" +target = "x86_64-linux-musl" +link = "dynamic" + +[build.phases] +configure = "true" +compile = "true" +# `cp -a` y no `cp -r`: hay que preservar symlinks, modos y tiempos o el rootfs deja de ser un +# rootfs. `metadata` se deja fuera igual que hace `hammer qorpa pull`, para que el árbol sellado y +# el traído de upstream sean el MISMO árbol y no dos primos. +install = ''' +set -e +D=/out/usr/share/qorpa/images/steam-runtime-sniper +mkdir -p "$D" +cp -a files "$D/rootfs" +test -x "$D/rootfs/usr/bin/env" || { echo "el árbol no parece un rootfs" >&2; exit 1; } +cat > "$D/image.toml" <<'EOF' +# Lo sella `recipes/steam-runtime-sniper.toml` (ADR 0015 D8) y lo consume `hammer qorpa import`. +# La identidad de la imagen es el sha256 del ARCHIVO de Valve, no el ArtifactHash de este +# artefacto: es lo que hace que coincida con la que otra máquina traiga de upstream. +sha256 = "1a293c312215c59e4e5348ce210594cd7967d1a1fcaec6248a024e0878776f2b" +url = "https://repo.steampowered.com/steamrt-images-sniper/snapshots/3.0.20260805.254768/com.valvesoftware.SteamRuntime.Platform-amd64%2Ci386-sniper-runtime.tar.gz" +label = "sniper-platform-3.0.20260805.254768" +archive_bytes = 302423522 +rootfs_subdir = "files" +dropped_siblings = ["metadata"] +EOF +''' + +[deps] +build = [] diff --git a/scripts/build-state.py b/scripts/build-state.py index af16b42e..af33caf8 100755 --- a/scripts/build-state.py +++ b/scripts/build-state.py @@ -127,6 +127,12 @@ def load_recipes(): deps=d.get("deps", {}).get("build", []), cls=classify(n, d), queue=queue, + # `foreign = true`: la receta SELLA bytes ajenos ya compilados, no construye desde + # fuente (ADR 0015 D8; hoy sólo `steam-runtime-sniper`). Se lee acá para que la + # clasificación de abajo la saque del corpus: un prebuilt sellado no puede sumar a + # «cuántas recetas construimos» — es la cifra que el ADR pidió proteger antes de + # que existiera la primera instancia. + foreign=bool(d.get("foreign")), ) return recs @@ -306,6 +312,15 @@ def main(): if dep not in recs: orphans.add(dep) + # Una receta `foreign` es AJENA aunque tenga fichero: hay bytes que sellamos y no construimos. + # Se marca ANTES del recuento para que no entre en el corpus por la puerta de atrás. Y no es lo + # mismo que un ajeno declarado en `qorpa-ajenos.toml`: aquél NO está en el store y éste SÍ, así + # que conserva su hash. Que un artefacto exista y el grafo lo niegue sería otra forma de mentir. + for r in recs.values(): + if r.get("foreign"): + r["ajeno"] = True + r["cls"] = "ajeno" + n_recetas = sum(1 for r in recs.values() if not r.get("wanted") and not r.get("ajeno")) print(f"== {n_recetas} recetas; computando hash vigente (~{n_recetas*18//1000}s)…", file=sys.stderr) for n, r in recs.items(): @@ -315,7 +330,13 @@ def main(): # `ajeno` tampoco, y por una razón más fuerte: no tiene procedencia de fuente. Un hash # afirmaría que lo reproducimos, y no lo reproducimos. if r.get("ajeno"): - r["hash"] = None; r["state"] = "ajeno"; continue + # El ajeno DECLARADO no se hashea (no hay receta que hashear, y un hash afirmaría que + # lo reproducimos). El ajeno SELLADO sí: es un puntero al contenido exacto, y sin él no + # se lo podría encontrar en el store ni pedir por `qorpa import`. Lo que no cambia en + # ninguno de los dos casos es el estado: `ajeno`, fuera del corpus. + r["hash"] = vigente_hash(r["path"]) if r.get("foreign") else None + r["state"] = "ajeno" + continue if "parse_error" in r: r["hash"] = None; r["state"] = "unhashable"; continue h = vigente_hash(r["path"]) @@ -397,6 +418,8 @@ def main(): sin_perfil=sin_perfil, orphan_deps=sorted(orphans), topo_ok=order is not None, + # `foreign` SÍ viaja al nodo (a diferencia de `ajeno`, que es derivado): es lo que le dice + # a quien lee el grafo que ese `sealed` de ahí son bytes de otro, no un build nuestro. nodes={n: {k: v for k, v in r.items() if k not in ("path", "wanted", "ajeno")} for n, r in sorted(recs.items())}, ) @@ -414,6 +437,12 @@ def main(): # sembrado, es la señal de que un manifiesto afirma algo que ya no existe en ningún lado. print(f" de los sellados, {len(REMOTO_ONLY)} NO están en el disco de esta máquina " f"(los avalan los manifiestos)") + sellados_ajenos = [n for n, r in recs.items() if r.get("foreign") and r.get("hash")] + if sellados_ajenos: + # Se dice aparte y con nombre: un prebuilt ajeno en el store es una excepción que hay que + # poder auditar de un vistazo, no un renglón más del corpus. + print(f" de las ajenas, {len(sellados_ajenos)} selladas al store (prebuilt pineado, " + f"sin procedencia de fuente): {', '.join(sorted(sellados_ajenos))}") if debt_by_cls: print(" deuda por clase:", " ".join(f"{k}={v}" for k, v in sorted(debt_by_cls.items()))) if len(by_queue) > 1: