qorpa D8: sniper sellado al store, con la marca que evita que la cifra mienta
D8 decía que sniper «entra al store por `file_drop`». Dos correcciones, y la primera es de vocabulario: **`file_drop` en hammer es otra cosa** — una operación de `hammer apply` que coloca un fichero en el sistema instalado verificando su hash. No tenía nada que ver con sellar. Lo que sella es lo de siempre, una receta. Queda escrito en el ADR: un término inventado que suena a mecanismo existente manda a buscar el código donde no está. `recipes/steam-runtime-sniper.toml` sella el árbol del runtime (11196 ficheros) pineado por el sha256 que ya estaba verificado. Entra donde Arch y Ubuntu no pueden por una propiedad, no por simpatía: **no muta** —nadie le instala nada adentro— así que el mismo tarball da siempre el mismo árbol y sellarlo es una afirmación verdadera. **La marca: `foreign = true`.** No cambia el build en un byte y **no entra en `hash_inputs`** (describe procedencia, no identidad — hay test). Lo que cambia 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 eso, sellar un prebuilt habría subido la cifra que todo el mundo lee como «cuánto construimos» — el riesgo que el ADR escribió antes de que existiera la primera instancia. Verificado: sigue diciendo 821 recetas, y aparte `de las ajenas, 1 selladas al store (prebuilt pineado, sin procedencia de fuente)`. Y la diferencia con el otro ajeno: `xwayland` no se hashea (no hay receta, y un hash afirmaría que lo reproducimos); el sellado **sí conserva su hash**, porque está en el store y que un artefacto exista mientras el grafo lo niega sería otra forma de mentir. Comparten el estado, que es lo que protege la cifra. **`hammer qorpa import --from-store <hash>`** lo consume, y ahí está el detalle que hace que valga: la imagen se registra bajo el **sha256 del archivo de upstream**, no bajo el ArtifactHash. 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. El árbol se **enlaza**: 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 la imagen cuesta ~0 bytes. La contracara conocida de `.dmerge`: mientras el artefacto siga en el store, borrar la imagen no libera disco; `--copy` lo evita. Licencia `LicenseRef-qorpa-ajena-no-enumerable` a propósito: adentro hay cientos de paquetes Debian y no podemos enumerarlos; vacío se leería como «todavía no la poblamos». SDD 20 lo recoge y afila la distinción: replicarla a nuestras máquinas es lo que ya hace ADR 0013 con las fuentes; publicarla a terceros sigue pidiendo licencia y marca. 29 tests verdes. El sellado en sí corre aparte, esperando el lock de la granja. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -143,6 +143,25 @@ pub enum QorpaCmd {
|
||||
#[arg(long)]
|
||||
root: Option<PathBuf>,
|
||||
},
|
||||
/// [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<PathBuf>,
|
||||
},
|
||||
/// [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<PathBuf> = 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<bool> {
|
||||
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();
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <hash>`**, 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
|
||||
|
||||
@@ -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 <hash> # ⇒ images/<sha256 de Valve>/{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 = []
|
||||
+30
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user