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…
⎇ d611f8577d0ebb0f265143be9007a5b94a7dd1fc
crates/takana-core/src/recipe.rs
Historia
Quién tocó qué
crudo
[[user]] en la receta: la otra mitad del SDD 30 — un demonio que no corre como root necesita cuenta
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 /// **Bajo qué términos se redistribuye este paquete.** Expresión SPDX (`"GPL-2.0-or-later"`,
30 /// `"MIT OR Apache-2.0"`, `"LGPL-2.1-only AND BSD-3-Clause"`).
31 ///
32 /// No es burocracia: al distribuir BINARIOS, la GPL y familia obligan a acompañarlos del texto
33 /// de la licencia y a poder entregar la fuente correspondiente. Una distro que no sabe bajo qué
34 /// términos redistribuye lo que empaqueta no puede cumplir ninguna de las dos cosas, y eso es
35 /// bloqueante para publicar (SDD 19 §2.1). Medido el 2026-08-07: **0 de 1141 recetas** lo
36 /// declaraban.
37 ///
38 /// Igual que `evidence` y `slots`, **NO entra en `hash_inputs`** — y acá esa propiedad es la que
39 /// hace la deuda pagable: describe términos legales, no identidad del build, así que se puede
40 /// poblar en las 1141 recetas YA SELLADAS sin mover un solo `ArtifactHash` ni invalidar el
41 /// baseline de reproducibilidad. Si entrara al hash, poner la licencia costaría reconstruir el
42 /// corpus entero y por eso no se habría hecho nunca.
43 ///
44 /// `None` = deuda declarada, no «sin licencia». `scripts/licencias.sh` mide la cobertura.
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub license: Option<String>,
47 /// La receta **sella bytes ajenos ya compilados**, no construye desde fuente (ADR 0015 D8).
48 ///
49 /// Hoy la usa una sola: el runtime *sniper* de Valve, que es la única imagen ajena que puede
50 /// entrar al store porque **no muta** — nadie le instala nada adentro, así que el mismo tarball
51 /// pineado da siempre el mismo árbol y sellarlo es una afirmación verdadera. Un rootfs mutable
52 /// (Arch, Ubuntu) no la cumple y por eso se queda fuera, en `/var/lib/hammer/qorpa`.
53 ///
54 /// **Lo que hace la marca es contable, no técnica.** El build no cambia en un solo byte: lo que
55 /// cambia es que `scripts/build-state.py` la clasifica como `ajeno`, la resta del denominador
56 /// de las imágenes y la deja fuera del recuento de recetas. Sin esto, sellar un prebuilt
57 /// inflaría la cifra que todo el mundo lee como «cuánto construimos» — el riesgo que el ADR
58 /// 0015 escribió antes de que existiera la primera instancia.
59 ///
60 /// **NO entra en `hash_inputs`**, igual que `license`: describe procedencia, no identidad del
61 /// build. Añadirla no mueve un solo `ArtifactHash`.
62 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
63 pub foreign: bool,
64 /// **Los servicios que este paquete TRAE** (`[[service]]`), como Cards de arje.
65 ///
66 /// Es la respuesta al hueco que la distro tenía abierto: un paquete con demonio no tenía dónde
67 /// declararlo, así que los dos servicios del producto vivían hardcodeados en constantes de Rust
68 /// y los ocho de una sesión GNOME se lanzaban con `&` desde un script, sin supervisión. Ver
69 /// [`crate::service`] para el porqué completo (incluido por qué `arje-absorb` no lo cubre).
70 ///
71 /// Declara **qué es** el servicio, no **si arranca**: lo segundo es del PERFIL, igual que
72 /// systemd separa `[Service]` de `[Install]`.
73 ///
74 /// **NO entra en `hash_inputs`**, como `license`, `slots` y `evidence`: describe cómo se
75 /// supervisa el artefacto, no qué bytes tiene ⇒ se puebla en recetas ya selladas sin mover un
76 /// solo `ArtifactHash`.
77 #[serde(default, rename = "service", skip_serializing_if = "Vec::is_empty")]
78 pub services: Vec<crate::service::Service>,
79 /// **Las cuentas que este paquete necesita para correr** (`[[user]]`).
80 ///
81 /// La otra mitad del SDD 30, y no es simetría por elegancia: un demonio que se niega a correr
82 /// como root —`gitea` lo hace explícitamente— tiene su Card haciendo `setuidgid`, y sin la
83 /// cuenta muere en bucle. Hasta hoy el `/etc/passwd` de la imagen era la constante
84 /// `takana_bootstrap::PRODUCT_PASSWD`: añadir un usuario era editar Rust, exactamente lo que
85 /// este SDD arregló para las Cards y dejó sin arreglar para las cuentas.
86 ///
87 /// El `uid` **se declara, no se asigna** — ver [`crate::user`].
88 ///
89 /// **NO entra en `hash_inputs`**, como `license` y `[[service]]`: describe qué cuenta necesita
90 /// el artefacto para correr, no qué bytes tiene.
91 #[serde(default, rename = "user", skip_serializing_if = "Vec::is_empty")]
92 pub users: Vec<crate::user::User>,
93 /// Directorio base contra el que se resuelven rutas relativas de la receta
94 /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío.
95 #[serde(skip, default)]
96 pub base_dir: PathBuf,
97}
98
99/// `Source` admite dos modos, mutuamente excluyentes, validados en runtime:
100///
101/// 1. **Git** — `repo` + `commit` (SHA fijado, nunca HEAD; ADR 0006). Apropiado para
102/// upstreams cuyo árbol de git ya es buildable o cuando queremos pinear un parche que
103/// aún no salió en release.
104/// 2. **Tarball** — `tarball` (URL) + `sha256`. Apropiado para releases estables: la
105/// mayoría de proyectos GNU/autotools traen `configure` ya generado y embebidos los
106/// submódulos (gnulib, etc.), evitando la necesidad de `./bootstrap` en el sandbox.
107///
108/// El hash de entrada del artefacto usa `commit` o `sha256` según el modo, ambos
109/// identificadores inmutables del contenido fuente.
110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
111pub struct Source {
112 // --- modo git ---
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub repo: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub commit: Option<String>,
117 // --- modo tarball ---
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub tarball: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub sha256: Option<String>,
122 /// Cuántos componentes recortar al extraer el tarball. Default 1 (los releases GNU
123 /// siempre tienen un único top-level `proyecto-version/`).
124 #[serde(default = "default_strip")]
125 pub strip_components: usize,
126 // --- modo directorio (recetas DERIVADAS) ---
127 /// Árbol fuente que vive en ESTE repo, relativo al directorio de la receta.
128 ///
129 /// Existe para las recetas cuyo contenido no viene de upstream sino de nosotros: un envoltorio
130 /// que se arma encima de otro artefacto (`atuq` sobre `firefox`, SDD 26), un tema, una
131 /// configuración. Antes de esto el `[source]` era obligatoriamente git o tarball, así que una
132 /// receta así no se podía ni escribir — y el rodeo (fetchear una fuente upstream que no se usa)
133 /// habría mentido sobre la identidad del artefacto.
134 ///
135 /// **Se hashea por CONTENIDO, no por ruta**: `ArtifactHash::of_tree` sobre el árbol entero, que
136 /// es lo mismo que ya se hace con los `patches` (bytes, no nombres). Editar un CSS del overlay
137 /// mueve el hash; renombrar el directorio sin tocar nada, no.
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub dir: Option<String>,
140 // --- común ---
141 /// Destino del vendoreo cargo (default `vendor`). Override per-receta para proyectos que
142 /// commitean su PROPIO `vendor/` (p.ej. mise → `vendor/aqua-registry`): `cargo vendor` borra
143 /// lo que no reconoce, así que se vendorea a un subdir aparte para no pisarlo. El fragmento
144 /// `.cargo/config.toml` que emite `cargo vendor` ya apunta al dir que se le pasa.
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub cargo_vendor_dir: Option<String>,
147 /// Fuerza el vendoreo cargo aunque el árbol NO se detecte como `BuildSys::Cargo`.
148 ///
149 /// Existe porque `detect_build_system` asume **un** sistema de build por árbol, y hay proyectos
150 /// que genuinamente tienen dos: C por fuera, Rust por dentro. El arquetipo es librsvg —trae
151 /// `configure.ac` Y `Cargo.toml`, y autotools gana por precedencia—, pero su `Makefile` invoca
152 /// `cargo build`. Sin vendoreo ese cargo intenta salir a la red DENTRO del sandbox hermético y
153 /// el build muere. Es un patrón, no una excepción: cada vez hay más GNOME así.
154 ///
155 /// No cambia qué se construye, sólo de dónde salen las deps —el `Cargo.lock` del source es el
156 /// que fija las versiones— así que **no entra al ArtifactHash**, igual que `cargo_vendor_dir`.
157 /// Es lo que permite prenderla en una receta ya sellada sin re-hashearla.
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub cargo_vendor: Option<bool>,
160 #[serde(default)]
161 pub patches: Vec<String>,
162}
163
164fn default_strip() -> usize {
165 1
166}
167
168/// Discriminante validado del `Source`. Se calcula bajo demanda; los campos de
169/// `Source` permanecen flat en TOML para que las recetas sean legibles.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum SourceKind<'a> {
172 Git { repo: &'a str, commit: &'a str },
173 Tarball { url: &'a str, sha256: &'a str },
174 /// Árbol del propio repo (recetas derivadas). Ver [`Source::dir`].
175 Dir { dir: &'a str },
176}
177
178impl Source {
179 pub fn kind(&self) -> crate::Result<SourceKind<'_>> {
180 // `dir` es EXCLUYENTE con los otros dos y se comprueba primero: declarar a la vez un
181 // árbol propio y una fuente upstream no es una receta ambigua, es un error de quien la
182 // escribió, y decirlo temprano evita un build que fetchea algo que después pisa.
183 if let Some(dir) = self.dir.as_deref() {
184 if self.repo.is_some() || self.tarball.is_some() {
185 return Err(crate::Error::Recipe(
186 "source: `dir` es excluyente con repo/tarball — una receta derivada no tiene fuente upstream".into(),
187 ));
188 }
189 return Ok(SourceKind::Dir { dir });
190 }
191 match (
192 self.repo.as_deref(),
193 self.commit.as_deref(),
194 self.tarball.as_deref(),
195 self.sha256.as_deref(),
196 ) {
197 (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }),
198 (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }),
199 (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => Err(
200 crate::Error::Recipe(
201 "source: usa repo+commit O tarball+sha256, no ambos".into(),
202 ),
203 ),
204 _ => Err(crate::Error::Recipe(
205 "source: faltan campos; necesito (repo+commit), (tarball+sha256) o (dir)".into(),
206 )),
207 }
208 }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Build {
213 #[serde(default)]
214 pub compiler: Compiler,
215 #[serde(default = "default_target")]
216 pub target: String,
217 #[serde(default)]
218 pub link: LinkMode,
219 #[serde(default)]
220 pub flags: Vec<String>,
221 /// Versión de zig a usar para ESTA receta, distinta del zig por defecto del lab. Sirve de
222 /// escotilla por-receta para esquivar la regresión de zig 0.14 (que miscompila ciertos binarios
223 /// musl dinámicos: flex/cmake/python/binutils/…): `zig_version = "0.13.0"` los construye con el
224 /// zig bueno sin tocar el toolchain global ni el baseline de reproducibilidad (estas piezas son
225 /// herramientas, no inputs del of_tree del 4/4). `None` ⇒ el zig por defecto. Ver SDD 11 §7.2b.
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub zig_version: Option<String>,
228 /// **Separa la información de depuración del artefacto** (SDD 23). Con `true`, el lab pasa un
229 /// `zig objcopy --strip-debug` sobre los ELF de `/out` DESPUÉS de la fase install.
230 ///
231 /// POR QUÉ IMPORTA, medido: el **79%** del contenido binario del store son secciones `.debug_*`
232 /// ⇒ ~96 G de reserva de disco y un espejo público de ~30 G en vez de ~126 G. Y además **cierra
233 /// una fuga de reproducibilidad**: `takana why-differs` mostró que `appstream` y `bison`
234 /// divergen entre rebuilds ÚNICAMENTE en `.debug_*` (rutas internas del árbol de build), con el
235 /// código ejecutable idéntico. Sin esas secciones, el artefacto reproduce.
236 ///
237 /// ⚠ **ENTRA EN `hash_inputs`, y sólo si está fijado** — mismo diseño que `zig_version`. Las dos
238 /// mitades de esa frase importan:
239 /// · *entra*: si el lab pudiera cambiar lo que sale del build SIN mover el hash, el store
240 /// diría que todo está al día mientras los artefactos ya no corresponden. En un sistema
241 /// direccionado por contenido ése es el peor fallo posible: no falla, **miente**.
242 /// · *sólo si está fijado*: una receta sin el campo conserva su hash de antes de que existiera,
243 /// así que esto se puede desplegar receta a receta sin re-hashear el corpus de golpe.
244 ///
245 /// POR QUÉ ES UNA PERILLA DEL LAB Y NO UN PASO EN CADA RECETA: de las 1161 recetas, **383 no
246 /// tienen fase `install` explícita** (usan la heurística por defecto). Meter el strip receta a
247 /// receta obligaría a escribir a mano ese install por defecto en las 383, con el riesgo de no
248 /// clavarlo exacto. El lab lo hace una vez, igual para todas.
249 ///
250 /// Usa `zig objcopy`, que SIEMPRE está en el sandbox (`/opt/zig`), en vez del `strip` de
251 /// binutils: así no hay que añadir una dep de build a cada receta que lo active.
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub strip_debug: Option<bool>,
254 /// Habilita CGO en un build Go (`BuildSys::Go`). Por defecto el lab compila con `CGO_ENABLED=0`
255 /// (Go puro, estático, sin C). Algunos proyectos necesitan cgo para drivers con C bundled (p.ej.
256 /// el driver sqlite3 de mattn en usql/sq/dolt): con `cgo = true` el compile usa `CGO_ENABLED=1`
257 /// + `CC="zig cc"` (musl nativo del sandbox) y liga estático (`-linkmode=external -extldflags=
258 /// -static`). Sólo para C bundled; cgo contra libs C EXTERNAS (gpgme/libvirt) requiere además
259 /// esas recetas en el sandbox. No-op fuera de BuildSys::Go.
260 #[serde(default)]
261 pub cgo: bool,
262 /// Subdirectorio donde vive el módulo Go cuando NO está en la raíz del repo (p.ej. dolt → `go`,
263 /// csvtk → su subdir). El lab vendorea, detecta el main y compila DENTRO de `<src>/<subdir>`.
264 /// Vacío = raíz (lo normal). Sólo aplica a BuildSys::Go.
265 #[serde(default)]
266 pub subdir: String,
267 /// Overrides explícitos de las fases del build. Lo no especificado se decide por
268 /// heurística en el lab (ver `docs/02-build-lab.md` §4).
269 #[serde(default)]
270 pub phases: Phases,
271}
272
273/// Comandos shell que se ejecutan en el sandbox por cada fase. Cualquiera puede ser `None`:
274/// el lab lo deriva por heurística (autotools / cmake / meson / make).
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
276pub struct Phases {
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub configure: Option<String>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub compile: Option<String>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub install: Option<String>,
283}
284
285impl Phases {
286 /// `true` si ninguna fase está sobreescrita (todo se decide por heurística). Sirve a
287 /// serde (`skip_serializing_if`) para que un `.swm` sin fases custom no las emita.
288 pub fn is_empty(&self) -> bool {
289 self.configure.is_none() && self.compile.is_none() && self.install.is_none()
290 }
291}
292
293#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
294pub struct Deps {
295 #[serde(default)]
296 pub build: Vec<String>,
297 #[serde(default)]
298 pub runtime: Vec<String>,
299}
300
301impl Deps {
302 /// `true` si no hay ninguna dep (ni build ni runtime). Sirve a serde para no emitir el
303 /// bloque `deps` vacío en un `.swm`/receta.
304 pub fn is_empty(&self) -> bool {
305 self.build.is_empty() && self.runtime.is_empty()
306 }
307}
308
309/// Las **superficies del sistema** de una config/paquete (H4b — SDD 15 §H4). Una config del
310/// usuario = un conjunto de modificaciones direccionadas por contenido: cada modificación
311/// **reclama** una superficie (la escribe) y/o **requiere** otra (depende de ella a una versión
312/// exacta). El valor de cada slot es un hash `b3:…` del contenido — misma disciplina de "todo por
313/// hash" que el resto del stack.
314///
315/// El gate de compatibilidad (`crate::compat`) usa estos dos mapas contra el estado instalado:
316/// - un `requires` que no resuelve contra el estado ⇒ **incompatible** (el caso "modifiqué el
317/// protocolo de Wayland y bajo algo que depende del Wayland stock"); es el análogo, a nivel de
318/// sistema, de una referencia colgante.
319/// - un `claims` que pisa una superficie ya ocupada por otro contenido ⇒ **colisión = elección**
320/// (el caso "ya cambié el logo y bajo otra config que también lo cambia"), no un error.
321#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
322pub struct Slots {
323 /// Superficies que este paquete **escribe** → hash del contenido nuevo (`slot → b3:…`).
324 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
325 pub claims: std::collections::BTreeMap<String, String>,
326 /// Superficies de las que **depende** → versión exacta requerida (`slot → b3:…`).
327 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
328 pub requires: std::collections::BTreeMap<String, String>,
329}
330
331impl Slots {
332 /// `true` si no declara ninguna superficie (ni claims ni requires). Para serde: una receta
333 /// sin bloque `slots` no lo emite.
334 pub fn is_empty(&self) -> bool {
335 self.claims.is_empty() && self.requires.is_empty()
336 }
337
338 /// Valida la forma (schema, no compatibilidad): cada valor de slot debe ser un hash `b3:…`.
339 /// El gate de `install` corre esto antes de evaluar.
340 pub fn validate(&self) -> crate::Result<()> {
341 for (mapa, etiqueta) in [(&self.claims, "claims"), (&self.requires, "requires")] {
342 for (slot, hash) in mapa {
343 if !hash.starts_with("b3:") {
344 return Err(crate::Error::Recipe(format!(
345 "slots.{etiqueta}[{slot:?}]: el valor debe ser un hash `b3:…`, es {hash:?}"
346 )));
347 }
348 }
349 }
350 Ok(())
351 }
352}
353
354/// Evidencia de comportamiento de una receta (H1 — proof-carrying recipes, SDD 15 §H1). La IA
355/// entrega, junto al build, un conjunto de `checks` que el verificador (`takana swm-verify
356/// --evidence`) corre DENTRO del sandbox reproducible; la mutación sólo se propone si todos pasan.
357/// La confianza vive en el checker (pequeño, auditable), no en el generador: el sistema mejora solo
358/// sin poder corromperse solo. Deliberadamente FUERA de `Recipe::hash_inputs` (comportamiento ≠
359/// identidad). Frontera honesta: garantiza que *lo declarado pasa*, no que *lo declarado basta*.
360#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
361pub struct Evidence {
362 #[serde(default, skip_serializing_if = "Vec::is_empty")]
363 pub checks: Vec<EvidenceCheck>,
364}
365
366impl Evidence {
367 /// `true` si no hay ningún check. Sirve a serde (`skip_serializing_if`) para no emitir el
368 /// bloque `evidence` vacío en una receta/`.swm`.
369 pub fn is_empty(&self) -> bool {
370 self.checks.is_empty()
371 }
372
373 /// Valida la forma del bloque (schema, no ejecución): cada `cmd` no vacío y cada
374 /// `expected_output`, si está, con prefijo `b3:`. El checker de H1b corre esto antes de ejecutar.
375 pub fn validate(&self) -> crate::Result<()> {
376 for (i, c) in self.checks.iter().enumerate() {
377 if c.cmd.trim().is_empty() {
378 return Err(crate::Error::Recipe(format!(
379 "evidence.checks[{i}] ({}): `cmd` vacío",
380 c.kind.as_str()
381 )));
382 }
383 if let Some(h) = &c.expected_output {
384 if !h.starts_with("b3:") {
385 return Err(crate::Error::Recipe(format!(
386 "evidence.checks[{i}] ({}): `expected_output` debe ser un hash `b3:…`, es {h:?}",
387 c.kind.as_str()
388 )));
389 }
390 }
391 }
392 Ok(())
393 }
394}
395
396/// Un ítem de evidencia: un comando + el resultado exigido. `kind` estratifica la CONFIANZA
397/// (`cmd-exit` < `proptest` < `contract` < `kani`), que se reporta tal cual — H1 no finge que todo
398/// es prueba formal.
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct EvidenceCheck {
401 pub kind: EvidenceKind,
402 /// Comando shell a correr en el sandbox reproducible tras el build (misma vía que las fases).
403 pub cmd: String,
404 /// Exit code exigido. Default 0.
405 #[serde(default)]
406 pub expected_exit: i32,
407 /// Hash BLAKE3 (`b3:…`) opcional del stdout, para checks deterministas cuyo OUTPUT también se
408 /// ancla (no sólo el exit). `None` ⇒ sólo se exige el exit code.
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub expected_output: Option<String>,
411}
412
413/// Estrato de confianza de un `EvidenceCheck`, en orden creciente (`Ord` = comparación de estratos).
414#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
415#[serde(rename_all = "kebab-case")]
416pub enum EvidenceKind {
417 /// El comando sale con `expected_exit`. La garantía más débil (un smoke test).
418 CmdExit,
419 /// Property tests (proptest/quickcheck): pasan sobre entradas generadas.
420 Proptest,
421 /// Contratos / aserciones de pre y post-condición.
422 Contract,
423 /// Prueba formal acotada (Kani/Creusot). La garantía más fuerte.
424 Kani,
425}
426
427impl EvidenceKind {
428 /// Nivel numérico de confianza (0 = más débil), para reportar/ordenar.
429 pub fn level(&self) -> u8 {
430 match self {
431 EvidenceKind::CmdExit => 0,
432 EvidenceKind::Proptest => 1,
433 EvidenceKind::Contract => 2,
434 EvidenceKind::Kani => 3,
435 }
436 }
437
438 pub fn as_str(&self) -> &'static str {
439 match self {
440 EvidenceKind::CmdExit => "cmd-exit",
441 EvidenceKind::Proptest => "proptest",
442 EvidenceKind::Contract => "contract",
443 EvidenceKind::Kani => "kani",
444 }
445 }
446}
447
448/// Veredicto de un `EvidenceCheck` tras ejecutarlo (H1b). Se computa comparando el resultado
449/// REAL (exit code + stdout) contra lo declarado — sin tocar el sandbox (eso lo hace el runner).
450#[derive(Debug, Clone, PartialEq, Eq)]
451pub struct CheckOutcome {
452 pub passed: bool,
453 /// Explicación legible de por qué pasó o falló (para el reporte al humano).
454 pub detail: String,
455}
456
457impl EvidenceCheck {
458 /// Evalúa el resultado real de correr `cmd` contra lo declarado: exige `expected_exit` y, si
459 /// hay `expected_output`, que `blake3(stdout)` coincida. **Función pura** — la ejecución en el
460 /// sandbox reproducible es H1b; acá vive la lógica auditable de "¿el resultado es el exigido?".
461 pub fn evaluate(&self, exit_code: i32, stdout: &[u8]) -> CheckOutcome {
462 if exit_code != self.expected_exit {
463 return CheckOutcome {
464 passed: false,
465 detail: format!("exit {exit_code} ≠ esperado {}", self.expected_exit),
466 };
467 }
468 if let Some(want) = &self.expected_output {
469 let got = ArtifactHash::of_bytes(stdout);
470 if got.as_str() != want {
471 return CheckOutcome {
472 passed: false,
473 detail: format!("stdout {got} ≠ esperado {want}"),
474 };
475 }
476 return CheckOutcome {
477 passed: true,
478 detail: format!("exit {exit_code} ok + stdout {want} ok"),
479 };
480 }
481 CheckOutcome {
482 passed: true,
483 detail: format!("exit {exit_code} ok"),
484 }
485 }
486}
487
488/// Compilador del lab, POR RECETA (no global). `zig-cc` por defecto; escotilla a clang/gcc
489/// para paquetes con gcc-ismos. Ver ADR 0003.
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
491#[serde(rename_all = "kebab-case")]
492pub enum Compiler {
493 #[default]
494 ZigCc,
495 Clang,
496 Gcc,
497}
498
499impl Compiler {
500 pub fn as_str(&self) -> &'static str {
501 match self {
502 Compiler::ZigCc => "zig-cc",
503 Compiler::Clang => "clang",
504 Compiler::Gcc => "gcc",
505 }
506 }
507}
508
509#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
510#[serde(rename_all = "lowercase")]
511pub enum LinkMode {
512 #[default]
513 Static,
514 Dynamic,
515}
516
517impl LinkMode {
518 pub fn as_str(&self) -> &'static str {
519 match self {
520 LinkMode::Static => "static",
521 LinkMode::Dynamic => "dynamic",
522 }
523 }
524}
525
526fn default_target() -> String {
527 "x86_64-linux-musl".to_string()
528}
529
530impl Recipe {
531 /// Parsea una receta desde su forma TOML. `base_dir` queda vacío: si la receta declara
532 /// `patches`, el caller debe fijar `base_dir` antes de hashear, o usar `load_from_path`.
533 pub fn from_toml(s: &str) -> crate::Result<Recipe> {
534 let r: Recipe = toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string()))?;
535 // Los servicios se validan AL CARGAR, no al emitir la seed: un `id` mal puesto rompe la
536 // reproducibilidad del rootfs de producto, y eso hay que verlo en la receta —no tres pasos
537 // después, en un rootfs que ya no reproduce y no dice por qué. Recetas sin `[[service]]`
538 // pasan por una lista vacía, así que esto no toca a las 979 existentes.
539 crate::service::validate_all(&r.services)?;
540 // Mismo criterio que los servicios: se valida al CARGAR. Un uid fuera de rango o un `:` en
541 // el nombre tienen que verse acá y no en el fichero `passwd` de una imagen ya compuesta.
542 crate::user::validate_all(&r.users)?;
543 Ok(r)
544 }
545
546 /// Serializa la receta a TOML. Útil para el sidecar de provenance que `takana-build`
547 /// escribe junto al artefacto en el store. `base_dir` no se serializa (es transient).
548 pub fn to_toml(&self) -> crate::Result<String> {
549 toml::to_string(self).map_err(|e| crate::Error::Recipe(e.to_string()))
550 }
551
552 /// Carga una receta desde el disco. `base_dir` se fija al directorio que contiene el
553 /// archivo, de modo que `source.patches` resuelve relativo a ahí (estilo Cargo).
554 pub fn load_from_path(path: impl AsRef<Path>) -> crate::Result<Recipe> {
555 let path = path.as_ref();
556 let text = std::fs::read_to_string(path)?;
557 let mut recipe = Recipe::from_toml(&text)?;
558 if let Some(parent) = path.parent() {
559 recipe.base_dir = parent.to_path_buf();
560 }
561 Ok(recipe)
562 }
563
564 /// Las entradas canónicas que alimentan el `ArtifactHash` de esta receta. Incluye el
565 /// **contenido** de cada patch (no su ruta), de modo que renombrar un archivo no cambia el
566 /// hash y editarlo sí lo hace. Ver `docs/02-build-lab.md` §2.
567 /// `lab` es la huella del toolchain que va a construir esto (ver [`crate::lab`]). Entra SIEMPRE
568 /// y es un parámetro obligatorio, no un `Option`: el 2026-08-10 se midió que dos labs con
569 /// distinto `rustc` producen bytes distintos, así que un hash calculado sin lab y otro con lab
570 /// son direcciones distintas para el mismo contenido. Un default silencioso aquí reintroduciría
571 /// exactamente la divergencia que esto cierra — quien no tiene lab usa
572 /// [`crate::lab::LabFingerprint::for_tests`] y lo dice.
573 pub fn hash_inputs(
574 &self,
575 dep_hashes: &[ArtifactHash],
576 lab: &crate::lab::LabFingerprint,
577 ) -> crate::Result<Vec<Vec<u8>>> {
578 // El identificador inmutable del contenido fuente: el commit en modo git, el
579 // sha256 del archivo en modo tarball. Cualquiera de los dos es un puntero al
580 // contenido exacto, lo que basta para reproducibilidad.
581 let source_id = match self.source.kind()? {
582 SourceKind::Git { commit, .. } => format!("git:{commit}"),
583 SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"),
584 // El árbol vive en el repo y puede cambiar en cualquier commit ⇒ el puntero al
585 // contenido hay que CALCULARLO, no leerlo de la receta. `of_tree` es determinista
586 // (rutas ordenadas, bit de ejecución, contenido; no sigue symlinks).
587 SourceKind::Dir { dir } => {
588 let root = self.base_dir.join(dir);
589 let h = crate::ArtifactHash::of_tree(&root).map_err(|e| {
590 crate::Error::Recipe(format!(
591 "no pude hashear el árbol de source.dir {}: {e}",
592 root.display()
593 ))
594 })?;
595 format!("dir:{}", h.as_str())
596 }
597 };
598 let mut v: Vec<Vec<u8>> = vec![
599 source_id.into_bytes(),
600 self.build.compiler.as_str().as_bytes().to_vec(),
601 self.build.target.as_bytes().to_vec(),
602 self.build.link.as_str().as_bytes().to_vec(),
603 format!("lab:{}", lab.as_str()).into_bytes(),
604 ];
605 // Sólo entra al hash si está fijado: una receta sin `zig_version` mantiene su hash de antes
606 // de existir el campo (compatibilidad hacia atrás; el baseline 9adefb82 no se mueve).
607 if let Some(zv) = &self.build.zig_version {
608 v.push(format!("zig:{zv}").into_bytes());
609 }
610 // Ver la doc de `strip_debug`: cambia el CONTENIDO del artefacto, así que TIENE que entrar
611 // al hash. Sólo si está fijado, para no mover los hashes de las recetas que no lo usan.
612 if let Some(sd) = self.build.strip_debug {
613 v.push(format!("strip_debug:{sd}").into_bytes());
614 }
615 for p in &self.source.patches {
616 let resolved = self.base_dir.join(p);
617 let bytes = std::fs::read(&resolved).map_err(|e| {
618 crate::Error::Recipe(format!("no pude leer patch {}: {e}", resolved.display()))
619 })?;
620 v.push(bytes);
621 }
622 for f in &self.build.flags {
623 v.push(f.as_bytes().to_vec());
624 }
625 // Phases override: si alguien cambia el comando del compile, el artefacto cambia.
626 // Etiquetamos cada uno para que añadir un override luego no colisione con un flag.
627 for (label, val) in [
628 ("phase:configure", &self.build.phases.configure),
629 ("phase:compile", &self.build.phases.compile),
630 ("phase:install", &self.build.phases.install),
631 ] {
632 if let Some(cmd) = val {
633 v.push(format!("{label}={cmd}").into_bytes());
634 }
635 }
636 for d in dep_hashes {
637 v.push(d.as_str().as_bytes().to_vec());
638 }
639 Ok(v)
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 const SAMPLE: &str = r#"
648name = "grep"
649version = "3.11"
650
651[source]
652repo = "git://git.savannah.gnu.org/grep.git"
653commit = "a1b2c3d4e5f6"
654
655[build]
656compiler = "zig-cc"
657target = "x86_64-linux-musl"
658link = "static"
659flags = ["--enable-perl-regexp"]
660
661[deps]
662build = ["pcre2"]
663"#;
664
665 #[test]
666 fn parse_full() {
667 let r = Recipe::from_toml(SAMPLE).expect("parse");
668 assert_eq!(r.name, "grep");
669 assert!(matches!(
670 r.source.kind().unwrap(),
671 SourceKind::Git { commit: "a1b2c3d4e5f6", .. }
672 ));
673 assert_eq!(r.build.compiler, Compiler::ZigCc);
674 assert_eq!(r.build.link, LinkMode::Static);
675 assert_eq!(r.deps.build, vec!["pcre2".to_string()]);
676 }
677
678 #[test]
679 fn to_toml_roundtrips_via_from_toml() {
680 let r1 = Recipe::from_toml(SAMPLE).unwrap();
681 let serialized = r1.to_toml().expect("to_toml");
682 let r2 = Recipe::from_toml(&serialized).expect("from_toml(roundtrip)");
683 assert_eq!(r2.name, r1.name);
684 assert_eq!(r2.version, r1.version);
685 assert_eq!(r2.build.compiler, r1.build.compiler);
686 assert_eq!(r2.build.link, r1.build.link);
687 assert_eq!(r2.build.flags, r1.build.flags);
688 assert_eq!(r2.deps.build, r1.deps.build);
689 // El kind del source también roundtripea.
690 match (r1.source.kind().unwrap(), r2.source.kind().unwrap()) {
691 (SourceKind::Git { commit: c1, .. }, SourceKind::Git { commit: c2, .. }) => {
692 assert_eq!(c1, c2);
693 }
694 _ => panic!("source kind cambió en el roundtrip"),
695 }
696 }
697
698 #[test]
699 fn parse_tarball_source() {
700 let s = r#"
701name = "grep"
702version = "3.11"
703[source]
704tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
705sha256 = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
706[build]
707"#;
708 let r = Recipe::from_toml(s).expect("parse");
709 assert!(matches!(r.source.kind().unwrap(), SourceKind::Tarball { .. }));
710 assert_eq!(r.source.strip_components, 1);
711 }
712
713 #[test]
714 fn source_mixed_modes_rejected() {
715 let s = r#"
716name = "x"
717version = "0"
718[source]
719repo = "git://x"
720commit = "deadbeef"
721tarball = "https://x/a.tar.gz"
722sha256 = "abc"
723[build]
724"#;
725 let r = Recipe::from_toml(s).unwrap();
726 let err = r.source.kind().unwrap_err().to_string();
727 assert!(err.contains("no ambos"), "msg = {err}");
728 }
729
730 #[test]
731 fn source_missing_fields_rejected() {
732 let s = r#"
733name = "x"
734version = "0"
735[source]
736repo = "git://x"
737[build]
738"#;
739 let r = Recipe::from_toml(s).unwrap();
740 let err = r.source.kind().unwrap_err().to_string();
741 assert!(err.contains("faltan campos"), "msg = {err}");
742 }
743
744 #[test]
745 fn tarball_and_git_hash_to_different_values() {
746 let git = Recipe::from_toml(SAMPLE).unwrap();
747 let tar_toml = r#"
748name = "grep"
749version = "3.11"
750[source]
751tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"
752sha256 = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb"
753[build]
754flags = ["--enable-perl-regexp"]
755"#;
756 let tar = Recipe::from_toml(tar_toml).unwrap();
757 assert_ne!(git.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), tar.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap());
758 }
759
760 #[test]
761 fn parse_minimal_uses_defaults() {
762 let s = r#"
763name = "x"
764version = "0"
765[source]
766repo = "git://x"
767commit = "deadbeef"
768[build]
769"#;
770 let r = Recipe::from_toml(s).expect("parse");
771 assert_eq!(r.build.compiler, Compiler::ZigCc); // default
772 assert_eq!(r.build.target, "x86_64-linux-musl"); // default
773 assert_eq!(r.build.link, LinkMode::Static); // default
774 assert!(r.deps.build.is_empty());
775 }
776
777 #[test]
778 fn hash_no_patches_is_deterministic() {
779 let r = Recipe::from_toml(SAMPLE).unwrap();
780 let a = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap();
781 let b = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap();
782 assert_eq!(a, b);
783 }
784
785 #[test]
786 fn hash_uses_patch_contents_not_path() {
787 let dir1 = tempfile::tempdir().unwrap();
788 let dir2 = tempfile::tempdir().unwrap();
789 std::fs::write(dir1.path().join("p.patch"), b"diff --content").unwrap();
790 std::fs::write(dir2.path().join("otro-nombre.patch"), b"diff --content").unwrap();
791
792 let mut r1 = Recipe::from_toml(SAMPLE).unwrap();
793 r1.source.patches = vec!["p.patch".into()];
794 r1.base_dir = dir1.path().to_path_buf();
795
796 let mut r2 = Recipe::from_toml(SAMPLE).unwrap();
797 r2.source.patches = vec!["otro-nombre.patch".into()];
798 r2.base_dir = dir2.path().to_path_buf();
799
800 // Mismo CONTENIDO ⇒ misma entrada al hash, aunque las rutas sean distintas.
801 assert_eq!(r1.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), r2.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap());
802 }
803
804 /// `foreign` marca PROCEDENCIA, no identidad del build: una receta que sella bytes ajenos
805 /// produce exactamente el mismo artefacto con la marca puesta o quitada. Si entrara al hash,
806 /// marcar la primera receta ajena habría movido su `ArtifactHash` — y peor, habría invitado a
807 /// no marcarla para no re-sellar. La marca tiene que ser gratis para que se use.
808 #[test]
809 fn foreign_no_entra_al_hash() {
810 let sin = Recipe::from_toml(SAMPLE).unwrap();
811 let con = Recipe::from_toml(&format!("foreign = true\n{SAMPLE}")).unwrap();
812 assert!(con.foreign && !sin.foreign, "la marca no se parseó");
813 let lab = crate::lab::LabFingerprint::for_tests();
814 assert_eq!(sin.hash_inputs(&[], &lab).unwrap(), con.hash_inputs(&[], &lab).unwrap());
815 }
816
817 #[test]
818 fn hash_changes_when_patch_content_changes() {
819 let dir = tempfile::tempdir().unwrap();
820 let patch = dir.path().join("p.patch");
821 std::fs::write(&patch, b"version A").unwrap();
822
823 let mut r = Recipe::from_toml(SAMPLE).unwrap();
824 r.source.patches = vec!["p.patch".into()];
825 r.base_dir = dir.path().to_path_buf();
826 let h_a = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap();
827
828 std::fs::write(&patch, b"version B").unwrap();
829 let h_b = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap();
830 assert_ne!(h_a, h_b);
831 }
832
833 #[test]
834 fn missing_patch_reports_path() {
835 let mut r = Recipe::from_toml(SAMPLE).unwrap();
836 r.source.patches = vec!["no-existe.patch".into()];
837 r.base_dir = PathBuf::from("/tmp/seguro-que-no-existe-hammer");
838 let err = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap_err().to_string();
839 assert!(err.contains("no-existe.patch"), "msg = {err}");
840 }
841
842 #[test]
843 fn load_from_path_sets_base_dir() {
844 let dir = tempfile::tempdir().unwrap();
845 let recipe_path = dir.path().join("foo.toml");
846 std::fs::write(&recipe_path, SAMPLE).unwrap();
847 let r = Recipe::load_from_path(&recipe_path).unwrap();
848 assert_eq!(r.base_dir, dir.path());
849 }
850
851 const WITH_EVIDENCE: &str = r#"
852name = "grep"
853version = "3.11"
854[source]
855repo = "git://git.savannah.gnu.org/grep.git"
856commit = "a1b2c3d4e5f6"
857[build]
858[deps]
859build = ["pcre2"]
860
861[[evidence.checks]]
862kind = "cmd-exit"
863cmd = "grep --version"
864
865[[evidence.checks]]
866kind = "proptest"
867cmd = "cd /src && cargo test --release proptest_"
868expected_exit = 0
869
870[[evidence.checks]]
871kind = "cmd-exit"
872cmd = "printf hola | grep -q hola"
873expected_output = "b3:deadbeef"
874"#;
875
876 #[test]
877 fn parse_evidence_block() {
878 let r = Recipe::from_toml(WITH_EVIDENCE).expect("parse");
879 assert_eq!(r.evidence.checks.len(), 3);
880 assert_eq!(r.evidence.checks[0].kind, EvidenceKind::CmdExit);
881 assert_eq!(r.evidence.checks[0].cmd, "grep --version");
882 assert_eq!(r.evidence.checks[0].expected_exit, 0); // default
883 assert_eq!(r.evidence.checks[1].kind, EvidenceKind::Proptest);
884 assert_eq!(r.evidence.checks[2].expected_output.as_deref(), Some("b3:deadbeef"));
885 r.evidence.validate().expect("schema válido");
886 }
887
888 #[test]
889 fn evidence_absent_is_empty_and_omitted() {
890 let r = Recipe::from_toml(SAMPLE).unwrap();
891 assert!(r.evidence.is_empty());
892 // `skip_serializing_if` ⇒ una receta sin evidencia no emite el bloque.
893 let toml = r.to_toml().unwrap();
894 assert!(!toml.contains("evidence"), "toml no debería traer `evidence`: {toml}");
895 }
896
897 #[test]
898 fn evidence_does_not_affect_artifact_hash() {
899 // La clave de H1a: la evidencia certifica COMPORTAMIENTO, no IDENTIDAD ⇒ el hash del
900 // artefacto es idéntico con y sin bloque evidence (el baseline de reproducibilidad no se mueve).
901 // Comparamos LA MISMA receta con y sin evidencia (clon + limpieza), no dos recetas distintas.
902 let con = Recipe::from_toml(WITH_EVIDENCE).unwrap();
903 let mut sin = con.clone();
904 sin.evidence = Evidence::default();
905 assert!(con.evidence.checks.len() == 3 && sin.evidence.is_empty());
906 assert_eq!(
907 sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
908 con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
909 "la evidencia NO debe entrar en hash_inputs"
910 );
911 }
912
913 #[test]
914 fn cargo_vendor_roundtripea_y_no_afecta_el_hash() {
915 // `cargo_vendor` decide DE DÓNDE salen las deps de Cargo (registro vs. `vendor/` local), no
916 // CUÁLES: eso lo fija el `Cargo.lock` del source, que ya está bajo el hash de la fuente. Por
917 // eso no entra a `hash_inputs`, igual que `cargo_vendor_dir` — y por eso se puede prender en
918 // una receta YA SELLADA para destrabar su build offline sin re-hashear nada aguas abajo.
919 let sin = Recipe::from_toml(SAMPLE).unwrap();
920 let mut con = sin.clone();
921 con.source.cargo_vendor = Some(true);
922 assert_eq!(
923 sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
924 con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
925 "cargo_vendor NO debe entrar en hash_inputs"
926 );
927 // Y roundtripea por TOML: si se perdiera al serializar, el forzado se apagaría solo en
928 // cualquier flujo que reescriba la receta.
929 let r = Recipe::from_toml(&con.to_toml().unwrap()).unwrap();
930 assert_eq!(r.source.cargo_vendor, Some(true));
931 // Ausente sigue siendo ausente (no `Some(false)`), que es lo que mantiene el TOML limpio.
932 assert_eq!(
933 Recipe::from_toml(&sin.to_toml().unwrap())
934 .unwrap()
935 .source
936 .cargo_vendor,
937 None
938 );
939 }
940
941 #[test]
942 fn slots_round_trip_y_no_afectan_el_hash() {
943 // H4b: el bloque `slots` declara TOPOLOGÍA (qué toca / de qué depende), no identidad ⇒
944 // como la evidencia, NO entra en hash_inputs. Y round-trippea por TOML.
945 let con_toml = format!(
946 "{SAMPLE}\n[slots.claims]\nlogo = \"b3:rojo\"\n[slots.requires]\n\"wayland-protocol\" = \"b3:stock\"\n"
947 );
948 let con = Recipe::from_toml(&con_toml).unwrap();
949 assert_eq!(con.slots.claims.get("logo").unwrap(), "b3:rojo");
950 assert_eq!(con.slots.requires.get("wayland-protocol").unwrap(), "b3:stock");
951 con.slots.validate().unwrap();
952
953 // Round-trip: serializa y vuelve a parsear ⇒ mismos slots.
954 let re = Recipe::from_toml(&con.to_toml().unwrap()).unwrap();
955 assert_eq!(re.slots, con.slots);
956
957 // Mismo hash con y sin slots.
958 let mut sin = con.clone();
959 sin.slots = Slots::default();
960 assert!(!con.slots.is_empty() && sin.slots.is_empty());
961 assert_eq!(
962 sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
963 con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
964 "los slots NO deben entrar en hash_inputs"
965 );
966 }
967
968 #[test]
969 fn licencia_round_trip_y_no_afecta_el_hash() {
970 // ESTE TEST ES LA RAZÓN DE QUE LA DEUDA DE LICENCIAS SEA PAGABLE.
971 //
972 // El 2026-08-07 ninguna de las 1141 recetas declaraba licencia, y sin ese dato la distro no
973 // puede cumplir la GPL al redistribuir binarios (SDD 19 §2.1). Poblarlo sólo es viable si
974 // NO mueve el `ArtifactHash`: si lo moviera, poner la licencia significaría reconstruir el
975 // corpus entero y perder el baseline de reproducibilidad, y nadie lo haría nunca.
976 //
977 // `hash_inputs` es una lista blanca, así que la propiedad se cumple por construcción — pero
978 // se clava acá para que nadie la rompa por descuido metiendo el campo en el hash.
979 // La línea va DELANTE: en TOML una clave suelta después de un `[table]` pertenece a esa
980 // tabla, no a la raíz. Puesta al final, `license` acabaría dentro de `[deps]` y se perdería
981 // en silencio (serde ignora campos desconocidos). Es el mismo cuidado que hay que tener al
982 // poblar las recetas: el campo va arriba, junto a `name` y `version`.
983 let con_toml = format!("license = \"GPL-3.0-or-later\"\n{SAMPLE}");
984 let con = Recipe::from_toml(&con_toml).unwrap();
985 assert_eq!(con.license.as_deref(), Some("GPL-3.0-or-later"));
986
987 // Round-trip por TOML: serializa y vuelve a parsear ⇒ misma licencia.
988 let re = Recipe::from_toml(&con.to_toml().unwrap()).unwrap();
989 assert_eq!(re.license, con.license);
990
991 // Una receta sin el campo parsea igual (las 1141 existentes no se tocan para compilar).
992 let sin = Recipe::from_toml(SAMPLE).unwrap();
993 assert_eq!(sin.license, None);
994
995 // Y el hash es el MISMO con y sin licencia.
996 assert_eq!(
997 sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
998 con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
999 "la licencia NO debe entrar en hash_inputs: si entrara, declararla re-hashearía las \
1000 1141 recetas ya selladas y tiraría el baseline de reproducibilidad"
1001 );
1002 }
1003
1004 #[test]
1005 fn strip_debug_entra_en_el_hash_solo_si_esta_fijado() {
1006 // Las DOS mitades de la invariante, porque cada una evita un desastre distinto.
1007 //
1008 // (1) SIN el campo, el hash NO se mueve. Es lo que permite desplegar el split de debug
1009 // receta a receta en vez de re-hashear las 1161 de golpe.
1010 let sin = Recipe::from_toml(SAMPLE).unwrap();
1011 assert_eq!(sin.build.strip_debug, None);
1012
1013 // (2) CON el campo, el hash SÍ cambia. `strip_debug` altera el CONTENIDO del artefacto
1014 // (le quita las secciones .debug_*), así que si no entrara al hash el lab podría cambiar
1015 // lo que sale del build sin que el store se enterara: diría que todo está al día
1016 // mientras los artefactos ya no corresponden a su hash. En un sistema direccionado por
1017 // contenido ése es el peor fallo posible — no falla, MIENTE. Es exactamente lo contrario
1018 // de `license`, que describe términos legales y por eso NO debe entrar.
1019 let mut con = Recipe::from_toml(SAMPLE).unwrap();
1020 con.build.strip_debug = Some(true);
1021 assert_ne!(
1022 sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
1023 con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(),
1024 "strip_debug DEBE entrar en hash_inputs: cambia el contenido del artefacto"
1025 );
1026
1027 // Y round-trippea por TOML, que es como se va a escribir en las recetas.
1028 let re = Recipe::from_toml(&con.to_toml().unwrap()).unwrap();
1029 assert_eq!(re.build.strip_debug, Some(true));
1030 }
1031
1032 #[test]
1033 fn slots_validate_rechaza_hash_sin_prefijo() {
1034 let mut s = Slots::default();
1035 s.claims.insert("logo".into(), "rojo".into()); // falta `b3:`
1036 assert!(s.validate().is_err());
1037 }
1038
1039 #[test]
1040 fn evidence_validate_rejects_empty_cmd_and_bad_hash() {
1041 let bad_cmd = r#"
1042name = "x"
1043version = "0"
1044[source]
1045repo = "git://x"
1046commit = "deadbeef"
1047[build]
1048[[evidence.checks]]
1049kind = "cmd-exit"
1050cmd = " "
1051"#;
1052 let r = Recipe::from_toml(bad_cmd).unwrap();
1053 assert!(r.evidence.validate().unwrap_err().to_string().contains("vacío"));
1054
1055 let bad_hash = r#"
1056name = "x"
1057version = "0"
1058[source]
1059repo = "git://x"
1060commit = "deadbeef"
1061[build]
1062[[evidence.checks]]
1063kind = "cmd-exit"
1064cmd = "true"
1065expected_output = "sha256:zzz"
1066"#;
1067 let r = Recipe::from_toml(bad_hash).unwrap();
1068 assert!(r.evidence.validate().unwrap_err().to_string().contains("b3:"));
1069 }
1070
1071 #[test]
1072 fn evidence_kind_levels_are_stratified() {
1073 assert!(EvidenceKind::CmdExit < EvidenceKind::Proptest);
1074 assert!(EvidenceKind::Proptest < EvidenceKind::Contract);
1075 assert!(EvidenceKind::Contract < EvidenceKind::Kani);
1076 assert_eq!(EvidenceKind::CmdExit.level(), 0);
1077 assert_eq!(EvidenceKind::Kani.level(), 3);
1078 }
1079
1080 fn check(expected_exit: i32, expected_output: Option<&str>) -> EvidenceCheck {
1081 EvidenceCheck {
1082 kind: EvidenceKind::CmdExit,
1083 cmd: "irrelevante".into(),
1084 expected_exit,
1085 expected_output: expected_output.map(|s| s.into()),
1086 }
1087 }
1088
1089 #[test]
1090 fn evaluate_exit_only_pass_and_fail() {
1091 let c = check(0, None);
1092 assert!(c.evaluate(0, b"lo que sea").passed);
1093 let bad = c.evaluate(1, b"");
1094 assert!(!bad.passed);
1095 assert!(bad.detail.contains("exit 1"), "{}", bad.detail);
1096 }
1097
1098 #[test]
1099 fn evaluate_non_zero_expected_exit() {
1100 // Un check puede exigir un exit != 0 (p.ej. "el binario rechaza input inválido con 2").
1101 let c = check(2, None);
1102 assert!(c.evaluate(2, b"").passed);
1103 assert!(!c.evaluate(0, b"").passed);
1104 }
1105
1106 #[test]
1107 fn evaluate_output_hash_pass_and_fail() {
1108 let want = ArtifactHash::of_bytes(b"hola\n");
1109 let c = check(0, Some(want.as_str()));
1110 // stdout correcto ⇒ pasa.
1111 let ok = c.evaluate(0, b"hola\n");
1112 assert!(ok.passed, "{}", ok.detail);
1113 assert!(ok.detail.contains("hash") || ok.detail.contains(want.as_str()));
1114 // stdout distinto ⇒ falla aunque el exit sea correcto.
1115 let bad = c.evaluate(0, b"chau\n");
1116 assert!(!bad.passed);
1117 assert!(bad.detail.contains("stdout"), "{}", bad.detail);
1118 // exit malo ⇒ falla antes de mirar el hash.
1119 assert!(!c.evaluate(1, b"hola\n").passed);
1120 }
1121
1122 // ── source.dir: recetas DERIVADAS (SDD 26) ─────────────────────────────────────────────────
1123 #[test]
1124 fn source_dir_es_su_propio_modo_y_excluye_a_los_otros() {
1125 let solo_dir: Source = toml::from_str("dir = \"atuq\"").unwrap();
1126 assert_eq!(
1127 solo_dir.kind().unwrap(),
1128 SourceKind::Dir { dir: "atuq" },
1129 "un `dir` solo tiene que resolver a modo directorio"
1130 );
1131
1132 // Declarar árbol propio Y fuente upstream no es ambigüedad que resolver por precedencia:
1133 // es un error de quien escribió la receta, y decirlo temprano evita un fetch que después
1134 // se pisa. Se comprueba con las dos formas de upstream.
1135 let con_git: Source =
1136 toml::from_str("dir = \"atuq\"\nrepo = \"git://x\"\ncommit = \"c0\"").unwrap();
1137 assert!(con_git.kind().is_err(), "dir + git debe fallar");
1138 let con_tar: Source =
1139 toml::from_str("dir = \"atuq\"\ntarball = \"http://x/t.tar\"\nsha256 = \"ab\"")
1140 .unwrap();
1141 assert!(con_tar.kind().is_err(), "dir + tarball debe fallar");
1142 }
1143
1144 #[test]
1145 fn el_error_de_source_vacio_nombra_los_tres_modos() {
1146 // El mensaje es la única guía que tiene quien escribe una receta a mano. Si sumamos un modo
1147 // y el error sigue nombrando dos, mandamos a la gente a buscar un campo que no existe.
1148 let vacio: Source = toml::from_str("").unwrap();
1149 let e = vacio.kind().unwrap_err().to_string();
1150 assert!(e.contains("repo"), "{e}");
1151 assert!(e.contains("tarball"), "{e}");
1152 assert!(e.contains("dir"), "{e}");
1153 }
1154}