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…
⎇ 98f0b2d228ffebb06941dfc1f436167ab40d8f21
crates/hammer-core/src/recipe.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
Fase 4 — provenance en `hammer export` (sidecar de receta en el store)
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}