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 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ 7a6bfb4c4db7b90a94e22d1edac7d41786d265a9 crates/hammer-build/src/swm_bridge.rs Rust · 269 lines · 9.2 KB History Who touched what raw

Fase 4: patch_url / content_url remotos en .swm 7a6bfb4c4 · 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, 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}