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…
⎇ 21d44c13cd8e1529557497f033c258f0ab6c79dc
crates/takana-build/src/swm_bridge.rs
Historia
Quién tocó qué
crudo
hydrate: instalar a otro filesystem ya no muere — EXDEV se copia, que es lo único posible
1//! Puente entre `takana_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 `takana-build` (no en `takana-core`) porque depende del store y del
7//! sandbox. `takana-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 takana_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 name: Option<&str>,
30) -> takana_core::Result<ArtifactHash> {
31 let catalog_dir = catalog_dir_for(cfg, scratch_root);
32 // El NOMBRE de la receta sintetizada decide el sufijo del store (`<hash>-<name>`) Y la
33 // clave de cache-hit (`store.has(hash, name)`). Si quien llama conoce el nombre del paquete
34 // (p.ej. `install ripgrep`), lo pasa: así reproduce como "ripgrep" y cache-hitea el artefacto
35 // del corpus, en vez de derivar "rg" del target_bin y sellar un duplicado bajo otro nombre.
36 let recipe = recipe_from_source_patch(mutation, name, &catalog_dir)?;
37
38 let expected = match mutation {
39 Mutation::SourcePatch { expected_hash, .. } => expected_hash.clone(),
40 _ => None,
41 };
42 let hash = build(&recipe, cfg, store)?;
43 if let Some(want) = &expected {
44 let want_hex = want.strip_prefix("b3:").unwrap_or(want);
45 let got_hex = hash
46 .as_str()
47 .strip_prefix("b3:")
48 .expect("of_inputs siempre devuelve 'b3:'");
49 if want_hex != got_hex {
50 return Err(takana_core::Error::Recipe(format!(
51 "expected_hash no coincide: declarado=b3:{want_hex}, obtenido={hash}"
52 )));
53 }
54 }
55 Ok(hash)
56}
57
58/// El directorio-catálogo donde se materializan los patches y los `{dep}.toml`. Determinista a
59/// partir de `cfg.work_root` (o `scratch_root`): por eso `install` puede pre-poblarlo con los
60/// `{dep}.toml` y `build_source_patch` los encontrará al resolver build-deps del mismo `base_dir`.
61pub fn catalog_dir_for(cfg: &BuildConfig, scratch_root: Option<&Path>) -> PathBuf {
62 scratch_root.unwrap_or(&cfg.work_root).join("swm-recipes")
63}
64
65/// Sintetiza (SIN construir) la `Recipe` efímera de un `source_patch`: parsea el origen,
66/// materializa el patch inline/remoto a disco bajo `catalog_dir`, y arma la receta con sus
67/// `deps` y `base_dir = catalog_dir` (para que las build-deps resuelvan `{dep}.toml` ahí).
68///
69/// `name` sobreescribe el nombre derivado del `target_bin`. Es OBLIGATORIO al escribir un
70/// `{dep}.toml`: el nombre de la receta DEBE ser el de la dep para que el dependiente la resuelva.
71pub fn recipe_from_source_patch(
72 mutation: &Mutation,
73 name: Option<&str>,
74 catalog_dir: &Path,
75) -> takana_core::Result<Recipe> {
76 let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps, evidence, slots) =
77 match mutation {
78 Mutation::SourcePatch {
79 repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
80 target_bin, deps, evidence, slots, ..
81 } => (
82 repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
83 target_bin, deps, evidence, slots,
84 ),
85 _ => {
86 return Err(takana_core::Error::Recipe(
87 "recipe_from_source_patch: la mutación no es 'source_patch'".into(),
88 ));
89 }
90 };
91
92 // git xor tarball — misma regla que el schema (`swm_source_kind`).
93 let source_kind = takana_core::swm::swm_source_kind(
94 repo.as_deref(),
95 commit.as_deref(),
96 tarball.as_deref(),
97 sha256.as_deref(),
98 )
99 .map_err(takana_core::Error::Recipe)?;
100
101 std::fs::create_dir_all(catalog_dir)?;
102
103 // Clave estable del origen para nombrar el patch a disco: el commit (git) o el sha256
104 // (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista.
105 let source_key: &str = match &source_kind {
106 takana_core::SourceKind::Git { commit, .. } => commit,
107 takana_core::SourceKind::Tarball { sha256, .. } => sha256,
108 // Inalcanzable por construcción —`swm_source_kind` sólo produce Git o Tarball— pero se
109 // escribe como error y no como `unreachable!`: esto es una librería, y un panic aquí
110 // mataría al llamador por una receta mal formada en vez de decírselo.
111 takana_core::SourceKind::Dir { .. } => {
112 return Err(takana_core::Error::Recipe(
113 "un .swm no puede llevar un source por directorio".into(),
114 ))
115 }
116 };
117
118 let mut patches: Vec<String> = Vec::new();
119 if patch.is_some() || patch_url.is_some() {
120 // Patch bajo un nombre derivado del nombre-de-receta + clave de origen, para no colisionar
121 // entre `{dep}.patch` distintos en el mismo catálogo.
122 let label = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
123 // `source.patches` guarda el NOMBRE relativo a `base_dir` (= catalog_dir); el lab resuelve
124 // `base_dir.join(nombre)` UNA vez al construir (fetch::apply_patches / Recipe hash). Guardar el
125 // path completo acá lo haría re-unir a base_dir aguas abajo (doble/triple prefijo).
126 let filename = format!("{label}-{source_key}.patch");
127 let patch_path = catalog_dir.join(&filename);
128 if let Some(text) = patch {
129 std::fs::write(&patch_path, text.as_bytes())?;
130 } else if let Some(url) = patch_url {
131 tracing::info!(%url, "fetch: descargando patch remoto");
132 crate::download::fetch_url_to_file(url, &patch_path)?;
133 }
134 patches.push(filename);
135 }
136
137 let name = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
138 synthesize_recipe(
139 &name,
140 &source_kind,
141 build_cfg,
142 *strip_components,
143 deps,
144 evidence,
145 slots,
146 patches,
147 catalog_dir,
148 )
149}
150
151/// Nombre legible para el directorio del store. `/usr/bin/grep` → `grep`. Si el path no
152/// tiene basename (raro), usamos `swm-bin`. El nombre afecta SÓLO al sufijo legible
153/// (`<hash>-<name>`), no al hash; cambiarlo no rompe la caché del store.
154fn derive_name(target_bin: &str) -> String {
155 Path::new(target_bin)
156 .file_name()
157 .and_then(|s| s.to_str())
158 .unwrap_or("swm-bin")
159 .to_string()
160}
161
162#[allow(clippy::too_many_arguments)]
163fn synthesize_recipe(
164 name: &str,
165 source: &takana_core::SourceKind<'_>,
166 build_cfg: &SwmBuild,
167 strip_components: Option<usize>,
168 deps: &takana_core::Deps,
169 evidence: &takana_core::Evidence,
170 slots: &takana_core::Slots,
171 patches: Vec<String>,
172 base_dir: &Path,
173) -> takana_core::Result<Recipe> {
174 let compiler = parse_compiler(&build_cfg.compiler)?;
175 let link = parse_link(&build_cfg.link)?;
176 // Sección `[source]` y sufijo de versión según el modo de origen. El validador de
177 // `Source::kind` ya garantiza git xor tarball; acá sólo emitimos el TOML correspondiente.
178 let (version_key, source_block) = match source {
179 takana_core::SourceKind::Git { repo, commit } => (
180 *commit,
181 format!("repo = \"{repo}\"\ncommit = \"{commit}\"\n"),
182 ),
183 takana_core::SourceKind::Tarball { url, sha256 } => (
184 *sha256,
185 format!("tarball = \"{url}\"\nsha256 = \"{sha256}\"\n"),
186 ),
187 // Ver la nota del `source_key` de arriba: un `.swm` no puede traer un `dir`.
188 takana_core::SourceKind::Dir { .. } => {
189 return Err(takana_core::Error::Recipe(
190 "un .swm no puede llevar un source por directorio".into(),
191 ))
192 }
193 };
194 // Construimos la receta vía TOML para reusar las defaults y el validador de `Source`.
195 let toml_text = format!(
196 r#"
197name = "{name}"
198version = "swm-{version_short}"
199
200[source]
201{source_block}
202[build]
203compiler = "{compiler}"
204target = "{target}"
205link = "{link}"
206flags = []
207"#,
208 name = name,
209 version_short = &version_key[..version_key.len().min(12)],
210 source_block = source_block,
211 compiler = compiler.as_str(),
212 target = build_cfg.target,
213 link = link.as_str(),
214 );
215 let mut recipe = Recipe::from_toml(&toml_text)?;
216 recipe.build.flags = build_cfg.flags.clone();
217 // Fidelidad de reconstrucción: fases custom, zig por-paquete y strip del tarball viajan en
218 // el .swm (si vienen) y se reinyectan en la receta efímera tal cual los tenía el original.
219 recipe.build.phases = build_cfg.phases.clone();
220 recipe.build.zig_version = build_cfg.zig_version.clone();
221 // `strip_debug` ENTRA EN `hash_inputs`: sin esta línea el receptor reconstruye con `None`,
222 // sella en otra dirección y el `expected_hash` anclado no coincide NUNCA. Ver la doc del campo
223 // en `swm::SwmBuild::strip_debug`.
224 recipe.build.strip_debug = build_cfg.strip_debug;
225 if let Some(n) = strip_components {
226 recipe.source.strip_components = n;
227 }
228 // `patches` ya viene como nombres relativos a `base_dir` (o rutas absolutas que generamos
229 // nosotros): el lab los resuelve con `base_dir.join(p)` UNA vez al construir. NO los re-unimos
230 // acá — hacerlo doblaba el prefijo `base_dir` y el join del build lo triplicaba (bug que el
231 // dogfood de `jq`, primer paquete con patch + build-dep instalado, destapó).
232 recipe.source.patches = patches;
233 // Deps por NOMBRE (de la receta original): el lab las resolverá como `{dep}.toml` bajo el
234 // `base_dir` — por eso base_dir = el catálogo (no "/"): los patches son absolutos y no lo
235 // necesitan, pero la resolución de build-deps SÍ. `install` puebla el catálogo con los
236 // `{dep}.toml` antes de construir; sin deps, el dir simplemente no se consulta.
237 recipe.deps = deps.clone();
238 // Evidencia de comportamiento (H1): viaja en el .swm y se reinyecta en la receta efímera para
239 // que el checker la vea al reproducir. No afecta el hash del artefacto (comportamiento ≠ identidad).
240 recipe.evidence = evidence.clone();
241 // Superficies del sistema (H4b): viajan en el .swm y se reinyectan en la receta efímera para
242 // que el gate de compatibilidad de `install` las evalúe. Como la evidencia, no afectan el hash.
243 recipe.slots = slots.clone();
244 recipe.base_dir = base_dir.to_path_buf();
245 Ok(recipe)
246}
247
248fn parse_compiler(s: &str) -> takana_core::Result<Compiler> {
249 match s {
250 "zig-cc" => Ok(Compiler::ZigCc),
251 "clang" => Ok(Compiler::Clang),
252 "gcc" => Ok(Compiler::Gcc),
253 other => Err(takana_core::Error::Recipe(format!(
254 "compiler desconocido: '{other}' (esperado zig-cc/clang/gcc)"
255 ))),
256 }
257}
258
259fn parse_link(s: &str) -> takana_core::Result<LinkMode> {
260 match s {
261 "static" => Ok(LinkMode::Static),
262 "dynamic" => Ok(LinkMode::Dynamic),
263 other => Err(takana_core::Error::Recipe(format!(
264 "link desconocido: '{other}' (esperado static/dynamic)"
265 ))),
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn slots_sobreviven_receta_a_swm_a_receta() {
275 // H4b: el gate de compatibilidad depende de que las superficies declaradas en la receta
276 // viajen intactas por el `.swm` y se recuperen al reproducir. Ejercita AMBAS direcciones
277 // del puente (Swm::from_recipe → recipe_from_source_patch).
278 let toml = r#"
279name = "tema-oscuro"
280version = "1.0"
281[source]
282repo = "git://x/tema.git"
283commit = "abc123"
284[build]
285compiler = "zig-cc"
286[slots.claims]
287logo = "b3:azul"
288[slots.requires]
289"wayland-protocol" = "b3:stock"
290"#;
291 let recipe = takana_core::Recipe::from_toml(toml).unwrap();
292 let swm =
293 takana_core::Swm::from_recipe(&recipe, "/usr/bin/tema", None, None, "2026-06-06").unwrap();
294 // El .swm YAML debe llevar el bloque slots.
295 let yaml = swm.to_yaml().unwrap();
296 assert!(yaml.contains("slots"), "el .swm debe transportar slots: {yaml}");
297
298 // Vuelta: source_patch → Recipe recupera exactamente los slots.
299 let d = tempfile::tempdir().unwrap();
300 let mutation = swm
301 .mutations
302 .iter()
303 .find(|m| matches!(m, Mutation::SourcePatch { .. }))
304 .unwrap();
305 let back = recipe_from_source_patch(mutation, Some("tema-oscuro"), d.path()).unwrap();
306 assert_eq!(back.slots, recipe.slots);
307 assert_eq!(back.slots.claims.get("logo").unwrap(), "b3:azul");
308 assert_eq!(back.slots.requires.get("wayland-protocol").unwrap(), "b3:stock");
309 }
310
311 fn fake_swm_build() -> SwmBuild {
312 SwmBuild {
313 compiler: "zig-cc".into(),
314 target: "x86_64-linux-musl".into(),
315 link: "static".into(),
316 flags: vec!["--enable-foo".into()],
317 phases: Default::default(),
318 zig_version: None,
319 // Agregado en 9bc51889 («no viajaba en el paquete, y es ENTRADA DE HASH»): este
320 // constructor de test se quedó sin él y dejó al crate ENTERO sin compilar sus tests.
321 strip_debug: None,
322 }
323 }
324
325 #[test]
326 fn derive_name_from_target() {
327 assert_eq!(derive_name("/usr/bin/grep"), "grep");
328 assert_eq!(derive_name("/bin/ls"), "ls");
329 assert_eq!(derive_name("/"), "swm-bin");
330 }
331
332 #[test]
333 fn synthesize_recipe_basic() {
334 let d = tempfile::tempdir().unwrap();
335 let r = synthesize_recipe(
336 "grep",
337 &takana_core::SourceKind::Git {
338 repo: "git://example/grep.git",
339 commit: "a1b2c3d4e5f6a7b8c9",
340 },
341 &fake_swm_build(),
342 None,
343 &takana_core::Deps::default(),
344 &takana_core::Evidence::default(),
345 &takana_core::Slots::default(),
346 vec![],
347 d.path(),
348 )
349 .unwrap();
350 assert_eq!(r.name, "grep");
351 assert!(r.version.starts_with("swm-a1b2c3d4"));
352 assert_eq!(r.build.flags, vec!["--enable-foo"]);
353 match r.source.kind().unwrap() {
354 takana_core::SourceKind::Git { repo, commit } => {
355 assert_eq!(repo, "git://example/grep.git");
356 assert_eq!(commit, "a1b2c3d4e5f6a7b8c9");
357 }
358 _ => panic!("modo git esperado"),
359 }
360 }
361
362 #[test]
363 fn synthesize_recipe_tarball() {
364 let d = tempfile::tempdir().unwrap();
365 let r = synthesize_recipe(
366 "grep",
367 &takana_core::SourceKind::Tarball {
368 url: "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz",
369 sha256: "abcdef0123456789",
370 },
371 &fake_swm_build(),
372 None,
373 &takana_core::Deps::default(),
374 &takana_core::Evidence::default(),
375 &takana_core::Slots::default(),
376 vec![],
377 d.path(),
378 )
379 .unwrap();
380 assert_eq!(r.name, "grep");
381 assert!(r.version.starts_with("swm-abcdef012345"));
382 match r.source.kind().unwrap() {
383 takana_core::SourceKind::Tarball { url, sha256 } => {
384 assert_eq!(url, "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz");
385 assert_eq!(sha256, "abcdef0123456789");
386 }
387 _ => panic!("modo tarball esperado"),
388 }
389 }
390
391 #[test]
392 fn inline_patch_resuelve_con_un_solo_join_de_base_dir() {
393 // Regresión: `source.patches` debe guardar el NOMBRE relativo a base_dir, de modo que el
394 // lab resuelva `base_dir.join(p)` UNA sola vez. El bug doblaba/triplicaba el prefijo
395 // `swm-recipes/` (lo destapó el dogfood de `jq`, primer paquete con patch instalado).
396 let d = tempfile::tempdir().unwrap();
397 let catalog = d.path().join("swm-recipes");
398 let m = Mutation::SourcePatch {
399 repo: None,
400 commit: None,
401 tarball: Some("https://x/jq-1.8.1.tar.gz".into()),
402 sha256: Some("2be64e7129cecb11d5906290eba10af694fb9e3e7f9fc208a311dc33ca837eb0".into()),
403 strip_components: None,
404 patch: Some("--- a\n+++ b\n".into()),
405 patch_url: None,
406 build: fake_swm_build(),
407 target_bin: "/usr/bin/jq".into(),
408 expected_hash: None,
409 deps: Default::default(),
410 evidence: Default::default(),
411 slots: Default::default(),
412 };
413 let recipe = recipe_from_source_patch(&m, None, &catalog).unwrap();
414 assert_eq!(recipe.source.patches.len(), 1);
415 let p = &recipe.source.patches[0];
416 // El nombre NO debe llevar embebido el dir del catálogo (eso causaba el doble prefijo).
417 assert!(!p.contains("swm-recipes"), "patch debe ser relativo a base_dir, no llevar el catálogo: {p}");
418 // Y base_dir.join(p) — el ÚNICO join que hace el lab — debe dar el fichero real escrito.
419 let resolved = recipe.base_dir.join(p);
420 assert!(resolved.is_file(), "base_dir.join(patch) debe existir en un solo paso: {}", resolved.display());
421 assert_eq!(std::fs::read(&resolved).unwrap(), b"--- a\n+++ b\n");
422 }
423
424 #[test]
425 fn parse_compiler_and_link() {
426 assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc);
427 assert!(parse_compiler("xlc").is_err());
428 assert_eq!(parse_link("static").unwrap(), LinkMode::Static);
429 assert!(parse_link("partial").is_err());
430 }
431
432 #[test]
433 fn rejects_non_source_patch_mutation() {
434 let m = Mutation::ConfigEdit {
435 file: "/etc/x".into(),
436 inline_diff: "- a\n+ b\n".into(),
437 };
438 let d = tempfile::tempdir().unwrap();
439 let store = Store::open(d.path().join("store")).unwrap();
440 let cfg = BuildConfig::defaults_for_store(store.root());
441 let err = build_source_patch(&m, &cfg, &store, None, None).unwrap_err().to_string();
442 assert!(err.contains("source_patch"), "{err}");
443 }
444
445 #[test]
446 fn patch_url_is_downloaded_before_build() {
447 // Servimos el patch por file:// (sin red). build_source_patch debe descargarlo y
448 // materializarlo en swm-recipes/<commit>.patch ANTES de fallar aguas abajo por no
449 // haber rootfs de build en este entorno. Eso prueba que el patch_url quedó cableado.
450 let d = tempfile::tempdir().unwrap();
451 let patch_src = d.path().join("remote.patch");
452 std::fs::write(&patch_src, b"--- a\n+++ b\n").unwrap();
453 let commit = "deadbeefcafe";
454 let m = Mutation::SourcePatch {
455 repo: Some("git://x".into()),
456 commit: Some(commit.into()),
457 tarball: None,
458 sha256: None,
459 strip_components: None,
460 patch: None,
461 patch_url: Some(format!("file://{}", patch_src.display())),
462 build: fake_swm_build(),
463 target_bin: "/bin/x".into(),
464 expected_hash: None,
465 deps: Default::default(),
466 evidence: Default::default(),
467 slots: Default::default(),
468 };
469 let store = Store::open(d.path().join("store")).unwrap();
470 let cfg = BuildConfig::defaults_for_store(store.root());
471 let scratch = d.path().join("scratch");
472 // Falla (no hay rootfs), pero el patch ya debe estar descargado.
473 let _ = build_source_patch(&m, &cfg, &store, Some(&scratch), None);
474 // El patch se materializa como `{label}-{source_key}.patch` (label = derive_name del
475 // target_bin) para no colisionar entre deps en el catálogo.
476 let landed = scratch.join("swm-recipes").join(format!("x-{commit}.patch"));
477 assert!(landed.is_file(), "el patch remoto debió descargarse a {}", landed.display());
478 assert_eq!(std::fs::read(&landed).unwrap(), b"--- a\n+++ b\n");
479 }
480}