Files
takana/crates/hammer-build/src/swm_bridge.rs
T
Sergio 09d50f9e60 Fase 4 — formato .swm: verify, apply (config_edit/file_drop/source_patch) y export
- hammer-core::swm: verify_schema (invariantes por mutación) + verify_base con
  BaseRef/BaseCompat/PinDiff (distro_version + pins). FileDrop.content_b64 para
  .swm autocontenidos.
- hammer-core::apply: primitivas puras apply_config_edit (hunks -/+ con búsqueda
  exacta de bloque, ambiguo => error), apply_file_drop (base64 + verify BLAKE3),
  rebase_path para tests con prefix.
- hammer-build::swm_bridge: Mutation::SourcePatch -> Recipe sintética + patch
  inline materializado + build, con comparación opcional contra expected_hash.
- hammer-cli: nuevos subcomandos apply [--prefix --base-ref --skip-source-patch
  --state-root], swm-verify, export --journal --base-ref --since.
- Tests: 18 unit nuevos en hammer-core (apply + verify), 5 en swm_bridge,
  4 e2e en hammer-cli (roundtrip yaml -> apply bajo prefix reproduce los archivos).
- Roadmap y SDD 06 actualizados con lo cerrado y lo pendiente (URLs remotos,
  provenance en export vía mapa artefacto->receta, firma ed25519).
2026-06-09 15:19:33 +00:00

261 lines
8.3 KiB
Rust

//! Puente entre `hammer_core::Mutation::SourcePatch` y el laboratorio.
//!
//! Convierte la mutación serializada en una `Recipe` efímera + un archivo de patch
//! temporal, y delega en `build` para sellar el artefacto en el store. Luego se hidrata.
//!
//! Este módulo vive en `hammer-build` (no en `hammer-core`) porque depende del store y del
//! sandbox. `hammer-core` debe permanecer puro (sin red ni mounts) para que el watcher y los
//! tests no arrastren toda la cadena del lab.
use std::path::{Path, PathBuf};
use hammer_core::{
swm::{Mutation, SwmBuild},
ArtifactHash, Compiler, LinkMode, Recipe, Store,
};
use crate::{build, BuildConfig};
/// Construye una `SourcePatch` y devuelve el hash sellado en el store. El llamador hidrata
/// luego (típicamente al overlay activo).
///
/// `scratch_root` sirve para escribir el patch inline a disco — usamos `cfg.work_root` por
/// defecto si el caller no quiere gestionarlo a mano.
pub fn build_source_patch(
mutation: &Mutation,
cfg: &BuildConfig,
store: &Store,
scratch_root: Option<&Path>,
) -> hammer_core::Result<ArtifactHash> {
let (repo, commit, patch, patch_url, build_cfg, target_bin, expected) = match mutation {
Mutation::SourcePatch {
repo,
commit,
patch,
patch_url,
build,
target_bin,
expected_hash,
} => (repo, commit, 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(),
));
}
};
if patch_url.is_some() {
return Err(hammer_core::Error::Recipe(
"patch_url remoto no soportado todavía: usa 'patch' inline".into(),
));
}
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)?;
// Si trae patch inline, lo materializamos a disco bajo un nombre derivado del commit
// (estable: si el .swm se reaplica, reusamos el mismo archivo y el hash de la receta no
// depende de aleatoriedad).
let mut patches: Vec<String> = Vec::new();
if let Some(text) = patch {
let patch_path = recipe_dir.join(format!("{commit}.patch"));
std::fs::write(&patch_path, text.as_bytes())?;
patches.push(patch_path.to_string_lossy().into_owned());
}
let name = derive_name(target_bin);
let recipe = synthesize_recipe(
&name,
commit,
repo,
build_cfg,
patches,
&recipe_dir,
)?;
let hash = build(&recipe, cfg, store)?;
if let Some(want) = expected {
let want_hex = want.strip_prefix("b3:").unwrap_or(want);
let got_hex = hash
.as_str()
.strip_prefix("b3:")
.expect("of_inputs siempre devuelve 'b3:'");
if want_hex != got_hex {
return Err(hammer_core::Error::Recipe(format!(
"expected_hash no coincide: declarado=b3:{want_hex}, obtenido={hash}"
)));
}
}
Ok(hash)
}
/// 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.
fn derive_name(target_bin: &str) -> String {
Path::new(target_bin)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("swm-bin")
.to_string()
}
fn synthesize_recipe(
name: &str,
commit: &str,
repo: &str,
build_cfg: &SwmBuild,
patches: Vec<String>,
base_dir: &Path,
) -> hammer_core::Result<Recipe> {
let compiler = parse_compiler(&build_cfg.compiler)?;
let link = parse_link(&build_cfg.link)?;
// Construimos la receta vía TOML para reusar las defaults y el validador de `Source`.
let toml_text = format!(
r#"
name = "{name}"
version = "swm-{commit_short}"
[source]
repo = "{repo}"
commit = "{commit}"
[build]
compiler = "{compiler}"
target = "{target}"
link = "{link}"
flags = []
"#,
name = name,
commit_short = &commit[..commit.len().min(12)],
repo = repo,
commit = commit,
compiler = compiler.as_str(),
target = build_cfg.target,
link = link.as_str(),
);
let mut recipe = Recipe::from_toml(&toml_text)?;
recipe.build.flags = build_cfg.flags.clone();
// Resolución de patches relativos al recipe_dir donde acabamos de escribir los inline.
// Aceptamos rutas absolutas tal cual: las hemos generado nosotros.
recipe.source.patches = patches
.into_iter()
.map(|p| {
let path = Path::new(&p);
if path.is_absolute() {
p
} else {
base_dir.join(&p).to_string_lossy().into_owned()
}
})
.collect();
recipe.base_dir = PathBuf::from("/"); // patches absolutos: base_dir es irrelevante
Ok(recipe)
}
fn parse_compiler(s: &str) -> hammer_core::Result<Compiler> {
match s {
"zig-cc" => Ok(Compiler::ZigCc),
"clang" => Ok(Compiler::Clang),
"gcc" => Ok(Compiler::Gcc),
other => Err(hammer_core::Error::Recipe(format!(
"compiler desconocido: '{other}' (esperado zig-cc/clang/gcc)"
))),
}
}
fn parse_link(s: &str) -> hammer_core::Result<LinkMode> {
match s {
"static" => Ok(LinkMode::Static),
"dynamic" => Ok(LinkMode::Dynamic),
other => Err(hammer_core::Error::Recipe(format!(
"link desconocido: '{other}' (esperado static/dynamic)"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_swm_build() -> SwmBuild {
SwmBuild {
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec!["--enable-foo".into()],
}
}
#[test]
fn derive_name_from_target() {
assert_eq!(derive_name("/usr/bin/grep"), "grep");
assert_eq!(derive_name("/bin/ls"), "ls");
assert_eq!(derive_name("/"), "swm-bin");
}
#[test]
fn synthesize_recipe_basic() {
let d = tempfile::tempdir().unwrap();
let r = synthesize_recipe(
"grep",
"a1b2c3d4e5f6a7b8c9",
"git://example/grep.git",
&fake_swm_build(),
vec![],
d.path(),
)
.unwrap();
assert_eq!(r.name, "grep");
assert!(r.version.starts_with("swm-a1b2c3d4"));
assert_eq!(r.build.flags, vec!["--enable-foo"]);
match r.source.kind().unwrap() {
hammer_core::SourceKind::Git { repo, commit } => {
assert_eq!(repo, "git://example/grep.git");
assert_eq!(commit, "a1b2c3d4e5f6a7b8c9");
}
_ => panic!("modo git esperado"),
}
}
#[test]
fn parse_compiler_and_link() {
assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc);
assert!(parse_compiler("xlc").is_err());
assert_eq!(parse_link("static").unwrap(), LinkMode::Static);
assert!(parse_link("partial").is_err());
}
#[test]
fn rejects_non_source_patch_mutation() {
let m = Mutation::ConfigEdit {
file: "/etc/x".into(),
inline_diff: "- a\n+ b\n".into(),
};
let d = tempfile::tempdir().unwrap();
let store = Store::open(d.path().join("store")).unwrap();
let cfg = BuildConfig::defaults_for_store(store.root());
let err = build_source_patch(&m, &cfg, &store, None).unwrap_err().to_string();
assert!(err.contains("source_patch"), "{err}");
}
#[test]
fn rejects_patch_url() {
let m = Mutation::SourcePatch {
repo: "git://x".into(),
commit: "abc".into(),
patch: None,
patch_url: Some("https://example/foo.patch".into()),
build: fake_swm_build(),
target_bin: "/bin/x".into(),
expected_hash: None,
};
let d = tempfile::tempdir().unwrap();
let store = Store::open(d.path().join("store")).unwrap();
let cfg = BuildConfig::defaults_for_store(store.root());
let err = build_source_patch(&m, &cfg, &store, None).unwrap_err().to_string();
assert!(err.contains("patch_url"), "{err}");
}
}