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…
⎇ 339a7b07ef8d72e101fecfbd722c2f8feb8a7d1c
crates/hammer-core/src/recipe.rs
History
Who touched what
raw
Etapa F paquetería #1: `hammer pack` — receta del corpus → paquete .swm (source_patch)
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}