gioser

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 815Rust 56Shell 37Markdown 291229 Definiciones

⎇ 0523ea066509b320d491ce444b9650bb1c7c9dfe crates/takana-core/src/recipe.rs Rust · 1171 líneas · 52.8 KB Historia Quién tocó qué crudo

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