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) { 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 Orchestrator { sha256, strip_components: _, patch, + patches, patch_url: _, build, target_bin, @@ -185,13 +186,28 @@ impl Orchestrator { .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 { - 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 = 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 = 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 `, 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, + /// 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 = 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 `, 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::>(), + "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::>() + .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 { - 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 { + 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); } - let mut all = String::new(); + // `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); + } + } + 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 { + 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 { 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, + /// 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, + /// 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, #[serde(default, skip_serializing_if = "Option::is_none")] patch_url: Option, 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, - patch_text: Option, + patch_texts: Vec, expected_hash: Option, distro_version: impl Into, ) -> crate::Result { @@ -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 /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