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…

Rust 45TOML 36Markdown 26Shell 7878 Definiciones

⎇ 98f0b2d228ffebb06941dfc1f436167ab40d8f21 crates/hammer-core/src/recipe.rs Rust · 434 líneas · 14.5 KB Historia Quién tocó qué crudo

Fase 4 — provenance en `hammer export` (sidecar de receta en el store) de036d366 · 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    /// Overrides explícitos de las fases del build. Lo no especificado se decide por
100    /// heurística en el lab (ver `docs/02-build-lab.md` §4).
101    #[serde(default)]
102    pub phases: Phases,
103}
104
105/// Comandos shell que se ejecutan en el sandbox por cada fase. Cualquiera puede ser `None`:
106/// el lab lo deriva por heurística (autotools / cmake / meson / make).
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct Phases {
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub configure: Option<String>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub compile: Option<String>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub install: Option<String>,
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct Deps {
119    #[serde(default)]
120    pub build: Vec<String>,
121    #[serde(default)]
122    pub runtime: Vec<String>,
123}
124
125/// Compilador del lab, POR RECETA (no global). `zig-cc` por defecto; escotilla a clang/gcc
126/// para paquetes con gcc-ismos. Ver ADR 0003.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
128#[serde(rename_all = "kebab-case")]
129pub enum Compiler {
130    #[default]
131    ZigCc,
132    Clang,
133    Gcc,
134}
135
136impl Compiler {
137    pub fn as_str(&self) -> &'static str {
138        match self {
139            Compiler::ZigCc => "zig-cc",
140            Compiler::Clang => "clang",
141            Compiler::Gcc => "gcc",
142        }
143    }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
147#[serde(rename_all = "lowercase")]
148pub enum LinkMode {
149    #[default]
150    Static,
151    Dynamic,
152}
153
154impl LinkMode {
155    pub fn as_str(&self) -> &'static str {
156        match self {
157            LinkMode::Static => "static",
158            LinkMode::Dynamic => "dynamic",
159        }
160    }
161}
162
163fn default_target() -> String {
164    "x86_64-linux-musl".to_string()
165}
166
167impl Recipe {
168    /// Parsea una receta desde su forma TOML. `base_dir` queda vacío: si la receta declara
169    /// `patches`, el caller debe fijar `base_dir` antes de hashear, o usar `load_from_path`.
170    pub fn from_toml(s: &str) -> crate::Result<Recipe> {
171        toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string()))
172    }
173
174    /// Serializa la receta a TOML. Útil para el sidecar de provenance que `hammer-build`
175    /// escribe junto al artefacto en el store. `base_dir` no se serializa (es transient).
176    pub fn to_toml(&self) -> crate::Result<String> {
177        toml::to_string(self).map_err(|e| crate::Error::Recipe(e.to_string()))
178    }
179
180    /// Carga una receta desde el disco. `base_dir` se fija al directorio que contiene el
181    /// archivo, de modo que `source.patches` resuelve relativo a ahí (estilo Cargo).
182    pub fn load_from_path(path: impl AsRef<Path>) -> crate::Result<Recipe> {
183        let path = path.as_ref();
184        let text = std::fs::read_to_string(path)?;
185        let mut recipe = Recipe::from_toml(&text)?;
186        if let Some(parent) = path.parent() {
187            recipe.base_dir = parent.to_path_buf();
188        }
189        Ok(recipe)
190    }
191
192    /// Las entradas canónicas que alimentan el `ArtifactHash` de esta receta. Incluye el
193    /// **contenido** de cada patch (no su ruta), de modo que renombrar un archivo no cambia el
194    /// hash y editarlo sí lo hace. Ver `docs/02-build-lab.md` §2.
195    pub fn hash_inputs(
196        &self,
197        dep_hashes: &[ArtifactHash],
198    ) -> crate::Result<Vec<Vec<u8>>> {
199        // El identificador inmutable del contenido fuente: el commit en modo git, el
200        // sha256 del archivo en modo tarball. Cualquiera de los dos es un puntero al
201        // contenido exacto, lo que basta para reproducibilidad.
202        let source_id = match self.source.kind()? {
203            SourceKind::Git { commit, .. } => format!("git:{commit}"),
204            SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"),
205        };
206        let mut v: Vec<Vec<u8>> = vec![
207            source_id.into_bytes(),
208            self.build.compiler.as_str().as_bytes().to_vec(),
209            self.build.target.as_bytes().to_vec(),
210            self.build.link.as_str().as_bytes().to_vec(),
211        ];
212        for p in &self.source.patches {
213            let resolved = self.base_dir.join(p);
214            let bytes = std::fs::read(&resolved).map_err(|e| {
215                crate::Error::Recipe(format!("no pude leer patch {}: {e}", resolved.display()))
216            })?;
217            v.push(bytes);
218        }
219        for f in &self.build.flags {
220            v.push(f.as_bytes().to_vec());
221        }
222        // Phases override: si alguien cambia el comando del compile, el artefacto cambia.
223        // Etiquetamos cada uno para que añadir un override luego no colisione con un flag.
224        for (label, val) in [
225            ("phase:configure", &self.build.phases.configure),
226            ("phase:compile", &self.build.phases.compile),
227            ("phase:install", &self.build.phases.install),
228        ] {
229            if let Some(cmd) = val {
230                v.push(format!("{label}={cmd}").into_bytes());
231            }
232        }
233        for d in dep_hashes {
234            v.push(d.as_str().as_bytes().to_vec());
235        }
236        Ok(v)
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    const SAMPLE: &str = r#"
245name    = "grep"
246version = "3.11"
247
248[source]
249repo   = "git://git.savannah.gnu.org/grep.git"
250commit = "a1b2c3d4e5f6"
251
252[build]
253compiler = "zig-cc"
254target   = "x86_64-linux-musl"
255link     = "static"
256flags    = ["--enable-perl-regexp"]
257
258[deps]
259build = ["pcre2"]
260"#;
261
262    #[test]
263    fn parse_full() {
264        let r = Recipe::from_toml(SAMPLE).expect("parse");
265        assert_eq!(r.name, "grep");
266        assert!(matches!(
267            r.source.kind().unwrap(),
268            SourceKind::Git { commit: "a1b2c3d4e5f6", .. }
269        ));
270        assert_eq!(r.build.compiler, Compiler::ZigCc);
271        assert_eq!(r.build.link, LinkMode::Static);
272        assert_eq!(r.deps.build, vec!["pcre2".to_string()]);
273    }
274
275    #[test]
276    fn to_toml_roundtrips_via_from_toml() {
277        let r1 = Recipe::from_toml(SAMPLE).unwrap();
278        let serialized = r1.to_toml().expect("to_toml");
279        let r2 = Recipe::from_toml(&serialized).expect("from_toml(roundtrip)");
280        assert_eq!(r2.name, r1.name);
281        assert_eq!(r2.version, r1.version);
282        assert_eq!(r2.build.compiler, r1.build.compiler);
283        assert_eq!(r2.build.link, r1.build.link);
284        assert_eq!(r2.build.flags, r1.build.flags);
285        assert_eq!(r2.deps.build, r1.deps.build);
286        // El kind del source también roundtripea.
287        match (r1.source.kind().unwrap(), r2.source.kind().unwrap()) {
288            (SourceKind::Git { commit: c1, .. }, SourceKind::Git { commit: c2, .. }) => {
289                assert_eq!(c1, c2);
290            }
291            _ => panic!("source kind cambió en el roundtrip"),
292        }
293    }
294
295    #[test]
296    fn parse_tarball_source() {
297        let s = r#"
298name = "grep"
299version = "3.11"
300[source]
301tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
302sha256  = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
303[build]
304"#;
305        let r = Recipe::from_toml(s).expect("parse");
306        assert!(matches!(r.source.kind().unwrap(), SourceKind::Tarball { .. }));
307        assert_eq!(r.source.strip_components, 1);
308    }
309
310    #[test]
311    fn source_mixed_modes_rejected() {
312        let s = r#"
313name = "x"
314version = "0"
315[source]
316repo = "git://x"
317commit = "deadbeef"
318tarball = "https://x/a.tar.gz"
319sha256 = "abc"
320[build]
321"#;
322        let r = Recipe::from_toml(s).unwrap();
323        let err = r.source.kind().unwrap_err().to_string();
324        assert!(err.contains("no ambos"), "msg = {err}");
325    }
326
327    #[test]
328    fn source_missing_fields_rejected() {
329        let s = r#"
330name = "x"
331version = "0"
332[source]
333repo = "git://x"
334[build]
335"#;
336        let r = Recipe::from_toml(s).unwrap();
337        let err = r.source.kind().unwrap_err().to_string();
338        assert!(err.contains("faltan campos"), "msg = {err}");
339    }
340
341    #[test]
342    fn tarball_and_git_hash_to_different_values() {
343        let git = Recipe::from_toml(SAMPLE).unwrap();
344        let tar_toml = r#"
345name = "grep"
346version = "3.11"
347[source]
348tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
349sha256  = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
350[build]
351flags = ["--enable-perl-regexp"]
352"#;
353        let tar = Recipe::from_toml(tar_toml).unwrap();
354        assert_ne!(git.hash_inputs(&[]).unwrap(), tar.hash_inputs(&[]).unwrap());
355    }
356
357    #[test]
358    fn parse_minimal_uses_defaults() {
359        let s = r#"
360name = "x"
361version = "0"
362[source]
363repo = "git://x"
364commit = "deadbeef"
365[build]
366"#;
367        let r = Recipe::from_toml(s).expect("parse");
368        assert_eq!(r.build.compiler, Compiler::ZigCc); // default
369        assert_eq!(r.build.target, "x86_64-linux-musl"); // default
370        assert_eq!(r.build.link, LinkMode::Static); // default
371        assert!(r.deps.build.is_empty());
372    }
373
374    #[test]
375    fn hash_no_patches_is_deterministic() {
376        let r = Recipe::from_toml(SAMPLE).unwrap();
377        let a = r.hash_inputs(&[]).unwrap();
378        let b = r.hash_inputs(&[]).unwrap();
379        assert_eq!(a, b);
380    }
381
382    #[test]
383    fn hash_uses_patch_contents_not_path() {
384        let dir1 = tempfile::tempdir().unwrap();
385        let dir2 = tempfile::tempdir().unwrap();
386        std::fs::write(dir1.path().join("p.patch"), b"diff --content").unwrap();
387        std::fs::write(dir2.path().join("otro-nombre.patch"), b"diff --content").unwrap();
388
389        let mut r1 = Recipe::from_toml(SAMPLE).unwrap();
390        r1.source.patches = vec!["p.patch".into()];
391        r1.base_dir = dir1.path().to_path_buf();
392
393        let mut r2 = Recipe::from_toml(SAMPLE).unwrap();
394        r2.source.patches = vec!["otro-nombre.patch".into()];
395        r2.base_dir = dir2.path().to_path_buf();
396
397        // Mismo CONTENIDO ⇒ misma entrada al hash, aunque las rutas sean distintas.
398        assert_eq!(r1.hash_inputs(&[]).unwrap(), r2.hash_inputs(&[]).unwrap());
399    }
400
401    #[test]
402    fn hash_changes_when_patch_content_changes() {
403        let dir = tempfile::tempdir().unwrap();
404        let patch = dir.path().join("p.patch");
405        std::fs::write(&patch, b"version A").unwrap();
406
407        let mut r = Recipe::from_toml(SAMPLE).unwrap();
408        r.source.patches = vec!["p.patch".into()];
409        r.base_dir = dir.path().to_path_buf();
410        let h_a = r.hash_inputs(&[]).unwrap();
411
412        std::fs::write(&patch, b"version B").unwrap();
413        let h_b = r.hash_inputs(&[]).unwrap();
414        assert_ne!(h_a, h_b);
415    }
416
417    #[test]
418    fn missing_patch_reports_path() {
419        let mut r = Recipe::from_toml(SAMPLE).unwrap();
420        r.source.patches = vec!["no-existe.patch".into()];
421        r.base_dir = PathBuf::from("/tmp/seguro-que-no-existe-hammer");
422        let err = r.hash_inputs(&[]).unwrap_err().to_string();
423        assert!(err.contains("no-existe.patch"), "msg = {err}");
424    }
425
426    #[test]
427    fn load_from_path_sets_base_dir() {
428        let dir = tempfile::tempdir().unwrap();
429        let recipe_path = dir.path().join("foo.toml");
430        std::fs::write(&recipe_path, SAMPLE).unwrap();
431        let r = Recipe::load_from_path(&recipe_path).unwrap();
432        assert_eq!(r.base_dir, dir.path());
433    }
434}