Files
hammer/crates/hammer-core/src/recipe.rs
T
sergioandClaude Opus 5 346cd59706 licencias: campo license en la receta — de 0 a 228 de 1141, sin re-hashear nada
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>
2026-08-07 10:24:11 -04:00

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);
}
}