La deuda legal bloqueante del SDD 19 §2.1. Medido hoy: **0 de 1141 recetas** declaraban licencia, no «5 de 771» como decía el informe anterior. Los dos números estaban mal: los «5» eran falsos positivos de `grep license` (el paquete `addlicense`, el paquete `cargo-bundle-licenses`, una línea `install .../share/licenses/` y un comentario), y las recetas son 1141. Contar con `grep -l <palabra>` sobre TOML cuenta comentarios y nombres, no campos; `scripts/licencias.sh` cuenta el campo de verdad (clave en la raíz, antes del primer `[table]`). LO QUE HACE LA DEUDA PAGABLE: `Recipe::hash_inputs` es una LISTA BLANCA — sólo entran source, compiler, target, link, patches, flags, phases y deps. `license` no entra, igual que `evidence` y `slots`. Por eso se puede poblar en las recetas YA SELLADAS sin mover un solo ArtifactHash. Verificado, no supuesto: en 40 recetas modificadas se comparó el hash con y sin la línea — 40 idénticos, 0 cambiados. Si el campo entrara al hash, declarar la licencia costaría reconstruir el corpus entero y no se haría nunca. Clavado con el test `licencia_round_trip_y_no_afecta_el_hash`. TRAMPA DE TOML: una clave suelta después de un `[table]` pertenece a esa tabla. Puesta al final del fichero, `license` acaba dentro de `[deps]` y se pierde EN SILENCIO, porque serde ignora los campos que no conoce — no hay error, simplemente no está. Va arriba, junto a `name` y `version`; el sembrador la inserta tras `version`. NO SE ADIVINA. Declarar mal una licencia es peor que dejarla vacía: convierte un hueco visible en una afirmación falsa. Sólo se puebla desde una tabla curada entrada por entrada (`docs/licencias-conocidas.tsv`); lo que no tiene evidencia queda vacío y se CUENTA. Concretamente se descartó el atajo «k* = KDE ⇒ LGPL»: en este catálogo `kail`, `kind`, `ko`, `kopia`, `krew`, `kustomize`, `kyverno`, `katana`, `kibi`, `kmon` y toda la familia `kube*` son herramientas Go sin relación con KDE. El nombre no es evidencia. Quedan 913, casi todas CLIs Go/Rust importados en masa — y ésas sí son automatizables con evidencia real: Cargo.toml trae el campo `license` y los módulos Go traen su LICENSE en el árbol. El cierre estructural es capturarlo en la fase de fetch, que ya descarga y extrae cada tarball, y inyectar el texto en `hammer pack` (aguas abajo del ArtifactHash) en vez de en la fase install (que sí re-hashearía). De paso, respaldo-storagebox.sh reordenado por valor irreemplazable y con zstd: medido en la oficina, el uplink da 8 Mbps iguales por cable y por wifi ⇒ 128 G no caben en una sentada, así que sube primero el cerebro (estado + repo) y `--partial-dir` hace que cortar a mitad de un artefacto no tire lo ya subido. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
947 lines
38 KiB
Rust
947 lines
38 KiB
Rust
//! 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<String>,
|
|
/// 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<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub commit: Option<String>,
|
|
// --- modo tarball ---
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub tarball: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub sha256: Option<String>,
|
|
/// 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,
|
|
// --- 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<String>,
|
|
/// 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<bool>,
|
|
#[serde(default)]
|
|
pub patches: Vec<String>,
|
|
}
|
|
|
|
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 },
|
|
}
|
|
|
|
impl Source {
|
|
pub fn kind(&self) -> crate::Result<SourceKind<'_>> {
|
|
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) o (tarball+sha256)".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<String>,
|
|
/// 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<String>,
|
|
/// 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 `<src>/<subdir>`.
|
|
/// 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<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub compile: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub install: Option<String>,
|
|
}
|
|
|
|
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<String>,
|
|
#[serde(default)]
|
|
pub runtime: Vec<String>,
|
|
}
|
|
|
|
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<String, String>,
|
|
/// 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<String, String>,
|
|
}
|
|
|
|
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 (`hammer 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<EvidenceCheck>,
|
|
}
|
|
|
|
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<String>,
|
|
}
|
|
|
|
/// 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<Recipe> {
|
|
toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string()))
|
|
}
|
|
|
|
/// Serializa la receta a TOML. Útil para el sidecar de provenance que `hammer-build`
|
|
/// escribe junto al artefacto en el store. `base_dir` no se serializa (es transient).
|
|
pub fn to_toml(&self) -> crate::Result<String> {
|
|
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<Path>) -> crate::Result<Recipe> {
|
|
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.
|
|
pub fn hash_inputs(
|
|
&self,
|
|
dep_hashes: &[ArtifactHash],
|
|
) -> crate::Result<Vec<Vec<u8>>> {
|
|
// 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}"),
|
|
};
|
|
let mut v: Vec<Vec<u8>> = 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(),
|
|
];
|
|
// 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());
|
|
}
|
|
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(&[]).unwrap(), tar.hash_inputs(&[]).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(&[]).unwrap();
|
|
let b = r.hash_inputs(&[]).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(&[]).unwrap(), r2.hash_inputs(&[]).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(&[]).unwrap();
|
|
|
|
std::fs::write(&patch, b"version B").unwrap();
|
|
let h_b = r.hash_inputs(&[]).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(&[]).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(&[]).unwrap(),
|
|
con.hash_inputs(&[]).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(&[]).unwrap(),
|
|
con.hash_inputs(&[]).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(&[]).unwrap(),
|
|
con.hash_inputs(&[]).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(&[]).unwrap(),
|
|
con.hash_inputs(&[]).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 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);
|
|
}
|
|
}
|