diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index f7c8e224..175620d6 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -707,16 +707,48 @@ pub fn product( recipes_dir: &Path, store: &Store, ) -> Result { - // 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 { + // 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 { 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> { + 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 { + 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 diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 595a6b48..310ac9d1 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -517,6 +517,15 @@ enum BootstrapCmd { /// determinista (firmas reproducibles). Sólo con `--attest`. #[arg(long)] rootkey: Option, + /// [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, + /// Directorio de claves de confianza para verificar la firma del release del `--from-repo`. + #[arg(long)] + trust: Option, }, /// [Stage 2] Ancla el content-hash del rootfs de Stage 1 como referencia de reproducibilidad /// (anota la línea del manifiesto). Con `--verify ` 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 (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)?;