swm: strip_debug no viajaba en el paquete — y es ENTRADA DE HASH, así que 40 recetas no instalaban

Encontrado instalando `takana` desde su propio repo (SDD 28 §5), que es la primera vez que se hace
el viaje completo receta → `.tkn` → `install --require-signed` sobre un paquete con `strip_debug`.

    Error: expected_hash no coincide:
      declarado = b3:941d1857e6e93ebbfbad8240a1c30405263e5ff8fc881a923aee312160563eea
      obtenido  = b3:2727050ebdbd7817b53e79ffe9796569e9364454ed35474b51e881fcc6048766

`why-differs` sobre los dos artefactos lo nombró: las recetas selladas diferían en `version`,
`license` y **`strip_debug`**. Los dos binarios pesaban EXACTAMENTE lo mismo (3.374.768 bytes) y sólo
divergían en `.shstrtab` — la firma de un `strip` que corrió una vez y la otra no.

`swm_bridge` re-inyecta con cuidado `flags`, `phases`, `zig_version`, `strip_components`, `patches`,
`deps`, `evidence` y `slots` («fidelidad de reconstrucción», dice su comentario) y **se olvidaba de
`strip_debug`**, que ni siquiera existía en `SwmBuild` ⇒ no viajaba en el `.swm` en absoluto. Como
ENTRA EN `hash_inputs` (`recipe.rs:572`, con el comentario «cambia el CONTENIDO del artefacto, así
que TIENE que entrar»), el receptor reconstruía con `None` y sellaba en otra dirección.

**Alcance medido: 40 recetas del corpus lo usan, 18 de las 88 del perfil `servidor`.** Para todas,
el `expected_hash` anclado por `pack --build` no coincidía NUNCA y `install --require-signed`
abortaba. O sea: una quinta parte del repo no se podía instalar, y nadie lo sabía porque nunca se
había cerrado el lazo.

Por qué no lo cazó nadie antes: `tree` —sin `strip_debug`— da cache-hit y funciona perfecto. El bug
sólo aparece en las recetas que lo declaran, y el dogfood previo no las tocaba.

Test de regresión con su control: una receta CON `strip_debug` lo lleva en el `.swm`, y una SIN él no
gana un `false` inventado. 221 tests de takana-core en verde.

Verificado end-to-end tras el arreglo: `install takana --repo http://<caja> --require-signed` da
cache-hit en `b3:941d1857…` —el mismo hash anclado— e hidrata los 3 ficheros.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
This commit is contained in:
Sergio
2026-09-10 21:33:45 +00:00
co-authored by Claude Opus 5
parent 5beb3aa461
commit 9bc518899e
5 changed files with 54 additions and 0 deletions
+1
View File
@@ -378,6 +378,7 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
flags: recipe.flags, flags: recipe.flags,
phases: Default::default(), phases: Default::default(),
zig_version: None, zig_version: None,
strip_debug: None,
}, },
target_bin: format!("/usr/bin/{name}"), target_bin: format!("/usr/bin/{name}"),
expected_hash: None, expected_hash: None,
+4
View File
@@ -218,6 +218,10 @@ flags = []
// el .swm (si vienen) y se reinyectan en la receta efímera tal cual los tenía el original. // el .swm (si vienen) y se reinyectan en la receta efímera tal cual los tenía el original.
recipe.build.phases = build_cfg.phases.clone(); recipe.build.phases = build_cfg.phases.clone();
recipe.build.zig_version = build_cfg.zig_version.clone(); recipe.build.zig_version = build_cfg.zig_version.clone();
// `strip_debug` ENTRA EN `hash_inputs`: sin esta línea el receptor reconstruye con `None`,
// sella en otra dirección y el `expected_hash` anclado no coincide NUNCA. Ver la doc del campo
// en `swm::SwmBuild::strip_debug`.
recipe.build.strip_debug = build_cfg.strip_debug;
if let Some(n) = strip_components { if let Some(n) = strip_components {
recipe.source.strip_components = n; recipe.source.strip_components = n;
} }
+1
View File
@@ -3020,6 +3020,7 @@ fn build_export_mutations(
flags: recipe.build.flags.clone(), flags: recipe.build.flags.clone(),
phases: recipe.build.phases.clone(), phases: recipe.build.phases.clone(),
zig_version: recipe.build.zig_version.clone(), zig_version: recipe.build.zig_version.clone(),
strip_debug: recipe.build.strip_debug,
}, },
target_bin, target_bin,
expected_hash: Some(art_hash), expected_hash: Some(art_hash),
+1
View File
@@ -352,6 +352,7 @@ mod tests {
flags: vec![], flags: vec![],
phases: Default::default(), phases: Default::default(),
zig_version: None, zig_version: None,
strip_debug: None,
}, },
target_bin: "/usr/bin/app".into(), target_bin: "/usr/bin/app".into(),
expected_hash: Some("b3:app".into()), expected_hash: Some("b3:app".into()),
+47
View File
@@ -157,6 +157,16 @@ pub struct SwmBuild {
/// `None` ⇒ el zig por defecto del receptor. /// `None` ⇒ el zig por defecto del receptor.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub zig_version: Option<String>, pub zig_version: Option<String>,
/// Split de la info de depuración (`Build::strip_debug`, SDD 23 etapa 4).
///
/// **Tiene que viajar, y que faltara era un bug de reproducibilidad medible**: `strip_debug`
/// ENTRA EN `hash_inputs` (cambia el contenido del artefacto), así que un `.swm` que no lo
/// llevara hacía que el receptor reconstruyera con `strip_debug` en `None` y sellara en OTRA
/// dirección. El `expected_hash` anclado por `pack --build` no coincidía nunca y
/// `install --require-signed` abortaba — para 40 recetas del corpus, 18 de las 88 del perfil
/// `servidor`. Encontrado el 2026-09-10 instalando `takana` desde su propio repo (SDD 28 §5).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strip_debug: Option<bool>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -292,6 +302,7 @@ impl Swm {
flags: recipe.build.flags.clone(), flags: recipe.build.flags.clone(),
phases: recipe.build.phases.clone(), phases: recipe.build.phases.clone(),
zig_version: recipe.build.zig_version.clone(), zig_version: recipe.build.zig_version.clone(),
strip_debug: recipe.build.strip_debug,
}; };
let swm = Swm { let swm = Swm {
@@ -609,6 +620,7 @@ mutations:
flags: vec![], flags: vec![],
phases: Default::default(), phases: Default::default(),
zig_version: None, zig_version: None,
strip_debug: None,
}, },
target_bin: "/bin/x".into(), target_bin: "/bin/x".into(),
expected_hash: None, expected_hash: None,
@@ -641,6 +653,7 @@ mutations:
flags: vec![], flags: vec![],
phases: Default::default(), phases: Default::default(),
zig_version: None, zig_version: None,
strip_debug: None,
}, },
target_bin: "/bin/grep".into(), target_bin: "/bin/grep".into(),
expected_hash: None, expected_hash: None,
@@ -670,6 +683,7 @@ mutations:
flags: vec![], flags: vec![],
phases: Default::default(), phases: Default::default(),
zig_version: None, zig_version: None,
strip_debug: None,
}, },
target_bin: "/bin/x".into(), target_bin: "/bin/x".into(),
expected_hash: None, expected_hash: None,
@@ -755,6 +769,38 @@ zig_version = "0.13.0"
} }
} }
/// `strip_debug` ENTRA EN `hash_inputs`, así que un `.swm` que no lo llevara hacía que el
/// receptor sellara en OTRA dirección y `install --require-signed` abortara siempre. Medido el
/// 2026-09-10 instalando `takana` desde su propio repo: declarado `b3:941d1857…`, obtenido
/// `b3:2727050e…`. Afectaba a 40 recetas del corpus. Este test es el que faltaba.
#[test]
fn from_recipe_lleva_strip_debug_porque_es_entrada_de_hash() {
let con = crate::Recipe::from_toml(
"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();
match &swm.mutations[0] {
Mutation::SourcePatch { build, .. } => {
assert_eq!(build.strip_debug, Some(true), "strip_debug tiene que viajar en el .swm");
}
other => panic!("esperaba SourcePatch, obtuve {other:?}"),
}
// Y el control que hace que el test valga: sin el campo, no se inventa un `false`.
let sin = crate::Recipe::from_toml(
"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();
match &swm2.mutations[0] {
Mutation::SourcePatch { build, .. } => {
assert_eq!(build.strip_debug, None, "una receta sin strip_debug no debe ganar uno");
}
other => panic!("esperaba SourcePatch, obtuve {other:?}"),
}
}
#[test] #[test]
fn verify_schema_source_patch_rechaza_sin_origen() { fn verify_schema_source_patch_rechaza_sin_origen() {
let m = Mutation::SourcePatch { let m = Mutation::SourcePatch {
@@ -772,6 +818,7 @@ zig_version = "0.13.0"
flags: vec![], flags: vec![],
phases: Default::default(), phases: Default::default(),
zig_version: None, zig_version: None,
strip_debug: None,
}, },
target_bin: "/bin/x".into(), target_bin: "/bin/x".into(),
expected_hash: None, expected_hash: None,