tawasuyu

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…

TOML 1436Shell 145Markdown 88Rust 852991 Definitions

⎇ 98f0b2d228ffebb06941dfc1f436167ab40d8f21 crates/hammer-build/src/swm_bridge.rs Rust · 320 lines · 11.5 KB History Who touched what raw

feat(swm): source_patch modela tarballs además de git (Fase 4) 0f42c3810 · Sergio ·

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, tarball, sha256, patch, patch_url, build_cfg, target_bin, expected) =
31        match mutation {
32            Mutation::SourcePatch {
33                repo,
34                commit,
35                tarball,
36                sha256,
37                patch,
38                patch_url,
39                build,
40                target_bin,
41                expected_hash,
42            } => (
43                repo, commit, tarball, sha256, patch, patch_url, build, target_bin, expected_hash,
44            ),
45            _ => {
46                return Err(hammer_core::Error::Recipe(
47                    "build_source_patch: la mutación no es 'source_patch'".into(),
48                ));
49            }
50        };
51
52    // git xor tarball — misma regla que el schema (`swm_source_kind`).
53    let source_kind = hammer_core::swm::swm_source_kind(
54        repo.as_deref(),
55        commit.as_deref(),
56        tarball.as_deref(),
57        sha256.as_deref(),
58    )
59    .map_err(hammer_core::Error::Recipe)?;
60
61    let scratch = scratch_root.unwrap_or(&cfg.work_root).to_path_buf();
62    let recipe_dir = scratch.join("swm-recipes");
63    std::fs::create_dir_all(&recipe_dir)?;
64
65    // Clave estable del origen para nombrar artefactos a disco: el commit (git) o el
66    // sha256 (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista.
67    let source_key: &str = match &source_kind {
68        hammer_core::SourceKind::Git { commit, .. } => commit,
69        hammer_core::SourceKind::Tarball { sha256, .. } => sha256,
70    };
71
72    // Materializamos el patch a disco bajo un nombre derivado de la clave de origen (estable:
73    // si el .swm se reaplica, reusamos el mismo archivo y el hash de la receta no depende de
74    // aleatoriedad). Origen: inline (`patch`) o remoto (`patch_url`); el schema garantiza que
75    // no vengan ambos.
76    let mut patches: Vec<String> = Vec::new();
77    let patch_path = recipe_dir.join(format!("{source_key}.patch"));
78    if let Some(text) = patch {
79        std::fs::write(&patch_path, text.as_bytes())?;
80        patches.push(patch_path.to_string_lossy().into_owned());
81    } else if let Some(url) = patch_url {
82        tracing::info!(%url, "fetch: descargando patch remoto");
83        crate::download::fetch_url_to_file(url, &patch_path)?;
84        patches.push(patch_path.to_string_lossy().into_owned());
85    }
86
87    let name = derive_name(target_bin);
88    let recipe = synthesize_recipe(&name, &source_kind, build_cfg, patches, &recipe_dir)?;
89
90    let hash = build(&recipe, cfg, store)?;
91    if let Some(want) = expected {
92        let want_hex = want.strip_prefix("b3:").unwrap_or(want);
93        let got_hex = hash
94            .as_str()
95            .strip_prefix("b3:")
96            .expect("of_inputs siempre devuelve 'b3:'");
97        if want_hex != got_hex {
98            return Err(hammer_core::Error::Recipe(format!(
99                "expected_hash no coincide: declarado=b3:{want_hex}, obtenido={hash}"
100            )));
101        }
102    }
103    Ok(hash)
104}
105
106/// Nombre legible para el directorio del store. `/usr/bin/grep` → `grep`. Si el path no
107/// tiene basename (raro), usamos `swm-bin`. El nombre afecta SÓLO al sufijo legible
108/// (`<hash>-<name>`), no al hash; cambiarlo no rompe la caché del store.
109fn derive_name(target_bin: &str) -> String {
110    Path::new(target_bin)
111        .file_name()
112        .and_then(|s| s.to_str())
113        .unwrap_or("swm-bin")
114        .to_string()
115}
116
117fn synthesize_recipe(
118    name: &str,
119    source: &hammer_core::SourceKind<'_>,
120    build_cfg: &SwmBuild,
121    patches: Vec<String>,
122    base_dir: &Path,
123) -> hammer_core::Result<Recipe> {
124    let compiler = parse_compiler(&build_cfg.compiler)?;
125    let link = parse_link(&build_cfg.link)?;
126    // Sección `[source]` y sufijo de versión según el modo de origen. El validador de
127    // `Source::kind` ya garantiza git xor tarball; acá sólo emitimos el TOML correspondiente.
128    let (version_key, source_block) = match source {
129        hammer_core::SourceKind::Git { repo, commit } => (
130            *commit,
131            format!("repo   = \"{repo}\"\ncommit = \"{commit}\"\n"),
132        ),
133        hammer_core::SourceKind::Tarball { url, sha256 } => (
134            *sha256,
135            format!("tarball = \"{url}\"\nsha256  = \"{sha256}\"\n"),
136        ),
137    };
138    // Construimos la receta vía TOML para reusar las defaults y el validador de `Source`.
139    let toml_text = format!(
140        r#"
141name = "{name}"
142version = "swm-{version_short}"
143
144[source]
145{source_block}
146[build]
147compiler = "{compiler}"
148target   = "{target}"
149link     = "{link}"
150flags    = []
151"#,
152        name = name,
153        version_short = &version_key[..version_key.len().min(12)],
154        source_block = source_block,
155        compiler = compiler.as_str(),
156        target = build_cfg.target,
157        link = link.as_str(),
158    );
159    let mut recipe = Recipe::from_toml(&toml_text)?;
160    recipe.build.flags = build_cfg.flags.clone();
161    // Resolución de patches relativos al recipe_dir donde acabamos de escribir los inline.
162    // Aceptamos rutas absolutas tal cual: las hemos generado nosotros.
163    recipe.source.patches = patches
164        .into_iter()
165        .map(|p| {
166            let path = Path::new(&p);
167            if path.is_absolute() {
168                p
169            } else {
170                base_dir.join(&p).to_string_lossy().into_owned()
171            }
172        })
173        .collect();
174    recipe.base_dir = PathBuf::from("/"); // patches absolutos: base_dir es irrelevante
175    Ok(recipe)
176}
177
178fn parse_compiler(s: &str) -> hammer_core::Result<Compiler> {
179    match s {
180        "zig-cc" => Ok(Compiler::ZigCc),
181        "clang" => Ok(Compiler::Clang),
182        "gcc" => Ok(Compiler::Gcc),
183        other => Err(hammer_core::Error::Recipe(format!(
184            "compiler desconocido: '{other}' (esperado zig-cc/clang/gcc)"
185        ))),
186    }
187}
188
189fn parse_link(s: &str) -> hammer_core::Result<LinkMode> {
190    match s {
191        "static" => Ok(LinkMode::Static),
192        "dynamic" => Ok(LinkMode::Dynamic),
193        other => Err(hammer_core::Error::Recipe(format!(
194            "link desconocido: '{other}' (esperado static/dynamic)"
195        ))),
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn fake_swm_build() -> SwmBuild {
204        SwmBuild {
205            compiler: "zig-cc".into(),
206            target: "x86_64-linux-musl".into(),
207            link: "static".into(),
208            flags: vec!["--enable-foo".into()],
209        }
210    }
211
212    #[test]
213    fn derive_name_from_target() {
214        assert_eq!(derive_name("/usr/bin/grep"), "grep");
215        assert_eq!(derive_name("/bin/ls"), "ls");
216        assert_eq!(derive_name("/"), "swm-bin");
217    }
218
219    #[test]
220    fn synthesize_recipe_basic() {
221        let d = tempfile::tempdir().unwrap();
222        let r = synthesize_recipe(
223            "grep",
224            &hammer_core::SourceKind::Git {
225                repo: "git://example/grep.git",
226                commit: "a1b2c3d4e5f6a7b8c9",
227            },
228            &fake_swm_build(),
229            vec![],
230            d.path(),
231        )
232        .unwrap();
233        assert_eq!(r.name, "grep");
234        assert!(r.version.starts_with("swm-a1b2c3d4"));
235        assert_eq!(r.build.flags, vec!["--enable-foo"]);
236        match r.source.kind().unwrap() {
237            hammer_core::SourceKind::Git { repo, commit } => {
238                assert_eq!(repo, "git://example/grep.git");
239                assert_eq!(commit, "a1b2c3d4e5f6a7b8c9");
240            }
241            _ => panic!("modo git esperado"),
242        }
243    }
244
245    #[test]
246    fn synthesize_recipe_tarball() {
247        let d = tempfile::tempdir().unwrap();
248        let r = synthesize_recipe(
249            "grep",
250            &hammer_core::SourceKind::Tarball {
251                url: "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz",
252                sha256: "abcdef0123456789",
253            },
254            &fake_swm_build(),
255            vec![],
256            d.path(),
257        )
258        .unwrap();
259        assert_eq!(r.name, "grep");
260        assert!(r.version.starts_with("swm-abcdef012345"));
261        match r.source.kind().unwrap() {
262            hammer_core::SourceKind::Tarball { url, sha256 } => {
263                assert_eq!(url, "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz");
264                assert_eq!(sha256, "abcdef0123456789");
265            }
266            _ => panic!("modo tarball esperado"),
267        }
268    }
269
270    #[test]
271    fn parse_compiler_and_link() {
272        assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc);
273        assert!(parse_compiler("xlc").is_err());
274        assert_eq!(parse_link("static").unwrap(), LinkMode::Static);
275        assert!(parse_link("partial").is_err());
276    }
277
278    #[test]
279    fn rejects_non_source_patch_mutation() {
280        let m = Mutation::ConfigEdit {
281            file: "/etc/x".into(),
282            inline_diff: "- a\n+ b\n".into(),
283        };
284        let d = tempfile::tempdir().unwrap();
285        let store = Store::open(d.path().join("store")).unwrap();
286        let cfg = BuildConfig::defaults_for_store(store.root());
287        let err = build_source_patch(&m, &cfg, &store, None).unwrap_err().to_string();
288        assert!(err.contains("source_patch"), "{err}");
289    }
290
291    #[test]
292    fn patch_url_is_downloaded_before_build() {
293        // Servimos el patch por file:// (sin red). build_source_patch debe descargarlo y
294        // materializarlo en swm-recipes/<commit>.patch ANTES de fallar aguas abajo por no
295        // haber rootfs de build en este entorno. Eso prueba que el patch_url quedó cableado.
296        let d = tempfile::tempdir().unwrap();
297        let patch_src = d.path().join("remote.patch");
298        std::fs::write(&patch_src, b"--- a\n+++ b\n").unwrap();
299        let commit = "deadbeefcafe";
300        let m = Mutation::SourcePatch {
301            repo: Some("git://x".into()),
302            commit: Some(commit.into()),
303            tarball: None,
304            sha256: None,
305            patch: None,
306            patch_url: Some(format!("file://{}", patch_src.display())),
307            build: fake_swm_build(),
308            target_bin: "/bin/x".into(),
309            expected_hash: None,
310        };
311        let store = Store::open(d.path().join("store")).unwrap();
312        let cfg = BuildConfig::defaults_for_store(store.root());
313        let scratch = d.path().join("scratch");
314        // Falla (no hay rootfs), pero el patch ya debe estar descargado.
315        let _ = build_source_patch(&m, &cfg, &store, Some(&scratch));
316        let landed = scratch.join("swm-recipes").join(format!("{commit}.patch"));
317        assert!(landed.is_file(), "el patch remoto debió descargarse a {}", landed.display());
318        assert_eq!(std::fs::read(&landed).unwrap(), b"--- a\n+++ b\n");
319    }
320}