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

⎇ 339a7b07ef8d72e101fecfbd722c2f8feb8a7d1c crates/hammer-core/src/recipe.rs Rust · 454 lines · 15.7 KB History Who touched what raw

Etapa F paquetería #1: `hammer pack` — receta del corpus → paquete .swm (source_patch) 339a7b07e · sergio ·

1//! La `Recipe`: descripción pura de un build. Ver `docs/02-build-lab.md` §1.
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::hash::ArtifactHash;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Recipe {
11    pub name: String,
12    pub version: String,
13    pub source: Source,
14    pub build: Build,
15    #[serde(default)]
16    pub deps: Deps,
17    /// Directorio base contra el que se resuelven rutas relativas de la receta
18    /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío.
19    #[serde(skip, default)]
20    pub base_dir: PathBuf,
21}
22
23/// `Source` admite dos modos, mutuamente excluyentes, validados en runtime:
24///
25/// 1. **Git** — `repo` + `commit` (SHA fijado, nunca HEAD; ADR 0006). Apropiado para
26///    upstreams cuyo árbol de git ya es buildable o cuando queremos pinear un parche que
27///    aún no salió en release.
28/// 2. **Tarball** — `tarball` (URL) + `sha256`. Apropiado para releases estables: la
29///    mayoría de proyectos GNU/autotools traen `configure` ya generado y embebidos los
30///    submódulos (gnulib, etc.), evitando la necesidad de `./bootstrap` en el sandbox.
31///
32/// El hash de entrada del artefacto usa `commit` o `sha256` según el modo, ambos
33/// identificadores inmutables del contenido fuente.
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35pub struct Source {
36    // --- modo git ---
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub repo: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub commit: Option<String>,
41    // --- modo tarball ---
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub tarball: Option<String>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub sha256: Option<String>,
46    /// Cuántos componentes recortar al extraer el tarball. Default 1 (los releases GNU
47    /// siempre tienen un único top-level `proyecto-version/`).
48    #[serde(default = "default_strip")]
49    pub strip_components: usize,
50    // --- común ---
51    #[serde(default)]
52    pub patches: Vec<String>,
53}
54
55fn default_strip() -> usize {
56    1
57}
58
59/// Discriminante validado del `Source`. Se calcula bajo demanda; los campos de
60/// `Source` permanecen flat en TOML para que las recetas sean legibles.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum SourceKind<'a> {
63    Git { repo: &'a str, commit: &'a str },
64    Tarball { url: &'a str, sha256: &'a str },
65}
66
67impl Source {
68    pub fn kind(&self) -> crate::Result<SourceKind<'_>> {
69        match (
70            self.repo.as_deref(),
71            self.commit.as_deref(),
72            self.tarball.as_deref(),
73            self.sha256.as_deref(),
74        ) {
75            (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }),
76            (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }),
77            (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => Err(
78                crate::Error::Recipe(
79                    "source: usa repo+commit O tarball+sha256, no ambos".into(),
80                ),
81            ),
82            _ => Err(crate::Error::Recipe(
83                "source: faltan campos; necesito (repo+commit) o (tarball+sha256)".into(),
84            )),
85        }
86    }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Build {
91    #[serde(default)]
92    pub compiler: Compiler,
93    #[serde(default = "default_target")]
94    pub target: String,
95    #[serde(default)]
96    pub link: LinkMode,
97    #[serde(default)]
98    pub flags: Vec<String>,
99    /// Versión de zig a usar para ESTA receta, distinta del zig por defecto del lab. Sirve de
100    /// escotilla por-receta para esquivar la regresión de zig 0.14 (que miscompila ciertos binarios
101    /// musl dinámicos: flex/cmake/python/binutils/…): `zig_version = "0.13.0"` los construye con el
102    /// zig bueno sin tocar el toolchain global ni el baseline de reproducibilidad (estas piezas son
103    /// herramientas, no inputs del of_tree del 4/4). `None` ⇒ el zig por defecto. Ver SDD 11 §7.2b.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub zig_version: Option<String>,
106    /// Overrides explícitos de las fases del build. Lo no especificado se decide por
107    /// heurística en el lab (ver `docs/02-build-lab.md` §4).
108    #[serde(default)]
109    pub phases: Phases,
110}
111
112/// Comandos shell que se ejecutan en el sandbox por cada fase. Cualquiera puede ser `None`:
113/// el lab lo deriva por heurística (autotools / cmake / meson / make).
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct Phases {
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub configure: Option<String>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub compile: Option<String>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub install: Option<String>,
122}
123
124impl Phases {
125    /// `true` si ninguna fase está sobreescrita (todo se decide por heurística). Sirve a
126    /// serde (`skip_serializing_if`) para que un `.swm` sin fases custom no las emita.
127    pub fn is_empty(&self) -> bool {
128        self.configure.is_none() && self.compile.is_none() && self.install.is_none()
129    }
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct Deps {
134    #[serde(default)]
135    pub build: Vec<String>,
136    #[serde(default)]
137    pub runtime: Vec<String>,
138}
139
140/// Compilador del lab, POR RECETA (no global). `zig-cc` por defecto; escotilla a clang/gcc
141/// para paquetes con gcc-ismos. Ver ADR 0003.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
143#[serde(rename_all = "kebab-case")]
144pub enum Compiler {
145    #[default]
146    ZigCc,
147    Clang,
148    Gcc,
149}
150
151impl Compiler {
152    pub fn as_str(&self) -> &'static str {
153        match self {
154            Compiler::ZigCc => "zig-cc",
155            Compiler::Clang => "clang",
156            Compiler::Gcc => "gcc",
157        }
158    }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
162#[serde(rename_all = "lowercase")]
163pub enum LinkMode {
164    #[default]
165    Static,
166    Dynamic,
167}
168
169impl LinkMode {
170    pub fn as_str(&self) -> &'static str {
171        match self {
172            LinkMode::Static => "static",
173            LinkMode::Dynamic => "dynamic",
174        }
175    }
176}
177
178fn default_target() -> String {
179    "x86_64-linux-musl".to_string()
180}
181
182impl Recipe {
183    /// Parsea una receta desde su forma TOML. `base_dir` queda vacío: si la receta declara
184    /// `patches`, el caller debe fijar `base_dir` antes de hashear, o usar `load_from_path`.
185    pub fn from_toml(s: &str) -> crate::Result<Recipe> {
186        toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string()))
187    }
188
189    /// Serializa la receta a TOML. Útil para el sidecar de provenance que `hammer-build`
190    /// escribe junto al artefacto en el store. `base_dir` no se serializa (es transient).
191    pub fn to_toml(&self) -> crate::Result<String> {
192        toml::to_string(self).map_err(|e| crate::Error::Recipe(e.to_string()))
193    }
194
195    /// Carga una receta desde el disco. `base_dir` se fija al directorio que contiene el
196    /// archivo, de modo que `source.patches` resuelve relativo a ahí (estilo Cargo).
197    pub fn load_from_path(path: impl AsRef<Path>) -> crate::Result<Recipe> {
198        let path = path.as_ref();
199        let text = std::fs::read_to_string(path)?;
200        let mut recipe = Recipe::from_toml(&text)?;
201        if let Some(parent) = path.parent() {
202            recipe.base_dir = parent.to_path_buf();
203        }
204        Ok(recipe)
205    }
206
207    /// Las entradas canónicas que alimentan el `ArtifactHash` de esta receta. Incluye el
208    /// **contenido** de cada patch (no su ruta), de modo que renombrar un archivo no cambia el
209    /// hash y editarlo sí lo hace. Ver `docs/02-build-lab.md` §2.
210    pub fn hash_inputs(
211        &self,
212        dep_hashes: &[ArtifactHash],
213    ) -> crate::Result<Vec<Vec<u8>>> {
214        // El identificador inmutable del contenido fuente: el commit en modo git, el
215        // sha256 del archivo en modo tarball. Cualquiera de los dos es un puntero al
216        // contenido exacto, lo que basta para reproducibilidad.
217        let source_id = match self.source.kind()? {
218            SourceKind::Git { commit, .. } => format!("git:{commit}"),
219            SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"),
220        };
221        let mut v: Vec<Vec<u8>> = vec![
222            source_id.into_bytes(),
223            self.build.compiler.as_str().as_bytes().to_vec(),
224            self.build.target.as_bytes().to_vec(),
225            self.build.link.as_str().as_bytes().to_vec(),
226        ];
227        // Sólo entra al hash si está fijado: una receta sin `zig_version` mantiene su hash de antes
228        // de existir el campo (compatibilidad hacia atrás; el baseline 9adefb82 no se mueve).
229        if let Some(zv) = &self.build.zig_version {
230            v.push(format!("zig:{zv}").into_bytes());
231        }
232        for p in &self.source.patches {
233            let resolved = self.base_dir.join(p);
234            let bytes = std::fs::read(&resolved).map_err(|e| {
235                crate::Error::Recipe(format!("no pude leer patch {}: {e}", resolved.display()))
236            })?;
237            v.push(bytes);
238        }
239        for f in &self.build.flags {
240            v.push(f.as_bytes().to_vec());
241        }
242        // Phases override: si alguien cambia el comando del compile, el artefacto cambia.
243        // Etiquetamos cada uno para que añadir un override luego no colisione con un flag.
244        for (label, val) in [
245            ("phase:configure", &self.build.phases.configure),
246            ("phase:compile", &self.build.phases.compile),
247            ("phase:install", &self.build.phases.install),
248        ] {
249            if let Some(cmd) = val {
250                v.push(format!("{label}={cmd}").into_bytes());
251            }
252        }
253        for d in dep_hashes {
254            v.push(d.as_str().as_bytes().to_vec());
255        }
256        Ok(v)
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    const SAMPLE: &str = r#"
265name    = "grep"
266version = "3.11"
267
268[source]
269repo   = "git://git.savannah.gnu.org/grep.git"
270commit = "a1b2c3d4e5f6"
271
272[build]
273compiler = "zig-cc"
274target   = "x86_64-linux-musl"
275link     = "static"
276flags    = ["--enable-perl-regexp"]
277
278[deps]
279build = ["pcre2"]
280"#;
281
282    #[test]
283    fn parse_full() {
284        let r = Recipe::from_toml(SAMPLE).expect("parse");
285        assert_eq!(r.name, "grep");
286        assert!(matches!(
287            r.source.kind().unwrap(),
288            SourceKind::Git { commit: "a1b2c3d4e5f6", .. }
289        ));
290        assert_eq!(r.build.compiler, Compiler::ZigCc);
291        assert_eq!(r.build.link, LinkMode::Static);
292        assert_eq!(r.deps.build, vec!["pcre2".to_string()]);
293    }
294
295    #[test]
296    fn to_toml_roundtrips_via_from_toml() {
297        let r1 = Recipe::from_toml(SAMPLE).unwrap();
298        let serialized = r1.to_toml().expect("to_toml");
299        let r2 = Recipe::from_toml(&serialized).expect("from_toml(roundtrip)");
300        assert_eq!(r2.name, r1.name);
301        assert_eq!(r2.version, r1.version);
302        assert_eq!(r2.build.compiler, r1.build.compiler);
303        assert_eq!(r2.build.link, r1.build.link);
304        assert_eq!(r2.build.flags, r1.build.flags);
305        assert_eq!(r2.deps.build, r1.deps.build);
306        // El kind del source también roundtripea.
307        match (r1.source.kind().unwrap(), r2.source.kind().unwrap()) {
308            (SourceKind::Git { commit: c1, .. }, SourceKind::Git { commit: c2, .. }) => {
309                assert_eq!(c1, c2);
310            }
311            _ => panic!("source kind cambió en el roundtrip"),
312        }
313    }
314
315    #[test]
316    fn parse_tarball_source() {
317        let s = r#"
318name = "grep"
319version = "3.11"
320[source]
321tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
322sha256  = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
323[build]
324"#;
325        let r = Recipe::from_toml(s).expect("parse");
326        assert!(matches!(r.source.kind().unwrap(), SourceKind::Tarball { .. }));
327        assert_eq!(r.source.strip_components, 1);
328    }
329
330    #[test]
331    fn source_mixed_modes_rejected() {
332        let s = r#"
333name = "x"
334version = "0"
335[source]
336repo = "git://x"
337commit = "deadbeef"
338tarball = "https://x/a.tar.gz"
339sha256 = "abc"
340[build]
341"#;
342        let r = Recipe::from_toml(s).unwrap();
343        let err = r.source.kind().unwrap_err().to_string();
344        assert!(err.contains("no ambos"), "msg = {err}");
345    }
346
347    #[test]
348    fn source_missing_fields_rejected() {
349        let s = r#"
350name = "x"
351version = "0"
352[source]
353repo = "git://x"
354[build]
355"#;
356        let r = Recipe::from_toml(s).unwrap();
357        let err = r.source.kind().unwrap_err().to_string();
358        assert!(err.contains("faltan campos"), "msg = {err}");
359    }
360
361    #[test]
362    fn tarball_and_git_hash_to_different_values() {
363        let git = Recipe::from_toml(SAMPLE).unwrap();
364        let tar_toml = r#"
365name = "grep"
366version = "3.11"
367[source]
368tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
369sha256  = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
370[build]
371flags = ["--enable-perl-regexp"]
372"#;
373        let tar = Recipe::from_toml(tar_toml).unwrap();
374        assert_ne!(git.hash_inputs(&[]).unwrap(), tar.hash_inputs(&[]).unwrap());
375    }
376
377    #[test]
378    fn parse_minimal_uses_defaults() {
379        let s = r#"
380name = "x"
381version = "0"
382[source]
383repo = "git://x"
384commit = "deadbeef"
385[build]
386"#;
387        let r = Recipe::from_toml(s).expect("parse");
388        assert_eq!(r.build.compiler, Compiler::ZigCc); // default
389        assert_eq!(r.build.target, "x86_64-linux-musl"); // default
390        assert_eq!(r.build.link, LinkMode::Static); // default
391        assert!(r.deps.build.is_empty());
392    }
393
394    #[test]
395    fn hash_no_patches_is_deterministic() {
396        let r = Recipe::from_toml(SAMPLE).unwrap();
397        let a = r.hash_inputs(&[]).unwrap();
398        let b = r.hash_inputs(&[]).unwrap();
399        assert_eq!(a, b);
400    }
401
402    #[test]
403    fn hash_uses_patch_contents_not_path() {
404        let dir1 = tempfile::tempdir().unwrap();
405        let dir2 = tempfile::tempdir().unwrap();
406        std::fs::write(dir1.path().join("p.patch"), b"diff --content").unwrap();
407        std::fs::write(dir2.path().join("otro-nombre.patch"), b"diff --content").unwrap();
408
409        let mut r1 = Recipe::from_toml(SAMPLE).unwrap();
410        r1.source.patches = vec!["p.patch".into()];
411        r1.base_dir = dir1.path().to_path_buf();
412
413        let mut r2 = Recipe::from_toml(SAMPLE).unwrap();
414        r2.source.patches = vec!["otro-nombre.patch".into()];
415        r2.base_dir = dir2.path().to_path_buf();
416
417        // Mismo CONTENIDO ⇒ misma entrada al hash, aunque las rutas sean distintas.
418        assert_eq!(r1.hash_inputs(&[]).unwrap(), r2.hash_inputs(&[]).unwrap());
419    }
420
421    #[test]
422    fn hash_changes_when_patch_content_changes() {
423        let dir = tempfile::tempdir().unwrap();
424        let patch = dir.path().join("p.patch");
425        std::fs::write(&patch, b"version A").unwrap();
426
427        let mut r = Recipe::from_toml(SAMPLE).unwrap();
428        r.source.patches = vec!["p.patch".into()];
429        r.base_dir = dir.path().to_path_buf();
430        let h_a = r.hash_inputs(&[]).unwrap();
431
432        std::fs::write(&patch, b"version B").unwrap();
433        let h_b = r.hash_inputs(&[]).unwrap();
434        assert_ne!(h_a, h_b);
435    }
436
437    #[test]
438    fn missing_patch_reports_path() {
439        let mut r = Recipe::from_toml(SAMPLE).unwrap();
440        r.source.patches = vec!["no-existe.patch".into()];
441        r.base_dir = PathBuf::from("/tmp/seguro-que-no-existe-hammer");
442        let err = r.hash_inputs(&[]).unwrap_err().to_string();
443        assert!(err.contains("no-existe.patch"), "msg = {err}");
444    }
445
446    #[test]
447    fn load_from_path_sets_base_dir() {
448        let dir = tempfile::tempdir().unwrap();
449        let recipe_path = dir.path().join("foo.toml");
450        std::fs::write(&recipe_path, SAMPLE).unwrap();
451        let r = Recipe::load_from_path(&recipe_path).unwrap();
452        assert_eq!(r.base_dir, dir.path());
453    }
454}