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();
|
||||
|
||||
Reference in New Issue
Block a user