//! La `Recipe`: descripción pura de un build. Ver `docs/02-build-lab.md` §1. use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::hash::ArtifactHash; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Recipe { pub name: String, pub version: String, pub source: Source, pub build: Build, #[serde(default)] pub deps: Deps, /// Evidencia de COMPORTAMIENTO que acompaña la receta (H1 — proof-carrying recipes, SDD 15). /// Opcional. **No entra en `hash_inputs`**: certifica comportamiento, no identidad ⇒ agregar o /// cambiar evidencia NO mueve el `artifact_hash` (el baseline de reproducibilidad no se toca). #[serde(default, skip_serializing_if = "Evidence::is_empty")] pub evidence: Evidence, /// **Superficies del sistema** que este paquete reclama y requiere (H4b — configs /// compatibles, SDD 15 §H4). Igual que `evidence`: **NO entra en `hash_inputs`** — declara /// TOPOLOGÍA (qué toca / de qué depende), no identidad del artefacto; agregar o cambiar el /// bloque no mueve el `artifact_hash`. Lo consume el gate de compatibilidad de `install` /// (`crate::compat`) contra el estado instalado, no el build. #[serde(default, skip_serializing_if = "Slots::is_empty")] pub slots: Slots, /// **Bajo qué términos se redistribuye este paquete.** Expresión SPDX (`"GPL-2.0-or-later"`, /// `"MIT OR Apache-2.0"`, `"LGPL-2.1-only AND BSD-3-Clause"`). /// /// No es burocracia: al distribuir BINARIOS, la GPL y familia obligan a acompañarlos del texto /// de la licencia y a poder entregar la fuente correspondiente. Una distro que no sabe bajo qué /// términos redistribuye lo que empaqueta no puede cumplir ninguna de las dos cosas, y eso es /// bloqueante para publicar (SDD 19 §2.1). Medido el 2026-08-07: **0 de 1141 recetas** lo /// declaraban. /// /// Igual que `evidence` y `slots`, **NO entra en `hash_inputs`** — y acá esa propiedad es la que /// hace la deuda pagable: describe términos legales, no identidad del build, así que se puede /// poblar en las 1141 recetas YA SELLADAS sin mover un solo `ArtifactHash` ni invalidar el /// baseline de reproducibilidad. Si entrara al hash, poner la licencia costaría reconstruir el /// corpus entero y por eso no se habría hecho nunca. /// /// `None` = deuda declarada, no «sin licencia». `scripts/licencias.sh` mide la cobertura. #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option, /// La receta **sella bytes ajenos ya compilados**, no construye desde fuente (ADR 0015 D8). /// /// Hoy la usa una sola: el runtime *sniper* de Valve, que es la única imagen ajena que puede /// entrar al store porque **no muta** — nadie le instala nada adentro, así que el mismo tarball /// pineado da siempre el mismo árbol y sellarlo es una afirmación verdadera. Un rootfs mutable /// (Arch, Ubuntu) no la cumple y por eso se queda fuera, en `/var/lib/hammer/qorpa`. /// /// **Lo que hace la marca es contable, no técnica.** El build no cambia en un solo byte: lo que /// cambia es que `scripts/build-state.py` la clasifica como `ajeno`, la resta del denominador /// de las imágenes y la deja fuera del recuento de recetas. Sin esto, sellar un prebuilt /// inflaría la cifra que todo el mundo lee como «cuánto construimos» — el riesgo que el ADR /// 0015 escribió antes de que existiera la primera instancia. /// /// **NO entra en `hash_inputs`**, igual que `license`: describe procedencia, no identidad del /// build. Añadirla no mueve un solo `ArtifactHash`. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub foreign: bool, /// **Los servicios que este paquete TRAE** (`[[service]]`), como Cards de arje. /// /// Es la respuesta al hueco que la distro tenía abierto: un paquete con demonio no tenía dónde /// declararlo, así que los dos servicios del producto vivían hardcodeados en constantes de Rust /// y los ocho de una sesión GNOME se lanzaban con `&` desde un script, sin supervisión. Ver /// [`crate::service`] para el porqué completo (incluido por qué `arje-absorb` no lo cubre). /// /// Declara **qué es** el servicio, no **si arranca**: lo segundo es del PERFIL, igual que /// systemd separa `[Service]` de `[Install]`. /// /// **NO entra en `hash_inputs`**, como `license`, `slots` y `evidence`: describe cómo se /// supervisa el artefacto, no qué bytes tiene ⇒ se puebla en recetas ya selladas sin mover un /// solo `ArtifactHash`. #[serde(default, rename = "service", skip_serializing_if = "Vec::is_empty")] pub services: Vec, /// Directorio base contra el que se resuelven rutas relativas de la receta /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío. #[serde(skip, default)] pub base_dir: PathBuf, } /// `Source` admite dos modos, mutuamente excluyentes, validados en runtime: /// /// 1. **Git** — `repo` + `commit` (SHA fijado, nunca HEAD; ADR 0006). Apropiado para /// upstreams cuyo árbol de git ya es buildable o cuando queremos pinear un parche que /// aún no salió en release. /// 2. **Tarball** — `tarball` (URL) + `sha256`. Apropiado para releases estables: la /// mayoría de proyectos GNU/autotools traen `configure` ya generado y embebidos los /// submódulos (gnulib, etc.), evitando la necesidad de `./bootstrap` en el sandbox. /// /// El hash de entrada del artefacto usa `commit` o `sha256` según el modo, ambos /// identificadores inmutables del contenido fuente. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Source { // --- modo git --- #[serde(default, skip_serializing_if = "Option::is_none")] pub repo: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub commit: Option, // --- modo tarball --- #[serde(default, skip_serializing_if = "Option::is_none")] pub tarball: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub sha256: Option, /// Cuántos componentes recortar al extraer el tarball. Default 1 (los releases GNU /// siempre tienen un único top-level `proyecto-version/`). #[serde(default = "default_strip")] pub strip_components: usize, // --- modo directorio (recetas DERIVADAS) --- /// Árbol fuente que vive en ESTE repo, relativo al directorio de la receta. /// /// Existe para las recetas cuyo contenido no viene de upstream sino de nosotros: un envoltorio /// que se arma encima de otro artefacto (`atuq` sobre `firefox`, SDD 26), un tema, una /// configuración. Antes de esto el `[source]` era obligatoriamente git o tarball, así que una /// receta así no se podía ni escribir — y el rodeo (fetchear una fuente upstream que no se usa) /// habría mentido sobre la identidad del artefacto. /// /// **Se hashea por CONTENIDO, no por ruta**: `ArtifactHash::of_tree` sobre el árbol entero, que /// es lo mismo que ya se hace con los `patches` (bytes, no nombres). Editar un CSS del overlay /// mueve el hash; renombrar el directorio sin tocar nada, no. #[serde(default, skip_serializing_if = "Option::is_none")] pub dir: Option, // --- común --- /// Destino del vendoreo cargo (default `vendor`). Override per-receta para proyectos que /// commitean su PROPIO `vendor/` (p.ej. mise → `vendor/aqua-registry`): `cargo vendor` borra /// lo que no reconoce, así que se vendorea a un subdir aparte para no pisarlo. El fragmento /// `.cargo/config.toml` que emite `cargo vendor` ya apunta al dir que se le pasa. #[serde(default, skip_serializing_if = "Option::is_none")] pub cargo_vendor_dir: Option, /// Fuerza el vendoreo cargo aunque el árbol NO se detecte como `BuildSys::Cargo`. /// /// Existe porque `detect_build_system` asume **un** sistema de build por árbol, y hay proyectos /// que genuinamente tienen dos: C por fuera, Rust por dentro. El arquetipo es librsvg —trae /// `configure.ac` Y `Cargo.toml`, y autotools gana por precedencia—, pero su `Makefile` invoca /// `cargo build`. Sin vendoreo ese cargo intenta salir a la red DENTRO del sandbox hermético y /// el build muere. Es un patrón, no una excepción: cada vez hay más GNOME así. /// /// No cambia qué se construye, sólo de dónde salen las deps —el `Cargo.lock` del source es el /// que fija las versiones— así que **no entra al ArtifactHash**, igual que `cargo_vendor_dir`. /// Es lo que permite prenderla en una receta ya sellada sin re-hashearla. #[serde(default, skip_serializing_if = "Option::is_none")] pub cargo_vendor: Option, #[serde(default)] pub patches: Vec, } fn default_strip() -> usize { 1 } /// Discriminante validado del `Source`. Se calcula bajo demanda; los campos de /// `Source` permanecen flat en TOML para que las recetas sean legibles. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SourceKind<'a> { Git { repo: &'a str, commit: &'a str }, Tarball { url: &'a str, sha256: &'a str }, /// Árbol del propio repo (recetas derivadas). Ver [`Source::dir`]. Dir { dir: &'a str }, } impl Source { pub fn kind(&self) -> crate::Result> { // `dir` es EXCLUYENTE con los otros dos y se comprueba primero: declarar a la vez un // árbol propio y una fuente upstream no es una receta ambigua, es un error de quien la // escribió, y decirlo temprano evita un build que fetchea algo que después pisa. if let Some(dir) = self.dir.as_deref() { if self.repo.is_some() || self.tarball.is_some() { return Err(crate::Error::Recipe( "source: `dir` es excluyente con repo/tarball — una receta derivada no tiene fuente upstream".into(), )); } return Ok(SourceKind::Dir { dir }); } match ( self.repo.as_deref(), self.commit.as_deref(), self.tarball.as_deref(), self.sha256.as_deref(), ) { (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }), (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }), (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => Err( crate::Error::Recipe( "source: usa repo+commit O tarball+sha256, no ambos".into(), ), ), _ => Err(crate::Error::Recipe( "source: faltan campos; necesito (repo+commit), (tarball+sha256) o (dir)".into(), )), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Build { #[serde(default)] pub compiler: Compiler, #[serde(default = "default_target")] pub target: String, #[serde(default)] pub link: LinkMode, #[serde(default)] pub flags: Vec, /// Versión de zig a usar para ESTA receta, distinta del zig por defecto del lab. Sirve de /// escotilla por-receta para esquivar la regresión de zig 0.14 (que miscompila ciertos binarios /// musl dinámicos: flex/cmake/python/binutils/…): `zig_version = "0.13.0"` los construye con el /// zig bueno sin tocar el toolchain global ni el baseline de reproducibilidad (estas piezas son /// herramientas, no inputs del of_tree del 4/4). `None` ⇒ el zig por defecto. Ver SDD 11 §7.2b. #[serde(default, skip_serializing_if = "Option::is_none")] pub zig_version: Option, /// **Separa la información de depuración del artefacto** (SDD 23). Con `true`, el lab pasa un /// `zig objcopy --strip-debug` sobre los ELF de `/out` DESPUÉS de la fase install. /// /// POR QUÉ IMPORTA, medido: el **79%** del contenido binario del store son secciones `.debug_*` /// ⇒ ~96 G de reserva de disco y un espejo público de ~30 G en vez de ~126 G. Y además **cierra /// una fuga de reproducibilidad**: `takana why-differs` mostró que `appstream` y `bison` /// divergen entre rebuilds ÚNICAMENTE en `.debug_*` (rutas internas del árbol de build), con el /// código ejecutable idéntico. Sin esas secciones, el artefacto reproduce. /// /// ⚠ **ENTRA EN `hash_inputs`, y sólo si está fijado** — mismo diseño que `zig_version`. Las dos /// mitades de esa frase importan: /// · *entra*: si el lab pudiera cambiar lo que sale del build SIN mover el hash, el store /// diría que todo está al día mientras los artefactos ya no corresponden. En un sistema /// direccionado por contenido ése es el peor fallo posible: no falla, **miente**. /// · *sólo si está fijado*: una receta sin el campo conserva su hash de antes de que existiera, /// así que esto se puede desplegar receta a receta sin re-hashear el corpus de golpe. /// /// POR QUÉ ES UNA PERILLA DEL LAB Y NO UN PASO EN CADA RECETA: de las 1161 recetas, **383 no /// tienen fase `install` explícita** (usan la heurística por defecto). Meter el strip receta a /// receta obligaría a escribir a mano ese install por defecto en las 383, con el riesgo de no /// clavarlo exacto. El lab lo hace una vez, igual para todas. /// /// Usa `zig objcopy`, que SIEMPRE está en el sandbox (`/opt/zig`), en vez del `strip` de /// binutils: así no hay que añadir una dep de build a cada receta que lo active. #[serde(default, skip_serializing_if = "Option::is_none")] pub strip_debug: Option, /// Habilita CGO en un build Go (`BuildSys::Go`). Por defecto el lab compila con `CGO_ENABLED=0` /// (Go puro, estático, sin C). Algunos proyectos necesitan cgo para drivers con C bundled (p.ej. /// el driver sqlite3 de mattn en usql/sq/dolt): con `cgo = true` el compile usa `CGO_ENABLED=1` /// + `CC="zig cc"` (musl nativo del sandbox) y liga estático (`-linkmode=external -extldflags= /// -static`). Sólo para C bundled; cgo contra libs C EXTERNAS (gpgme/libvirt) requiere además /// esas recetas en el sandbox. No-op fuera de BuildSys::Go. #[serde(default)] pub cgo: bool, /// Subdirectorio donde vive el módulo Go cuando NO está en la raíz del repo (p.ej. dolt → `go`, /// csvtk → su subdir). El lab vendorea, detecta el main y compila DENTRO de `/`. /// Vacío = raíz (lo normal). Sólo aplica a BuildSys::Go. #[serde(default)] pub subdir: String, /// Overrides explícitos de las fases del build. Lo no especificado se decide por /// heurística en el lab (ver `docs/02-build-lab.md` §4). #[serde(default)] pub phases: Phases, } /// Comandos shell que se ejecutan en el sandbox por cada fase. Cualquiera puede ser `None`: /// el lab lo deriva por heurística (autotools / cmake / meson / make). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Phases { #[serde(default, skip_serializing_if = "Option::is_none")] pub configure: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub compile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub install: Option, } impl Phases { /// `true` si ninguna fase está sobreescrita (todo se decide por heurística). Sirve a /// serde (`skip_serializing_if`) para que un `.swm` sin fases custom no las emita. pub fn is_empty(&self) -> bool { self.configure.is_none() && self.compile.is_none() && self.install.is_none() } } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Deps { #[serde(default)] pub build: Vec, #[serde(default)] pub runtime: Vec, } impl Deps { /// `true` si no hay ninguna dep (ni build ni runtime). Sirve a serde para no emitir el /// bloque `deps` vacío en un `.swm`/receta. pub fn is_empty(&self) -> bool { self.build.is_empty() && self.runtime.is_empty() } } /// Las **superficies del sistema** de una config/paquete (H4b — SDD 15 §H4). Una config del /// usuario = un conjunto de modificaciones direccionadas por contenido: cada modificación /// **reclama** una superficie (la escribe) y/o **requiere** otra (depende de ella a una versión /// exacta). El valor de cada slot es un hash `b3:…` del contenido — misma disciplina de "todo por /// hash" que el resto del stack. /// /// El gate de compatibilidad (`crate::compat`) usa estos dos mapas contra el estado instalado: /// - un `requires` que no resuelve contra el estado ⇒ **incompatible** (el caso "modifiqué el /// protocolo de Wayland y bajo algo que depende del Wayland stock"); es el análogo, a nivel de /// sistema, de una referencia colgante. /// - un `claims` que pisa una superficie ya ocupada por otro contenido ⇒ **colisión = elección** /// (el caso "ya cambié el logo y bajo otra config que también lo cambia"), no un error. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Slots { /// Superficies que este paquete **escribe** → hash del contenido nuevo (`slot → b3:…`). #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub claims: std::collections::BTreeMap, /// Superficies de las que **depende** → versión exacta requerida (`slot → b3:…`). #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub requires: std::collections::BTreeMap, } impl Slots { /// `true` si no declara ninguna superficie (ni claims ni requires). Para serde: una receta /// sin bloque `slots` no lo emite. pub fn is_empty(&self) -> bool { self.claims.is_empty() && self.requires.is_empty() } /// Valida la forma (schema, no compatibilidad): cada valor de slot debe ser un hash `b3:…`. /// El gate de `install` corre esto antes de evaluar. pub fn validate(&self) -> crate::Result<()> { for (mapa, etiqueta) in [(&self.claims, "claims"), (&self.requires, "requires")] { for (slot, hash) in mapa { if !hash.starts_with("b3:") { return Err(crate::Error::Recipe(format!( "slots.{etiqueta}[{slot:?}]: el valor debe ser un hash `b3:…`, es {hash:?}" ))); } } } Ok(()) } } /// Evidencia de comportamiento de una receta (H1 — proof-carrying recipes, SDD 15 §H1). La IA /// entrega, junto al build, un conjunto de `checks` que el verificador (`takana swm-verify /// --evidence`) corre DENTRO del sandbox reproducible; la mutación sólo se propone si todos pasan. /// La confianza vive en el checker (pequeño, auditable), no en el generador: el sistema mejora solo /// sin poder corromperse solo. Deliberadamente FUERA de `Recipe::hash_inputs` (comportamiento ≠ /// identidad). Frontera honesta: garantiza que *lo declarado pasa*, no que *lo declarado basta*. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Evidence { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub checks: Vec, } impl Evidence { /// `true` si no hay ningún check. Sirve a serde (`skip_serializing_if`) para no emitir el /// bloque `evidence` vacío en una receta/`.swm`. pub fn is_empty(&self) -> bool { self.checks.is_empty() } /// Valida la forma del bloque (schema, no ejecución): cada `cmd` no vacío y cada /// `expected_output`, si está, con prefijo `b3:`. El checker de H1b corre esto antes de ejecutar. pub fn validate(&self) -> crate::Result<()> { for (i, c) in self.checks.iter().enumerate() { if c.cmd.trim().is_empty() { return Err(crate::Error::Recipe(format!( "evidence.checks[{i}] ({}): `cmd` vacío", c.kind.as_str() ))); } if let Some(h) = &c.expected_output { if !h.starts_with("b3:") { return Err(crate::Error::Recipe(format!( "evidence.checks[{i}] ({}): `expected_output` debe ser un hash `b3:…`, es {h:?}", c.kind.as_str() ))); } } } Ok(()) } } /// Un ítem de evidencia: un comando + el resultado exigido. `kind` estratifica la CONFIANZA /// (`cmd-exit` < `proptest` < `contract` < `kani`), que se reporta tal cual — H1 no finge que todo /// es prueba formal. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EvidenceCheck { pub kind: EvidenceKind, /// Comando shell a correr en el sandbox reproducible tras el build (misma vía que las fases). pub cmd: String, /// Exit code exigido. Default 0. #[serde(default)] pub expected_exit: i32, /// Hash BLAKE3 (`b3:…`) opcional del stdout, para checks deterministas cuyo OUTPUT también se /// ancla (no sólo el exit). `None` ⇒ sólo se exige el exit code. #[serde(default, skip_serializing_if = "Option::is_none")] pub expected_output: Option, } /// Estrato de confianza de un `EvidenceCheck`, en orden creciente (`Ord` = comparación de estratos). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum EvidenceKind { /// El comando sale con `expected_exit`. La garantía más débil (un smoke test). CmdExit, /// Property tests (proptest/quickcheck): pasan sobre entradas generadas. Proptest, /// Contratos / aserciones de pre y post-condición. Contract, /// Prueba formal acotada (Kani/Creusot). La garantía más fuerte. Kani, } impl EvidenceKind { /// Nivel numérico de confianza (0 = más débil), para reportar/ordenar. pub fn level(&self) -> u8 { match self { EvidenceKind::CmdExit => 0, EvidenceKind::Proptest => 1, EvidenceKind::Contract => 2, EvidenceKind::Kani => 3, } } pub fn as_str(&self) -> &'static str { match self { EvidenceKind::CmdExit => "cmd-exit", EvidenceKind::Proptest => "proptest", EvidenceKind::Contract => "contract", EvidenceKind::Kani => "kani", } } } /// Veredicto de un `EvidenceCheck` tras ejecutarlo (H1b). Se computa comparando el resultado /// REAL (exit code + stdout) contra lo declarado — sin tocar el sandbox (eso lo hace el runner). #[derive(Debug, Clone, PartialEq, Eq)] pub struct CheckOutcome { pub passed: bool, /// Explicación legible de por qué pasó o falló (para el reporte al humano). pub detail: String, } impl EvidenceCheck { /// Evalúa el resultado real de correr `cmd` contra lo declarado: exige `expected_exit` y, si /// hay `expected_output`, que `blake3(stdout)` coincida. **Función pura** — la ejecución en el /// sandbox reproducible es H1b; acá vive la lógica auditable de "¿el resultado es el exigido?". pub fn evaluate(&self, exit_code: i32, stdout: &[u8]) -> CheckOutcome { if exit_code != self.expected_exit { return CheckOutcome { passed: false, detail: format!("exit {exit_code} ≠ esperado {}", self.expected_exit), }; } if let Some(want) = &self.expected_output { let got = ArtifactHash::of_bytes(stdout); if got.as_str() != want { return CheckOutcome { passed: false, detail: format!("stdout {got} ≠ esperado {want}"), }; } return CheckOutcome { passed: true, detail: format!("exit {exit_code} ok + stdout {want} ok"), }; } CheckOutcome { passed: true, detail: format!("exit {exit_code} ok"), } } } /// Compilador del lab, POR RECETA (no global). `zig-cc` por defecto; escotilla a clang/gcc /// para paquetes con gcc-ismos. Ver ADR 0003. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Compiler { #[default] ZigCc, Clang, Gcc, } impl Compiler { pub fn as_str(&self) -> &'static str { match self { Compiler::ZigCc => "zig-cc", Compiler::Clang => "clang", Compiler::Gcc => "gcc", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LinkMode { #[default] Static, Dynamic, } impl LinkMode { pub fn as_str(&self) -> &'static str { match self { LinkMode::Static => "static", LinkMode::Dynamic => "dynamic", } } } fn default_target() -> String { "x86_64-linux-musl".to_string() } impl Recipe { /// Parsea una receta desde su forma TOML. `base_dir` queda vacío: si la receta declara /// `patches`, el caller debe fijar `base_dir` antes de hashear, o usar `load_from_path`. pub fn from_toml(s: &str) -> crate::Result { let r: Recipe = toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string()))?; // Los servicios se validan AL CARGAR, no al emitir la seed: un `id` mal puesto rompe la // reproducibilidad del rootfs de producto, y eso hay que verlo en la receta —no tres pasos // después, en un rootfs que ya no reproduce y no dice por qué. Recetas sin `[[service]]` // pasan por una lista vacía, así que esto no toca a las 979 existentes. crate::service::validate_all(&r.services)?; Ok(r) } /// Serializa la receta a TOML. Útil para el sidecar de provenance que `takana-build` /// escribe junto al artefacto en el store. `base_dir` no se serializa (es transient). pub fn to_toml(&self) -> crate::Result { toml::to_string(self).map_err(|e| crate::Error::Recipe(e.to_string())) } /// Carga una receta desde el disco. `base_dir` se fija al directorio que contiene el /// archivo, de modo que `source.patches` resuelve relativo a ahí (estilo Cargo). pub fn load_from_path(path: impl AsRef) -> crate::Result { let path = path.as_ref(); let text = std::fs::read_to_string(path)?; let mut recipe = Recipe::from_toml(&text)?; if let Some(parent) = path.parent() { recipe.base_dir = parent.to_path_buf(); } Ok(recipe) } /// Las entradas canónicas que alimentan el `ArtifactHash` de esta receta. Incluye el /// **contenido** de cada patch (no su ruta), de modo que renombrar un archivo no cambia el /// hash y editarlo sí lo hace. Ver `docs/02-build-lab.md` §2. /// `lab` es la huella del toolchain que va a construir esto (ver [`crate::lab`]). Entra SIEMPRE /// y es un parámetro obligatorio, no un `Option`: el 2026-08-10 se midió que dos labs con /// distinto `rustc` producen bytes distintos, así que un hash calculado sin lab y otro con lab /// son direcciones distintas para el mismo contenido. Un default silencioso aquí reintroduciría /// exactamente la divergencia que esto cierra — quien no tiene lab usa /// [`crate::lab::LabFingerprint::for_tests`] y lo dice. pub fn hash_inputs( &self, dep_hashes: &[ArtifactHash], lab: &crate::lab::LabFingerprint, ) -> crate::Result>> { // El identificador inmutable del contenido fuente: el commit en modo git, el // sha256 del archivo en modo tarball. Cualquiera de los dos es un puntero al // contenido exacto, lo que basta para reproducibilidad. let source_id = match self.source.kind()? { SourceKind::Git { commit, .. } => format!("git:{commit}"), SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"), // El árbol vive en el repo y puede cambiar en cualquier commit ⇒ el puntero al // contenido hay que CALCULARLO, no leerlo de la receta. `of_tree` es determinista // (rutas ordenadas, bit de ejecución, contenido; no sigue symlinks). SourceKind::Dir { dir } => { let root = self.base_dir.join(dir); let h = crate::ArtifactHash::of_tree(&root).map_err(|e| { crate::Error::Recipe(format!( "no pude hashear el árbol de source.dir {}: {e}", root.display() )) })?; format!("dir:{}", h.as_str()) } }; let mut v: Vec> = vec![ source_id.into_bytes(), self.build.compiler.as_str().as_bytes().to_vec(), self.build.target.as_bytes().to_vec(), self.build.link.as_str().as_bytes().to_vec(), format!("lab:{}", lab.as_str()).into_bytes(), ]; // Sólo entra al hash si está fijado: una receta sin `zig_version` mantiene su hash de antes // de existir el campo (compatibilidad hacia atrás; el baseline 9adefb82 no se mueve). if let Some(zv) = &self.build.zig_version { v.push(format!("zig:{zv}").into_bytes()); } // Ver la doc de `strip_debug`: cambia el CONTENIDO del artefacto, así que TIENE que entrar // al hash. Sólo si está fijado, para no mover los hashes de las recetas que no lo usan. if let Some(sd) = self.build.strip_debug { v.push(format!("strip_debug:{sd}").into_bytes()); } for p in &self.source.patches { let resolved = self.base_dir.join(p); let bytes = std::fs::read(&resolved).map_err(|e| { crate::Error::Recipe(format!("no pude leer patch {}: {e}", resolved.display())) })?; v.push(bytes); } for f in &self.build.flags { v.push(f.as_bytes().to_vec()); } // Phases override: si alguien cambia el comando del compile, el artefacto cambia. // Etiquetamos cada uno para que añadir un override luego no colisione con un flag. for (label, val) in [ ("phase:configure", &self.build.phases.configure), ("phase:compile", &self.build.phases.compile), ("phase:install", &self.build.phases.install), ] { if let Some(cmd) = val { v.push(format!("{label}={cmd}").into_bytes()); } } for d in dep_hashes { v.push(d.as_str().as_bytes().to_vec()); } Ok(v) } } #[cfg(test)] mod tests { use super::*; const SAMPLE: &str = r#" name = "grep" version = "3.11" [source] repo = "git://git.savannah.gnu.org/grep.git" commit = "a1b2c3d4e5f6" [build] compiler = "zig-cc" target = "x86_64-linux-musl" link = "static" flags = ["--enable-perl-regexp"] [deps] build = ["pcre2"] "#; #[test] fn parse_full() { let r = Recipe::from_toml(SAMPLE).expect("parse"); assert_eq!(r.name, "grep"); assert!(matches!( r.source.kind().unwrap(), SourceKind::Git { commit: "a1b2c3d4e5f6", .. } )); assert_eq!(r.build.compiler, Compiler::ZigCc); assert_eq!(r.build.link, LinkMode::Static); assert_eq!(r.deps.build, vec!["pcre2".to_string()]); } #[test] fn to_toml_roundtrips_via_from_toml() { let r1 = Recipe::from_toml(SAMPLE).unwrap(); let serialized = r1.to_toml().expect("to_toml"); let r2 = Recipe::from_toml(&serialized).expect("from_toml(roundtrip)"); assert_eq!(r2.name, r1.name); assert_eq!(r2.version, r1.version); assert_eq!(r2.build.compiler, r1.build.compiler); assert_eq!(r2.build.link, r1.build.link); assert_eq!(r2.build.flags, r1.build.flags); assert_eq!(r2.deps.build, r1.deps.build); // El kind del source también roundtripea. match (r1.source.kind().unwrap(), r2.source.kind().unwrap()) { (SourceKind::Git { commit: c1, .. }, SourceKind::Git { commit: c2, .. }) => { assert_eq!(c1, c2); } _ => panic!("source kind cambió en el roundtrip"), } } #[test] fn parse_tarball_source() { let s = r#" name = "grep" version = "3.11" [source] tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz" sha256 = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb" [build] "#; let r = Recipe::from_toml(s).expect("parse"); assert!(matches!(r.source.kind().unwrap(), SourceKind::Tarball { .. })); assert_eq!(r.source.strip_components, 1); } #[test] fn source_mixed_modes_rejected() { let s = r#" name = "x" version = "0" [source] repo = "git://x" commit = "deadbeef" tarball = "https://x/a.tar.gz" sha256 = "abc" [build] "#; let r = Recipe::from_toml(s).unwrap(); let err = r.source.kind().unwrap_err().to_string(); assert!(err.contains("no ambos"), "msg = {err}"); } #[test] fn source_missing_fields_rejected() { let s = r#" name = "x" version = "0" [source] repo = "git://x" [build] "#; let r = Recipe::from_toml(s).unwrap(); let err = r.source.kind().unwrap_err().to_string(); assert!(err.contains("faltan campos"), "msg = {err}"); } #[test] fn tarball_and_git_hash_to_different_values() { let git = Recipe::from_toml(SAMPLE).unwrap(); let tar_toml = r#" name = "grep" version = "3.11" [source] tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz" sha256 = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb" [build] flags = ["--enable-perl-regexp"] "#; let tar = Recipe::from_toml(tar_toml).unwrap(); assert_ne!(git.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), tar.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap()); } #[test] fn parse_minimal_uses_defaults() { let s = r#" name = "x" version = "0" [source] repo = "git://x" commit = "deadbeef" [build] "#; let r = Recipe::from_toml(s).expect("parse"); assert_eq!(r.build.compiler, Compiler::ZigCc); // default assert_eq!(r.build.target, "x86_64-linux-musl"); // default assert_eq!(r.build.link, LinkMode::Static); // default assert!(r.deps.build.is_empty()); } #[test] fn hash_no_patches_is_deterministic() { let r = Recipe::from_toml(SAMPLE).unwrap(); let a = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(); let b = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(); assert_eq!(a, b); } #[test] fn hash_uses_patch_contents_not_path() { let dir1 = tempfile::tempdir().unwrap(); let dir2 = tempfile::tempdir().unwrap(); std::fs::write(dir1.path().join("p.patch"), b"diff --content").unwrap(); std::fs::write(dir2.path().join("otro-nombre.patch"), b"diff --content").unwrap(); let mut r1 = Recipe::from_toml(SAMPLE).unwrap(); r1.source.patches = vec!["p.patch".into()]; r1.base_dir = dir1.path().to_path_buf(); let mut r2 = Recipe::from_toml(SAMPLE).unwrap(); r2.source.patches = vec!["otro-nombre.patch".into()]; r2.base_dir = dir2.path().to_path_buf(); // Mismo CONTENIDO ⇒ misma entrada al hash, aunque las rutas sean distintas. assert_eq!(r1.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), r2.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap()); } /// `foreign` marca PROCEDENCIA, no identidad del build: una receta que sella bytes ajenos /// produce exactamente el mismo artefacto con la marca puesta o quitada. Si entrara al hash, /// marcar la primera receta ajena habría movido su `ArtifactHash` — y peor, habría invitado a /// no marcarla para no re-sellar. La marca tiene que ser gratis para que se use. #[test] fn foreign_no_entra_al_hash() { let sin = Recipe::from_toml(SAMPLE).unwrap(); let con = Recipe::from_toml(&format!("foreign = true\n{SAMPLE}")).unwrap(); assert!(con.foreign && !sin.foreign, "la marca no se parseó"); let lab = crate::lab::LabFingerprint::for_tests(); assert_eq!(sin.hash_inputs(&[], &lab).unwrap(), con.hash_inputs(&[], &lab).unwrap()); } #[test] fn hash_changes_when_patch_content_changes() { let dir = tempfile::tempdir().unwrap(); let patch = dir.path().join("p.patch"); std::fs::write(&patch, b"version A").unwrap(); let mut r = Recipe::from_toml(SAMPLE).unwrap(); r.source.patches = vec!["p.patch".into()]; r.base_dir = dir.path().to_path_buf(); let h_a = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(); std::fs::write(&patch, b"version B").unwrap(); let h_b = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(); assert_ne!(h_a, h_b); } #[test] fn missing_patch_reports_path() { let mut r = Recipe::from_toml(SAMPLE).unwrap(); r.source.patches = vec!["no-existe.patch".into()]; r.base_dir = PathBuf::from("/tmp/seguro-que-no-existe-hammer"); let err = r.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap_err().to_string(); assert!(err.contains("no-existe.patch"), "msg = {err}"); } #[test] fn load_from_path_sets_base_dir() { let dir = tempfile::tempdir().unwrap(); let recipe_path = dir.path().join("foo.toml"); std::fs::write(&recipe_path, SAMPLE).unwrap(); let r = Recipe::load_from_path(&recipe_path).unwrap(); assert_eq!(r.base_dir, dir.path()); } const WITH_EVIDENCE: &str = r#" name = "grep" version = "3.11" [source] repo = "git://git.savannah.gnu.org/grep.git" commit = "a1b2c3d4e5f6" [build] [deps] build = ["pcre2"] [[evidence.checks]] kind = "cmd-exit" cmd = "grep --version" [[evidence.checks]] kind = "proptest" cmd = "cd /src && cargo test --release proptest_" expected_exit = 0 [[evidence.checks]] kind = "cmd-exit" cmd = "printf hola | grep -q hola" expected_output = "b3:deadbeef" "#; #[test] fn parse_evidence_block() { let r = Recipe::from_toml(WITH_EVIDENCE).expect("parse"); assert_eq!(r.evidence.checks.len(), 3); assert_eq!(r.evidence.checks[0].kind, EvidenceKind::CmdExit); assert_eq!(r.evidence.checks[0].cmd, "grep --version"); assert_eq!(r.evidence.checks[0].expected_exit, 0); // default assert_eq!(r.evidence.checks[1].kind, EvidenceKind::Proptest); assert_eq!(r.evidence.checks[2].expected_output.as_deref(), Some("b3:deadbeef")); r.evidence.validate().expect("schema válido"); } #[test] fn evidence_absent_is_empty_and_omitted() { let r = Recipe::from_toml(SAMPLE).unwrap(); assert!(r.evidence.is_empty()); // `skip_serializing_if` ⇒ una receta sin evidencia no emite el bloque. let toml = r.to_toml().unwrap(); assert!(!toml.contains("evidence"), "toml no debería traer `evidence`: {toml}"); } #[test] fn evidence_does_not_affect_artifact_hash() { // La clave de H1a: la evidencia certifica COMPORTAMIENTO, no IDENTIDAD ⇒ el hash del // artefacto es idéntico con y sin bloque evidence (el baseline de reproducibilidad no se mueve). // Comparamos LA MISMA receta con y sin evidencia (clon + limpieza), no dos recetas distintas. let con = Recipe::from_toml(WITH_EVIDENCE).unwrap(); let mut sin = con.clone(); sin.evidence = Evidence::default(); assert!(con.evidence.checks.len() == 3 && sin.evidence.is_empty()); assert_eq!( sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), "la evidencia NO debe entrar en hash_inputs" ); } #[test] fn cargo_vendor_roundtripea_y_no_afecta_el_hash() { // `cargo_vendor` decide DE DÓNDE salen las deps de Cargo (registro vs. `vendor/` local), no // CUÁLES: eso lo fija el `Cargo.lock` del source, que ya está bajo el hash de la fuente. Por // eso no entra a `hash_inputs`, igual que `cargo_vendor_dir` — y por eso se puede prender en // una receta YA SELLADA para destrabar su build offline sin re-hashear nada aguas abajo. let sin = Recipe::from_toml(SAMPLE).unwrap(); let mut con = sin.clone(); con.source.cargo_vendor = Some(true); assert_eq!( sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), "cargo_vendor NO debe entrar en hash_inputs" ); // Y roundtripea por TOML: si se perdiera al serializar, el forzado se apagaría solo en // cualquier flujo que reescriba la receta. let r = Recipe::from_toml(&con.to_toml().unwrap()).unwrap(); assert_eq!(r.source.cargo_vendor, Some(true)); // Ausente sigue siendo ausente (no `Some(false)`), que es lo que mantiene el TOML limpio. assert_eq!( Recipe::from_toml(&sin.to_toml().unwrap()) .unwrap() .source .cargo_vendor, None ); } #[test] fn slots_round_trip_y_no_afectan_el_hash() { // H4b: el bloque `slots` declara TOPOLOGÍA (qué toca / de qué depende), no identidad ⇒ // como la evidencia, NO entra en hash_inputs. Y round-trippea por TOML. let con_toml = format!( "{SAMPLE}\n[slots.claims]\nlogo = \"b3:rojo\"\n[slots.requires]\n\"wayland-protocol\" = \"b3:stock\"\n" ); let con = Recipe::from_toml(&con_toml).unwrap(); assert_eq!(con.slots.claims.get("logo").unwrap(), "b3:rojo"); assert_eq!(con.slots.requires.get("wayland-protocol").unwrap(), "b3:stock"); con.slots.validate().unwrap(); // Round-trip: serializa y vuelve a parsear ⇒ mismos slots. let re = Recipe::from_toml(&con.to_toml().unwrap()).unwrap(); assert_eq!(re.slots, con.slots); // Mismo hash con y sin slots. let mut sin = con.clone(); sin.slots = Slots::default(); assert!(!con.slots.is_empty() && sin.slots.is_empty()); assert_eq!( sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), "los slots NO deben entrar en hash_inputs" ); } #[test] fn licencia_round_trip_y_no_afecta_el_hash() { // ESTE TEST ES LA RAZÓN DE QUE LA DEUDA DE LICENCIAS SEA PAGABLE. // // El 2026-08-07 ninguna de las 1141 recetas declaraba licencia, y sin ese dato la distro no // puede cumplir la GPL al redistribuir binarios (SDD 19 §2.1). Poblarlo sólo es viable si // NO mueve el `ArtifactHash`: si lo moviera, poner la licencia significaría reconstruir el // corpus entero y perder el baseline de reproducibilidad, y nadie lo haría nunca. // // `hash_inputs` es una lista blanca, así que la propiedad se cumple por construcción — pero // se clava acá para que nadie la rompa por descuido metiendo el campo en el hash. // La línea va DELANTE: en TOML una clave suelta después de un `[table]` pertenece a esa // tabla, no a la raíz. Puesta al final, `license` acabaría dentro de `[deps]` y se perdería // en silencio (serde ignora campos desconocidos). Es el mismo cuidado que hay que tener al // poblar las recetas: el campo va arriba, junto a `name` y `version`. let con_toml = format!("license = \"GPL-3.0-or-later\"\n{SAMPLE}"); let con = Recipe::from_toml(&con_toml).unwrap(); assert_eq!(con.license.as_deref(), Some("GPL-3.0-or-later")); // Round-trip por TOML: serializa y vuelve a parsear ⇒ misma licencia. let re = Recipe::from_toml(&con.to_toml().unwrap()).unwrap(); assert_eq!(re.license, con.license); // Una receta sin el campo parsea igual (las 1141 existentes no se tocan para compilar). let sin = Recipe::from_toml(SAMPLE).unwrap(); assert_eq!(sin.license, None); // Y el hash es el MISMO con y sin licencia. assert_eq!( sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), "la licencia NO debe entrar en hash_inputs: si entrara, declararla re-hashearía las \ 1141 recetas ya selladas y tiraría el baseline de reproducibilidad" ); } #[test] fn strip_debug_entra_en_el_hash_solo_si_esta_fijado() { // Las DOS mitades de la invariante, porque cada una evita un desastre distinto. // // (1) SIN el campo, el hash NO se mueve. Es lo que permite desplegar el split de debug // receta a receta en vez de re-hashear las 1161 de golpe. let sin = Recipe::from_toml(SAMPLE).unwrap(); assert_eq!(sin.build.strip_debug, None); // (2) CON el campo, el hash SÍ cambia. `strip_debug` altera el CONTENIDO del artefacto // (le quita las secciones .debug_*), así que si no entrara al hash el lab podría cambiar // lo que sale del build sin que el store se enterara: diría que todo está al día // mientras los artefactos ya no corresponden a su hash. En un sistema direccionado por // contenido ése es el peor fallo posible — no falla, MIENTE. Es exactamente lo contrario // de `license`, que describe términos legales y por eso NO debe entrar. let mut con = Recipe::from_toml(SAMPLE).unwrap(); con.build.strip_debug = Some(true); assert_ne!( sin.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), con.hash_inputs(&[], &crate::lab::LabFingerprint::for_tests()).unwrap(), "strip_debug DEBE entrar en hash_inputs: cambia el contenido del artefacto" ); // Y round-trippea por TOML, que es como se va a escribir en las recetas. let re = Recipe::from_toml(&con.to_toml().unwrap()).unwrap(); assert_eq!(re.build.strip_debug, Some(true)); } #[test] fn slots_validate_rechaza_hash_sin_prefijo() { let mut s = Slots::default(); s.claims.insert("logo".into(), "rojo".into()); // falta `b3:` assert!(s.validate().is_err()); } #[test] fn evidence_validate_rejects_empty_cmd_and_bad_hash() { let bad_cmd = r#" name = "x" version = "0" [source] repo = "git://x" commit = "deadbeef" [build] [[evidence.checks]] kind = "cmd-exit" cmd = " " "#; let r = Recipe::from_toml(bad_cmd).unwrap(); assert!(r.evidence.validate().unwrap_err().to_string().contains("vacío")); let bad_hash = r#" name = "x" version = "0" [source] repo = "git://x" commit = "deadbeef" [build] [[evidence.checks]] kind = "cmd-exit" cmd = "true" expected_output = "sha256:zzz" "#; let r = Recipe::from_toml(bad_hash).unwrap(); assert!(r.evidence.validate().unwrap_err().to_string().contains("b3:")); } #[test] fn evidence_kind_levels_are_stratified() { assert!(EvidenceKind::CmdExit < EvidenceKind::Proptest); assert!(EvidenceKind::Proptest < EvidenceKind::Contract); assert!(EvidenceKind::Contract < EvidenceKind::Kani); assert_eq!(EvidenceKind::CmdExit.level(), 0); assert_eq!(EvidenceKind::Kani.level(), 3); } fn check(expected_exit: i32, expected_output: Option<&str>) -> EvidenceCheck { EvidenceCheck { kind: EvidenceKind::CmdExit, cmd: "irrelevante".into(), expected_exit, expected_output: expected_output.map(|s| s.into()), } } #[test] fn evaluate_exit_only_pass_and_fail() { let c = check(0, None); assert!(c.evaluate(0, b"lo que sea").passed); let bad = c.evaluate(1, b""); assert!(!bad.passed); assert!(bad.detail.contains("exit 1"), "{}", bad.detail); } #[test] fn evaluate_non_zero_expected_exit() { // Un check puede exigir un exit != 0 (p.ej. "el binario rechaza input inválido con 2"). let c = check(2, None); assert!(c.evaluate(2, b"").passed); assert!(!c.evaluate(0, b"").passed); } #[test] fn evaluate_output_hash_pass_and_fail() { let want = ArtifactHash::of_bytes(b"hola\n"); let c = check(0, Some(want.as_str())); // stdout correcto ⇒ pasa. let ok = c.evaluate(0, b"hola\n"); assert!(ok.passed, "{}", ok.detail); assert!(ok.detail.contains("hash") || ok.detail.contains(want.as_str())); // stdout distinto ⇒ falla aunque el exit sea correcto. let bad = c.evaluate(0, b"chau\n"); assert!(!bad.passed); assert!(bad.detail.contains("stdout"), "{}", bad.detail); // exit malo ⇒ falla antes de mirar el hash. assert!(!c.evaluate(1, b"hola\n").passed); } // ── source.dir: recetas DERIVADAS (SDD 26) ───────────────────────────────────────────────── #[test] fn source_dir_es_su_propio_modo_y_excluye_a_los_otros() { let solo_dir: Source = toml::from_str("dir = \"atuq\"").unwrap(); assert_eq!( solo_dir.kind().unwrap(), SourceKind::Dir { dir: "atuq" }, "un `dir` solo tiene que resolver a modo directorio" ); // Declarar árbol propio Y fuente upstream no es ambigüedad que resolver por precedencia: // es un error de quien escribió la receta, y decirlo temprano evita un fetch que después // se pisa. Se comprueba con las dos formas de upstream. let con_git: Source = toml::from_str("dir = \"atuq\"\nrepo = \"git://x\"\ncommit = \"c0\"").unwrap(); assert!(con_git.kind().is_err(), "dir + git debe fallar"); let con_tar: Source = toml::from_str("dir = \"atuq\"\ntarball = \"http://x/t.tar\"\nsha256 = \"ab\"") .unwrap(); assert!(con_tar.kind().is_err(), "dir + tarball debe fallar"); } #[test] fn el_error_de_source_vacio_nombra_los_tres_modos() { // El mensaje es la única guía que tiene quien escribe una receta a mano. Si sumamos un modo // y el error sigue nombrando dos, mandamos a la gente a buscar un campo que no existe. let vacio: Source = toml::from_str("").unwrap(); let e = vacio.kind().unwrap_err().to_string(); assert!(e.contains("repo"), "{e}"); assert!(e.contains("tarball"), "{e}"); assert!(e.contains("dir"), "{e}"); } }