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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+303
-39
@@ -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);
|
||||
}
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user