paso 8: zlib-rs sellado y medido — drop-in que anda, con tres reservas que cambian la campaña
El paso 8 pedía: receta que publique el mismo SONAME, swap y comparar; si anda, la campaña es calendario. ANDA. b3:61fb18d1… sellado, publicando libz.a/libz.so.1/libz.so + zlib.h/zconf.h + zlib.pc con los NOMBRES de zlib, y un consumidor compilado contra el artefacto sellado corre y da bytes deterministas. MEDIDO con el corpus Silesia (15,7 MB), el mismo binario contra las dos libs: nivel 1: 7.050.606 B / 226,6 ms → 8.526.731 B / 95,1 ms (+20,9 % · 2,4× más rápido) nivel 6: 6.409.962 B / 760,4 ms → 6.457.822 B / 335,4 ms ( +0,7 % · 2,3× más rápido) nivel 9: 6.371.670 B /1496,3 ms → 6.358.939 B / 634,9 ms ( −0,2 % · 2,4× más rápido) Round-trip OK en los tres. ⚠ Mi primera corrida usó una entrada SINTÉTICA y daba +84 % al nivel 5; con datos reales es +0,7 %. Queda escrito porque el dato falso era mío y casi decide al revés. TRES RESERVAS, que son el hallazgo: 1. NO es idéntica byte a byte (fnv distinto en los tres niveles) ⇒ todo artefacto que EMBEBA datos comprimidos cambia de contenido. La campaña de las 122 produce artefactos DISTINTOS, no los mismos: es decisión de calendario Y DE CONTENIDO. 2. gzprintf/gzvprintf NO existen: de los 101 símbolos públicos faltan esos dos y su feature pide Rust nightly. Un consumidor que los llame no enlaza ⇒ hay que mirar receta por receta. 3. El consumidor necesita -lunwind (Rust dentro de C; sin él, undefined symbol: _Unwind_Resume). Va en el zlib.pc como Libs.private, pero un -lz a mano no alcanza. El build llevó un parche de dos líneas + el lock, y el porqué vale más que el parche: el crate de la ABI de C está EXCLUIDO del workspace por upstream, y meterlo sin más lo hace abortar con `panic="abort" is mandatory because unwinding to C is undefined behavior` — el profile de un miembro se ignora. El parche sube el miembro, pone panic=abort en la raíz y actualiza Cargo.lock, porque el vendoreo corre con --locked. Tamaño: el artefacto pasa de 537 K a 13 M (libz.a de 422 K a 11 M). Queda en el catálogo con 0 consumidores y en ningún perfil.
Semantic delta
No definition changed: this change is text, documentation or data.
3 files · +215 −0
- docs/plan-que-sigue-tras-busybox.md +69 −0
- recipes/zlib-rs-workspace.patch +40 −0
- recipes/zlib-rs.toml +106 −0
diff --git a/docs/plan-que-sigue-tras-busybox.md b/docs/plan-que-sigue-tras-busybox.md
index 1dcc3f1c..9737b880 100644
--- a/docs/plan-que-sigue-tras-busybox.md
+++ b/docs/plan-que-sigue-tras-busybox.md
@@ -933,3 +933,72 @@ bytes de imagen.
`never` que el drenaje cuenta como deuda. Cuando el gate se cierre, la receta es de quince líneas y
los comandos están medidos acá: `sh bootstrap.sh build-boot` → `build-boot/muon-bootstrap setup -Dlibcurl=disabled
-Dlibarchive=disabled -Dlibpkgconf=disabled build` → `muon-bootstrap -C build samu`.
+
+---
+
+# Paso 8 — MEDIDO Y SELLADO: `zlib-rs` funciona como drop-in, con tres reservas que cambian la forma de la campaña (2026-09-22)
+
+El paso 8 pedía «receta que publique el mismo SONAME, swap en UN consumidor, y comparar. Si ahí anda,
+la campaña es una decisión de calendario; si no, costó una receta». **Anda.** Y la comparación
+contra la `zlib` del corpus, con datos reales, dice cosas que no se podían suponer.
+
+## Sellado
+
+```
+recipes/zlib-rs.toml v0.6.8 · commit 0254e07204884bfc902d6ebb68c8b703d0ea49cf (tag v0.6.8)
+b3:61fb18d1f2850c5c3152925d06c0bd19256caaaff97e1e3877f4f3bc72278d7e (el hash del dry-run)
+publica: usr/lib/{libz.a, libz.so.1, libz.so} · usr/include/{zlib.h, zconf.h} · pkgconfig/zlib.pc
+```
+
+Con los NOMBRES de zlib, para que un consumidor que hoy pide `-lz` y `#include <zlib.h>` no cambie una
+línea. Un consumidor compilado contra el **artefacto sellado** (header y lib suyos) corre y da los
+mismos bytes que el build de scratch — o sea que es determinista.
+
+⚠ **El build necesitó un parche de dos líneas y medio**, y el porqué vale más que el parche: el crate
+que publica la ABI de C (`libz-rs-sys-cdylib`) está **excluido del workspace** por upstream, y
+compilarlo desde su subdirectorio no sirve acá porque el vendoreo del lab corre en la RAÍZ. Meterlo al
+workspace sin más tampoco: el crate **aborta a propósito** con `compile_error!("panic=\"abort\" is
+mandatory because unwinding to C is undefined behavior")`, porque el `[profile.release]` de un miembro
+se ignora. El parche sube el crate a `members`, pone `panic = "abort"` en el profile de la raíz **y
+actualiza `Cargo.lock`** (8 líneas), porque el vendoreo corre con `--locked`.
+
+## Lo medido, con el corpus Silesia (15,7 MB) y el mismo binario contra las dos libs
+
+| nivel | zlib 1.3.1 | zlib-rs 0.6.8 | tamaño | tiempo |
+|---|---|---|---|---|
+| 1 | 7.050.606 B · 226,6 ms | 8.526.731 B · **95,1 ms** | **+20,9 %** | **2,4× más rápido** |
+| **6 (por defecto)** | 6.409.962 B · 760,4 ms | 6.457.822 B · **335,4 ms** | **+0,7 %** | **2,3× más rápido** |
+| 9 | 6.371.670 B · 1496,3 ms | **6.358.939 B** · **634,9 ms** | **−0,2 %** | **2,4× más rápido** |
+
+Round-trip correcto en los tres niveles. Al nivel por defecto cuesta **0,7 % de tamaño** y devuelve
+**2,3× de velocidad**; al 9 es más chica *y* más rápida; al 1 comprime bastante peor.
+
+⚠ La primera corrida la hice con una entrada SINTÉTICA y daba +84 % al nivel 5. No era representativa:
+con datos reales el número es +0,7 %. Queda escrito porque el dato falso era mío y estuvo a punto de
+decidir el paso al revés.
+
+## Las tres reservas, que son lo que cambia la forma de la campaña
+
+1. **NO es idéntica byte a byte.** El `fnv` de la salida difiere en los tres niveles. Cualquier
+ artefacto que EMBEBA datos comprimidos —páginas de manual gzipeadas, PNG, `.tar.gz`, un
+ initramfs— **cambia de contenido**. No es no-determinismo (es determinista y distinto), pero la
+ campaña de las 122 no es «reconstruir y obtener lo mismo»: **produce artefactos distintos**.
+2. **`gzprintf`/`gzvprintf` no existen.** Medido comparando símbolos: de los 101 públicos de `libz.a`
+ faltan esos dos, y su feature en upstream **pide Rust nightly** (`c_variadic`). El resto que falta
+ es interno de zlib (`_tr_*`, `inflate_fast`, `inflate_table`, `zcalloc`, `z_errmsg`). Un consumidor
+ que llame `gzprintf` **no enlaza** ⇒ hay que mirarlo receta por receta.
+3. **El consumidor necesita `-lunwind`.** Es Rust adentro de C: el `staticlib` arrastra el backtrace
+ de std y sin el unwinder el enlace muere con `undefined symbol: _Unwind_Resume`. Está en el
+ `zlib.pc` como `Libs.private`, pero un `-lz` a mano no alcanza. Es la perilla que el corpus ya
+ conocía ([[lab-cargo-vendor-poliglota]]).
+
+Y un dato de tamaño para que nadie se sorprenda: el artefacto pasa de **537 K a 13 M** (`libz.a` de
+422 K a 11 M), porque un `staticlib` de Rust trae su mundo. En el binario final sólo entra lo que se
+usa, pero el store lo paga.
+
+## Veredicto
+
+**GO como variante, y la campaña es una decisión de calendario Y DE CONTENIDO** — que es más de lo
+que el plan suponía. La receta queda sellada en el catálogo con 0 consumidores y en ningún perfil: el
+siguiente paso natural es swapear UN consumidor real (`lld21` pide `libz.so.1`; `pigz` ejercita
+deflate a fondo) y correr su prueba, ahora que el artefacto existe y el enlace está caracterizado.
diff --git a/recipes/zlib-rs-workspace.patch b/recipes/zlib-rs-workspace.patch
new file mode 100644
index 00000000..5aa7d4a8
--- /dev/null
+++ b/recipes/zlib-rs-workspace.patch
@@ -0,0 +1,40 @@
+diff --git a/Cargo.lock b/Cargo.lock
+index de78668..a6750b9 100644
+--- a/Cargo.lock
++++ b/Cargo.lock
+@@ -120,6 +120,14 @@ dependencies = [
+ "zlib-rs",
+ ]
+
++[[package]]
++name = "libz-rs-sys-cdylib"
++version = "0.6.8"
++dependencies = [
++ "libz-rs-sys",
++ "zlib-rs",
++]
++
+ [[package]]
+ name = "libz-sys"
+ version = "1.1.29"
+diff --git a/Cargo.toml b/Cargo.toml
+index b644561..c7e1597 100644
+--- a/Cargo.toml
++++ b/Cargo.toml
+@@ -3,8 +3,6 @@ members = [
+ "zlib-rs",
+ "libz-rs-sys",
+ "test-libz-rs-sys",
+-]
+-exclude = [
+ "libz-rs-sys-cdylib",
+ ]
+
+@@ -25,6 +23,7 @@ publish = true
+ rust-version = "1.75" # MSRV
+
+ [profile.release]
++panic = "abort"
+ debug = true
+
+ [profile.dev]
diff --git a/recipes/zlib-rs.toml b/recipes/zlib-rs.toml
new file mode 100644
index 00000000..e2429445
--- /dev/null
+++ b/recipes/zlib-rs.toml
@@ -0,0 +1,106 @@
+# zlib-rs 0.6.8 — `libz` en Rust, con la MISMA ABI de C. Variante para reemplazar a `zlib`.
+#
+# ── POR QUÉ ESTÁ EN EL CATÁLOGO Y NO EN NINGÚN PERFIL ────────────────────────────────────────────
+# `zlib` es la librería C **más consumida del corpus**: 122 recetas la declaran (más 83 la variante
+# `-shared`). Eso la vuelve la pieza donde la seguridad de memoria compra más… y también la más cara
+# de cambiar: el swap re-hashea esas 122. Esta receta existe para que la decisión se pueda tomar con
+# el artefacto delante, no en abstracto — el plan lo pedía así (`docs/plan-que-sigue-tras-busybox.md`
+# paso 8: «receta que publique el mismo SONAME, swap en UN consumidor, y comparar»).
+#
+# ── LO MEDIDO (2026-09-22, corpus Silesia de 15,7 MB, el mismo binario de prueba con las dos libs) ─
+#
+# nivel │ zlib 1.3.1 │ zlib-rs 0.6.8 │ tamaño │ tiempo
+# ──────┼────────────────────────────┼────────────────────────────┼──────────┼─────────
+# 1 │ 7.050.606 B · 226,6 ms │ 8.526.731 B · 95,1 ms │ +20,9 % │ 2,4× más rápido
+# 6 │ 6.409.962 B · 760,4 ms │ 6.457.822 B · 335,4 ms │ +0,7 % │ 2,3× más rápido
+# 9 │ 6.371.670 B · 1496,3 ms │ 6.358.939 B · 634,9 ms │ −0,2 % │ 2,4× más rápido
+#
+# Round-trip correcto en los tres niveles. O sea: al nivel POR DEFECTO cuesta 0,7 % de tamaño y
+# devuelve 2,3× de velocidad; al 9 es más chica Y más rápida; al 1 comprime bastante peor.
+#
+# ⚠ **NO es idéntica byte a byte.** El `fnv` de la salida difiere en los tres niveles. Consecuencia
+# que hay que decir antes de que alguien se sorprenda: cualquier artefacto que EMBEBA datos
+# comprimidos (páginas de manual gzipeadas, PNG, `.tar.gz`, un initramfs) **cambia de contenido** al
+# hacer el swap. No es no-determinismo —es determinista y distinto—, pero significa que la campaña de
+# las 122 no es «reconstruir y obtener lo mismo»: produce artefactos DISTINTOS.
+#
+# ⚠ **`gzprintf`/`gzvprintf` NO están.** Medido comparando símbolos contra `libz.a` del corpus: de los
+# 101 públicos faltan esos dos, y su feature en upstream **pide Rust nightly** (`c_variadic`). El
+# resto de la lista que falta es interna de zlib (`_tr_*`, `inflate_fast`, `inflate_table`, `zcalloc`,
+# `z_errmsg`), que ningún consumidor debería usar. Un consumidor que llame `gzprintf` NO enlaza.
+#
+# ⚠ **Quien enlace esto necesita `-lunwind`**, porque es Rust adentro de C: el `staticlib` arrastra el
+# backtrace de std (`addr2line`/`gimli`) y sin el unwinder el enlace muere con `undefined symbol:
+# _Unwind_Resume`. Es la perilla que el corpus ya conoce ([[lab-cargo-vendor-poliglota]]); zig la
+# provee. Por eso esta receta **no** se puede declarar como dep y ya: el consumidor tiene que sumar
+# `LIBS=-lunwind` o su equivalente.
+#
+# ⚠ **`zlibVersion()` contesta `1.3.0-zlib-rs-0.6.8`.** El chequeo de versión de `deflateInit_` compara
+# sólo el primer carácter, así que pasa; un `configure` que parsee la cadena entera puede no gustarle.
+#
+# ── EL BUILD, Y POR QUÉ LLEVA UN PARCHE DE DOS LÍNEAS ───────────────────────────────────────────
+# El crate que publica la ABI de C es `libz-rs-sys-cdylib`, y upstream lo tiene **excluido** del
+# workspace. Compilarlo desde su directorio no sirve acá: el vendoreo del lab corre en la RAÍZ, así
+# que un `cargo build --offline` adentro del subdirectorio no encuentra las deps del crate excluido
+# («perhaps a crate was updated and forgotten to be re-vendored?»). Y meterlo al workspace sin más
+# tampoco: el crate aborta a propósito con
+#
+# compile_error!("panic=\"abort\" is mandatory because unwinding to C is undefined behavior");
+#
+# porque el `[profile.release]` de un MIEMBRO se ignora (gana el de la raíz) — que es exactamente por
+# lo que upstream lo excluyó. El parche hace las dos cosas juntas: sube el crate a `members` y pone
+# `panic = "abort"` en el profile de la RAÍZ. Entra en `hash_inputs` (los parches sí entran), que es
+# donde tiene que estar una decisión de build.
+#
+# ⚠ Y toca **también el `Cargo.lock`**, porque el vendoreo del lab corre con `--locked`: con un miembro
+# nuevo el lock cambia y cargo se niega a actualizarlo («cannot update the lock file … because
+# --locked was passed»). El añadido son las 8 líneas del paquete nuevo; el lock sigue pineado, que es
+# la premisa del ADR 0006.
+#
+# `--features libz-rs-sys-cdylib/gz` exporta la familia `gz*`, que sí usan consumidores de verdad.
+name = "zlib-rs"
+version = "0.6.8"
+license = "Zlib"
+
+[source]
+repo = "https://github.com/trifectatechfoundation/zlib-rs"
+# tag v0.6.8. El commit es el identificador inmutable (ADR 0006), no el tag.
+commit = "0254e07204884bfc902d6ebb68c8b703d0ea49cf"
+patches = ["zlib-rs-workspace.patch"]
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "static"
+flags = []
+
+[build.phases]
+configure = "true"
+compile = "cargo build --release --locked --offline -p libz-rs-sys-cdylib --features libz-rs-sys-cdylib/gz"
+# Se instala con los NOMBRES de zlib (`libz.a`, `libz.so.1`) y con los headers de zlib-rs, para que un
+# consumidor que hoy pide `-lz` y `#include <zlib.h>` no tenga que cambiar una línea. El `.so.1` lleva
+# el enlace `libz.so` porque es el que busca el enlazador al ver `-lz`.
+install = '''
+mkdir -p /out/usr/lib /out/usr/include /out/usr/lib/pkgconfig
+cp target/release/libz_rs.a /out/usr/lib/libz.a
+cp target/release/libz_rs.so /out/usr/lib/libz.so.1
+ln -sf libz.so.1 /out/usr/lib/libz.so
+cp libz-rs-sys/include/zlib.h /out/usr/include/zlib.h
+cp libz-rs-sys/include/zconf.h /out/usr/include/zconf.h
+# El .pc con el nombre de zlib: los `configure` del corpus preguntan por `zlib`, no por `zlib-rs`.
+# `Libs.private: -lunwind` es la parte que NO es cosmética: sin el unwinder el enlace estático muere.
+cat > /out/usr/lib/pkgconfig/zlib.pc <<'PC'
+prefix=/usr
+libdir=${prefix}/lib
+includedir=${prefix}/include
+Name: zlib
+Description: zlib compression library (zlib-rs, Rust)
+Version: 1.3.0
+Libs: -L${libdir} -lz
+Libs.private: -lunwind
+Cflags: -I${includedir}
+PC
+'''
+
+[deps]
+build = ["binutils"]