hammer: source.dir — el modo de fuente que faltaba para las recetas DERIVADAS

Al ir a escribir `recipes/atuq.toml` apareció un muro que el SDD 26 no había visto: `[source]` era
obligatoriamente git o tarball (`Source::kind` no tiene tercera salida), así que una receta cuyo
contenido no viene de upstream sino de NOSOTROS —un envoltorio sobre otro artefacto, un tema, una
configuración— no se podía ni escribir. Los rodeos posibles eran todos peores: fetchear una fuente
upstream que después se ignora MIENTE sobre la identidad del artefacto, y colgar el overlay de un
repo aparte obliga a que el worker tenga acceso de lectura a un repo privado.

`dir = "atuq"` apunta a un árbol dentro del propio repo, relativo al directorio de la receta.

SE HASHEA POR CONTENIDO, NO POR RUTA. `ArtifactHash::of_tree` ya existía (lo usa Stage 2 para
verificar bit-reproducibilidad) y hace exactamente lo que hace falta: rutas ordenadas, bit de
ejecución, contenido, sin seguir symlinks. Es la misma disciplina que ya tenían los `patches`, que
entran al hash por bytes y no por nombre. Comprobado a mano: editar un CSS del overlay mueve el
ArtifactHash y revertirlo lo devuelve exacto.

`dir` es EXCLUYENTE con repo/tarball y se comprueba primero. Declarar las dos cosas no es una
ambigüedad para resolver por precedencia: es un error de quien escribió la receta, y decirlo antes
del fetch evita bajar algo que después se pisa.

Los cuatro sitios que hacían match sobre `SourceKind` se cierran a mano y no con un `_`:
- `swm.rs` (×2) y `swm_bridge.rs` (×2): un `.swm` es un manifiesto COMPARTIBLE y necesita un puntero
  que el otro lado pueda resolver (commit o sha256). El árbol de una derivada vive en este repo y no
  hay puntero que mandar ⇒ error explícito en vez de emitir un manifiesto con el source vacío, que
  viajaría bien y rompería del otro lado. Error y no `unreachable!`: esto es librería, y un panic
  mataría al llamador por una receta mal escrita.
- `hammer pin`: una derivada ya está anclada por contenido ⇒ no hay ref flotante que fijar, lo dice
  y sale con 0.
- `hammer` → file_drop: mismo tratamiento que un source que no se puede expresar.

Dos tests: que `dir` resuelve y excluye a los otros dos, y que el mensaje de «source vacío» nombra
los TRES modos — ese texto es la única guía de quien escribe una receta a mano, y si sumamos un modo
sin tocarlo mandamos a la gente a buscar un campo que no existe.

⚠ Al correr la suite aparece un fallo AJENO a esto y que NO toqué:
`kernel::contract::tests::el_contrato_del_repo_cierra_sobre_si_mismo` — «proceso-por-descriptor no
declara símbolos», del frente kernel (commit abda7d3, alta de pidfd). Queda anotado para su dueño.
This commit is contained in:
Sergio
2026-09-05 04:12:34 +00:00
parent 52e4dbe8cc
commit 54fa4a941c
5 changed files with 159 additions and 2 deletions
+43
View File
@@ -21,9 +21,52 @@ pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result<PathBuf>
match recipe.source.kind()? {
SourceKind::Git { repo, commit } => fetch_git(recipe, repo, commit, work_root),
SourceKind::Tarball { url, sha256 } => fetch_tarball(recipe, url, sha256, work_root),
SourceKind::Dir { dir } => fetch_dir(recipe, dir, work_root),
}
}
/// Materializa el árbol de una receta DERIVADA: copia `<dir-de-la-receta>/<dir>` al workspace.
///
/// No hay red, ni caché que invalidar, ni mirror: la fuente ya está en el disco. Se COPIA y no se
/// bindea porque las fases mutan el árbol (parches, vendoreo, ficheros generados) y el original vive
/// en el repo git — un build que ensucie el working tree del repo sería una forma silenciosa de que
/// dos recetas se pisen, que es justo lo que el ADR 0012 documenta para las fuentes compartidas.
///
/// El nombre del workspace lleva el hash del CONTENIDO y no el de la ruta: editar el overlay cambia
/// de directorio, así que un árbol viejo nunca se reusa por accidente.
fn fetch_dir(recipe: &Recipe, dir: &str, work_root: &Path) -> hammer_core::Result<PathBuf> {
let origen = recipe.base_dir.join(dir);
if !origen.is_dir() {
return Err(hammer_core::Error::Other(anyhow::anyhow!(
"source.dir apunta a {} y no es un directorio",
origen.display()
)));
}
let h = hammer_core::ArtifactHash::of_tree(&origen)
.map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("hash del árbol {}: {e}", origen.display())))?;
let bare = h.as_str().strip_prefix("b3:").unwrap_or(h.as_str());
let sources_dir = work_root.join("sources");
std::fs::create_dir_all(&sources_dir)?;
let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &bare[..16]));
if work_tree.is_dir() {
std::fs::remove_dir_all(&work_tree)?;
}
std::fs::create_dir_all(&work_tree)?;
let st = Command::new("cp")
.arg("-a")
.arg(format!("{}/.", origen.display()))
.arg(&work_tree)
.status()
.map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn cp -a del source.dir: {e}")))?;
if !st.success() {
return Err(hammer_core::Error::Other(anyhow::anyhow!(
"cp -a de {} falló",
origen.display()
)));
}
Ok(work_tree)
}
fn fetch_git(
recipe: &Recipe,
repo: &str,
+14
View File
@@ -105,6 +105,14 @@ pub fn recipe_from_source_patch(
let source_key: &str = match &source_kind {
hammer_core::SourceKind::Git { commit, .. } => commit,
hammer_core::SourceKind::Tarball { sha256, .. } => sha256,
// Inalcanzable por construcción —`swm_source_kind` sólo produce Git o Tarball— pero se
// escribe como error y no como `unreachable!`: esto es una librería, y un panic aquí
// mataría al llamador por una receta mal formada en vez de decírselo.
hammer_core::SourceKind::Dir { .. } => {
return Err(hammer_core::Error::Recipe(
"un .swm no puede llevar un source por directorio".into(),
))
}
};
let mut patches: Vec<String> = Vec::new();
@@ -176,6 +184,12 @@ fn synthesize_recipe(
*sha256,
format!("tarball = \"{url}\"\nsha256 = \"{sha256}\"\n"),
),
// Ver la nota del `source_key` de arriba: un `.swm` no puede traer un `dir`.
hammer_core::SourceKind::Dir { .. } => {
return Err(hammer_core::Error::Recipe(
"un .swm no puede llevar un source por directorio".into(),
))
}
};
// Construimos la receta vía TOML para reusar las defaults y el validador de `Source`.
let toml_text = format!(
+9 -1
View File
@@ -2295,6 +2295,12 @@ fn run_pin(recipe_path: &std::path::Path, out: Option<&std::path::Path>) -> anyh
eprintln!("{}: tarball (ya anclado por sha256) — nada que pinear", recipe.name);
return Ok(());
}
// Una receta derivada ya está anclada por el CONTENIDO de su árbol (el hash entra en
// `hash_inputs` calculado, no declarado) ⇒ no hay ref flotante que fijar.
Ok(hammer_core::SourceKind::Dir { dir }) => {
eprintln!("{}: source.dir ({dir}) — anclado por contenido, nada que pinear", recipe.name);
return Ok(());
}
Err(e) => anyhow::bail!("{}: {e}", recipe.name),
};
if is_git_sha(&commit) {
@@ -2954,7 +2960,9 @@ fn build_export_mutations(
Ok(hammer_core::SourceKind::Tarball { url, sha256 }) => {
(None, None, Some(url.to_string()), Some(sha256.to_string()))
}
Err(_) => {
// El árbol de una receta derivada no se puede mandar en un `.swm` (no hay puntero
// resoluble del otro lado) ⇒ mismo tratamiento que un source inválido: file_drop.
Ok(hammer_core::SourceKind::Dir { .. }) | Err(_) => {
stats.warnings += 1;
eprintln!(
"warning: receta de {art_hash} tiene source inválido; cayendo a file_drop"
+74 -1
View File
@@ -94,6 +94,20 @@ pub struct Source {
/// siempre tienen un único top-level `proyecto-version/`).
#[serde(default = "default_strip")]
pub strip_components: usize,
// --- modo directorio (recetas DERIVADAS) ---
/// Árbol fuente que vive en ESTE repo, relativo al directorio de la receta.
///
/// Existe para las recetas cuyo contenido no viene de upstream sino de nosotros: un envoltorio
/// que se arma encima de otro artefacto (`atuq` sobre `firefox`, SDD 26), un tema, una
/// configuración. Antes de esto el `[source]` era obligatoriamente git o tarball, así que una
/// receta así no se podía ni escribir — y el rodeo (fetchear una fuente upstream que no se usa)
/// habría mentido sobre la identidad del artefacto.
///
/// **Se hashea por CONTENIDO, no por ruta**: `ArtifactHash::of_tree` sobre el árbol entero, que
/// es lo mismo que ya se hace con los `patches` (bytes, no nombres). Editar un CSS del overlay
/// mueve el hash; renombrar el directorio sin tocar nada, no.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dir: Option<String>,
// --- 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
@@ -128,10 +142,23 @@ fn default_strip() -> usize {
pub enum SourceKind<'a> {
Git { repo: &'a str, commit: &'a str },
Tarball { url: &'a str, sha256: &'a str },
/// Árbol del propio repo (recetas derivadas). Ver [`Source::dir`].
Dir { dir: &'a str },
}
impl Source {
pub fn kind(&self) -> crate::Result<SourceKind<'_>> {
// `dir` es EXCLUYENTE con los otros dos y se comprueba primero: declarar a la vez un
// árbol propio y una fuente upstream no es una receta ambigua, es un error de quien la
// escribió, y decirlo temprano evita un build que fetchea algo que después pisa.
if let Some(dir) = self.dir.as_deref() {
if self.repo.is_some() || self.tarball.is_some() {
return Err(crate::Error::Recipe(
"source: `dir` es excluyente con repo/tarball — una receta derivada no tiene fuente upstream".into(),
));
}
return Ok(SourceKind::Dir { dir });
}
match (
self.repo.as_deref(),
self.commit.as_deref(),
@@ -146,7 +173,7 @@ impl Source {
),
),
_ => Err(crate::Error::Recipe(
"source: faltan campos; necesito (repo+commit) o (tarball+sha256)".into(),
"source: faltan campos; necesito (repo+commit), (tarball+sha256) o (dir)".into(),
)),
}
}
@@ -516,6 +543,19 @@ impl Recipe {
let source_id = match self.source.kind()? {
SourceKind::Git { commit, .. } => format!("git:{commit}"),
SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"),
// El árbol vive en el repo y puede cambiar en cualquier commit ⇒ el puntero al
// contenido hay que CALCULARLO, no leerlo de la receta. `of_tree` es determinista
// (rutas ordenadas, bit de ejecución, contenido; no sigue symlinks).
SourceKind::Dir { dir } => {
let root = self.base_dir.join(dir);
let h = crate::ArtifactHash::of_tree(&root).map_err(|e| {
crate::Error::Recipe(format!(
"no pude hashear el árbol de source.dir {}: {e}",
root.display()
))
})?;
format!("dir:{}", h.as_str())
}
};
let mut v: Vec<Vec<u8>> = vec![
source_id.into_bytes(),
@@ -1040,4 +1080,37 @@ expected_output = "sha256:zzz"
// exit malo ⇒ falla antes de mirar el hash.
assert!(!c.evaluate(1, b"hola\n").passed);
}
// ── source.dir: recetas DERIVADAS (SDD 26) ─────────────────────────────────────────────────
#[test]
fn source_dir_es_su_propio_modo_y_excluye_a_los_otros() {
let solo_dir: Source = toml::from_str("dir = \"atuq\"").unwrap();
assert_eq!(
solo_dir.kind().unwrap(),
SourceKind::Dir { dir: "atuq" },
"un `dir` solo tiene que resolver a modo directorio"
);
// Declarar árbol propio Y fuente upstream no es ambigüedad que resolver por precedencia:
// es un error de quien escribió la receta, y decirlo temprano evita un fetch que después
// se pisa. Se comprueba con las dos formas de upstream.
let con_git: Source =
toml::from_str("dir = \"atuq\"\nrepo = \"git://x\"\ncommit = \"c0\"").unwrap();
assert!(con_git.kind().is_err(), "dir + git debe fallar");
let con_tar: Source =
toml::from_str("dir = \"atuq\"\ntarball = \"http://x/t.tar\"\nsha256 = \"ab\"")
.unwrap();
assert!(con_tar.kind().is_err(), "dir + tarball debe fallar");
}
#[test]
fn el_error_de_source_vacio_nombra_los_tres_modos() {
// El mensaje es la única guía que tiene quien escribe una receta a mano. Si sumamos un modo
// y el error sigue nombrando dos, mandamos a la gente a buscar un campo que no existe.
let vacio: Source = toml::from_str("").unwrap();
let e = vacio.kind().unwrap_err().to_string();
assert!(e.contains("repo"), "{e}");
assert!(e.contains("tarball"), "{e}");
assert!(e.contains("dir"), "{e}");
}
}
+19
View File
@@ -272,6 +272,17 @@ impl Swm {
SourceKind::Tarball { url, sha256 } => {
(None, None, Some(url.to_string()), Some(sha256.to_string()))
}
// Un `.swm` es un manifiesto COMPARTIBLE: describe la mutación con punteros que el otro
// lado pueda resolver (un commit, un sha256). El árbol de una receta derivada vive en
// ESTE repo y no hay puntero que mandar, así que no se puede expresar todavía. Falla
// diciéndolo en vez de emitir un manifiesto con el source vacío, que viajaría bien y
// rompería del otro lado.
SourceKind::Dir { dir } => {
return Err(crate::Error::Recipe(format!(
"`{}` usa source.dir ({dir}) y una receta derivada no se puede exportar a .swm: el manifiesto necesita un puntero resoluble desde fuera (commit o sha256)",
recipe.name
)))
}
};
let build = SwmBuild {
@@ -363,6 +374,14 @@ impl Mutation {
return Err("source_patch: 'sha256' vacío".into());
}
}
// `swm_source_kind` sólo construye Git o Tarball a partir de los campos del
// manifiesto: no hay forma de que un `.swm` traiga un `dir`. El brazo existe
// para que el compilador siga vigilando este match si mañana lo trae.
SourceKind::Dir { .. } => {
return Err(
"source_patch: un .swm no puede llevar un source por directorio".into(),
);
}
}
if patch.is_some() && patch_url.is_some() {
return Err(