> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
paquetería: zsh se puede instalar — los parches múltiples viajaban unidos y el target_bin era una adivinanza
Cerrar el lazo servir→instalar sobre `zsh` (pedido del usuario) destapó dos fallos de la familia
del `strip_debug` del SDD 28 §5.2: cosas que el `.swm` no transportaba fiel y que sólo se ven
cerrando el lazo, no leyendo el código.
1. `pack` CONCATENABA los N parches en el `patch` inline único, y `hash_inputs` mete una entrada
por parche (con `of_inputs` length-prefijado) ⇒ el receptor sellaba en otra dirección y el
`expected_hash` no coincidía NUNCA. Medido: zsh anclado b3:0be3630d…, reproducido b3:d6329a62…
(idéntico al de una copia de la receta con sus 6 parches concatenados a mano). Afectaba a las
16 recetas del corpus con ≥2 parches. Ahora viajan como lista `patches`, en orden, un fichero
por parche del lado del receptor; el `patch` único se sigue leyendo para no invalidar lo
publicado. Regresión con control negativo en swm_bridge.
2. `target_bin` se adivinaba `/usr/bin/{name}`; zsh instala en `/bin` (--bindir=/bin), así que el
paquete prometía un path que el artefacto no tiene y el error salía en el cliente DESPUÉS de
hidratar 1331 ficheros. Con `--build` el artefacto está delante: se comprueba, se corrige
diciéndolo, y si no hay ningún binario con ese nombre se ABORTA al publicar.
Y el verbo que faltaba para «avisame cuándo actualizar»: `takana outdated` compara la DB de
instalados con el índice firmado (por hash, no por versión: una re-publicación con la misma
etiqueta es otro artefacto). No construye, no baja .swm y no actualiza — instalar es verificar.
Medido tras el arreglo: install zsh ⇒ cache-hit del hash anclado, 1331 ficheros en 0,14 s, zsh 5.9
corriendo.
diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs
index 5d036511..79e8fc6e 100644
--- a/crates/hammerd/src/bus.rs
+++ b/crates/hammerd/src/bus.rs
@@ -369,7 +369,10 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
tarball: recipe.tarball,
sha256: recipe.sha256,
strip_components: None,
+ // El proto del bus transporta UN parche inline (`RecipeInline.patch`); el orquestador
+ // corta antes si el `.swm` traía varios (ver `Mutation::SourcePatch::patches`).
patch: recipe.patch,
+ patches: Vec::new(),
patch_url: None,
build: takana_core::swm::SwmBuild {
compiler: recipe.compiler,
diff --git a/crates/takana-agent/src/orchestrator.rs b/crates/takana-agent/src/orchestrator.rs
index 5afd4159..8f8bbaff 100644
--- a/crates/takana-agent/src/orchestrator.rs
+++ b/crates/takana-agent/src/orchestrator.rs
@@ -165,6 +165,7 @@ impl<T: IntentTranslator> Orchestrator<T> {
sha256,
strip_components: _,
patch,
+ patches,
patch_url: _,
build,
target_bin,
@@ -185,13 +186,28 @@ impl<T: IntentTranslator> Orchestrator<T> {
.and_then(|s| s.to_str())
.unwrap_or("swm-bin")
.to_string();
+ // El proto del bus (`RecipeInline.patch`) transporta UN parche. Con dos o más
+ // no se puede reproducir por esta vía: mandarlos concatenados sellaría en otra
+ // dirección (ver `Mutation::SourcePatch::patches`), así que se corta diciéndolo
+ // en vez de compilar algo que va a fallar contra el `expected_hash`.
+ let patch_inline = match (patches.len(), patch) {
+ (0, p) => p.clone(),
+ (1, _) => Some(patches[0].clone()),
+ (n, _) => {
+ return Err(Error::Orchestrate(format!(
+ "source_patch {target_bin}: el .swm trae {n} parches y el bus de \
+ compilación sólo transporta uno; reproducilo con `takana apply` / \
+ `takana install`, que los materializan uno a uno"
+ )))
+ }
+ };
let recipe = RecipeInline {
name: name.clone(),
repo: repo.clone(),
commit: commit.clone(),
tarball: tarball.clone(),
sha256: sha256.clone(),
- patch: patch.clone(),
+ patch: patch_inline,
compiler: build.compiler.clone(),
target: build.target.clone(),
link: build.link.clone(),
@@ -625,6 +641,7 @@ mod tests {
sha256: None,
strip_components: None,
patch: None,
+ patches: Vec::new(),
patch_url: None,
build: takana_core::swm::SwmBuild {
compiler: "zig-cc".into(),
@@ -633,6 +650,7 @@ mod tests {
flags: vec![],
phases: Default::default(),
zig_version: None,
+ strip_debug: None,
},
target_bin: "/bin/x".into(),
expected_hash: None,
diff --git a/crates/takana-agent/tests/orchestrator_evidence_gate.rs b/crates/takana-agent/tests/orchestrator_evidence_gate.rs
index f5831b9c..478be996 100644
--- a/crates/takana-agent/tests/orchestrator_evidence_gate.rs
+++ b/crates/takana-agent/tests/orchestrator_evidence_gate.rs
@@ -98,6 +98,7 @@ fn swm_with_evidence() -> Swm {
sha256: None,
strip_components: None,
patch: None,
+ patches: Vec::new(),
patch_url: None,
build: SwmBuild {
compiler: "zig-cc".into(),
@@ -106,6 +107,7 @@ fn swm_with_evidence() -> Swm {
flags: vec![],
phases: Default::default(),
zig_version: None,
+ strip_debug: None,
},
target_bin: "/usr/bin/grep".into(),
expected_hash: None,
diff --git a/crates/takana-build/src/swm_bridge.rs b/crates/takana-build/src/swm_bridge.rs
index 97c7bdfb..29c6bc49 100644
--- a/crates/takana-build/src/swm_bridge.rs
+++ b/crates/takana-build/src/swm_bridge.rs
@@ -73,13 +73,13 @@ pub fn recipe_from_source_patch(
name: Option<&str>,
catalog_dir: &Path,
) -> takana_core::Result<Recipe> {
- let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps, evidence, slots) =
+ let (repo, commit, tarball, sha256, strip_components, patch, patch_list, patch_url, build_cfg, target_bin, deps, evidence, slots) =
match mutation {
Mutation::SourcePatch {
- repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
+ repo, commit, tarball, sha256, strip_components, patch, patches, patch_url, build,
target_bin, deps, evidence, slots, ..
} => (
- repo, commit, tarball, sha256, strip_components, patch, patch_url, build,
+ repo, commit, tarball, sha256, strip_components, patch, patches, patch_url, build,
target_bin, deps, evidence, slots,
),
_ => {
@@ -115,23 +115,37 @@ pub fn recipe_from_source_patch(
}
};
+ // Los parches se materializan **uno por fichero y en el orden del manifiesto**: el lab los
+ // aplica en ese orden y `Recipe::hash_inputs` mete una entrada por fichero. Unirlos en uno
+ // solo sella en otra dirección — ver `Mutation::SourcePatch::patches`.
let mut patches: Vec<String> = Vec::new();
- if patch.is_some() || patch_url.is_some() {
+ if !patch_list.is_empty() || patch.is_some() || patch_url.is_some() {
// Patch bajo un nombre derivado del nombre-de-receta + clave de origen, para no colisionar
// entre `{dep}.patch` distintos en el mismo catálogo.
let label = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
// `source.patches` guarda el NOMBRE relativo a `base_dir` (= catalog_dir); el lab resuelve
// `base_dir.join(nombre)` UNA vez al construir (fetch::apply_patches / Recipe hash). Guardar el
// path completo acá lo haría re-unir a base_dir aguas abajo (doble/triple prefijo).
- let filename = format!("{label}-{source_key}.patch");
- let patch_path = catalog_dir.join(&filename);
- if let Some(text) = patch {
- std::fs::write(&patch_path, text.as_bytes())?;
- } else if let Some(url) = patch_url {
- tracing::info!(%url, "fetch: descargando patch remoto");
- crate::download::fetch_url_to_file(url, &patch_path)?;
+ if !patch_list.is_empty() {
+ // El índice va en el nombre para conservar el orden Y para no pisar un parche con otro
+ // cuando la receta trae varios del mismo origen.
+ for (i, text) in patch_list.iter().enumerate() {
+ let filename = format!("{label}-{source_key}-{i:02}.patch");
+ std::fs::write(catalog_dir.join(&filename), text.as_bytes())?;
+ patches.push(filename);
+ }
+ } else {
+ // Formato histórico: un único parche inline (o remoto).
+ let filename = format!("{label}-{source_key}.patch");
+ let patch_path = catalog_dir.join(&filename);
+ if let Some(text) = patch {
+ std::fs::write(&patch_path, text.as_bytes())?;
+ } else if let Some(url) = patch_url {
+ tracing::info!(%url, "fetch: descargando patch remoto");
+ crate::download::fetch_url_to_file(url, &patch_path)?;
+ }
+ patches.push(filename);
}
- patches.push(filename);
}
let name = name.map(|n| n.to_string()).unwrap_or_else(|| derive_name(target_bin));
@@ -290,7 +304,7 @@ logo = "b3:azul"
"#;
let recipe = takana_core::Recipe::from_toml(toml).unwrap();
let swm =
- takana_core::Swm::from_recipe(&recipe, "/usr/bin/tema", None, None, "2026-06-06").unwrap();
+ takana_core::Swm::from_recipe(&recipe, "/usr/bin/tema", Vec::new(), None, "2026-06-06").unwrap();
// El .swm YAML debe llevar el bloque slots.
let yaml = swm.to_yaml().unwrap();
assert!(yaml.contains("slots"), "el .swm debe transportar slots: {yaml}");
@@ -388,6 +402,98 @@ logo = "b3:azul"
}
}
+ /// Regresión del 2026-09-21: **una receta con VARIOS parches tiene que reproducir su hash**.
+ ///
+ /// `pack` concatenaba los N parches en el `patch` inline único, y `Recipe::hash_inputs` mete
+ /// **una entrada por parche** (con `of_inputs` length-prefijado) ⇒ el receptor sellaba en otra
+ /// dirección y el `expected_hash` anclado no coincidía NUNCA. Medido sobre `zsh` (6 parches):
+ /// anclado `b3:0be3630d…`, reproducido `b3:d6329a62…`. Afectaba a 16 recetas del corpus.
+ ///
+ /// El test compara los `hash_inputs` —no el hash final— porque así, cuando falle, dice *qué*
+ /// entrada se movió en vez de sólo que dos hex difieren. Lleva su **control negativo**: con
+ /// los parches concatenados (el formato viejo) los inputs TIENEN que diferir, o el test
+ /// pasaría por la razón equivocada.
+ #[test]
+ fn varios_parches_viajan_sueltos_y_el_hash_reproduce() {
+ let d = tempfile::tempdir().unwrap();
+ let recipes = d.path().join("recipes");
+ std::fs::create_dir_all(&recipes).unwrap();
+ std::fs::write(recipes.join("uno.patch"), b"--- a/uno\n+++ b/uno\n").unwrap();
+ std::fs::write(recipes.join("dos.patch"), b"--- a/dos\n+++ b/dos\n").unwrap();
+
+ let toml = r#"
+name = "multi"
+version = "1.0"
+[source]
+tarball = "https://x/multi-1.0.tar.gz"
+sha256 = "abcdef0123456789"
+patches = ["uno.patch", "dos.patch"]
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "static"
+"#;
+ let mut original = Recipe::from_toml(toml).unwrap();
+ original.base_dir = recipes.clone();
+
+ let textos: Vec<String> = original
+ .source
+ .patches
+ .iter()
+ .map(|p| std::fs::read_to_string(recipes.join(p)).unwrap())
+ .collect();
+ let swm =
+ takana_core::Swm::from_recipe(&original, "/usr/bin/multi", textos.clone(), None, "dev")
+ .unwrap();
+
+ let catalog = d.path().join("swm-recipes");
+ let rehecha = recipe_from_source_patch(&swm.mutations[0], Some("multi"), &catalog).unwrap();
+ assert_eq!(
+ rehecha.source.patches.len(),
+ 2,
+ "los parches tienen que llegar SUELTOS, no unidos: {:?}",
+ rehecha.source.patches
+ );
+
+ let lab = takana_core::lab::LabFingerprint::for_tests();
+ assert_eq!(
+ original.hash_inputs(&[], &lab).unwrap(),
+ rehecha.hash_inputs(&[], &lab).unwrap(),
+ "la receta rehecha desde el .swm debe hashear igual que la del corpus"
+ );
+
+ // Control negativo: el formato viejo (los dos parches en un solo `patch`) NO reproduce.
+ let Mutation::SourcePatch { tarball, sha256, build, deps, evidence, slots, .. } =
+ swm.mutations[0].clone()
+ else {
+ panic!("esperaba source_patch");
+ };
+ let concatenado = Mutation::SourcePatch {
+ repo: None,
+ commit: None,
+ tarball,
+ sha256,
+ strip_components: None,
+ patch: Some(textos.concat()),
+ patches: Vec::new(),
+ patch_url: None,
+ build,
+ target_bin: "/usr/bin/multi".into(),
+ expected_hash: None,
+ deps,
+ evidence,
+ slots,
+ };
+ let viejo = d.path().join("swm-recipes-viejo");
+ let rehecha_vieja =
+ recipe_from_source_patch(&concatenado, Some("multi"), &viejo).unwrap();
+ assert_ne!(
+ original.hash_inputs(&[], &lab).unwrap(),
+ rehecha_vieja.hash_inputs(&[], &lab).unwrap(),
+ "control: concatenar los parches TIENE que mover el hash (si no, este test no prueba nada)"
+ );
+ }
+
#[test]
fn inline_patch_resuelve_con_un_solo_join_de_base_dir() {
// Regresión: `source.patches` debe guardar el NOMBRE relativo a base_dir, de modo que el
@@ -402,6 +508,7 @@ logo = "b3:azul"
sha256: Some("2be64e7129cecb11d5906290eba10af694fb9e3e7f9fc208a311dc33ca837eb0".into()),
strip_components: None,
patch: Some("--- a\n+++ b\n".into()),
+ patches: Vec::new(),
patch_url: None,
build: fake_swm_build(),
target_bin: "/usr/bin/jq".into(),
@@ -458,6 +565,7 @@ logo = "b3:azul"
sha256: None,
strip_components: None,
patch: None,
+ patches: Vec::new(),
patch_url: Some(format!("file://{}", patch_src.display())),
build: fake_swm_build(),
target_bin: "/bin/x".into(),
diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs
index cf7da82e..9ef2638d 100644
--- a/crates/takana-cli/src/main.rs
+++ b/crates/takana-cli/src/main.rs
@@ -340,6 +340,24 @@ enum Cmd {
#[arg(long, default_value = takana_core::installed::DEFAULT_DB)]
db: PathBuf,
},
+ /// [Etapa F] Dice qué paquetes INSTALADOS quedaron atrás respecto del catálogo de un repo.
+ /// No construye, no descarga `.swm` y no toca nada: sólo lee el índice (firmado) y la DB de
+ /// instalados. Es lo que un disparador periódico corre para AVISAR de que hay novedades; la
+ /// actualización en sí la hace `install <nombre>`, que es quien verifica y reproduce.
+ Outdated {
+ /// Repositorio: directorio local (con `index.json`) o lista de orígenes HTTP(S).
+ #[arg(long, default_value = DEFAULT_REPO)]
+ repo: String,
+ /// Base de datos de instalados a comparar.
+ #[arg(long, default_value = takana_core::installed::DEFAULT_DB)]
+ db: PathBuf,
+ /// Directorio de claves de confianza para validar la firma del release.
+ #[arg(long)]
+ trust: Option<PathBuf>,
+ /// Salida JSON, para que un guion decida si avisar (y de qué) sin parsear texto.
+ #[arg(long)]
+ json: bool,
+ },
/// [H4d] BÚSQUEDA de compatibilidad: particiona un repo entero contra el estado instalado en
/// {compatibles, requieren-elección, incompatibles} — sin construir ni tocar nada. Responde
/// "cuando busco, ¿cuáles puedo adoptar?" (SDD 15 §H4).
@@ -1319,6 +1337,9 @@ fn main() -> anyhow::Result<()> {
}
Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?,
Cmd::Installed { db } => run_installed_list(&db)?,
+ Cmd::Outdated { repo, db, trust, json } => {
+ run_outdated(&repo, &db, trust.as_deref(), json)?
+ }
Cmd::Compat { repo, db } => run_compat(&repo, &db)?,
Cmd::ImportNix { file, out } => run_import_nix(&file, out.as_deref())?,
Cmd::ImportAlpine { file, out } => run_import_alpine(&file, out.as_deref())?,
@@ -2429,28 +2450,24 @@ fn run_pack(
}
// Texto de los patches: cada uno se resuelve relativo al directorio de la receta (igual que
- // el lab) y se concatena. El `source_patch` del .swm modela un único `patch` inline; un diff
- // unificado multi-fichero concatenado se aplica hunk-a-hunk igual (git apply / patch -p1).
- let patch_text = if recipe.source.patches.is_empty() {
- None
- } else {
- let mut buf = String::new();
- for p in &recipe.source.patches {
- let path = std::path::Path::new(p);
- let abs = if path.is_absolute() {
- path.to_path_buf()
- } else {
- recipe.base_dir.join(path)
- };
- let txt = std::fs::read_to_string(&abs)
- .map_err(|e| anyhow::anyhow!("leyendo patch {}: {e}", abs.display()))?;
- if !buf.is_empty() && !buf.ends_with('\n') {
- buf.push('\n');
- }
- buf.push_str(&txt);
- }
- Some(buf)
- };
+ // el lab) y viaja COMO UNA ENTRADA POR PARCHE, en el orden de la receta.
+ //
+ // ⚠ Antes se concatenaban en un único `patch` inline, y eso hacía IMPOSIBLE reproducir
+ // cualquier receta con dos o más parches: `Recipe::hash_inputs` mete una entrada por parche y
+ // `of_inputs` va con longitud prefijada, así que el receptor sellaba en otra dirección y el
+ // `expected_hash` anclado no coincidía nunca. Ver `Mutation::SourcePatch::patches`.
+ let mut patch_texts: Vec<String> = Vec::new();
+ for p in &recipe.source.patches {
+ let path = std::path::Path::new(p);
+ let abs = if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ recipe.base_dir.join(path)
+ };
+ let txt = std::fs::read_to_string(&abs)
+ .map_err(|e| anyhow::anyhow!("leyendo patch {}: {e}", abs.display()))?;
+ patch_texts.push(txt);
+ }
// target_bin: `--target-bin` manda. Si no, y la receta Cargo declara `--bin <X>`, el binario
// instalado es X (no el nombre del paquete: ripgrep→rg, repgrep→rgr) ⇒ derivamos de ahí. Sólo
@@ -2481,10 +2498,19 @@ fn run_pack(
None
};
+ // `target_bin` es una ADIVINANZA (`/usr/bin/{name}`) y el que instala la cree. Si el artefacto
+ // está sellado acá, la comprobamos contra él en vez de publicar un paquete que sólo falla del
+ // otro lado: `zsh` se instala en `/bin/zsh` (su receta pasa `--bindir=/bin`) y el paquete salía
+ // diciendo `/usr/bin/zsh`, así que el `install` hidrataba sus 1331 ficheros y MORÍA al final.
+ let target_bin = match expected_hash.as_deref() {
+ Some(h) => target_bin_verificado(store_path, h, &recipe.name, target_bin)?,
+ None => target_bin,
+ };
+
let mut swm = takana_core::Swm::from_recipe(
&recipe,
target_bin,
- patch_text,
+ patch_texts,
expected_hash.clone(),
distro_version,
)?;
@@ -3105,6 +3131,155 @@ fn run_installed_list(db_path: &std::path::Path) -> anyhow::Result<()> {
Ok(())
}
+/// ¿El paquete instalado quedó atrás respecto de lo que el repo publica? `None` ⇒ al día.
+///
+/// Manda el **hash** cuando los dos lados lo tienen: es la identidad real del artefacto, y una
+/// re-publicación con la misma versión upstream (otra flag, otro lab, otro parche) cambia el hash
+/// sin tocar la etiqueta. Si a alguno le falta el ancla, sólo queda comparar versiones **por
+/// desigualdad** — acá no hay semver y "distinta" es lo único afirmable.
+fn motivo_novedad(
+ hash_local: Option<&str>,
+ version_local: &str,
+ hash_repo: Option<&str>,
+ version_repo: &str,
+) -> Option<&'static str> {
+ match (hash_local, hash_repo) {
+ (Some(local), Some(remoto)) if local != remoto => Some("hash distinto"),
+ (Some(_), Some(_)) => None,
+ _ if version_local != version_repo => Some("versión distinta (sin hash que comparar)"),
+ _ => None,
+ }
+}
+
+/// Compara la DB de instalados con el índice del repo y dice qué quedó atrás. No construye, no
+/// baja `.swm` y no escribe nada: el índice firmado ya trae `version` y `expected_hash` de cada
+/// paquete, que es todo lo que hace falta para contestar «¿hay novedades?».
+///
+/// **Por qué el hash y no sólo la versión.** Un repo se re-publica sin que cambie la versión
+/// upstream cada vez que se mueve algo que entra en `hash_inputs` (una flag, el lab, un parche):
+/// el paquete es OTRO artefacto con la misma etiqueta. Comparar sólo `version` diría «al día»
+/// sobre un binario que ya no es el que el repo sirve. La versión se usa como respaldo cuando
+/// alguno de los dos lados no lleva ancla (`expected_hash` ausente ⇒ paquete sin verificar).
+///
+/// **Lo que NO hace, a propósito:** actualizar. Instalar es reproducir y verificar
+/// (`run_install`), y eso no puede colgarse de un aviso que corre solo en un cron.
+fn run_outdated(
+ repo: &str,
+ db_path: &std::path::Path,
+ trust: Option<&std::path::Path>,
+ json: bool,
+) -> anyhow::Result<()> {
+ let source = RepoSource::parse(repo);
+ let index = source.read_index()?;
+ let idb = takana_core::InstalledDb::load(db_path)?;
+
+ // Estado de la firma del release. Un índice MANIPULADO aborta —decidir "hay que actualizar"
+ // a partir de un catálogo falseado es justo la forma de que te instalen lo que quieran—; sin
+ // firma o con clave desconocida se informa y se sigue, igual que en el resto de la CLI.
+ let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
+ let release = match index.verify_signature(&takana_core::TrustStore::load(trust_dir)?) {
+ takana_core::SigStatus::Trusted { by } => format!("trusted (by {by})"),
+ takana_core::SigStatus::UnknownKey { by } => format!("unknown-key (by {by})"),
+ takana_core::SigStatus::Unsigned => "sin firmar".to_string(),
+ takana_core::SigStatus::BadSig { by, reason } => {
+ anyhow::bail!("release: BAD-SIG (by {by}): {reason} — el índice fue manipulado")
+ }
+ };
+
+ struct Novedad {
+ name: String,
+ instalada: String,
+ publicada: String,
+ motivo: &'static str,
+ }
+ let (mut novedades, mut sin_origen) = (Vec::new(), Vec::new());
+ for p in &idb.packages {
+ let Some(e) = index.find(&p.name) else {
+ sin_origen.push(p.name.clone());
+ continue;
+ };
+ let Some(motivo) = motivo_novedad(
+ p.hash.as_deref(),
+ &p.version,
+ e.expected_hash.as_deref(),
+ &e.version,
+ ) else {
+ continue;
+ };
+ novedades.push(Novedad {
+ name: p.name.clone(),
+ instalada: p.version.clone(),
+ publicada: e.version.clone(),
+ motivo,
+ });
+ }
+ let no_instalados = index
+ .packages
+ .iter()
+ .filter(|e| !idb.packages.iter().any(|p| p.name == e.name))
+ .count();
+
+ if json {
+ let v = serde_json::json!({
+ "repo": source.label(),
+ "release": release,
+ "instalados": idb.packages.len(),
+ "novedades": novedades.iter().map(|n| serde_json::json!({
+ "name": n.name,
+ "instalada": n.instalada,
+ "publicada": n.publicada,
+ "motivo": n.motivo,
+ })).collect::<Vec<_>>(),
+ "sin_origen": sin_origen,
+ "no_instalados": no_instalados,
+ });
+ println!("{}", serde_json::to_string_pretty(&v)?);
+ return Ok(());
+ }
+
+ println!("repo {} — release: {release}", source.label());
+ if idb.packages.is_empty() {
+ println!("nada instalado (DB {})", db_path.display());
+ return Ok(());
+ }
+ if novedades.is_empty() {
+ println!("{} paquete(s) instalado(s), todos al día.", idb.packages.len());
+ } else {
+ println!(
+ "{} paquete(s) instalado(s) · {} con novedad:",
+ idb.packages.len(),
+ novedades.len()
+ );
+ for n in &novedades {
+ println!(
+ " {:<16} {:<10} → {:<10} {}",
+ n.name, n.instalada, n.publicada, n.motivo
+ );
+ }
+ println!(
+ "\nactualizá con: {}",
+ novedades
+ .iter()
+ .map(|n| format!("takana install {}", n.name))
+ .collect::<Vec<_>>()
+ .join(" · ")
+ );
+ }
+ if !sin_origen.is_empty() {
+ println!(
+ "\n{} instalado(s) que el repo NO publica: {} — nadie los va a actualizar",
+ sin_origen.len(),
+ sin_origen.join(", ")
+ );
+ }
+ if no_instalados > 0 {
+ println!(
+ "\n({no_instalados} paquete(s) del repo sin instalar; `takana compat` dice cuáles podés adoptar)"
+ );
+ }
+ Ok(())
+}
+
/// [H4d] La BÚSQUEDA de compatibilidad: evalúa cada paquete del repo contra el estado instalado
/// y lo clasifica. Es `compat::filtrar` del prototipo `wawa-memo`, ahora sobre el repo real —
/// read-only, no construye ni toca nada. Combina la vía DECLARADA (slots, H4b) con la OBSERVADA
@@ -3711,9 +3886,10 @@ fn build_export_mutations(
}
};
- // Patches inline: si la receta tenía patches, los leemos y concatenamos. El
- // hash del artefacto incluye su contenido (ver `Recipe::hash_inputs`), así que
- // el receptor reconstruye byte-a-byte si y sólo si el patch coincide.
+ // Patches inline: si la receta tenía patches, los leemos UNO A UNO (nunca concatenados —
+ // ver `Mutation::SourcePatch::patches`). El hash del artefacto incluye su contenido (ver
+ // `Recipe::hash_inputs`), así que el receptor reconstruye byte-a-byte si y sólo si los
+ // parches y su orden coinciden.
let patch_inline = collect_patches_inline(&recipe);
if paths.len() > 1 {
@@ -3732,7 +3908,8 @@ fn build_export_mutations(
sha256,
strip_components: (recipe.source.strip_components != 1)
.then_some(recipe.source.strip_components),
- patch: patch_inline,
+ patch: None,
+ patches: patch_inline,
patch_url: None,
build: takana_core::SwmBuild {
compiler: recipe.build.compiler.as_str().to_string(),
@@ -3786,16 +3963,67 @@ fn build_export_mutations(
(mutations, stats)
}
-/// Concatena los contenidos de todos los patches declarados por la receta en un único
-/// blob (separado por una línea con `--- /dev/null` falsa). Si la receta no tiene
-/// patches, devuelve `None`. Si algún patch no se puede leer, devuelve `None` y deja un
-/// warning en stderr — el receptor reconstruirá sin patches y verificará con
-/// `expected_hash` que el binario coincide, así que el error es detectable.
-fn collect_patches_inline(recipe: &takana_core::Recipe) -> Option<String> {
- if recipe.source.patches.is_empty() {
- return None;
+/// Comprueba que el `target_bin` que va a viajar en el paquete EXISTE dentro del artefacto
+/// sellado, y lo corrige si el binario quedó en otro bindir.
+///
+/// `target_bin` se adivina como `/usr/bin/{name}` (o del `--bin` de cargo). La adivinanza falla en
+/// cuanto una receta elige otro prefijo: `zsh` configura `--bindir=/bin`, así que su binario queda
+/// en `/bin/zsh` — y el paquete publicado prometía `/usr/bin/zsh`. Eso no se nota al publicar: se
+/// nota en la máquina que instala, **después** de hidratar el artefacto entero, con un error que
+/// habla de un path que nadie escribió a mano. Con el artefacto delante, esto es comprobable en el
+/// momento de empaquetar, y un paquete que promete un binario que no trae no se publica.
+///
+/// Sin artefacto en el store (p. ej. `--expected` sin `--build`) no hay nada contra qué comprobar:
+/// se devuelve la adivinanza tal cual.
+fn target_bin_verificado(
+ store_path: &str,
+ hash: &str,
+ recipe_name: &str,
+ target_bin: String,
+) -> anyhow::Result<String> {
+ let Ok(store) = takana_core::Store::open(store_path) else {
+ return Ok(target_bin);
+ };
+ let h = takana_core::ArtifactHash::from_hex(hash.trim_start_matches("b3:"));
+ let root = store.path_of(&h, recipe_name);
+ if !root.is_dir() {
+ return Ok(target_bin);
+ }
+ // `symlink_metadata` y no `exists()`: dentro del artefacto un `/bin` puede ser un symlink
+ // ABSOLUTO a `/usr/bin`, y `exists()` lo resolvería contra el root de ESTA máquina.
+ let presente = |rel: &str| root.join(rel).symlink_metadata().is_ok();
+ if presente(target_bin.trim_start_matches('/')) {
+ return Ok(target_bin);
+ }
+ let bin = std::path::Path::new(&target_bin)
+ .file_name()
+ .and_then(|s| s.to_str())
+ .ok_or_else(|| anyhow::anyhow!("target_bin sin nombre de fichero: {target_bin}"))?;
+ for dir in ["bin", "usr/bin", "sbin", "usr/sbin", "usr/local/bin"] {
+ if presente(&format!("{dir}/{bin}")) {
+ let corregido = format!("/{dir}/{bin}");
+ eprintln!(
+ "target_bin: '{target_bin}' no está en el artefacto; el binario quedó en \
+ '{corregido}' — publico ése (pasá --target-bin para fijarlo a mano)"
+ );
+ return Ok(corregido);
+ }
}
- let mut all = String::new();
+ anyhow::bail!(
+ "el artefacto {} no trae ningún '{bin}' en bin/, usr/bin/, sbin/, usr/sbin/ ni \
+ usr/local/bin/: el paquete prometería un binario que no existe y el `install` fallaría \
+ DESPUÉS de hidratar. Fijá el path real con --target-bin",
+ root.display()
+ )
+}
+
+/// Lee los patches declarados por la receta **en orden y uno por entrada** (nunca concatenados:
+/// ver `Mutation::SourcePatch::patches` — unirlos cambia el hash del artefacto). Receta sin
+/// patches ⇒ vector vacío. Si algún patch no se puede leer, devuelve el vector VACÍO y deja un
+/// warning en stderr — el receptor reconstruirá sin patches y verificará con `expected_hash` que
+/// el binario coincide, así que el error es detectable.
+fn collect_patches_inline(recipe: &takana_core::Recipe) -> Vec<String> {
+ let mut all = Vec::new();
for p in &recipe.source.patches {
let path = if std::path::Path::new(p).is_absolute() {
std::path::PathBuf::from(p)
@@ -3803,17 +4031,17 @@ fn collect_patches_inline(recipe: &takana_core::Recipe) -> Option<String> {
recipe.base_dir.join(p)
};
match std::fs::read_to_string(&path) {
- Ok(text) => all.push_str(&text),
+ Ok(text) => all.push(text),
Err(e) => {
eprintln!(
"warning: no pude leer patch {} para inline: {e}",
path.display()
);
- return None;
+ return Vec::new();
}
}
}
- Some(all)
+ all
}
/// Ejecuta el bucle agéntico de Fase 6 vía `takana-agent`. Acepta una intención NL +
@@ -4334,3 +4562,39 @@ flags = ["--enable-foo"]
}
}
+
+#[cfg(test)]
+mod outdated_tests {
+ use super::motivo_novedad;
+
+ /// El caso que motiva el verbo: el repo re-publicó el paquete con la MISMA versión upstream
+ /// (cambió una flag, el lab, un parche) y el artefacto es otro. Mirar sólo la etiqueta diría
+ /// "al día" sobre un binario que ya no es el que el repo sirve.
+ #[test]
+ fn misma_version_distinto_hash_es_novedad() {
+ assert_eq!(
+ motivo_novedad(Some("b3:aaa"), "5.9", Some("b3:bbb"), "5.9"),
+ Some("hash distinto")
+ );
+ }
+
+ #[test]
+ fn mismo_hash_es_al_dia_aunque_la_version_cambie_de_etiqueta() {
+ assert_eq!(motivo_novedad(Some("b3:aaa"), "5.9", Some("b3:aaa"), "5.9-r1"), None);
+ }
+
+ /// Sin ancla de alguno de los dos lados no hay identidad que comparar: queda la etiqueta, y se
+ /// dice explícitamente que es un juicio más débil.
+ #[test]
+ fn sin_hash_cae_a_la_version() {
+ assert_eq!(
+ motivo_novedad(None, "0.9.1", None, "1.0.0"),
+ Some("versión distinta (sin hash que comparar)")
+ );
+ assert_eq!(
+ motivo_novedad(Some("b3:aaa"), "0.9.1", None, "1.0.0"),
+ Some("versión distinta (sin hash que comparar)")
+ );
+ assert_eq!(motivo_novedad(None, "0.9.1", None, "0.9.1"), None);
+ }
+}
diff --git a/crates/takana-core/src/compat.rs b/crates/takana-core/src/compat.rs
index 8ed53a45..c535046a 100644
--- a/crates/takana-core/src/compat.rs
+++ b/crates/takana-core/src/compat.rs
@@ -344,6 +344,7 @@ mod tests {
sha256: None,
strip_components: None,
patch: None,
+ patches: Vec::new(),
patch_url: None,
build: crate::swm::SwmBuild {
compiler: "zig-cc".into(),
diff --git a/crates/takana-core/src/swm.rs b/crates/takana-core/src/swm.rs
index c952d378..ccf5501f 100644
--- a/crates/takana-core/src/swm.rs
+++ b/crates/takana-core/src/swm.rs
@@ -87,8 +87,27 @@ pub enum Mutation {
/// del lab (1, un único top-level `proyecto-version/`). Sólo se emite si difiere.
#[serde(default, skip_serializing_if = "Option::is_none")]
strip_components: Option<usize>,
+ /// UN parche inline. **Formato histórico**: sólo sirve para recetas de un solo parche, y
+ /// se sigue leyendo para no invalidar los `.swm` ya publicados. Lo que `pack` emite hoy es
+ /// [`patches`](#variant.SourcePatch.field.patches) — ver por qué ahí.
#[serde(default, skip_serializing_if = "Option::is_none")]
patch: Option<String>,
+ /// Los parches inline **en orden y por separado**, tal como los listaba la receta.
+ ///
+ /// ⚠ **No es un lujo de formato: concatenarlos cambia el hash del artefacto.**
+ /// [`Recipe::hash_inputs`](crate::Recipe::hash_inputs) mete **una entrada por parche** y
+ /// `of_inputs` va con longitud prefijada, así que N parches unidos en uno solo hashean
+ /// distinto que los N sueltos. Mientras `pack` los concatenaba en `patch`, el receptor
+ /// reconstruía en otra dirección y el `expected_hash` anclado **no coincidía nunca**:
+ /// medido sobre `zsh` (6 parches) el 2026-09-21 — anclado `b3:0be3630d…`, reproducido
+ /// `b3:d6329a62…` tras recompilar entero. Afectaba a las 16 recetas del corpus con dos o
+ /// más parches (waterfox 12, firefox y gnupg 11, parted/zsh/strace 6…). Es la misma
+ /// familia que el `strip_debug` que no viajaba (SDD 28 §5.2): un campo que ENTRA en
+ /// `hash_inputs` y no sobrevivía al viaje.
+ ///
+ /// Vacío ⇒ el paquete no trae parches inline (o los trae en el `patch` histórico).
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ patches: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
patch_url: Option<String>,
build: SwmBuild,
@@ -257,12 +276,13 @@ impl Swm {
/// Es la inversa de `takana_build::swm_bridge` (que va `source_patch` → `Recipe` → build).
///
/// `takana-core` no toca disco: el caller resuelve y **lee** los patches de la receta
- /// (relativos a `recipe.base_dir`) y pasa su texto ya concatenado en `patch_text`. El
+ /// (relativos a `recipe.base_dir`) y pasa sus textos **en orden y uno por parche** en
+ /// `patch_texts` — ver [`Mutation::SourcePatch::patches`] para por qué no van concatenados. El
/// `expected_hash`, si se da, ancla "verificar, no confiar" (el receptor rehace y compara).
pub fn from_recipe(
recipe: &crate::Recipe,
target_bin: impl Into<String>,
- patch_text: Option<String>,
+ patch_texts: Vec<String>,
expected_hash: Option<String>,
distro_version: impl Into<String>,
) -> crate::Result<Swm> {
@@ -317,7 +337,8 @@ impl Swm {
tarball,
sha256,
strip_components,
- patch: patch_text,
+ patch: None,
+ patches: patch_texts,
patch_url: None,
build,
target_bin: target_bin.into(),
@@ -612,6 +633,7 @@ mutations:
sha256: None,
strip_components: None,
patch: None,
+ patches: Vec::new(),
patch_url: None,
build: SwmBuild {
compiler: "zig-cc".into(),
@@ -645,6 +667,7 @@ mutations:
sha256: Some("deadbeef".into()),
strip_components: None,
patch: None,
+ patches: Vec::new(),
patch_url: None,
build: SwmBuild {
compiler: "zig-cc".into(),
@@ -675,6 +698,7 @@ mutations:
sha256: Some("deadbeef".into()),
strip_components: None,
patch: None,
+ patches: Vec::new(),
patch_url: None,
build: SwmBuild {
compiler: "zig-cc".into(),
@@ -716,7 +740,7 @@ install = "cp target/release/rg /out/usr/bin/rg"
let swm = Swm::from_recipe(
&recipe,
"/usr/bin/rg",
- Some("PATCH-TEXT".into()),
+ vec!["PATCH-TEXT".to_string()],
Some("b3:cafe".into()),
"2026-06-21",
)
@@ -756,7 +780,7 @@ compiler = "zig-cc"
zig_version = "0.13.0"
"#;
let recipe = crate::Recipe::from_toml(toml).unwrap();
- let swm = Swm::from_recipe(&recipe, "/usr/bin/openssl", None, None, "dev").unwrap();
+ let swm = Swm::from_recipe(&recipe, "/usr/bin/openssl", Vec::new(), None, "dev").unwrap();
match &swm.mutations[0] {
Mutation::SourcePatch { tarball, sha256, strip_components, build, expected_hash, .. } => {
assert_eq!(tarball.as_deref(), Some("https://example/openssl-3.5.4.tar.gz"));
@@ -779,7 +803,7 @@ zig_version = "0.13.0"
"name = \"takana\"\nversion = \"0.0.1\"\n[source]\nrepo = \"ssh://x/y.git\"\ncommit = \"abc\"\n[build]\nstrip_debug = true\n",
)
.unwrap();
- let swm = Swm::from_recipe(&con, "/usr/bin/takana", None, None, "dev").unwrap();
+ let swm = Swm::from_recipe(&con, "/usr/bin/takana", Vec::new(), None, "dev").unwrap();
match &swm.mutations[0] {
Mutation::SourcePatch { build, .. } => {
assert_eq!(build.strip_debug, Some(true), "strip_debug tiene que viajar en el .swm");
@@ -792,7 +816,7 @@ zig_version = "0.13.0"
"name = \"tree\"\nversion = \"2.3.2\"\n[source]\nrepo = \"ssh://x/y.git\"\ncommit = \"abc\"\n[build]\n",
)
.unwrap();
- let swm2 = Swm::from_recipe(&sin, "/usr/bin/tree", None, None, "dev").unwrap();
+ let swm2 = Swm::from_recipe(&sin, "/usr/bin/tree", Vec::new(), None, "dev").unwrap();
match &swm2.mutations[0] {
Mutation::SourcePatch { build, .. } => {
assert_eq!(build.strip_debug, None, "una receta sin strip_debug no debe ganar uno");
@@ -810,6 +834,7 @@ zig_version = "0.13.0"
sha256: None,
strip_components: None,
patch: None,
+ patches: Vec::new(),
patch_url: None,
build: SwmBuild {
compiler: "zig-cc".into(),
@@ -850,7 +875,7 @@ cmd = "cd /src && cargo test proptest_"
expected_exit = 0
"#;
let recipe = crate::Recipe::from_toml(toml).unwrap();
- let swm = Swm::from_recipe(&recipe, "/usr/bin/grep", None, None, "dev").unwrap();
+ let swm = Swm::from_recipe(&recipe, "/usr/bin/grep", Vec::new(), None, "dev").unwrap();
// La evidencia llegó a la mutación.
match &swm.mutations[0] {
Mutation::SourcePatch { evidence, .. } => {
@@ -905,7 +930,7 @@ commit = "deadbeef"
[build]
"#;
let recipe = crate::Recipe::from_toml(toml).unwrap();
- let swm = Swm::from_recipe(&recipe, "/usr/bin/x", None, None, "dev").unwrap();
+ let swm = Swm::from_recipe(&recipe, "/usr/bin/x", Vec::new(), None, "dev").unwrap();
let yaml = swm.to_yaml().unwrap();
assert!(!yaml.contains("evidence"), "no debería emitir evidence: {yaml}");
}
diff --git a/docs/28-servidor-de-produccion.md b/docs/28-servidor-de-produccion.md
index c05ff491..0d22e81a 100644
--- a/docs/28-servidor-de-produccion.md
+++ b/docs/28-servidor-de-produccion.md
@@ -483,6 +483,71 @@ el Storage Box o gioser. Si no, el primer origen roto es el último.
([SDD 19 §5.2](19-lanzamiento-publico.md)), hecho sobre una máquina que importa pero que **no es el
laptop de nadie**.
+### 5.5 ⚠ Dos bugs más del lazo, que sólo aparecen si el paquete NO es un binario de Rust *(2026-09-21)*
+
+Pedido del usuario: «instalar zsh con el comando takana». `zsh` es el primer paquete que se instala
+**con varios parches** y **con el binario fuera de `/usr/bin`**, y cada una de esas dos cosas era un
+fallo distinto. Los dos son de la familia del `strip_debug` del §5.2 —algo que el `.swm` no
+transportaba fiel— y los dos **se descubren sólo cerrando el lazo**, no leyendo el código.
+
+**1. Los parches viajaban CONCATENADOS, y eso cambia el hash.** `pack` unía los N parches de la
+receta en el `patch` inline único; `Recipe::hash_inputs` mete **una entrada por parche** y
+`of_inputs` va con longitud prefijada ⇒ N sueltos y N unidos hashean distinto. Medido sin esperar al
+build, con una copia de `recipes/zsh.toml` con sus 6 parches concatenados en uno:
+
+```
+b3:0be3630d… ← expected_hash anclado (y lo que hay en /store)
+b3:d6329a62… ← lo que reconstruye el receptor (= la copia concatenada, exacto)
+```
+
+O sea: `install zsh` recompilaba zsh **entero** y recién entonces moría con `expected_hash no
+coincide`. **Alcance: las 16 recetas del corpus con ≥2 parches** — waterfox 12, firefox y gnupg 11,
+parted/zsh/strace 6, doas/mandoc/giflib 4, freetype 3, libxml2/file/brotli 2…
+
+Arreglado con un campo `patches` (lista, en orden) en el `source_patch`; el `patch` único se sigue
+leyendo para no invalidar lo ya publicado. El receptor materializa **un fichero por parche**.
+Regresión con su **control negativo** en `swm_bridge`: si se concatenan, los `hash_inputs` TIENEN
+que diferir, o el test pasaría por la razón equivocada.
+
+⚠ La tentación era arreglarlo del otro lado —hashear los parches concatenados— y habría sido mucho
+peor: mueve el hash de las **50** recetas con parches e invalida sus artefactos sellados.
+
+**2. `target_bin` era una adivinanza que nadie comprobaba.** `pack` publica `/usr/bin/{name}`;
+`zsh` configura `--bindir=/bin`, así que su binario queda en `/bin/zsh`. El paquete se publicaba
+prometiendo un path que el artefacto no tiene, y el error salía **en la máquina que instala,
+después de hidratar los 1331 ficheros**:
+
+```
+Error: source_patch declara target_bin=/usr/bin/zsh pero no quedó en <prefix>/usr/bin/zsh
+```
+
+Con `--build` el artefacto está delante: ahora `pack` comprueba el path contra él, lo corrige si el
+binario quedó en otro bindir (diciéndolo) y **aborta al publicar** si no hay ningún binario con ese
+nombre. Un paquete que promete algo que no trae no debería llegar a existir.
+
+**El lazo, después:** `takana install zsh --repo http://… --require-signed` ⇒ cache-hit del hash
+anclado, **1331 ficheros hidratados en 0,14 s**, y el binario corre (`zsh 5.9`). Antes: recompilar
+zsh entero para morir al final.
+
+### 5.6 `takana outdated` — el aviso que faltaba *(2026-09-21)*
+
+Ningún verbo comparaba lo instalado con el catálogo (`upgrade` es de **árboles/generaciones**, no de
+paquetes), así que «avisame cuándo hay que actualizar» no tenía respuesta. `outdated` la da leyendo
+sólo el índice firmado y la DB de instalados — no construye, no baja `.swm`, no escribe nada.
+
+**Compara por hash, no por versión**, y eso es lo único que lo hace útil: un repo se re-publica sin
+que cambie la versión upstream cada vez que se mueve algo que entra en `hash_inputs` (una flag, el
+lab, un parche), y mirar la etiqueta diría «al día» sobre un artefacto que ya no es el que el repo
+sirve. La versión es el respaldo para cuando falta el ancla, y se dice que el juicio es más débil.
+
+`scripts/servidor/avisar-actualizaciones.sh` lo corre por cron y **sólo grita cuando la lista de
+novedades cambia** (un guardián que repite lo mismo cada 30 min deja de leerse). Un repo caído no se
+disfraza de «al día»: lo dice, sale ≠ 0 y **conserva el último estado bueno**.
+
+⚠ **Lo que sigue sin poder hacerse, y no lo arregla ningún verbo:** actualizar en una máquina sin
+lab. El aviso llega a cualquiera; el `install` que lo resuelve sigue siendo reproducir desde fuente
+(§5.4). Por eso `outdated` **no actualiza**: instalar es verificar, y eso no se cuelga de un cron.
+
---
## 6. La mudanza como experimento: el criterio de borrado