Etapa F paquetería #3: deps entre paquetes — install resuelve el cierre y reconstruye el catálogo

Cierra el hueco que pack/install advertían: un paquete con build-deps (bwrap→libcap,
openssh→zlib,openssl) ahora se instala por nombre reproduciéndose desde fuente CON sus deps.

Modelo: las build-deps viajan por NOMBRE en el source_patch y en la PackageEntry; install
resuelve el cierre transitivo desde el índice y reconstruye un catálogo de recetas que el lab
consulta al materializar deps en el sandbox.

- hammer-core: `Mutation::SourcePatch.deps` (Deps, serde-skip si vacío) + `from_recipe` lo
  carga. `Deps::is_empty`. `RepoIndex`/`PackageEntry.deps` + `resolve_closure(name)` (DFS
  topológico, deps antes que dependientes, detecta dep faltante y ciclo). 8 tests nuevos.
- hammer-build/swm_bridge: refactor — `recipe_from_source_patch` (síntesis pública, setea deps
  + base_dir=catálogo) + `catalog_dir_for` (dir determinista compartido). build_source_patch
  lo reusa. synthesize_recipe ahora setea recipe.deps + base_dir al catálogo (no "/").
- hammer-cli: pack puebla PackageEntry.deps; install resuelve el cierre y escribe un {dep}.toml
  por dep en el catalog_dir ANTES de aplicar el target (mismo dir determinista que usa
  build_source_patch ⇒ el lab resuelve {dep}.toml por nombre). Warning de pack actualizado.
- VALIDADO E2E REAL contra ./store: `install bwrap` resuelve libcap del catálogo y reproduce
  el artefacto CACHEADO EXACTO (b3:f89e716…) → hidrata bwrap (1.8MB ELF). El paquete con dep
  hashea bit-idéntico al original. Resolución/diamante/faltante/ciclo unit-tested. 31 suites verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 06:43:30 -04:00
co-authored by Claude Opus 4.8
parent e867bac388
commit b89e2dba51
9 changed files with 308 additions and 87 deletions
+99 -70
View File
@@ -27,77 +27,15 @@ pub fn build_source_patch(
store: &Store,
scratch_root: Option<&Path>,
) -> hammer_core::Result<ArtifactHash> {
let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, expected) =
match mutation {
Mutation::SourcePatch {
repo,
commit,
tarball,
sha256,
strip_components,
patch,
patch_url,
build,
target_bin,
expected_hash,
} => (
repo, commit, tarball, sha256, strip_components, patch, patch_url, build, target_bin,
expected_hash,
),
_ => {
return Err(hammer_core::Error::Recipe(
"build_source_patch: la mutación no es 'source_patch'".into(),
));
}
};
let catalog_dir = catalog_dir_for(cfg, scratch_root);
let recipe = recipe_from_source_patch(mutation, None, &catalog_dir)?;
// git xor tarball — misma regla que el schema (`swm_source_kind`).
let source_kind = hammer_core::swm::swm_source_kind(
repo.as_deref(),
commit.as_deref(),
tarball.as_deref(),
sha256.as_deref(),
)
.map_err(hammer_core::Error::Recipe)?;
let scratch = scratch_root.unwrap_or(&cfg.work_root).to_path_buf();
let recipe_dir = scratch.join("swm-recipes");
std::fs::create_dir_all(&recipe_dir)?;
// Clave estable del origen para nombrar artefactos a disco: el commit (git) o el
// sha256 (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista.
let source_key: &str = match &source_kind {
hammer_core::SourceKind::Git { commit, .. } => commit,
hammer_core::SourceKind::Tarball { sha256, .. } => sha256,
let expected = match mutation {
Mutation::SourcePatch { expected_hash, .. } => expected_hash.clone(),
_ => None,
};
// Materializamos el patch a disco bajo un nombre derivado de la clave de origen (estable:
// si el .swm se reaplica, reusamos el mismo archivo y el hash de la receta no depende de
// aleatoriedad). Origen: inline (`patch`) o remoto (`patch_url`); el schema garantiza que
// no vengan ambos.
let mut patches: Vec<String> = Vec::new();
let patch_path = recipe_dir.join(format!("{source_key}.patch"));
if let Some(text) = patch {
std::fs::write(&patch_path, text.as_bytes())?;
patches.push(patch_path.to_string_lossy().into_owned());
} else if let Some(url) = patch_url {
tracing::info!(%url, "fetch: descargando patch remoto");
crate::download::fetch_url_to_file(url, &patch_path)?;
patches.push(patch_path.to_string_lossy().into_owned());
}
let name = derive_name(target_bin);
let recipe = synthesize_recipe(
&name,
&source_kind,
build_cfg,
*strip_components,
patches,
&recipe_dir,
)?;
let hash = build(&recipe, cfg, store)?;
if let Some(want) = expected {
if let Some(want) = &expected {
let want_hex = want.strip_prefix("b3:").unwrap_or(want);
let got_hex = hash
.as_str()
@@ -112,6 +50,85 @@ pub fn build_source_patch(
Ok(hash)
}
/// El directorio-catálogo donde se materializan los patches y los `{dep}.toml`. Determinista a
/// partir de `cfg.work_root` (o `scratch_root`): por eso `install` puede pre-poblarlo con los
/// `{dep}.toml` y `build_source_patch` los encontrará al resolver build-deps del mismo `base_dir`.
pub fn catalog_dir_for(cfg: &BuildConfig, scratch_root: Option<&Path>) -> PathBuf {
scratch_root.unwrap_or(&cfg.work_root).join("swm-recipes")
}
/// Sintetiza (SIN construir) la `Recipe` efímera de un `source_patch`: parsea el origen,
/// materializa el patch inline/remoto a disco bajo `catalog_dir`, y arma la receta con sus
/// `deps` y `base_dir = catalog_dir` (para que las build-deps resuelvan `{dep}.toml` ahí).
///
/// `name` sobreescribe el nombre derivado del `target_bin`. Es OBLIGATORIO al escribir un
/// `{dep}.toml`: el nombre de la receta DEBE ser el de la dep para que el dependiente la resuelva.
pub fn recipe_from_source_patch(
mutation: &Mutation,
name: Option<&str>,
catalog_dir: &Path,
) -> hammer_core::Result<Recipe> {
let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps) =
match mutation {
Mutation::SourcePatch {
repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
target_bin, deps, ..
} => (
repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
target_bin, deps,
),
_ => {
return Err(hammer_core::Error::Recipe(
"recipe_from_source_patch: la mutación no es 'source_patch'".into(),
));
}
};
// git xor tarball — misma regla que el schema (`swm_source_kind`).
let source_kind = hammer_core::swm::swm_source_kind(
repo.as_deref(),
commit.as_deref(),
tarball.as_deref(),
sha256.as_deref(),
)
.map_err(hammer_core::Error::Recipe)?;
std::fs::create_dir_all(catalog_dir)?;
// Clave estable del origen para nombrar el patch a disco: el commit (git) o el sha256
// (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista.
let source_key: &str = match &source_kind {
hammer_core::SourceKind::Git { commit, .. } => commit,
hammer_core::SourceKind::Tarball { sha256, .. } => sha256,
};
let mut patches: Vec<String> = Vec::new();
if patch.is_some() || patch_url.is_some() {
// Patch bajo un nombre derivado del nombre-de-receta + clave de origen, para no colisionar
// entre `{dep}.patch` distintos en el mismo catálogo.
let label = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
let patch_path = catalog_dir.join(format!("{label}-{source_key}.patch"));
if let Some(text) = patch {
std::fs::write(&patch_path, text.as_bytes())?;
} else if let Some(url) = patch_url {
tracing::info!(%url, "fetch: descargando patch remoto");
crate::download::fetch_url_to_file(url, &patch_path)?;
}
patches.push(patch_path.to_string_lossy().into_owned());
}
let name = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
synthesize_recipe(
&name,
&source_kind,
build_cfg,
*strip_components,
deps,
patches,
catalog_dir,
)
}
/// Nombre legible para el directorio del store. `/usr/bin/grep` → `grep`. Si el path no
/// tiene basename (raro), usamos `swm-bin`. El nombre afecta SÓLO al sufijo legible
/// (`<hash>-<name>`), no al hash; cambiarlo no rompe la caché del store.
@@ -123,11 +140,13 @@ fn derive_name(target_bin: &str) -> String {
.to_string()
}
#[allow(clippy::too_many_arguments)]
fn synthesize_recipe(
name: &str,
source: &hammer_core::SourceKind<'_>,
build_cfg: &SwmBuild,
strip_components: Option<usize>,
deps: &hammer_core::Deps,
patches: Vec<String>,
base_dir: &Path,
) -> hammer_core::Result<Recipe> {
@@ -188,7 +207,12 @@ flags = []
}
})
.collect();
recipe.base_dir = PathBuf::from("/"); // patches absolutos: base_dir es irrelevante
// Deps por NOMBRE (de la receta original): el lab las resolverá como `{dep}.toml` bajo el
// `base_dir` — por eso base_dir = el catálogo (no "/"): los patches son absolutos y no lo
// necesitan, pero la resolución de build-deps SÍ. `install` puebla el catálogo con los
// `{dep}.toml` antes de construir; sin deps, el dir simplemente no se consulta.
recipe.deps = deps.clone();
recipe.base_dir = base_dir.to_path_buf();
Ok(recipe)
}
@@ -246,6 +270,7 @@ mod tests {
},
&fake_swm_build(),
None,
&hammer_core::Deps::default(),
vec![],
d.path(),
)
@@ -273,6 +298,7 @@ mod tests {
},
&fake_swm_build(),
None,
&hammer_core::Deps::default(),
vec![],
d.path(),
)
@@ -329,13 +355,16 @@ mod tests {
build: fake_swm_build(),
target_bin: "/bin/x".into(),
expected_hash: None,
deps: Default::default(),
};
let store = Store::open(d.path().join("store")).unwrap();
let cfg = BuildConfig::defaults_for_store(store.root());
let scratch = d.path().join("scratch");
// Falla (no hay rootfs), pero el patch ya debe estar descargado.
let _ = build_source_patch(&m, &cfg, &store, Some(&scratch));
let landed = scratch.join("swm-recipes").join(format!("{commit}.patch"));
// El patch se materializa como `{label}-{source_key}.patch` (label = derive_name del
// target_bin) para no colisionar entre deps en el catálogo.
let landed = scratch.join("swm-recipes").join(format!("x-{commit}.patch"));
assert!(landed.is_file(), "el patch remoto debió descargarse a {}", landed.display());
assert_eq!(std::fs::read(&landed).unwrap(), b"--- a\n+++ b\n");
}