- 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).
261 lines
8.3 KiB
Rust
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}");
|
|
}
|
|
}
|