Destraba proyectos Go cuyo módulo no está en la raíz (dolt→go, csvtk→subdir): vendor_go_deps corre en el subdir, detect_build_system/detect_go_main operan ahí, y el compile hace 'cd <subdir>' primero. Test go_subdir_compiles_in_subdir.
476 lines
17 KiB
Rust
476 lines
17 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,
|
|
/// 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 ---
|
|
#[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()
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|