sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ 7a6bfb4c4db7b90a94e22d1edac7d41786d265a9
crates/hammer-build/src/swm_bridge.rs
History
Who touched what
raw
Fase 4: patch_url / content_url remotos en .swm
1//! Puente entre `hammer_core::Mutation::SourcePatch` y el laboratorio.
2//!
3//! Convierte la mutación serializada en una `Recipe` efímera + un archivo de patch
4//! temporal, y delega en `build` para sellar el artefacto en el store. Luego se hidrata.
5//!
6//! Este módulo vive en `hammer-build` (no en `hammer-core`) porque depende del store y del
7//! sandbox. `hammer-core` debe permanecer puro (sin red ni mounts) para que el watcher y los
8//! tests no arrastren toda la cadena del lab.
9
10use std::path::{Path, PathBuf};
11
12use hammer_core::{
13 swm::{Mutation, SwmBuild},
14 ArtifactHash, Compiler, LinkMode, Recipe, Store,
15};
16
17use crate::{build, BuildConfig};
18
19/// Construye una `SourcePatch` y devuelve el hash sellado en el store. El llamador hidrata
20/// luego (típicamente al overlay activo).
21///
22/// `scratch_root` sirve para escribir el patch inline a disco — usamos `cfg.work_root` por
23/// defecto si el caller no quiere gestionarlo a mano.
24pub fn build_source_patch(
25 mutation: &Mutation,
26 cfg: &BuildConfig,
27 store: &Store,
28 scratch_root: Option<&Path>,
29) -> hammer_core::Result<ArtifactHash> {
30 let (repo, commit, patch, patch_url, build_cfg, target_bin, expected) = match mutation {
31 Mutation::SourcePatch {
32 repo,
33 commit,
34 patch,
35 patch_url,
36 build,
37 target_bin,
38 expected_hash,
39 } => (repo, commit, patch, patch_url, build, target_bin, expected_hash),
40 _ => {
41 return Err(hammer_core::Error::Recipe(
42 "build_source_patch: la mutación no es 'source_patch'".into(),
43 ));
44 }
45 };
46
47 let scratch = scratch_root.unwrap_or(&cfg.work_root).to_path_buf();
48 let recipe_dir = scratch.join("swm-recipes");
49 std::fs::create_dir_all(&recipe_dir)?;
50
51 // Materializamos el patch a disco bajo un nombre derivado del commit (estable: si el .swm
52 // se reaplica, reusamos el mismo archivo y el hash de la receta no depende de
53 // aleatoriedad). Origen: inline (`patch`) o remoto (`patch_url`); el schema garantiza que
54 // no vengan ambos.
55 let mut patches: Vec<String> = Vec::new();
56 let patch_path = recipe_dir.join(format!("{commit}.patch"));
57 if let Some(text) = patch {
58 std::fs::write(&patch_path, text.as_bytes())?;
59 patches.push(patch_path.to_string_lossy().into_owned());
60 } else if let Some(url) = patch_url {
61 tracing::info!(%url, "fetch: descargando patch remoto");
62 crate::download::fetch_url_to_file(url, &patch_path)?;
63 patches.push(patch_path.to_string_lossy().into_owned());
64 }
65
66 let name = derive_name(target_bin);
67 let recipe = synthesize_recipe(
68 &name,
69 commit,
70 repo,
71 build_cfg,
72 patches,
73 &recipe_dir,
74 )?;
75
76 let hash = build(&recipe, cfg, store)?;
77 if let Some(want) = expected {
78 let want_hex = want.strip_prefix("b3:").unwrap_or(want);
79 let got_hex = hash
80 .as_str()
81 .strip_prefix("b3:")
82 .expect("of_inputs siempre devuelve 'b3:'");
83 if want_hex != got_hex {
84 return Err(hammer_core::Error::Recipe(format!(
85 "expected_hash no coincide: declarado=b3:{want_hex}, obtenido={hash}"
86 )));
87 }
88 }
89 Ok(hash)
90}
91
92/// Nombre legible para el directorio del store. `/usr/bin/grep` → `grep`. Si el path no
93/// tiene basename (raro), usamos `swm-bin`. El nombre afecta SÓLO al sufijo legible
94/// (`<hash>-<name>`), no al hash; cambiarlo no rompe la caché del store.
95fn derive_name(target_bin: &str) -> String {
96 Path::new(target_bin)
97 .file_name()
98 .and_then(|s| s.to_str())
99 .unwrap_or("swm-bin")
100 .to_string()
101}
102
103fn synthesize_recipe(
104 name: &str,
105 commit: &str,
106 repo: &str,
107 build_cfg: &SwmBuild,
108 patches: Vec<String>,
109 base_dir: &Path,
110) -> hammer_core::Result<Recipe> {
111 let compiler = parse_compiler(&build_cfg.compiler)?;
112 let link = parse_link(&build_cfg.link)?;
113 // Construimos la receta vía TOML para reusar las defaults y el validador de `Source`.
114 let toml_text = format!(
115 r#"
116name = "{name}"
117version = "swm-{commit_short}"
118
119[source]
120repo = "{repo}"
121commit = "{commit}"
122
123[build]
124compiler = "{compiler}"
125target = "{target}"
126link = "{link}"
127flags = []
128"#,
129 name = name,
130 commit_short = &commit[..commit.len().min(12)],
131 repo = repo,
132 commit = commit,
133 compiler = compiler.as_str(),
134 target = build_cfg.target,
135 link = link.as_str(),
136 );
137 let mut recipe = Recipe::from_toml(&toml_text)?;
138 recipe.build.flags = build_cfg.flags.clone();
139 // Resolución de patches relativos al recipe_dir donde acabamos de escribir los inline.
140 // Aceptamos rutas absolutas tal cual: las hemos generado nosotros.
141 recipe.source.patches = patches
142 .into_iter()
143 .map(|p| {
144 let path = Path::new(&p);
145 if path.is_absolute() {
146 p
147 } else {
148 base_dir.join(&p).to_string_lossy().into_owned()
149 }
150 })
151 .collect();
152 recipe.base_dir = PathBuf::from("/"); // patches absolutos: base_dir es irrelevante
153 Ok(recipe)
154}
155
156fn parse_compiler(s: &str) -> hammer_core::Result<Compiler> {
157 match s {
158 "zig-cc" => Ok(Compiler::ZigCc),
159 "clang" => Ok(Compiler::Clang),
160 "gcc" => Ok(Compiler::Gcc),
161 other => Err(hammer_core::Error::Recipe(format!(
162 "compiler desconocido: '{other}' (esperado zig-cc/clang/gcc)"
163 ))),
164 }
165}
166
167fn parse_link(s: &str) -> hammer_core::Result<LinkMode> {
168 match s {
169 "static" => Ok(LinkMode::Static),
170 "dynamic" => Ok(LinkMode::Dynamic),
171 other => Err(hammer_core::Error::Recipe(format!(
172 "link desconocido: '{other}' (esperado static/dynamic)"
173 ))),
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 fn fake_swm_build() -> SwmBuild {
182 SwmBuild {
183 compiler: "zig-cc".into(),
184 target: "x86_64-linux-musl".into(),
185 link: "static".into(),
186 flags: vec!["--enable-foo".into()],
187 }
188 }
189
190 #[test]
191 fn derive_name_from_target() {
192 assert_eq!(derive_name("/usr/bin/grep"), "grep");
193 assert_eq!(derive_name("/bin/ls"), "ls");
194 assert_eq!(derive_name("/"), "swm-bin");
195 }
196
197 #[test]
198 fn synthesize_recipe_basic() {
199 let d = tempfile::tempdir().unwrap();
200 let r = synthesize_recipe(
201 "grep",
202 "a1b2c3d4e5f6a7b8c9",
203 "git://example/grep.git",
204 &fake_swm_build(),
205 vec![],
206 d.path(),
207 )
208 .unwrap();
209 assert_eq!(r.name, "grep");
210 assert!(r.version.starts_with("swm-a1b2c3d4"));
211 assert_eq!(r.build.flags, vec!["--enable-foo"]);
212 match r.source.kind().unwrap() {
213 hammer_core::SourceKind::Git { repo, commit } => {
214 assert_eq!(repo, "git://example/grep.git");
215 assert_eq!(commit, "a1b2c3d4e5f6a7b8c9");
216 }
217 _ => panic!("modo git esperado"),
218 }
219 }
220
221 #[test]
222 fn parse_compiler_and_link() {
223 assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc);
224 assert!(parse_compiler("xlc").is_err());
225 assert_eq!(parse_link("static").unwrap(), LinkMode::Static);
226 assert!(parse_link("partial").is_err());
227 }
228
229 #[test]
230 fn rejects_non_source_patch_mutation() {
231 let m = Mutation::ConfigEdit {
232 file: "/etc/x".into(),
233 inline_diff: "- a\n+ b\n".into(),
234 };
235 let d = tempfile::tempdir().unwrap();
236 let store = Store::open(d.path().join("store")).unwrap();
237 let cfg = BuildConfig::defaults_for_store(store.root());
238 let err = build_source_patch(&m, &cfg, &store, None).unwrap_err().to_string();
239 assert!(err.contains("source_patch"), "{err}");
240 }
241
242 #[test]
243 fn patch_url_is_downloaded_before_build() {
244 // Servimos el patch por file:// (sin red). build_source_patch debe descargarlo y
245 // materializarlo en swm-recipes/<commit>.patch ANTES de fallar aguas abajo por no
246 // haber rootfs de build en este entorno. Eso prueba que el patch_url quedó cableado.
247 let d = tempfile::tempdir().unwrap();
248 let patch_src = d.path().join("remote.patch");
249 std::fs::write(&patch_src, b"--- a\n+++ b\n").unwrap();
250 let commit = "deadbeefcafe";
251 let m = Mutation::SourcePatch {
252 repo: "git://x".into(),
253 commit: commit.into(),
254 patch: None,
255 patch_url: Some(format!("file://{}", patch_src.display())),
256 build: fake_swm_build(),
257 target_bin: "/bin/x".into(),
258 expected_hash: None,
259 };
260 let store = Store::open(d.path().join("store")).unwrap();
261 let cfg = BuildConfig::defaults_for_store(store.root());
262 let scratch = d.path().join("scratch");
263 // Falla (no hay rootfs), pero el patch ya debe estar descargado.
264 let _ = build_source_patch(&m, &cfg, &store, Some(&scratch));
265 let landed = scratch.join("swm-recipes").join(format!("{commit}.patch"));
266 assert!(landed.is_file(), "el patch remoto debió descargarse a {}", landed.display());
267 assert_eq!(std::fs::read(&landed).unwrap(), b"--- a\n+++ b\n");
268 }
269}