Etapa F: cierra el hilo userland — el producto arma su userland desde el repo FIRMADO
Hasta hoy el bootstrap del producto hidrataba el userland desde recetas locales hardcodeadas (build_components sobre USERLAND_COMPONENTS/SERVICE_COMPONENTS). Cierre del dogfood: la IMAGEN ahora puede armar su userland por la CADENA DE SUMINISTRO de paquetes — verifica la firma del release y REPRODUCE cada componente desde su .swm. hammer-bootstrap: - product_from_repo(base, repo, trust, ...): igual que product() pero el userland + servicios salen de install_components_from_repo en vez de build_components. - install_components_from_repo: verifica firma del release (exige TRUSTED), por cada componente resuelve el cierre de deps + puebla el catalogo dep.toml + reproduce el source_patch con build_source_patch (chequea el expected_hash anclado). Devuelve los mismos (nombre,hash) que build_components. - seal_product_rootfs: pasos 2-3 comunes extraidos (seed+hash+ensamblado+sellado). hammer-cli: bootstrap product --from-repo DIR --trust DIR. PROPIEDAD CLAVE VERIFICADA E2E: como el hash es por-CONTENIDO, reproducir desde el .swm da los mismos (nombre,hash) que construir la receta -> el product-rootfs via repo firmado es BIT-IDENTICO al hardcodeado (ambas vias -> ba351f1b). Firma del release verificada (TRUSTED by release) antes de tocar nada. "Verificar, no confiar" aplicado al propio ensamblado de la imagen. 40 tests bootstrap verde. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -707,16 +707,48 @@ pub fn product(
|
||||
recipes_dir: &Path,
|
||||
store: &Store,
|
||||
) -> Result<RootfsHash> {
|
||||
// 1) construir/cachear el userland Rust + los servicios.
|
||||
// 1) construir/cachear el userland Rust + los servicios DESDE LAS RECETAS LOCALES.
|
||||
let userland = build_components(USERLAND_COMPONENTS, recipes_dir, base_cfg, store)?;
|
||||
let services = build_components(SERVICE_COMPONENTS, recipes_dir, base_cfg, store)?;
|
||||
// 2-3) ensamblar + sellar.
|
||||
seal_product_rootfs(base, &userland, &services, store)
|
||||
}
|
||||
|
||||
// 2) seed de producto + hash de identidad.
|
||||
/// **Producto desde un REPO FIRMADO** — cierra el dogfood de [[etapa-f]]: la imagen de producto arma
|
||||
/// su userland vía la cadena de suministro de paquetes (no recetas locales hardcodeadas). Verifica la
|
||||
/// firma del release, REPRODUCE cada componente desde su `.swm` (`build_source_patch` chequea el
|
||||
/// `expected_hash` anclado) y lo hidrata. Como el hash es por-CONTENIDO, los `(nombre,hash)` que
|
||||
/// reproduce desde el repo son IDÉNTICOS a los que `build_components` construye de la receta ⇒ el
|
||||
/// `product-rootfs` resultante es BIT-IDÉNTICO al de [`product`]: la prueba de que el dogfood es fiel
|
||||
/// ("verificar, no confiar" aplicado al propio ensamblado del producto). El núcleo sigue blindado.
|
||||
pub fn product_from_repo(
|
||||
base: &RootfsHash,
|
||||
repo_dir: &Path,
|
||||
trust_dir: &Path,
|
||||
base_cfg: &hammer_build::BuildConfig,
|
||||
store: &Store,
|
||||
) -> Result<RootfsHash> {
|
||||
// 1) userland + servicios REPRODUCIDOS desde el repo firmado (mismo shape que build_components).
|
||||
let userland = install_components_from_repo(USERLAND_COMPONENTS, repo_dir, trust_dir, base_cfg, store)?;
|
||||
let services = install_components_from_repo(SERVICE_COMPONENTS, repo_dir, trust_dir, base_cfg, store)?;
|
||||
// 2-3) ensamblar + sellar (idéntico a `product`).
|
||||
seal_product_rootfs(base, &userland, &services, store)
|
||||
}
|
||||
|
||||
/// Pasos 2-3 comunes a [`product`] y [`product_from_repo`]: seed de producto + hash de identidad +
|
||||
/// ensamblado + sellado idempotente. Sólo varía la PROCEDENCIA de los `(nombre,hash)` (recetas
|
||||
/// locales vs repo firmado); el árbol sellado es función de ellos ⇒ ambas vetas convergen al mismo
|
||||
/// `product-rootfs` si los hashes coinciden (lo hacen: el hash es por-contenido).
|
||||
fn seal_product_rootfs(
|
||||
base: &RootfsHash,
|
||||
userland: &[(String, ArtifactHash)],
|
||||
services: &[(String, ArtifactHash)],
|
||||
store: &Store,
|
||||
) -> Result<RootfsHash> {
|
||||
let seed = product_seed_card()?;
|
||||
let phash = product_rootfs_hash(base, &userland, &services, &seed);
|
||||
let phash = product_rootfs_hash(base, userland, services, &seed);
|
||||
let store_name = "product-rootfs";
|
||||
|
||||
// 3) ensamblar + sellar (si no estaba ya).
|
||||
if !store.has(&phash, store_name) {
|
||||
let staging = store
|
||||
.root()
|
||||
@@ -725,7 +757,7 @@ pub fn product(
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
std::fs::create_dir_all(&staging)?;
|
||||
let result = (|| -> Result<()> {
|
||||
assemble_product_rootfs(store, base, &userland, &services, &seed, &staging)?;
|
||||
assemble_product_rootfs(store, base, userland, services, &seed, &staging)?;
|
||||
store.seal(&staging, &phash, store_name)?;
|
||||
Ok(())
|
||||
})();
|
||||
@@ -738,6 +770,87 @@ pub fn product(
|
||||
Ok(phash)
|
||||
}
|
||||
|
||||
/// Reproduce un conjunto de componentes DESDE UN REPO FIRMADO, devolviendo sus `(nombre,hash)` sellados
|
||||
/// — el MISMO shape que [`build_components`], así [`assemble_product_rootfs`] los hidrata igual. Por cada
|
||||
/// componente: resuelve su cierre de deps del índice, puebla el catálogo con los `{dep}.toml` (para que
|
||||
/// el lab materialice build-deps al reproducir, igual que `hammer install`), y REPRODUCE su `source_patch`
|
||||
/// con `build_source_patch` (que sella y CHEQUEA el `expected_hash` anclado en el índice). Verifica la
|
||||
/// firma del release ANTES de tocar nada: el producto NO se ensambla desde un repo no confiado.
|
||||
pub fn install_components_from_repo(
|
||||
names: &[&str],
|
||||
repo_dir: &Path,
|
||||
trust_dir: &Path,
|
||||
cfg: &hammer_build::BuildConfig,
|
||||
store: &Store,
|
||||
) -> Result<Vec<(String, ArtifactHash)>> {
|
||||
use hammer_core::{RepoIndex, SigStatus, TrustStore};
|
||||
|
||||
let index = RepoIndex::load(repo_dir)?;
|
||||
// Firma del release: exigimos TRUSTED. Un release no firmado / con clave desconocida / con firma
|
||||
// mala NO ensambla un producto (la cadena de suministro debe ser confiable y no manipulada).
|
||||
let ts = TrustStore::load(trust_dir)?;
|
||||
match index.verify_signature(&ts) {
|
||||
SigStatus::Trusted { by } => {
|
||||
tracing::info!(%by, "product: release del repo firmado y TRUSTED");
|
||||
}
|
||||
other => {
|
||||
return Err(Error::Other(format!(
|
||||
"el producto exige un repo firmado por clave confiada en {}; estado: {other:?}",
|
||||
trust_dir.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(cfg, None);
|
||||
std::fs::create_dir_all(&catalog_dir)?;
|
||||
|
||||
let mut out = Vec::with_capacity(names.len());
|
||||
for name in names {
|
||||
let entry = index.find(name).ok_or_else(|| {
|
||||
Error::Other(format!("'{name}' no está en el repo firmado {}", repo_dir.display()))
|
||||
})?;
|
||||
// Poblar el catálogo con las DEPS del cierre (deps antes del target), igual que `install`:
|
||||
// el lab resuelve build-deps por nombre leyendo `{dep}.toml` del catalog_dir.
|
||||
let closure = index.resolve_closure(name)?;
|
||||
for dep in closure.iter().filter(|p| p.name != *name) {
|
||||
let dep_swm = load_swm_file(&RepoIndex::swm_path(repo_dir, dep))?;
|
||||
let mutation = source_patch_of(&dep_swm).ok_or_else(|| {
|
||||
Error::Other(format!("la dep '{}' no es un paquete source_patch", dep.name))
|
||||
})?;
|
||||
let dep_recipe = hammer_build::swm_bridge::recipe_from_source_patch(
|
||||
mutation,
|
||||
Some(&dep.name),
|
||||
&catalog_dir,
|
||||
)?;
|
||||
std::fs::write(catalog_dir.join(format!("{}.toml", dep.name)), dep_recipe.to_toml()?)?;
|
||||
}
|
||||
// Reproducir el target: build_source_patch sella + chequea el expected_hash del índice.
|
||||
let swm = load_swm_file(&RepoIndex::swm_path(repo_dir, entry))?;
|
||||
let mutation = source_patch_of(&swm).ok_or_else(|| {
|
||||
Error::Other(format!("'{name}' no es un paquete source_patch"))
|
||||
})?;
|
||||
let hash = hammer_build::swm_bridge::build_source_patch(mutation, cfg, store, None)?;
|
||||
tracing::info!(%name, %hash, "product: componente reproducido desde el repo firmado");
|
||||
out.push((name.to_string(), hash));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Lee un `.swm` (YAML) de disco a `Swm`. Equivalente al helper homónimo de la CLI (privado allí).
|
||||
fn load_swm_file(path: &Path) -> Result<hammer_core::Swm> {
|
||||
let yaml = std::fs::read_to_string(path)
|
||||
.map_err(|e| Error::Other(format!("no pude leer {}: {e}", path.display())))?;
|
||||
hammer_core::Swm::from_yaml(&yaml)
|
||||
.map_err(|e| Error::Other(format!("no pude parsear {}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Primera mutación `SourcePatch` de un `.swm` (un paquete hammer es un único source_patch).
|
||||
fn source_patch_of(swm: &hammer_core::Swm) -> Option<&hammer_core::Mutation> {
|
||||
swm.mutations
|
||||
.iter()
|
||||
.find(|m| matches!(m, hammer_core::Mutation::SourcePatch { .. }))
|
||||
}
|
||||
|
||||
// ── Producto ATESTADO: la capa FIRMADA que el gate de arje verifica al boot (I4 / Etapa D) ───────
|
||||
//
|
||||
// `product()` sella el `product-rootfs` con el arje-zero del núcleo + un `/ente/attest.json` de
|
||||
|
||||
@@ -517,6 +517,15 @@ enum BootstrapCmd {
|
||||
/// determinista (firmas reproducibles). Sólo con `--attest`.
|
||||
#[arg(long)]
|
||||
rootkey: Option<String>,
|
||||
/// [Dogfood] Arma el userland REPRODUCIÉNDOLO desde un REPO FIRMADO (dir local) en vez de las
|
||||
/// recetas locales: verifica la firma del release y reconstruye cada paquete desde su `.swm`
|
||||
/// comparando el `expected_hash`. El `product-rootfs` sale bit-idéntico al de la vía local (hash
|
||||
/// por-contenido). Requiere `--trust`. La cadena de suministro de la propia imagen.
|
||||
#[arg(long)]
|
||||
from_repo: Option<String>,
|
||||
/// Directorio de claves de confianza para verificar la firma del release del `--from-repo`.
|
||||
#[arg(long)]
|
||||
trust: Option<String>,
|
||||
},
|
||||
/// [Stage 2] Ancla el content-hash del rootfs de Stage 1 como referencia de reproducibilidad
|
||||
/// (anota la línea del manifiesto). Con `--verify <hash>` compara contra un rebuild `stage1'`
|
||||
@@ -906,14 +915,31 @@ fn main() -> anyhow::Result<()> {
|
||||
let hash = hammer_bootstrap::stage1(&spec, &base_cfg, &store)?;
|
||||
println!("{hash}");
|
||||
}
|
||||
BootstrapCmd::Product { rootfs, recipes, attest, policy, rootkey } => {
|
||||
BootstrapCmd::Product { rootfs, recipes, attest, policy, rootkey, from_repo, trust } => {
|
||||
let store = hammer_core::Store::open(&cli.store)?;
|
||||
// El zig del lab (no la semilla): los servicios usan su `zig_version` propia
|
||||
// (openssh ⇒ 0.13.0, hermano del default), y si ya están sellados, salen cacheados.
|
||||
let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
||||
let base = hammer_core::ArtifactHash::from_hex(rootfs.trim_start_matches("b3:"));
|
||||
let recipes_dir = std::path::Path::new(&recipes);
|
||||
let hash = if attest {
|
||||
let hash = if let Some(repo) = from_repo {
|
||||
// [Dogfood] userland desde el repo FIRMADO (la imagen arma su userland por la cadena
|
||||
// de suministro de paquetes, no por recetas locales). --attest se combina como capa
|
||||
// posterior; acá producimos el product-rootfs base reproducido del repo.
|
||||
if attest {
|
||||
anyhow::bail!("--from-repo y --attest aún no se combinan; producí el base con --from-repo y atestá aparte");
|
||||
}
|
||||
let trust_dir = trust.ok_or_else(|| {
|
||||
anyhow::anyhow!("--from-repo requiere --trust <dir> (verificar la firma del release)")
|
||||
})?;
|
||||
hammer_bootstrap::product_from_repo(
|
||||
&base,
|
||||
std::path::Path::new(&repo),
|
||||
std::path::Path::new(&trust_dir),
|
||||
&base_cfg,
|
||||
&store,
|
||||
)?
|
||||
} else if attest {
|
||||
let mut cfg = hammer_bootstrap::AttestConfig { policy, ..Default::default() };
|
||||
if let Some(path) = rootkey {
|
||||
let bytes = std::fs::read(&path)?;
|
||||
|
||||
Reference in New Issue
Block a user