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 1574Shell 204Rust 111Markdown 993763 Definiciones

⎇ b580c5bfc5b134c64a571136441f585c025deaaf crates/hammer-core/src/recipe.rs Rust · 851 líneas · 32.2 KB Historia Quién tocó qué crudo

fetch: cargo_vendor_dir per-receta — no pisar el vendor/ propio del proyecto b580c5bfc · 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    /// Evidencia de COMPORTAMIENTO que acompaña la receta (H1 — proof-carrying recipes, SDD 15).
18    /// Opcional. **No entra en `hash_inputs`**: certifica comportamiento, no identidad ⇒ agregar o
19    /// cambiar evidencia NO mueve el `artifact_hash` (el baseline de reproducibilidad no se toca).
20    #[serde(default, skip_serializing_if = "Evidence::is_empty")]
21    pub evidence: Evidence,
22    /// **Superficies del sistema** que este paquete reclama y requiere (H4b — configs
23    /// compatibles, SDD 15 §H4). Igual que `evidence`: **NO entra en `hash_inputs`** — declara
24    /// TOPOLOGÍA (qué toca / de qué depende), no identidad del artefacto; agregar o cambiar el
25    /// bloque no mueve el `artifact_hash`. Lo consume el gate de compatibilidad de `install`
26    /// (`crate::compat`) contra el estado instalado, no el build.
27    #[serde(default, skip_serializing_if = "Slots::is_empty")]
28    pub slots: Slots,
29    /// Directorio base contra el que se resuelven rutas relativas de la receta
30    /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío.
31    #[serde(skip, default)]
32    pub base_dir: PathBuf,
33}
34
35/// `Source` admite dos modos, mutuamente excluyentes, validados en runtime:
36///
37/// 1. **Git** — `repo` + `commit` (SHA fijado, nunca HEAD; ADR 0006). Apropiado para
38///    upstreams cuyo árbol de git ya es buildable o cuando queremos pinear un parche que
39///    aún no salió en release.
40/// 2. **Tarball** — `tarball` (URL) + `sha256`. Apropiado para releases estables: la
41///    mayoría de proyectos GNU/autotools traen `configure` ya generado y embebidos los
42///    submódulos (gnulib, etc.), evitando la necesidad de `./bootstrap` en el sandbox.
43///
44/// El hash de entrada del artefacto usa `commit` o `sha256` según el modo, ambos
45/// identificadores inmutables del contenido fuente.
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct Source {
48    // --- modo git ---
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub repo: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub commit: Option<String>,
53    // --- modo tarball ---
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub tarball: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub sha256: Option<String>,
58    /// Cuántos componentes recortar al extraer el tarball. Default 1 (los releases GNU
59    /// siempre tienen un único top-level `proyecto-version/`).
60    #[serde(default = "default_strip")]
61    pub strip_components: usize,
62    // --- común ---
63    /// Destino del vendoreo cargo (default `vendor`). Override per-receta para proyectos que
64    /// commitean su PROPIO `vendor/` (p.ej. mise → `vendor/aqua-registry`): `cargo vendor` borra
65    /// lo que no reconoce, así que se vendorea a un subdir aparte para no pisarlo. El fragmento
66    /// `.cargo/config.toml` que emite `cargo vendor` ya apunta al dir que se le pasa.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub cargo_vendor_dir: Option<String>,
69    #[serde(default)]
70    pub patches: Vec<String>,
71}
72
73fn default_strip() -> usize {
74    1
75}
76
77/// Discriminante validado del `Source`. Se calcula bajo demanda; los campos de
78/// `Source` permanecen flat en TOML para que las recetas sean legibles.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum SourceKind<'a> {
81    Git { repo: &'a str, commit: &'a str },
82    Tarball { url: &'a str, sha256: &'a str },
83}
84
85impl Source {
86    pub fn kind(&self) -> crate::Result<SourceKind<'_>> {
87        match (
88            self.repo.as_deref(),
89            self.commit.as_deref(),
90            self.tarball.as_deref(),
91            self.sha256.as_deref(),
92        ) {
93            (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }),
94            (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }),
95            (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => Err(
96                crate::Error::Recipe(
97                    "source: usa repo+commit O tarball+sha256, no ambos".into(),
98                ),
99            ),
100            _ => Err(crate::Error::Recipe(
101                "source: faltan campos; necesito (repo+commit) o (tarball+sha256)".into(),
102            )),
103        }
104    }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Build {
109    #[serde(default)]
110    pub compiler: Compiler,
111    #[serde(default = "default_target")]
112    pub target: String,
113    #[serde(default)]
114    pub link: LinkMode,
115    #[serde(default)]
116    pub flags: Vec<String>,
117    /// Versión de zig a usar para ESTA receta, distinta del zig por defecto del lab. Sirve de
118    /// escotilla por-receta para esquivar la regresión de zig 0.14 (que miscompila ciertos binarios
119    /// musl dinámicos: flex/cmake/python/binutils/…): `zig_version = "0.13.0"` los construye con el
120    /// zig bueno sin tocar el toolchain global ni el baseline de reproducibilidad (estas piezas son
121    /// herramientas, no inputs del of_tree del 4/4). `None` ⇒ el zig por defecto. Ver SDD 11 §7.2b.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub zig_version: Option<String>,
124    /// Habilita CGO en un build Go (`BuildSys::Go`). Por defecto el lab compila con `CGO_ENABLED=0`
125    /// (Go puro, estático, sin C). Algunos proyectos necesitan cgo para drivers con C bundled (p.ej.
126    /// el driver sqlite3 de mattn en usql/sq/dolt): con `cgo = true` el compile usa `CGO_ENABLED=1`
127    /// + `CC="zig cc"` (musl nativo del sandbox) y liga estático (`-linkmode=external -extldflags=
128    /// -static`). Sólo para C bundled; cgo contra libs C EXTERNAS (gpgme/libvirt) requiere además
129    /// esas recetas en el sandbox. No-op fuera de BuildSys::Go.
130    #[serde(default)]
131    pub cgo: bool,
132    /// Subdirectorio donde vive el módulo Go cuando NO está en la raíz del repo (p.ej. dolt → `go`,
133    /// csvtk → su subdir). El lab vendorea, detecta el main y compila DENTRO de `<src>/<subdir>`.
134    /// Vacío = raíz (lo normal). Sólo aplica a BuildSys::Go.
135    #[serde(default)]
136    pub subdir: String,
137    /// Overrides explícitos de las fases del build. Lo no especificado se decide por
138    /// heurística en el lab (ver `docs/02-build-lab.md` §4).
139    #[serde(default)]
140    pub phases: Phases,
141}
142
143/// Comandos shell que se ejecutan en el sandbox por cada fase. Cualquiera puede ser `None`:
144/// el lab lo deriva por heurística (autotools / cmake / meson / make).
145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146pub struct Phases {
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub configure: Option<String>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub compile: Option<String>,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub install: Option<String>,
153}
154
155impl Phases {
156    /// `true` si ninguna fase está sobreescrita (todo se decide por heurística). Sirve a
157    /// serde (`skip_serializing_if`) para que un `.swm` sin fases custom no las emita.
158    pub fn is_empty(&self) -> bool {
159        self.configure.is_none() && self.compile.is_none() && self.install.is_none()
160    }
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
164pub struct Deps {
165    #[serde(default)]
166    pub build: Vec<String>,
167    #[serde(default)]
168    pub runtime: Vec<String>,
169}
170
171impl Deps {
172    /// `true` si no hay ninguna dep (ni build ni runtime). Sirve a serde para no emitir el
173    /// bloque `deps` vacío en un `.swm`/receta.
174    pub fn is_empty(&self) -> bool {
175        self.build.is_empty() && self.runtime.is_empty()
176    }
177}
178
179/// Las **superficies del sistema** de una config/paquete (H4b — SDD 15 §H4). Una config del
180/// usuario = un conjunto de modificaciones direccionadas por contenido: cada modificación
181/// **reclama** una superficie (la escribe) y/o **requiere** otra (depende de ella a una versión
182/// exacta). El valor de cada slot es un hash `b3:…` del contenido — misma disciplina de "todo por
183/// hash" que el resto del stack.
184///
185/// El gate de compatibilidad (`crate::compat`) usa estos dos mapas contra el estado instalado:
186/// - un `requires` que no resuelve contra el estado ⇒ **incompatible** (el caso "modifiqué el
187///   protocolo de Wayland y bajo algo que depende del Wayland stock"); es el análogo, a nivel de
188///   sistema, de una referencia colgante.
189/// - un `claims` que pisa una superficie ya ocupada por otro contenido ⇒ **colisión = elección**
190///   (el caso "ya cambié el logo y bajo otra config que también lo cambia"), no un error.
191#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
192pub struct Slots {
193    /// Superficies que este paquete **escribe** → hash del contenido nuevo (`slot → b3:…`).
194    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
195    pub claims: std::collections::BTreeMap<String, String>,
196    /// Superficies de las que **depende** → versión exacta requerida (`slot → b3:…`).
197    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
198    pub requires: std::collections::BTreeMap<String, String>,
199}
200
201impl Slots {
202    /// `true` si no declara ninguna superficie (ni claims ni requires). Para serde: una receta
203    /// sin bloque `slots` no lo emite.
204    pub fn is_empty(&self) -> bool {
205        self.claims.is_empty() && self.requires.is_empty()
206    }
207
208    /// Valida la forma (schema, no compatibilidad): cada valor de slot debe ser un hash `b3:…`.
209    /// El gate de `install` corre esto antes de evaluar.
210    pub fn validate(&self) -> crate::Result<()> {
211        for (mapa, etiqueta) in [(&self.claims, "claims"), (&self.requires, "requires")] {
212            for (slot, hash) in mapa {
213                if !hash.starts_with("b3:") {
214                    return Err(crate::Error::Recipe(format!(
215                        "slots.{etiqueta}[{slot:?}]: el valor debe ser un hash `b3:…`, es {hash:?}"
216                    )));
217                }
218            }
219        }
220        Ok(())
221    }
222}
223
224/// Evidencia de comportamiento de una receta (H1 — proof-carrying recipes, SDD 15 §H1). La IA
225/// entrega, junto al build, un conjunto de `checks` que el verificador (`hammer swm-verify
226/// --evidence`) corre DENTRO del sandbox reproducible; la mutación sólo se propone si todos pasan.
227/// La confianza vive en el checker (pequeño, auditable), no en el generador: el sistema mejora solo
228/// sin poder corromperse solo. Deliberadamente FUERA de `Recipe::hash_inputs` (comportamiento ≠
229/// identidad). Frontera honesta: garantiza que *lo declarado pasa*, no que *lo declarado basta*.
230#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
231pub struct Evidence {
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub checks: Vec<EvidenceCheck>,
234}
235
236impl Evidence {
237    /// `true` si no hay ningún check. Sirve a serde (`skip_serializing_if`) para no emitir el
238    /// bloque `evidence` vacío en una receta/`.swm`.
239    pub fn is_empty(&self) -> bool {
240        self.checks.is_empty()
241    }
242
243    /// Valida la forma del bloque (schema, no ejecución): cada `cmd` no vacío y cada
244    /// `expected_output`, si está, con prefijo `b3:`. El checker de H1b corre esto antes de ejecutar.
245    pub fn validate(&self) -> crate::Result<()> {
246        for (i, c) in self.checks.iter().enumerate() {
247            if c.cmd.trim().is_empty() {
248                return Err(crate::Error::Recipe(format!(
249                    "evidence.checks[{i}] ({}): `cmd` vacío",
250                    c.kind.as_str()
251                )));
252            }
253            if let Some(h) = &c.expected_output {
254                if !h.starts_with("b3:") {
255                    return Err(crate::Error::Recipe(format!(
256                        "evidence.checks[{i}] ({}): `expected_output` debe ser un hash `b3:…`, es {h:?}",
257                        c.kind.as_str()
258                    )));
259                }
260            }
261        }
262        Ok(())
263    }
264}
265
266/// Un ítem de evidencia: un comando + el resultado exigido. `kind` estratifica la CONFIANZA
267/// (`cmd-exit` < `proptest` < `contract` < `kani`), que se reporta tal cual — H1 no finge que todo
268/// es prueba formal.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct EvidenceCheck {
271    pub kind: EvidenceKind,
272    /// Comando shell a correr en el sandbox reproducible tras el build (misma vía que las fases).
273    pub cmd: String,
274    /// Exit code exigido. Default 0.
275    #[serde(default)]
276    pub expected_exit: i32,
277    /// Hash BLAKE3 (`b3:…`) opcional del stdout, para checks deterministas cuyo OUTPUT también se
278    /// ancla (no sólo el exit). `None` ⇒ sólo se exige el exit code.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub expected_output: Option<String>,
281}
282
283/// Estrato de confianza de un `EvidenceCheck`, en orden creciente (`Ord` = comparación de estratos).
284#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
285#[serde(rename_all = "kebab-case")]
286pub enum EvidenceKind {
287    /// El comando sale con `expected_exit`. La garantía más débil (un smoke test).
288    CmdExit,
289    /// Property tests (proptest/quickcheck): pasan sobre entradas generadas.
290    Proptest,
291    /// Contratos / aserciones de pre y post-condición.
292    Contract,
293    /// Prueba formal acotada (Kani/Creusot). La garantía más fuerte.
294    Kani,
295}
296
297impl EvidenceKind {
298    /// Nivel numérico de confianza (0 = más débil), para reportar/ordenar.
299    pub fn level(&self) -> u8 {
300        match self {
301            EvidenceKind::CmdExit => 0,
302            EvidenceKind::Proptest => 1,
303            EvidenceKind::Contract => 2,
304            EvidenceKind::Kani => 3,
305        }
306    }
307
308    pub fn as_str(&self) -> &'static str {
309        match self {
310            EvidenceKind::CmdExit => "cmd-exit",
311            EvidenceKind::Proptest => "proptest",
312            EvidenceKind::Contract => "contract",
313            EvidenceKind::Kani => "kani",
314        }
315    }
316}
317
318/// Veredicto de un `EvidenceCheck` tras ejecutarlo (H1b). Se computa comparando el resultado
319/// REAL (exit code + stdout) contra lo declarado — sin tocar el sandbox (eso lo hace el runner).
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct CheckOutcome {
322    pub passed: bool,
323    /// Explicación legible de por qué pasó o falló (para el reporte al humano).
324    pub detail: String,
325}
326
327impl EvidenceCheck {
328    /// Evalúa el resultado real de correr `cmd` contra lo declarado: exige `expected_exit` y, si
329    /// hay `expected_output`, que `blake3(stdout)` coincida. **Función pura** — la ejecución en el
330    /// sandbox reproducible es H1b; acá vive la lógica auditable de "¿el resultado es el exigido?".
331    pub fn evaluate(&self, exit_code: i32, stdout: &[u8]) -> CheckOutcome {
332        if exit_code != self.expected_exit {
333            return CheckOutcome {
334                passed: false,
335                detail: format!("exit {exit_code} ≠ esperado {}", self.expected_exit),
336            };
337        }
338        if let Some(want) = &self.expected_output {
339            let got = ArtifactHash::of_bytes(stdout);
340            if got.as_str() != want {
341                return CheckOutcome {
342                    passed: false,
343                    detail: format!("stdout {got} ≠ esperado {want}"),
344                };
345            }
346            return CheckOutcome {
347                passed: true,
348                detail: format!("exit {exit_code} ok + stdout {want} ok"),
349            };
350        }
351        CheckOutcome {
352            passed: true,
353            detail: format!("exit {exit_code} ok"),
354        }
355    }
356}
357
358/// Compilador del lab, POR RECETA (no global). `zig-cc` por defecto; escotilla a clang/gcc
359/// para paquetes con gcc-ismos. Ver ADR 0003.
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
361#[serde(rename_all = "kebab-case")]
362pub enum Compiler {
363    #[default]
364    ZigCc,
365    Clang,
366    Gcc,
367}
368
369impl Compiler {
370    pub fn as_str(&self) -> &'static str {
371        match self {
372            Compiler::ZigCc => "zig-cc",
373            Compiler::Clang => "clang",
374            Compiler::Gcc => "gcc",
375        }
376    }
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
380#[serde(rename_all = "lowercase")]
381pub enum LinkMode {
382    #[default]
383    Static,
384    Dynamic,
385}
386
387impl LinkMode {
388    pub fn as_str(&self) -> &'static str {
389        match self {
390            LinkMode::Static => "static",
391            LinkMode::Dynamic => "dynamic",
392        }
393    }
394}
395
396fn default_target() -> String {
397    "x86_64-linux-musl".to_string()
398}
399
400impl Recipe {
401    /// Parsea una receta desde su forma TOML. `base_dir` queda vacío: si la receta declara
402    /// `patches`, el caller debe fijar `base_dir` antes de hashear, o usar `load_from_path`.
403    pub fn from_toml(s: &str) -> crate::Result<Recipe> {
404        toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string()))
405    }
406
407    /// Serializa la receta a TOML. Útil para el sidecar de provenance que `hammer-build`
408    /// escribe junto al artefacto en el store. `base_dir` no se serializa (es transient).
409    pub fn to_toml(&self) -> crate::Result<String> {
410        toml::to_string(self).map_err(|e| crate::Error::Recipe(e.to_string()))
411    }
412
413    /// Carga una receta desde el disco. `base_dir` se fija al directorio que contiene el
414    /// archivo, de modo que `source.patches` resuelve relativo a ahí (estilo Cargo).
415    pub fn load_from_path(path: impl AsRef<Path>) -> crate::Result<Recipe> {
416        let path = path.as_ref();
417        let text = std::fs::read_to_string(path)?;
418        let mut recipe = Recipe::from_toml(&text)?;
419        if let Some(parent) = path.parent() {
420            recipe.base_dir = parent.to_path_buf();
421        }
422        Ok(recipe)
423    }
424
425    /// Las entradas canónicas que alimentan el `ArtifactHash` de esta receta. Incluye el
426    /// **contenido** de cada patch (no su ruta), de modo que renombrar un archivo no cambia el
427    /// hash y editarlo sí lo hace. Ver `docs/02-build-lab.md` §2.
428    pub fn hash_inputs(
429        &self,
430        dep_hashes: &[ArtifactHash],
431    ) -> crate::Result<Vec<Vec<u8>>> {
432        // El identificador inmutable del contenido fuente: el commit en modo git, el
433        // sha256 del archivo en modo tarball. Cualquiera de los dos es un puntero al
434        // contenido exacto, lo que basta para reproducibilidad.
435        let source_id = match self.source.kind()? {
436            SourceKind::Git { commit, .. } => format!("git:{commit}"),
437            SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"),
438        };
439        let mut v: Vec<Vec<u8>> = vec![
440            source_id.into_bytes(),
441            self.build.compiler.as_str().as_bytes().to_vec(),
442            self.build.target.as_bytes().to_vec(),
443            self.build.link.as_str().as_bytes().to_vec(),
444        ];
445        // Sólo entra al hash si está fijado: una receta sin `zig_version` mantiene su hash de antes
446        // de existir el campo (compatibilidad hacia atrás; el baseline 9adefb82 no se mueve).
447        if let Some(zv) = &self.build.zig_version {
448            v.push(format!("zig:{zv}").into_bytes());
449        }
450        for p in &self.source.patches {
451            let resolved = self.base_dir.join(p);
452            let bytes = std::fs::read(&resolved).map_err(|e| {
453                crate::Error::Recipe(format!("no pude leer patch {}: {e}", resolved.display()))
454            })?;
455            v.push(bytes);
456        }
457        for f in &self.build.flags {
458            v.push(f.as_bytes().to_vec());
459        }
460        // Phases override: si alguien cambia el comando del compile, el artefacto cambia.
461        // Etiquetamos cada uno para que añadir un override luego no colisione con un flag.
462        for (label, val) in [
463            ("phase:configure", &self.build.phases.configure),
464            ("phase:compile", &self.build.phases.compile),
465            ("phase:install", &self.build.phases.install),
466        ] {
467            if let Some(cmd) = val {
468                v.push(format!("{label}={cmd}").into_bytes());
469            }
470        }
471        for d in dep_hashes {
472            v.push(d.as_str().as_bytes().to_vec());
473        }
474        Ok(v)
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    const SAMPLE: &str = r#"
483name    = "grep"
484version = "3.11"
485
486[source]
487repo   = "git://git.savannah.gnu.org/grep.git"
488commit = "a1b2c3d4e5f6"
489
490[build]
491compiler = "zig-cc"
492target   = "x86_64-linux-musl"
493link     = "static"
494flags    = ["--enable-perl-regexp"]
495
496[deps]
497build = ["pcre2"]
498"#;
499
500    #[test]
501    fn parse_full() {
502        let r = Recipe::from_toml(SAMPLE).expect("parse");
503        assert_eq!(r.name, "grep");
504        assert!(matches!(
505            r.source.kind().unwrap(),
506            SourceKind::Git { commit: "a1b2c3d4e5f6", .. }
507        ));
508        assert_eq!(r.build.compiler, Compiler::ZigCc);
509        assert_eq!(r.build.link, LinkMode::Static);
510        assert_eq!(r.deps.build, vec!["pcre2".to_string()]);
511    }
512
513    #[test]
514    fn to_toml_roundtrips_via_from_toml() {
515        let r1 = Recipe::from_toml(SAMPLE).unwrap();
516        let serialized = r1.to_toml().expect("to_toml");
517        let r2 = Recipe::from_toml(&serialized).expect("from_toml(roundtrip)");
518        assert_eq!(r2.name, r1.name);
519        assert_eq!(r2.version, r1.version);
520        assert_eq!(r2.build.compiler, r1.build.compiler);
521        assert_eq!(r2.build.link, r1.build.link);
522        assert_eq!(r2.build.flags, r1.build.flags);
523        assert_eq!(r2.deps.build, r1.deps.build);
524        // El kind del source también roundtripea.
525        match (r1.source.kind().unwrap(), r2.source.kind().unwrap()) {
526            (SourceKind::Git { commit: c1, .. }, SourceKind::Git { commit: c2, .. }) => {
527                assert_eq!(c1, c2);
528            }
529            _ => panic!("source kind cambió en el roundtrip"),
530        }
531    }
532
533    #[test]
534    fn parse_tarball_source() {
535        let s = r#"
536name = "grep"
537version = "3.11"
538[source]
539tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
540sha256  = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
541[build]
542"#;
543        let r = Recipe::from_toml(s).expect("parse");
544        assert!(matches!(r.source.kind().unwrap(), SourceKind::Tarball { .. }));
545        assert_eq!(r.source.strip_components, 1);
546    }
547
548    #[test]
549    fn source_mixed_modes_rejected() {
550        let s = r#"
551name = "x"
552version = "0"
553[source]
554repo = "git://x"
555commit = "deadbeef"
556tarball = "https://x/a.tar.gz"
557sha256 = "abc"
558[build]
559"#;
560        let r = Recipe::from_toml(s).unwrap();
561        let err = r.source.kind().unwrap_err().to_string();
562        assert!(err.contains("no ambos"), "msg = {err}");
563    }
564
565    #[test]
566    fn source_missing_fields_rejected() {
567        let s = r#"
568name = "x"
569version = "0"
570[source]
571repo = "git://x"
572[build]
573"#;
574        let r = Recipe::from_toml(s).unwrap();
575        let err = r.source.kind().unwrap_err().to_string();
576        assert!(err.contains("faltan campos"), "msg = {err}");
577    }
578
579    #[test]
580    fn tarball_and_git_hash_to_different_values() {
581        let git = Recipe::from_toml(SAMPLE).unwrap();
582        let tar_toml = r#"
583name = "grep"
584version = "3.11"
585[source]
586tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
587sha256  = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
588[build]
589flags = ["--enable-perl-regexp"]
590"#;
591        let tar = Recipe::from_toml(tar_toml).unwrap();
592        assert_ne!(git.hash_inputs(&[]).unwrap(), tar.hash_inputs(&[]).unwrap());
593    }
594
595    #[test]
596    fn parse_minimal_uses_defaults() {
597        let s = r#"
598name = "x"
599version = "0"
600[source]
601repo = "git://x"
602commit = "deadbeef"
603[build]
604"#;
605        let r = Recipe::from_toml(s).expect("parse");
606        assert_eq!(r.build.compiler, Compiler::ZigCc); // default
607        assert_eq!(r.build.target, "x86_64-linux-musl"); // default
608        assert_eq!(r.build.link, LinkMode::Static); // default
609        assert!(r.deps.build.is_empty());
610    }
611
612    #[test]
613    fn hash_no_patches_is_deterministic() {
614        let r = Recipe::from_toml(SAMPLE).unwrap();
615        let a = r.hash_inputs(&[]).unwrap();
616        let b = r.hash_inputs(&[]).unwrap();
617        assert_eq!(a, b);
618    }
619
620    #[test]
621    fn hash_uses_patch_contents_not_path() {
622        let dir1 = tempfile::tempdir().unwrap();
623        let dir2 = tempfile::tempdir().unwrap();
624        std::fs::write(dir1.path().join("p.patch"), b"diff --content").unwrap();
625        std::fs::write(dir2.path().join("otro-nombre.patch"), b"diff --content").unwrap();
626
627        let mut r1 = Recipe::from_toml(SAMPLE).unwrap();
628        r1.source.patches = vec!["p.patch".into()];
629        r1.base_dir = dir1.path().to_path_buf();
630
631        let mut r2 = Recipe::from_toml(SAMPLE).unwrap();
632        r2.source.patches = vec!["otro-nombre.patch".into()];
633        r2.base_dir = dir2.path().to_path_buf();
634
635        // Mismo CONTENIDO ⇒ misma entrada al hash, aunque las rutas sean distintas.
636        assert_eq!(r1.hash_inputs(&[]).unwrap(), r2.hash_inputs(&[]).unwrap());
637    }
638
639    #[test]
640    fn hash_changes_when_patch_content_changes() {
641        let dir = tempfile::tempdir().unwrap();
642        let patch = dir.path().join("p.patch");
643        std::fs::write(&patch, b"version A").unwrap();
644
645        let mut r = Recipe::from_toml(SAMPLE).unwrap();
646        r.source.patches = vec!["p.patch".into()];
647        r.base_dir = dir.path().to_path_buf();
648        let h_a = r.hash_inputs(&[]).unwrap();
649
650        std::fs::write(&patch, b"version B").unwrap();
651        let h_b = r.hash_inputs(&[]).unwrap();
652        assert_ne!(h_a, h_b);
653    }
654
655    #[test]
656    fn missing_patch_reports_path() {
657        let mut r = Recipe::from_toml(SAMPLE).unwrap();
658        r.source.patches = vec!["no-existe.patch".into()];
659        r.base_dir = PathBuf::from("/tmp/seguro-que-no-existe-hammer");
660        let err = r.hash_inputs(&[]).unwrap_err().to_string();
661        assert!(err.contains("no-existe.patch"), "msg = {err}");
662    }
663
664    #[test]
665    fn load_from_path_sets_base_dir() {
666        let dir = tempfile::tempdir().unwrap();
667        let recipe_path = dir.path().join("foo.toml");
668        std::fs::write(&recipe_path, SAMPLE).unwrap();
669        let r = Recipe::load_from_path(&recipe_path).unwrap();
670        assert_eq!(r.base_dir, dir.path());
671    }
672
673    const WITH_EVIDENCE: &str = r#"
674name    = "grep"
675version = "3.11"
676[source]
677repo   = "git://git.savannah.gnu.org/grep.git"
678commit = "a1b2c3d4e5f6"
679[build]
680[deps]
681build = ["pcre2"]
682
683[[evidence.checks]]
684kind = "cmd-exit"
685cmd  = "grep --version"
686
687[[evidence.checks]]
688kind = "proptest"
689cmd  = "cd /src && cargo test --release proptest_"
690expected_exit = 0
691
692[[evidence.checks]]
693kind = "cmd-exit"
694cmd  = "printf hola | grep -q hola"
695expected_output = "b3:deadbeef"
696"#;
697
698    #[test]
699    fn parse_evidence_block() {
700        let r = Recipe::from_toml(WITH_EVIDENCE).expect("parse");
701        assert_eq!(r.evidence.checks.len(), 3);
702        assert_eq!(r.evidence.checks[0].kind, EvidenceKind::CmdExit);
703        assert_eq!(r.evidence.checks[0].cmd, "grep --version");
704        assert_eq!(r.evidence.checks[0].expected_exit, 0); // default
705        assert_eq!(r.evidence.checks[1].kind, EvidenceKind::Proptest);
706        assert_eq!(r.evidence.checks[2].expected_output.as_deref(), Some("b3:deadbeef"));
707        r.evidence.validate().expect("schema válido");
708    }
709
710    #[test]
711    fn evidence_absent_is_empty_and_omitted() {
712        let r = Recipe::from_toml(SAMPLE).unwrap();
713        assert!(r.evidence.is_empty());
714        // `skip_serializing_if` ⇒ una receta sin evidencia no emite el bloque.
715        let toml = r.to_toml().unwrap();
716        assert!(!toml.contains("evidence"), "toml no debería traer `evidence`: {toml}");
717    }
718
719    #[test]
720    fn evidence_does_not_affect_artifact_hash() {
721        // La clave de H1a: la evidencia certifica COMPORTAMIENTO, no IDENTIDAD ⇒ el hash del
722        // artefacto es idéntico con y sin bloque evidence (el baseline de reproducibilidad no se mueve).
723        // Comparamos LA MISMA receta con y sin evidencia (clon + limpieza), no dos recetas distintas.
724        let con = Recipe::from_toml(WITH_EVIDENCE).unwrap();
725        let mut sin = con.clone();
726        sin.evidence = Evidence::default();
727        assert!(con.evidence.checks.len() == 3 && sin.evidence.is_empty());
728        assert_eq!(
729            sin.hash_inputs(&[]).unwrap(),
730            con.hash_inputs(&[]).unwrap(),
731            "la evidencia NO debe entrar en hash_inputs"
732        );
733    }
734
735    #[test]
736    fn slots_round_trip_y_no_afectan_el_hash() {
737        // H4b: el bloque `slots` declara TOPOLOGÍA (qué toca / de qué depende), no identidad ⇒
738        // como la evidencia, NO entra en hash_inputs. Y round-trippea por TOML.
739        let con_toml = format!(
740            "{SAMPLE}\n[slots.claims]\nlogo = \"b3:rojo\"\n[slots.requires]\n\"wayland-protocol\" = \"b3:stock\"\n"
741        );
742        let con = Recipe::from_toml(&con_toml).unwrap();
743        assert_eq!(con.slots.claims.get("logo").unwrap(), "b3:rojo");
744        assert_eq!(con.slots.requires.get("wayland-protocol").unwrap(), "b3:stock");
745        con.slots.validate().unwrap();
746
747        // Round-trip: serializa y vuelve a parsear ⇒ mismos slots.
748        let re = Recipe::from_toml(&con.to_toml().unwrap()).unwrap();
749        assert_eq!(re.slots, con.slots);
750
751        // Mismo hash con y sin slots.
752        let mut sin = con.clone();
753        sin.slots = Slots::default();
754        assert!(!con.slots.is_empty() && sin.slots.is_empty());
755        assert_eq!(
756            sin.hash_inputs(&[]).unwrap(),
757            con.hash_inputs(&[]).unwrap(),
758            "los slots NO deben entrar en hash_inputs"
759        );
760    }
761
762    #[test]
763    fn slots_validate_rechaza_hash_sin_prefijo() {
764        let mut s = Slots::default();
765        s.claims.insert("logo".into(), "rojo".into()); // falta `b3:`
766        assert!(s.validate().is_err());
767    }
768
769    #[test]
770    fn evidence_validate_rejects_empty_cmd_and_bad_hash() {
771        let bad_cmd = r#"
772name = "x"
773version = "0"
774[source]
775repo = "git://x"
776commit = "deadbeef"
777[build]
778[[evidence.checks]]
779kind = "cmd-exit"
780cmd  = "   "
781"#;
782        let r = Recipe::from_toml(bad_cmd).unwrap();
783        assert!(r.evidence.validate().unwrap_err().to_string().contains("vacío"));
784
785        let bad_hash = r#"
786name = "x"
787version = "0"
788[source]
789repo = "git://x"
790commit = "deadbeef"
791[build]
792[[evidence.checks]]
793kind = "cmd-exit"
794cmd  = "true"
795expected_output = "sha256:zzz"
796"#;
797        let r = Recipe::from_toml(bad_hash).unwrap();
798        assert!(r.evidence.validate().unwrap_err().to_string().contains("b3:"));
799    }
800
801    #[test]
802    fn evidence_kind_levels_are_stratified() {
803        assert!(EvidenceKind::CmdExit < EvidenceKind::Proptest);
804        assert!(EvidenceKind::Proptest < EvidenceKind::Contract);
805        assert!(EvidenceKind::Contract < EvidenceKind::Kani);
806        assert_eq!(EvidenceKind::CmdExit.level(), 0);
807        assert_eq!(EvidenceKind::Kani.level(), 3);
808    }
809
810    fn check(expected_exit: i32, expected_output: Option<&str>) -> EvidenceCheck {
811        EvidenceCheck {
812            kind: EvidenceKind::CmdExit,
813            cmd: "irrelevante".into(),
814            expected_exit,
815            expected_output: expected_output.map(|s| s.into()),
816        }
817    }
818
819    #[test]
820    fn evaluate_exit_only_pass_and_fail() {
821        let c = check(0, None);
822        assert!(c.evaluate(0, b"lo que sea").passed);
823        let bad = c.evaluate(1, b"");
824        assert!(!bad.passed);
825        assert!(bad.detail.contains("exit 1"), "{}", bad.detail);
826    }
827
828    #[test]
829    fn evaluate_non_zero_expected_exit() {
830        // Un check puede exigir un exit != 0 (p.ej. "el binario rechaza input inválido con 2").
831        let c = check(2, None);
832        assert!(c.evaluate(2, b"").passed);
833        assert!(!c.evaluate(0, b"").passed);
834    }
835
836    #[test]
837    fn evaluate_output_hash_pass_and_fail() {
838        let want = ArtifactHash::of_bytes(b"hola\n");
839        let c = check(0, Some(want.as_str()));
840        // stdout correcto ⇒ pasa.
841        let ok = c.evaluate(0, b"hola\n");
842        assert!(ok.passed, "{}", ok.detail);
843        assert!(ok.detail.contains("hash") || ok.detail.contains(want.as_str()));
844        // stdout distinto ⇒ falla aunque el exit sea correcto.
845        let bad = c.evaluate(0, b"chau\n");
846        assert!(!bad.passed);
847        assert!(bad.detail.contains("stdout"), "{}", bad.detail);
848        // exit malo ⇒ falla antes de mirar el hash.
849        assert!(!c.evaluate(1, b"hola\n").passed);
850    }
851}