gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 815Rust 56Shell 37Markdown 291229 Definiciones

⎇ 8836ce70622e30c6525be879078fa957a416b122 docs/plan-que-sigue-tras-busybox.md Markdown · 1055 líneas · 59.6 KB Historia crudo

paso 8 bis: pigz sobre zlib-rs, sellado — el swap en un consumidor real no costó una línea de su fuente 8836ce706 · Sergio ·

1# Plan — con qué conviene hacer lo de busybox, MEDIDO
2
3**Fecha:** 2026-09-22 · **Pregunta:** «botado busybox, ¿con qué conviene hacer lo mismo? ¿Hay que
4rusterizar o soberanizar algo vital, hay opciones modernas a cosas tradicionales, o hay una
5arquitectura mejor?»
6
7**Instrumento** (todo reproducible, nada escrito a mano):
8
9```sh
10# grafo y clases: los 950 nodos con su clase, hash, perfiles y dependientes
11python3 -c 'import json;json.load(open("docs/state/build-state.json"))'
12# aristas y membresía: la MISMA función que usan el grafo, la hidratación y el repo firmado
13python3 -c 'import sys;sys.path.insert(0,"scripts");import yupana;yupana.cargar_colas()'
14# bytes: tamaño real de cada artefacto sellado
15cd /store && du -sk --apparent-size *          # 1.435 artefactos, 59,8 GiB
16# runtime real: quién pide qué SONAME
17readelf -d <artefacto>/usr/bin/<bin>
18```
19
20## El hallazgo que ordena todo, y no es una pieza de userland
21
22> **La clausura que define una imagen es `build ∪ runtime`. Entonces cada imagen de takana lleva
23> adentro la cadena de compilación: `llvm21`, `go`, `python3`, `binutils`, `cmake`, `perl`, `nasm`,
24> `bison`, `flex`, `m4`, `meson`, `wasi-sdk`, `nodejs`.**
25
26Es **exactamente la forma del caso busybox**, y por eso vale la pena verlo dos veces: `busybox`
27nunca estuvo declarado en `targets.toml` —llegaba a los 7 perfiles por la clausura, porque 24
28recetas lo pedían en `[deps] build`—, y sacarlo no costó escribir una línea de userland (paso 6 de
29`plan-botar-busybox.md`). Lo que ese paso arregló **para un nombre**, esto lo arregla **para la
30clase entera**.
31
32La definición está en `scripts/yupana.py:98` (`_deps`) y en `scripts/build-state.py:128`, y en las
33dos hay un comentario que explica por qué es la unión: `firefox` declaraba `runtime = ["gcc-libs"]`,
34nadie leía ese campo, y la imagen se armaba sin `libstdc++.so.6`. **El arreglo fue correcto y la
35unión es lo que hay que afinar, no lo que hay que deshacer** — ver el riesgo más abajo.
36
37### Lo que cuesta, en nodos y en bytes
38
39Clausura de hoy contra «raíces + aristas de runtime», medido sobre los artefactos sellados:
40
41| perfil | nodos hoy | sólo-runtime | GiB hoy | GiB sólo-runtime | grasa |
42|---|---|---|---|---|---|
43| `base` | 85 | 48 | **1,35** | 0,71 | **47 %** |
44| `cli` | 152 | 103 | **2,57** | 1,56 | 39 % |
45| `servidor` | 185 | 132 | **4,56** | 2,09 | **54 %** |
46| `escritorio-kde` | 444 | 163 | **13,05** | 5,76 | **56 %** |
47| `escritorio-gnome` | 344 | 158 | 9,63 | 3,86 | 60 % |
48| `escritorio-cosmic` | 295 | 157 | 8,48 | 4,34 | 49 % |
49| `escritorio-sway` | 281 | 143 | 7,90 | 4,11 | 48 % |
50| `escritorio-mirada` | 41 | 17 | 0,79 | 0,20 | 75 % |
51
52Y los caminos, que es lo que lo hace concreto:
53
54| en la imagen | pesa | entra por | qué es |
55|---|---|---|---|
56| `llvm21` | **1.527 MiB** | `servidor` → `rust` → `llvm21` | libs estáticas + headers de LLVM |
57| `python3` | 209 MiB | **`base` → `iputils` → `python3`** | `ping` se construye con meson |
58| `go` | 222 MiB | `cli` → `fzf` → `go` | el toolchain de Go |
59| `binutils` | 127 MiB | `base` → `bwrap` → `binutils` | ensamblador y linker |
60| `perl` | 84 MiB | `base` → `git` → `perl` | los `configure` de OpenSSL/curl |
61| `cmake` | 94 MiB | `servidor` → `rust` → `cmake` | generador de builds |
62| `nodejs` | 86 MiB | `escritorio-kde` → `atuq` → `firefox` → `nodejs` | el JS del build de Gecko |
63
64`llvm21` es el caso de libro y se verificó **en el binario, no en la receta**:
65
66```
67readelf -d /store/…-rust/usr/bin/rustc   ⇒ NEEDED librustc_driver-*.so, libc.so   (NO libLLVM)
68readelf -d /store/…-lld21/usr/bin/lld    ⇒ NEEDED libz.so.1, libgcc_s.so.1, libc.so
69recipes/llvm21.toml:75-76                ⇒ -DLLVM_BUILD_LLVM_DYLIB=OFF -DLLVM_LINK_LLVM_DYLIB=OFF
70```
71
72O sea: 1,5 G de LLVM viajan en la imagen del servidor para que **nadie** los abra. Y `servidor` es
73el perfil donde compilar **sí** es deliberado (`targets.toml:1725-1745`, «la caja que sirve el repo
74y construye, sí»): incluso ahí, el compilador es `rust` + `lld21`, no `llvm21`.
75
76### Por qué es barato: **CERO reconstrucciones**
77
78Esto no toca ninguna receta ⇒ no toca ningún `ArtifactHash` ⇒ no invalida nada. Se cambia **cómo se
79proyecta**, no qué se construye. Es lo que lo pone primero: todo lo demás de este documento cuesta
80builds.
81
82### Por qué es seguro: la ambigüedad es ENUMERABLE
83
84El miedo legítimo es la cicatriz de `gcc-libs`: si una lib dinámica se cae de la imagen, el binario
85sella, reproduce y no arranca. Medido sobre el cierre de `servidor`:
86
87> **de 185 nodos, sólo 28 contienen algún `.so`.** 144 no traen ni uno (todo el corpus es
88> `link = static`), y 13 no están en el store de este hub.
89
90Los 28: `alsa-lib binutils bzip2-shared cmus dbus elfutils-libdw ffmpeg gcc-libs gmp go
91libffi-shared libnl libudev-zero llvm21 mold mpc mpfr musl-fts musl-shared ncurses-shared perl
92python3 readline-shared sqlite-shared util-linux xz-shared zlib-shared zstd`.
93
94Entonces la regla correcta **no** es «usá sólo las aristas de runtime» (eso es lo que rompió
95firefox). Es:
96
97```
98cierre de imagen = raíces ∪ deps de runtime declaradas ∪ proveedores de los SONAME OBSERVADOS
99```
100
101y el instrumento para la tercera parte **ya existe y ya se corre**: `scripts/vigia-sonames.py`
102contesta «¿qué `.so` pide esta imagen que ningún artefacto de su cierre provee?». Hoy es un guardián
103*a posteriori*; acá pasa a ser la **definición**.
104
105### Dónde se toca, y el criterio de terminado
106
107- `scripts/yupana.py` — una tercera vista además de `cargar_colas()` y `cargar_colas(solo_build=True)`:
108  la de **imagen**. Las otras dos no se tocan: `radio` necesita la de build (re-hasheo) y el grafo de
109  estado necesita la unión (cota de «qué hay que construir»).
110- `scripts/hydrate-profile.py` — hoy «proyecta al FHS el cierre ENTERO».
111- `scripts/repo-perfil.sh` y `scripts/build-state.py` — consumen la misma membresía.
112- ⚠ **Hay DOS linajes de imagen y este es el que no está curado.** El otro —`takana bootstrap
113  product`— se arma de tres listas a mano (`STAGE1_COMPONENTS` 4, `USERLAND_COMPONENTS` 5,
114  `SERVICE_COMPONENTS` 2) y por eso `product-image.sh` puede anunciarse «lean, SIN toolchain». La
115  imagen del servidor es **producto + hidratación del perfil encima** (SDD 28 §«Receta del
116  ensamblado»), y la segunda mitad es la que trae los 2,46 GiB. Unificar los linajes es su propia
117  unidad de trabajo; **no hace falta para cobrar esto.**
118
119**Terminado cuando:** `hydrate-profile.py servidor` no proyecta `llvm21 python3 perl binutils cmake
120go`, `vigia-sonames.py` da **cero** huecos en los 8 perfiles, y `product-boot-test.sh` arranca. La
121cifra a batir: **4,56 → 2,09 GiB** en `servidor` y **13,05 → 5,76** en KDE.
122
123⚠ **Un detalle que sale de paso y no es de este frente:** el **kernel no está en ningún perfil**
124(`linux*` → `perfiles: []`); lo pasa un `KERNEL=` de script. Lo que sí viaja en los 7 es
125`linux-headers`. La imagen declara los headers del kernel y no el kernel.
126
127---
128
129## Los tres grupos, porque mezclarlos es lo que cuesta el trabajo
130
131Igual que los tres frentes de busybox, acá la diferencia que importa es **cuántas reconstrucciones
132paga cada cosa** — `[deps] build` entra en `hash_inputs`, `[deps] runtime` no:
133
134| grupo | qué es | rebuilds |
135|---|---|---|
136| **1 · proyección** | cómo se arma la imagen; ninguna receta cambia | **0** |
137| **2 · hojas** | recetas con **0 consumidores**: nadie las declara | **0** (sólo ellas) |
138| **3 · campañas** | tocar una herramienta de build que N recetas declaran | **N** |
139
140---
141
142## Grupo 2 · Las hojas: cambio moderno, coste cero
143
144Todas verificadas con `yupana`: **0 consumidores**, o sea que tocarlas no invalida a nadie.
145
146### 2.1 · `sudo` **y** `doas` en los 7 perfiles — y ninguno de los dos puede funcionar
147
148Dos herramientas para lo mismo, declaradas juntas en `base` (`targets.toml:58`) y heredadas por los
149otros 6 perfiles. `sudo` = 12,1 MiB, `doas` = 0,1 MiB. Pero el hallazgo es otro:
150
151```
152/store/…-sudo/usr/bin/sudo   → -r-xr-xr-x   (sin setuid)
153/store/…-doas/usr/bin/doas   → -r-xr-xr-x   (sin setuid)
154grep -rn "S_ISUID\|4755" crates/   → 0 hits que pongan el bit
155```
156
157Y las recetas dicen tres cosas distintas:
158
159| receta | qué afirma | ¿cierto? |
160|---|---|---|
161| `recipes/sudo.toml:57` | «el paquete lo re-aplica al hidratar» | **no** |
162| `recipes/doas.toml:38` | «el binario setuid es cosa de la hidratación» | **no** |
163| `recipes/shadow.toml:15` | «el modelo de artefactos no puede ni expresar ni garantizar un setuid» | **sí** |
164
165Es la familia de [[needed-colgante-libstdcxx]] y [[subcomando-sin-driver]]: sella, reproduce y no
166corre. **Hoy es LATENTE**, y por eso nadie lo vio: el instalador sólo pone contraseña de root
167(`takana-live-install.sh`) y los `[[user]]` de las 7 recetas que los declaran son cuentas de servicio, que no
168escalan. Se vuelve real el día que la imagen tenga un usuario humano no-root — y entonces `sudo`,
169`doas`, `su` y `passwd` fallan los cuatro.
170
171**Orden correcto, y no es «poner sudo-rs»:**
1721. **Decidir quién pone el bit** (el modelo de artefactos, la hidratación o el `.swm`). Es la
173   conversación que `shadow.toml:18` ya pidió y que sigue abierta.
1742. **Elegir UNA.** Si la respuesta es «la chica», `doas` ya ganó. Si es «la compatible con los
175   runbooks», el reemplazo moderno es **`sudo-rs`** (Rust, auditado, drop-in de `sudo`) y de paso
176   saca un setuid en C del producto.
1773. Corregir las dos recetas que afirman algo falso, gane quien gane.
178
179### 2.2 · `chrony` → `ntpd-rs`
180
181La hora es vital y el modo de fallo es traicionero: sin NTP, TLS y las firmas fallan con errores que
182dicen «firma inválida» y no «la hora» — está escrito en el propio `targets.toml`. `chrony` es C, 0
183consumidores, sólo en `servidor`. `ntpd-rs` es el reemplazo Rust hecho exactamente para este nicho
184(cliente + servidor, NTS). Receta Cargo con commit pineado, el patrón de `uutils`.
185
186⚠ Verificar antes de prometer: que cubra el modo *cliente* con el mismo contrato de arranque, y que
187su `[[service]]` encaje en arje (SDD 30). El criterio es `arjectl status` + reloj convergido en
188QEMU, no que compile.
189
190### 2.3 · `gnupg` — mirar si hay que reemplazarlo o **borrarlo**
191
192`gnupg` es raíz de `base` (`targets.toml:60`) ⇒ viaja en los 7 perfiles, 39,1 MiB, **0 consumidores**, y en todo el repo lo invoca **un
193solo script de prueba** (`scripts/product-base-system-boot-test.sh`). La firma de takana **no lo
194usa**: es ed25519 propio (`takana keygen release`, `trust/`). Entonces son dos preguntas distintas:
195
196- Como **herramienta del usuario**: el reemplazo moderno es `sequoia` (`sq`, Rust). ⚠ No confundir
197  con `recipes/sq.toml`, que es otro `sq` (neilotoole, Go, consultas a datos).
198- Como **infraestructura**: no lo es. Si nadie lo pide, es la clase «105 BORRAR» del censo de
199  busybox, no la clase «reemplazar».
200
201⚠ `libgcrypt` (18 consumidores) y `libgpg-error` (20) **se quedan** con o sin gnupg.
202
203---
204
205## Grupo 3 · Las campañas: buen cambio, factura grande
206
207### 3.1 · `meson` → `muon`: matar el intérprete de Python del sandbox
208
209| dato | valor |
210|---|---|
211| recetas que declaran `meson` | **107** |
212| de esas, que declaran también `python3` | **107 (todas)** |
213| consumidores de `python3` en total | 302 |
214| `python3` en el store | **209,0 MiB** |
215| `meson` | 3,9 MiB |
216
217El precedente ya está sentado y es del propio repo: **`samurai`** (C, 0,6 MiB, 256 consumidores)
218reemplazó a ninja. `muon` es su hermano exacto: implementación en C de `meson.build`, sin Python,
219pensada para correr con samurai. Es el cambio que convierte «`ping` arrastra 209 MiB de intérprete»
220en un no-problema del lado del build, además del lado de la imagen.
221
222⚠ **Cuesta 107 recetas re-selladas** (y sus consumidores): `deps.build` está en `hash_inputs`.
223`yupana radio meson` antes de tocar nada. Conviene **sólo pegado a otra invalidación grande**, no
224como cambio suelto.
225⚠ Y se mide primero, no se adopta: muon **no cubre el 100 %** de meson. El criterio de decisión es
226cuántas de las 107 configuran con muon sin tocar el `meson.build`; con una muestra de 10 ya se sabe
227si el frente vale.
228
229### 3.2 · `zlib` → `zlib-rs`: la lib C más consumida del corpus
230
231`zlib` tiene **122 consumidores** (+83 de `zlib-shared`) — más que ninguna otra librería del
232catálogo, y es un clásico de CVEs de memoria. `zlib-rs` publica la **misma ABI C** (`libz.a` /
233`libz.so.1`), así que en teoría es un swap sin migrar llamadores, que es justo lo que no pasa con
234`ripgrep`/`sd`/`fd`.
235
236⚠ 122 recetas re-selladas: es **la campaña más cara de este documento**. Y ⚠ hay que verificar su
237estado upstream antes de prometerlo — la misma cautela que el plan de busybox aplicó a
238`uutils/util-linux`.
239
240**Cómo se decide sin pagar la campaña:** receta `zlib-rs` publicando el mismo SONAME, swap en **un**
241consumidor dinámico (`lld21` pide `libz.so.1`), y comparar. Si ahí anda, la campaña es una decisión
242de calendario; si no, costó una receta.
243
244### 3.3 · Lo que NO es campaña aunque se parezca
245
246`openssl`: 55 consumidores, 44,1 MiB. **9 son recetas Rust** (`cargo-generate`, `cargo-outdated`,
247`cargo-udeps`, `cargo-cache`, `cosmic-settings-daemon`, `hurl`, `monolith`, `nu`, `rink`) que podrían
248usar `rustls` por *feature*. Pero los otros 34 son C y openssl seguiría viajando igual ⇒ **el
249esfuerzo no compra bytes ni superficie**. Es trabajo del día que openssl se caiga sola.
250
251---
252
253## Correcciones al plan de busybox que salen de esta medición
254
255`plan-botar-busybox.md` cifró **23 applets de trabajo real**. Dos de las ocho piezas ya están
256hechas y una **no hace falta**:
257
258| pieza | applets | estado hoy |
259|---|---|---|
260| `psmisc` | `killall killall5 pstree fuser` | ✅ `sealed`, en 7 perfiles (commit `4357b29c`) |
261| `bc` | `bc dc` | ✅ `sealed`, en 7 perfiles (commit `4357b29c`) |
262| `awk` | `awk` | ✅ resuelto **escribiendo**: `akllay`, Rust, 3.531 líneas (`86915f26`) |
263| **`kmod`** | `insmod rmmod lsmod modprobe depmod modinfo` | **no hace falta: no hay módulos** |
264
265El detalle de `kmod`, que es el que cambia la cuenta: **los cuatro kernels que arman imágenes llevan
266`-d MODULES`** — `linux-generic.toml:69`, `linux-metal`, `linux-metal-dual`, `linux-metal-kexec`. El
267único que conserva los módulos del `defconfig` es `recipes/linux.toml`, y ése **no arma ninguna
268imagen** (el SDD 28 ya lo descartó: apaga SCSI y no ve el disco de hcloud). ADR 0018 lo dice del
269otro lado: «un único bzImage, sin módulos ⇒ una sola firma».
270
271⇒ **los 6 applets de `kmod` son clase BORRAR, no clase escribir.** Vuelven el día que exista un
272módulo fuera del árbol (el tester NVIDIA es el candidato), y ese día `kmod` es una receta, no seis
273applets. Con eso el trabajo restante del censo baja de 23 a **11** (`grep` 3, `sed` 1, gestor de
274`/dev` 3, `getty` 2, `nslookup` 1), y de esos `getty` **ya se sabe que es ADR** y no escritura.
275
276---
277
278## Lo único del producto que no sale de una receta: el cargador de arranque
279
280```
281recipes/grub*.toml                       ⇒ no existe
282scripts/iso-image.sh:37                  ⇒ GRUB_LIB="${GRUB_LIB:-/usr/lib/grub/i386-pc}"
283scripts/install-image.sh:49              ⇒ idem  ·  exige grub-mkimage, grub-bios-setup
284```
285
286O sea que **el GRUB que arranca una imagen de takana es el de la caja que la construyó**, y su
287versión no entra en ningún hash. Es la rama BIOS/El-Torito; la rama UEFI **ya se escapó** por
288EFI-stub (`EFI_STUB=1`, `metal-iso.sh:104`, y el ADR 0018 con vendor path propio + reconciliador de
289NVRAM, implementado y verificado en metal).
290
291La decisión es chica y está sin tomar: **o `grub` entra al corpus como receta, o la rama BIOS se
292retira** y todo el arranque queda EFI-stub, que es además la arquitectura moderna (UKI: un PE
293firmable, sin gestor intermedio). Lo que no conviene es lo de hoy, que es depender de un binario
294ajeno sin decirlo. ⚠ Y el ADR 0018 deja abierta la de Secure Boot, que es la que lo ata.
295
296---
297
298## Arquitectura: los tres pendientes, por lo que cuestan HOY
299
3001. **ADR 0012 · el árbol de fuentes compartido — PENDIENTE desde 2026-07-22, y es el que más paga.**
301   Es lo que obliga a `JOBS=1` y a envolver todo en `flock`: la granja compila **de a uno** en una
302   caja de 6 cores. Medido en su día: al invalidarse `libdrm`, ~93 de 205 recetas KDE murieron con el
303   wrapper de zig barrido por el fetch de otra. Resolverlo (caché inmutable direccionada por
304   contenido + copia privada por build, con reflink/overlay) es **la única de estas tres que
305   multiplica la velocidad del frente entero**. Todo lo demás de este documento se construye más
306   rápido después de esto.
3072. **Las 28 recetas `*-shared`.** `zlib`/`zlib-shared`, `pcre2`/`pcre2-shared`, `glib`/`glib-shared`…
308   168,4 MiB de artefactos y, sobre todo, **28 builds y 28 puntos de mantenimiento duplicados** para
309   emitir dos formas del mismo código. La arquitectura correcta es una receta con **varias salidas**
310   (estática / dinámica / headers), que además es lo que haría al vigía de sonames innecesario en su
311   caso más común. ⚠ Re-hashea; va después del §1, que es el que hace que la distinción importe.
3123. **Los dos linajes de imagen** (tres listas a mano vs. clausura del perfil). Hoy conviven y
313   divergen en silencio: es la misma forma que los `hydrate-*.sh` contra `targets.toml`
314   ([[hidratar-desde-el-perfil-no-el-script]]) y que el `sshd` que entra por
315   `product-userland-from-repo.sh` y no por el manifiesto — lo dice el propio `targets.toml`.
316
317---
318
319## Lo que NO conviene tocar, y por qué (para que no se proponga en tres semanas)
320
321| candidato | por qué no |
322|---|---|
323| `openssh` | no hay reemplazo Rust drop-in de un servidor SSH. Es el acceso a la caja: un experimento acá se paga con una máquina inalcanzable |
324| `linux-pam` | 5 consumidores, todos greeters de escritorio. No hay reemplazo; la deuda está contenida y nombrada |
325| `cups` / `bluez` | 291,8 y 35,5 MiB en los 4 escritorios, **ya decidido a conciencia** (`targets.toml`: viajan como paquete y NO se arrancan como servicio). Es el punto 8 de PUBLICABLE, no un olvido |
326| `NetworkManager` | 50,3 MiB, pero los applets de los 4 escritorios le hablan **por D-Bus**. Es migración de llamadores, no borrado — la lección de `ifconfig` del censo de busybox |
327| el demonio de `dbus` | 124 consumidores son **clientes** de libdbus; cambiar el demonio (busd/dbus-broker) es posible y no urge |
328| forkear `ripgrep`/`sd`/`fd` | ya cerrado en `plan-botar-busybox.md`: se usan como **librerías**, no se forkean |
329| deduplicar `work/sources` | el árbol por receta ES el aislamiento del ADR 0012. Deduplicar *de paso* re-crea la carrera |
330
331---
332
333## Orden de ataque
334
335| # | unidad | rebuilds | gana |
336|---|---|---|---|
337| 1 | cierre de imagen `build` vs `runtime` (+ el vigía como definición) | **0** | −2,5 GiB en servidor, −7,3 en KDE, y el toolchain fuera del producto |
338| 2 | decidir el setuid y quedarse con **una** de `sudo`/`doas` | 0 | cierra un «sella y no corre» antes de que sea visible |
339| 3 | `kmod` a BORRAR en el censo de busybox (23 → 11 applets) | 0 | no se escribe lo que nadie necesita |
340| 4 | la rama BIOS: receta de `grub` o retirarla | 0 | ningún binario ajeno sin hash en el arranque |
341| 5 | `chrony` → `ntpd-rs` | 0 | una hoja C vital menos, con drop-in Rust |
342| 6 | **ADR 0012** | — | la granja deja de compilar de a uno |
343| 7 | `meson` → `muon` (medir 10 antes) | 107 | un intérprete menos en el sandbox |
344| 8 | `zlib-rs` en UN consumidor, para decidir | 1 | la lib C más consumida del corpus, con la misma ABI |
345
346Los cinco primeros **no cuestan una sola reconstrucción**. Eso es lo que los pone antes: la lección
347de `uutils`/`brush`/`foot` —trabajo hecho y no cobrado— aplicada al calendario.
348
349---
350
351# Paso 1 — HECHO: el toolchain sale de las imágenes (2026-09-22)
352
353**Cero reconstrucciones, como estaba previsto: no se tocó ninguna receta.** Lo que cambió es la
354proyección, declarada en `[solo-build]` de `docs/state/targets.toml` (44 entradas, cada una con su
355motivo) y aplicada por `yupana.membresia_imagen()`.
356
357## La regla que se implementó, y la que se descartó
358
359**Se poda por NODO, no por tipo de ARISTA.** Podar las aristas de build es lo que dejó a firefox sin
360`libstdc++.so.6`; acá una receta de la lista no entra a la imagen y no se explora desde ella, y todo
361lo demás sigue entrando por donde entraba. Consecuencia buscada: `qtbase`, `glib` o `harfbuzz`
362llegan por aristas de build de apps que sí viajan y **se quedan**. Decidir sobre cada librería es
363otra unidad de trabajo.
364
365**La válvula: una raíz declarada gana.** Por eso `servidor` sigue llevando `rust` y `lld21`, y `cli`
366sigue llevando `mold` — «la caja que sirve el repo y construye, sí».
367
368## Medido en disco, no calculado — hidratando de verdad con `--closure build|image`
369
370`hydrate-profile.py` ganó `--closure build` para poder medir el ANTES: sin control, un «−X MiB» es
371una afirmación sin testigo.
372
373| perfil | ficheros antes → después | tamaño antes → después |
374|---|---|---|
375| `base` | 15.743 → **8.228** | 1,4 G → **871 M** (−38 %) |
376| `servidor` | 40.235 → **11.516** | 4,9 G → **2,6 G** (−47 %) |
377
378⚠ Los dos lados se hidrataron con los **mismos 12 nodos ausentes** de este hub (`chrony`, `cronie`,
379`rust`, `nftables`… están en el store del worker): la comparación es válida, el absoluto es cota
380inferior. Y ⚠ **`du` de los dos árboles en UN solo comando miente**: proyectan por hardlink y `du`
381deduplica entre sus argumentos — daba 45 M para el segundo. Se miden por separado.
382
383Nodos por perfil (`build` = qué hay que construir, sigue igual; `imagen` = qué viaja):
384
385| perfil | build | imagen | fuera |
386|---|---|---|---|
387| `base` | 85 | **68** | 17 |
388| `cli` | 152 | **132** | 20 |
389| `servidor` | 185 | **165** | 20 |
390| `escritorio-mirada` | 41 | **23** | 18 |
391| `escritorio-kde` | 443 | **409** | 34 |
392| `escritorio-gnome` | 339 | **307** | 32 |
393| `escritorio-cosmic` | 295 | **260** | 35 |
394| `escritorio-sway` | 281 | **248** | 33 |
395
396El `base` hidratado ya no tiene `python3`, `perl`, `cmake`, `meson`, `binutils`, `nasm`, `bison`,
397`flex`, `m4`, `pkgconf`, `go` ni `rustc` — comprobado buscándolos en el árbol, no en la lista. Lo
398único que queda con ese nombre es `usr/share/cmake`, que son ficheros de datos que instala otra
399receta, no el programa.
400
401## El instrumento que faltaba: `scripts/vigia-interpretes.py`
402
403El vigía de sonames dio **0 huecos en los 8 perfiles** con el toolchain fuera (`--imagen --fail`,
404`rc=0`). Pero **un `.so` no es la única dependencia de ejecución: un `#!` también**, y de eso no
405había instrumento. El guardián nuevo compara cada script del cierre de imagen contra lo que la
406imagen provee, con dos tablas —`POR_CONTRATO` (andamiaje de build horneado dentro de una librería) y
407`POR_DECISION` (lo que se aceptó perder)— y falla con `--fail` ante cualquier caso no declarado.
408
409**Encontró tres cosas que ningún soname delataba, y las tres cambiaron la decisión:**
410
411| hallazgo | qué era | qué se hizo |
412|---|---|---|
413| `gnome-tweaks` es un **programa Python entero** | sin `python3` la app no abre | `python3` declarado en `escritorio-gnome` |
414| `gdctl` de mutter (configurar salidas) | idem | idem |
415| el `kconf_update` de plasma-workspace | **migra la config al ACTUALIZAR**; si no corre, no migra y nada lo dice | `python3` declarado en `escritorio-kde` |
416
417O sea: `python3` (209 MiB) viaja en las dos imágenes grandes —donde es el 2 % de 10-13 G— y **no**
418en `base`, `cli`, `servidor`, `sway`, `cosmic` ni `mirada`, donde en `base` era el 17 %.
419
420Lo que queda inerte a propósito, contado y nombrado (`•` en la salida del vigía): los 10 scripts
421perl de `git` (`send-email`, `svn`, `cvs*`, `gitweb.cgi`), los 4 de `lm-sensors`
422(`sensors-detect`…), los diagnósticos de `libinput`, `routel`, `rrsync`, `lsusb.py`, los filtros de
423`aerc` y `helper-mux` de squid. Un `#!` que no resuelve **falla ruidoso y nombra al intérprete**;
424por eso la decisión es defendible y no una fuga silenciosa. Revertir cualquiera es una línea en el
425`paquetes` del perfil.
426
427⚠ **Lo que no se midió:** un binario que llame al intérprete con `system()` sin `#!`. No hay
428instrumento para eso; lo caza el arranque, y por eso el criterio de terminado incluye
429`scripts/product-boot-test.sh` en QEMU, que esta jaula no puede correr.
430
431## Lo que NO cambió, a propósito
432
433- **`build-state.py` sigue contando `clausura` = `build ∪ runtime`** — es «qué hay que construir» y
434  para eso el toolchain hace falta. Ahora informa `imagen` al lado, en el mismo renglón.
435- **`repo-perfil.sh` sigue publicando el cierre entero**: un repositorio no es una imagen. Que se
436  pueda instalar `cmake` desde el repo es correcto; que venga horneado en el rootfs, no.
437- **Las recetas**: ninguna. `[solo-build]` vive en el manifiesto y no en el `.toml` justamente para
438  no entrar en `hash_inputs`.
439
440## Guardado contra regresiones
441
442`scripts/targets.py --selftest` gana 6 casos con roturas a propósito sobre la poda (control sin
443poda, poda transitiva, la válvula, podar algo inalcanzable, y que ninguna entrada de la lista sea
444raíz de todos los perfiles). Y se corrigió un comentario de `vigia-sonames.py` que afirmaba que «la
445hidratación de la imagen NO las instala: va por lista explícita» — era del OTRO linaje
446(`bootstrap product`), y por creerlo nadie miró los 1.527 MiB de LLVM que sí se instalaban.
447
448---
449
450# Paso 2 — HECHO: el bit de privilegio, y `doas` como único camino (2026-09-22)
451
452El SDD 28 §5.11 dejó esto «anotado, sin decidir» el 21/09 con tres preguntas. Están contestadas, y
453la respuesta es un mecanismo con guardián, no un `chmod` en un script.
454
455## El hueco medido: 26 binarios, todos inertes
456
457Cruzando el cierre de IMAGEN de los 8 perfiles contra los nombres que en cualquier distro son setuid
458o setgid, y mirando el modo real del artefacto:
459
460| lo trae | binarios | modo hoy | perfiles |
461|---|---|---|---|
462| `shadow` | `passwd chage chfn chsh expiry gpasswd newgrp sg su login newuidmap newgidmap` | `555` | 7 |
463| `util-linux` | `mount umount sulogin wall` | `555` | 7 |
464| `linux-pam` | **`unix_chkpwd`** | `555` | 5 (los escritorios) |
465| `dbus` | `dbus-daemon-launch-helper` | `555` | 7 |
466| `fuse3` | `fusermount3` | `555` | 3 |
467| `iputils` | `ping` | `555` | 7 |
468| `openssh` | `ssh-agent` | `555` | 1 |
469| `sudo` / `doas` | `sudo sudoedit` / `doas` | `555` | 7 |
470
471**Ninguno tiene el bit** y `grep -rn S_ISUID crates/` daba **cero**: no había nadie que lo pusiera.
472El más caro de la lista no es `sudo`: es **`unix_chkpwd`**, que es con lo que PAM verifica una
473contraseña — sin él los greeters de los cuatro escritorios no pueden autenticar a nadie.
474
475Sigue siendo **LATENTE**, y por eso nadie lo chocó: el instalador sólo pone contraseña de root y los
476`[[user]]` de las recetas son cuentas de servicio, que no escalan.
477
478## 1 · ¿Quién lo pone? El ensamblado, no el artefacto — y son dos hechos, no una preferencia
479
480- **El store no puede hashearlo.** `ArtifactHash::of_tree` sólo mira el bit de EJECUCIÓN
481  (`mode & 0o111`, `hash.rs:79`). Dos artefactos que difieran sólo en el setuid tienen el MISMO
482  hash ⇒ dejarlo ahí es privilegio no cubierto por el contrato que cubre todo lo demás.
483- **La hidratación es por HARDLINK** (ADR 0005). El fichero del rootfs *es* el inodo del store: un
484  `chmod u+s` en una imagen de pruebas dejaría setuid el artefacto que comparten todas las demás.
485
486⇒ Se declara en la receta (`[[setuid]]`, con `why` **obligatorio**) y lo aplica `takana setuid
487--apply <rootfs>`, que **rompe el enlace duro** antes del `chmod`. Y la mitad buena del diseño sale
488de ahí: la copia la crea quien ensambla, así que **queda a su nombre** — y el `unshare -r` de
489`install-image.sh` mapea ese uid a 0, o sea setuid ROOT en la imagen, sin un `chown` en ningún lado.
490
491**Cero reconstrucciones**, verificado: el campo está fuera de `hash_inputs` (como `license`,
492`[[service]]` y `[[user]]`) y el hash de `doas` sigue siendo `b3:28bc42c2…`, sellado.
493
494## 2 · ¿Quién lo verifica? Las dos direcciones, y la segunda es la que vale
495
496```
497✓ 4755 usr/bin/doas               (doas)  [enlace duro roto: el store queda intacto]
498✓ ningún setuid/setgid sin declarar en <rootfs>
499```
500
501- *lo declarado está*: cada ruta existe y queda con el modo pedido (una declaración que apunta a un
502  binario que la imagen no trae **falla**; es la trampa de [[catalogo-no-es-imagen]]).
503- **lo que está, está declarado**: ningún fichero del rootfs tiene el bit sin declararlo. Eso
504  convierte la lista en un invariante, y acá es barato justamente porque el rootfs sale de artefactos
505  inmutables: un bit que aparece sin declaración es, por construcción, alguien pisando la imagen.
506
507Probado con roturas a propósito sobre un rootfs de verdad, no sólo en tests: un setuid colado ⇒
508`rc=1`; una declaración que apunta a una ruta inexistente ⇒ `rc=1`. Más 6 tests en
509`crates/takana-core/src/setuid.rs`, incluido el que justifica el módulo entero: tras aplicar el bit
510**el artefacto del store sigue en `0555`** y el del rootfs es `4755`.
511
512## 3 · ¿Qué significa que el hash no lo cubra?
513
514Que **el privilegio no es propiedad del artefacto: es propiedad de la imagen.** El `ArtifactHash`
515sigue afirmando lo mismo que antes —estos bytes, con este bit de ejecución— y no se mueve. Lo que
516cambia es que el privilegio gana su propio registro (la receta, versionada en git) y su propia
517verificación (las dos direcciones de arriba). Lo que no se puede hacer es guardarlo en el store y
518creer que el hash lo protege.
519
520## La elección: `doas`, y `sudo` sale de las imágenes
521
522Decisión del usuario con lo medido delante:
523
524| | `sudo` | `doas` |
525|---|---|---|
526| tamaño | **13 M** | **134 KB** |
527| config que instala | `/etc/sudoers` + `/etc/sudo.conf` | ninguna |
528| lo que autoriza tal cual | `root ALL=(ALL:ALL) ALL` — **a nadie más** | nada hasta que exista `doas.conf` |
529
530Viajaban **los dos** en los 7 perfiles y ninguno podía funcionar. Ahora `sudo` no está en ninguna
531imagen (su receta se queda en el catálogo, que no es la imagen) y `doas` lleva su `[[setuid]]`.
532
533⚠ **Falta el `/etc/doas.conf`: sin él, `doas` rechaza todo.** No se inventó una regla acá — quién
534puede escalar es una decisión de la imagen (¿grupo `wheel`?, ¿qué usuario humano?) y **hoy las
535imágenes no tienen usuario humano**. Es el mismo hueco que el `/etc/shells` del §5.11 y es la unidad
536siguiente; declarar el bit ahora es lo que no depende de esa decisión.
537
538## Dos comentarios que afirmaban algo falso, corregidos
539
540| receta | decía | realidad |
541|---|---|---|
542| `recipes/sudo.toml:57` | «el paquete lo re-aplica al hidratar» | nadie lo re-aplicaba |
543| `recipes/doas.toml:38` | «el binario setuid es cosa de la hidratación» | no había hidratación que lo hiciera |
544| `recipes/shadow.toml:15` | «el modelo no puede ni expresar ni garantizar un setuid» | **la única que lo decía bien** |
545
546## Lo que el guardián encontró en su PRIMERA corrida, y no era del setuid
547
548El aviso de dueño saltó con `doas` y destapó otra cosa: **el store tiene artefactos de dos uid
549distintos.** Visto desde el anfitrión, `…-doas/usr/bin/doas` es del **uid 1000** (el usuario del
550worker, que llega con la cosecha) y `…-bash/bin/bash` es del **1001** (el hub). Dentro de la jaula el
5511000 no está mapeado y se ve `nobody`.
552
553Medido sobre el rootfs de `base` recién hidratado: **5.984 ficheros de `sergio` y 1.159 de `nobody`**.
554Como `unshare -r` sólo mapea a 0 el uid que corre, **esos 1.159 salen a la imagen con dueño 65534** —
555y nadie lo había medido. Para el setuid no importa (romper el enlace normaliza el dueño), pero es su
556propia unidad de trabajo y queda anotada acá.
557
558## Lo que queda, dicho para que no se lea como cubierto
559
560- **Los otros 25 binarios del inventario.** El mecanismo está; declararlos es una decisión de
561  superficie de ataque, una línea por binario, y **no se tomó de paso**. El orden por lo que
562  desbloquea: `unix_chkpwd` (sin él no hay login gráfico), `passwd`+`chage`+`chfn`+`chsh`+`gpasswd`,
563  `mount`/`umount`, `fusermount3`, `ping`, `newuidmap`/`newgidmap`.
564- **El camino del `.swm`**: `Swm::from_recipe` lleva `deps`, `evidence` y `slots`, pero **no**
565  `[[service]]`, `[[user]]` ni `[[setuid]]` ⇒ un `takana install` de un paquete no pone el bit. Es el
566  mismo hueco que las otras dos declaraciones ya tenían; no lo empeora, pero hay que nombrarlo.
567- **`product-rootfs`**: el otro linaje (`takana bootstrap product`) no pasa por `hydrate-profile.py`,
568  así que tampoco aplica el bit. Cuando el frente C de busybox lo toque, la llamada es una línea.
569
570---
571
572# Paso 3 — HECHO: el censo de busybox deja de pedir trabajo hecho (2026-09-22)
573
574El detalle está en `docs/plan-botar-busybox.md` §7. En corto, y todo con `rc` comprobado:
575
576- **`--auditar-destinos`** nuevo en `scripts/busybox-censo-proveedores.py`: el `destino` y el
577  `estado_del_dueño` dejan de ser juicio. Regla mecánica: *si un paquete que viaja en la imagen
578  provee el applet, ése es el destino*. **293 celdas estaban mal** (22 de `destino`, 271 de estado).
579  `BORRAR` nunca se reescribe: es una decisión, y medir no la contradice.
580- **`kmod` (6) y el gestor de `/dev` (3) pasan a BORRAR**, medido en los `/boot/config-*` que
581  publican los artefactos: 5 de los 6 kernels sellados traen MODULES off y **ningún artefacto del
582  store publica `lib/modules`**. De paso, el vigía afirmaba «los cuatro kernels, cero `=m`» y son
583  seis, con 13 módulos en el sexto — la conclusión aguanta, la afirmación no.
584- **`killall5`** prometía `psmisc`, que no lo trae: lo cubre `arje-zero` por función. A BORRAR.
585- **El vigía no veía las cadenas crudas de Rust**, o sea los guiones del producto: decía «nadie los
586  invoca» con `insmod` retirado y `lib.rs:1388` invocándolo. Arreglado, con el mapa de líneas reales
587  y una sexta regla anti-ruido para el `/toolchain/`. Probado con una rotura a propósito.
588
589**De 23 applets de trabajo real quedan 3**, y dos son el ADR de `getty`. El paso 3 del plan decía
590«23 → 11»; medido, es **23 → 3**.
591
592---
593
594# Paso 4 — HECHO: las herramientas del ensamblado salen del store; el gestor de arranque es el único hueco (2026-09-22)
595
596El paso 4 estaba planteado como una disyuntiva: «o `grub` entra al corpus como receta, o la rama BIOS
597se retira». **Medir la cerró de un lado y abrió algo más grande del otro.**
598
599## La rama BIOS no se puede retirar, y está medido en el repo
600
601> **Hetzner Cloud arranca por BIOS** — «medido en gioser: no existe `/sys/firmware/efi`; GPT + GRUB»
602> (SDD 28 §3.1).
603
604O sea que la imagen del **servidor de producción** es justamente la que necesita la rama BIOS. La
605disyuntiva se resuelve por el otro lado: el gestor de arranque tiene que entrar al corpus.
606
607## Lo que apareció al mirar: el pipeline de imagen estaba roto en las TRES máquinas
608
609Los scripts tomaban sus herramientas del `PATH` del host. Medido en el hub, en su anfitrión (`raiz`) y
610en el worker `dev.gioser.net`:
611
612```
613grub-mkimage AUSENTE · grub-bios-setup AUSENTE · cpio AUSENTE · mformat AUSENTE · xorriso AUSENTE
614```
615
616**Ninguna imagen se podía armar en ninguna de las tres**, y sólo se veía al intentarlo, con un
617`falta cpio` que se lee como un descuido de la máquina. Y lo absurdo: **seis de las siete son
618nuestras y están selladas** — `util-linux` (sfdisk), `e2fsprogs` (mke2fs), `libarchive` (bsdcpio),
619`gzip`, `mtools`, `xorriso`, más `dosfstools`. Todas estáticas musl; **corren tal cual en el host**,
620comprobado ejecutándolas.
621
622## Lo hecho
623
624**`scripts/lib/herramientas-desde-store.sh`** — resolvedor único: la herramienta sale del artefacto
625sellado **por hash vigente**, con el host como CAÍDA ruidosa (un binario del host no tiene hash ⇒ la
626imagen deja de ser reproducible por ahí, y eso se dice en el log) y abortando con las dos rutas
627nombradas si no está en ninguno.
628
629⚠ **Por hash, no por fecha ni por el primer glob**: `iso-image.sh` resolvía xorriso con
630`ls store/*-xorriso/…|head -1` y `install-image-efi.sh` mtools con `ls -dt …|head -1`. Con dos
631artefactos del mismo paquete conviviendo —lo normal tras cualquier re-hash— el más reciente puede ser
632el viejo. Es la lección que `hydrate-profile.py` y `libs-compartidas-desde-store.sh` ya habían pagado.
633Y el store por defecto se **detecta**: en este hub el `./store` del clon está vacío y el de verdad es
634`/store`, así que un default mudo manda a diagnosticar al lugar equivocado.
635
636Cableados: `install-image.sh`, `install-image-efi.sh`, `iso-image.sh`, `metal-usb-sdboot.sh`.
637
638**`scripts/vigia-ensamblado.sh`** — el guardián: de dónde sale cada herramienta.
639
640```
641✓ sfdisk util-linux · ✓ mke2fs e2fsprogs · ✓ bsdcpio libarchive · ✓ gzip · ✓ mformat/mcopy mtools
642✓ xorriso · ✓ mkfs.vfat dosfstools
643✗ grub (no hay receta) — grub-mkimage AUSENTE, grub-bios-setup AUSENTE
644== resumen: 8 sellada(s) · 0 del host (sin hash) · 2 ausente(s)
645```
646
647## La prueba: una imagen EFI armada EN EL HUB, que antes era imposible
648
649```
650ROOTFS=<product-rootfs> KERNEL=<linux-metal/bzImage> STAGE=/store/.efi-stage \
651  sh scripts/install-image-efi.sh        ⇒ rc=0
652==> imagen EFI creada: 297M en disco (sparse), 754M virtuales
653```
654
655Todas las herramientas del store (con su hash en el log), tabla GPT verificada con el `sfdisk`
656sellado (ESP 48M + root 512M + store 128M + estado 64M) y la ESP con las dos rutas del kernel más el
657initramfs, verificada por el propio script con `mdir`.
658
659⚠ **Y el primer intento falló con EXDEV**, que es el otro arreglo de este paso: el staging **enlaza en
660duro** desde el rootfs y un enlace no cruza un montaje aunque sea el mismo disco
661([[hardlink-cruza-mount-exdev]]). El fallo llegaba como una **pared de `cp: Invalid cross-device
662link`** —cientos de líneas— sin que ninguna dijera la causa. Ahora hay `sonda_hardlink`, FUNCIONAL
663(intenta un enlace de verdad, porque `store` y `work/out` son bind-mounts del mismo dispositivo y
664comparar `st_dev` no dispara), y dice qué montaje es cada uno:
665
666```
667✗ EXDEV: no se puede ENLAZAR de '/store/…-product-rootfs' a '/work/sergio/work/.efi-stage'.
668  origen  está en: /store       destino está en: /work/sergio
669  Poné STAGE= (y IMG=) bajo el MISMO montaje que el rootfs.
670```
671
672Es la misma sonda que `hydrate-profile.py` ya tenía; `install-image.sh` sólo tenía el comentario.
673
674## Lo que queda, y es UNA receta
675
676- **El gestor de arranque BIOS.** Dos salidas, ninguna gratis: **`grub`** (lo que los scripts ya
677  hablan) o **`limine`**, que cubre BIOS **y** UEFI de una y es mucho más chico. Es un build de
678  granja, no un cambio de script, y por eso no entra en este paso.
679- **`metal-usb-sdboot.sh` arranca con un `systemd-bootx64.efi` sacado de un rootfs de Arch del home
680  del usuario** — otro binario ajeno, sin hash y fuera del store, en el camino del metal. Anotado y
681  no tocado: esa ruta se usa para bringup con menú, y cambiarla por EFI-stub (que ya existe) es su
682  propia decisión.
683- Para cualquier objetivo **UEFI la cadena ya es soberana y no lleva gestor**: el bzImage *es* el
684  binario EFI (ADR 0018), y ahora además se puede armar acá.
685
686---
687
688# Paso 5 — HECHO: la hora la da `ntpd-rs`, en Rust (2026-09-22)
689
690`chrony` era la hoja C **más barata de cambiar** del corpus: 0 consumidores, declarada sólo en
691`servidor`. Tocarla no invalida a nadie, y lo que se gana es concreto: el demonio que **ajusta el
692reloj del sistema como root** deja de ser C.
693
694## Sellado y verificado
695
696```
697recipes/ntpd-rs.toml  v1.9.0 · commit 46ec9bb4d5b6cb24f814f5543d85b9138afb4cba (tag v1.9.0)
698b3:44e1e0079eb7d33ee37261db9cdc3e3b5432c7d08ca8ee0617a59b19c6eb4a84   ⇒ el hash del dry-run,
699                                                                        idéntico al del sellado
700artefacto: 5,1 M · ntp-daemon y ntp-ctl ESTÁTICOS, 0 NEEDED · los dos contestan `--version`
701ntp-ctl validate contra el ejemplo que viaja  ⇒  "Config looks good"
702```
703
704Se construyen **dos** de los tres binarios: el demonio y `ntp-ctl`, que es el equivalente de
705`chronyc` — sin él la caja no se puede inspeccionar, y eso sería una regresión frente a chrony. Como
706`cargo rustc --` exige UN target y `ntpd` produce tres, la fase `compile` itera, calcada de
707`recipes/cargo-edit.toml` (que existe por lo mismo): `+crt-static` no puede ir en `RUSTFLAGS` porque
708alcanza a los proc-macros y rustc aborta con «cannot produce proc-macro».
709
710## Lo que NO se gana, medido antes de prometerlo: NTS sigue apagado
711
712`recipes/chrony.toml` apaga NTS porque necesita nettle o gnutls y **ninguna está en el corpus**. Acá
713el muro es el mismo un piso más arriba: el TLS de NTS lo hace `rustls`, y rustls necesita un
714*proveedor* de criptografía compilado — `aws-lc-rs` o `ring` — **y los dos son C** (`aws-lc-sys`
715arrastra cmake + asm; leído en el `Cargo.lock` del tag). Por eso el build va con
716`--no-default-features --features rustcrypto`: la criptografía del **protocolo** (AES-SIV, MD5) sí
717queda pura-Rust —`ntp-proto` la declara proveedor de primera clase y aborta con `compile_error!` si no
718hay ninguno— pero el NTS-KE queda sin TLS. ⇒ **NTP clásico, igual que chrony hoy.** Subir NTS pide un
719proveedor de rustls, no esta receta.
720
721## El label cambia con el paquete
722
723`servicios` pasa de `"chronyd"` a `"ntpd-rs"`. Un label es el nombre del SERVICIO, no de la función:
724dejar `chronyd` apuntando a ntpd-rs haría que `arjectl status chronyd` mostrara algo que no es chrony.
725La card guarda el mismo contrato que la de chrony —sale **78** nombrando `/etc/ntpd-rs/ntp.toml`, con
726el ejemplo en `/usr/share/doc/ntpd-rs/ntp.toml.default`— porque a qué servidores NTP se pregunta es
727decisión del sitio. `chrony` se queda en el CATÁLOGO, que no es la imagen: su receta documenta media
728docena de lecciones sobre un `configure` que prueba el entorno, y borrarla sería tirar eso.
729
730## Y el bloqueante que apareció: el `git` del corpus SÍ trae los helpers
731
732El primer intento de build murió en el `fetch`:
733
734```
735Cloning into bare repository '/work/repos/ntpd-rs.git'...
736git: 'remote-https' is not a git command.
737```
738
739**Y la causa no es la que este repo tenía escrita.** `CLAUDE.md` §2 dice que el git del corpus «no trae
740los helpers `remote-http`/`remote-https`». **Los trae** — están en su artefacto, medidos:
741
742```
743ls …-git/usr/libexec/git-core | grep -c remote-http   ⇒ 2
744git --exec-path                                       ⇒ /usr/libexec/git-core   (ruta COMPILADA)
745ls /usr/libexec/git-core | grep -c remote-http        ⇒ 0   (el libexec de la IMAGEN, vacío)
746```
747
748git es un multiplexor que busca sus treinta ayudantes en un directorio **horneado al build**: la ruta
749FHS. Corrido desde el store, mira el `/usr/libexec/git-core` de la jaula, que no tiene ninguno. Lo
750único que faltaba era `GIT_EXEC_PATH`. Con eso, el MISMO binario clona por https **y empuja al espejo
751de GitHub** — comprobado con `ls-remote` y con `push --dry-run`, que llegó hasta el rechazo por
752non-fast-forward, o sea que habló TLS y autenticó.
753
754Arreglado donde se genera (`scripts/servidor/jaula-herramientas.sh`: `git` sale del bucle y lleva su
755`export GIT_EXEC_PATH="$b/usr/libexec/git-core"`) y en el envoltorio vivo `/work/sergio/bin/git`.
756⇒ **El push doble desde la jaula deja de estar roto**, que era una molestia diaria documentada.
757
758---
759
760# Paso 6 — HECHO: el ADR 0012, decidido e implementado (2026-09-22)
761
762Estaba PENDIENTE desde el 2026-07-22 con una nota que decía «no implementar sin resolver el dilema».
763El dilema se resolvió **midiendo las tres cosas que el propio ADR pedía**, y la primera resultó estar
764mal planteada — verlo es la mitad de la decisión.
765
766## 1 · «¿Cuánto serializaría un lock por árbol?» — nada, y el ADR no lo había mirado
767
768El ADR descartaba a medias la opción A porque «el grafo es un diamante que converge ⇒ puede degenerar
769en serializar casi todo». Eso supone que un consumidor toca el árbol de su dep. **No lo toca:**
770
771```
7721. store.has(h)              → si está, vuelve sin tocar NINGÚN árbol
7732. materialize_build_deps()  → construye las deps (recursivo) y TERMINA
7743. fetch → patches → vendor → compilar → sellar   ← recién acá se toca el árbol PROPIO
775```
776
777- Un consumidor recibe el **artefacto sellado** de su dep como capa overlay, no su árbol ⇒ dos
778  recetas distintas **nunca comparten su árbol propio** y no se serializan entre sí.
779- Los locks **no se anidan**: al tomar el lock del árbol propio, las deps ya están construidas y sus
780  locks liberados ⇒ **no puede haber deadlock**, sin ordenar nada ni detectar ciclos.
781
782## 2 · «¿Cuánto cuesta re-extraer y re-vendorear?» — 2,4 %, pero el disco duele
783
784Medido en el build real de `ntpd-rs` (el del paso 5), con las marcas de tiempo de su propio log:
785fetch **4,0 s** + `cargo vendor` **5,4 s** contra **6 min 25 s** de compilación. El tiempo no era el
786problema de la opción B; el disco sí: ese árbol pesa **707 MiB**, y B lo multiplica por build
787concurrente.
788
789## 3 · «¿Espacio pico de la caché + copia?» — 12,4 GiB en 46 árboles
790
791Con los mayores en 2,4 G (`boveda`, `shuma-pregunta`) y 2,5 G (`llama-cpp`). La opción C duplica lo
792que esté en vuelo y **no ahorra el vendoreo** (que muta ⇒ va en la copia): compra los 4 s del extract
793al precio de duplicar el disco.
794
795## La decisión: opción A, precisada
796
797Lock exclusivo **por nombre de receta**, tomado en `fetch` y sostenido por RAII hasta el `seal`
798(`fetch::GuardaArbol`), más **re-chequeo de la tienda** al obtenerlo: el que esperó sale por cache hit
799en vez de reconstruir. El grano es el nombre —y no el árbol— porque el mirror git
800(`repos/<nombre>.git`) también es estado compartido y su clave es sólo el nombre.
801
802⚠ **El fd no se hereda**, y eso es el punto fino: Rust abre con `O_CLOEXEC`, así que ni `tar`, ni
803`git`, ni `bwrap`, ni un nieto fugado se quedan con el lock — que es exactamente el modo de fallo que
804`CLAUDE.md` documenta para el `flock(1)` de alrededor (hora y media de granja parada, en silencio).
805
806## Verificado, con control
807
808Dos builds concurrentes de la misma receta, con el árbol borrado antes para forzar la extracción:
809
810```
811A: fetch … → sealed
812B: «árbol: esperando el lock» → «lock obtenido tras esperar» (1,27 s)
813   → «caché: otro build lo selló mientras esperábamos el árbol»
814los dos rc=0 · UN artefacto
815```
816
817Y el control, que es lo que prueba que el lock hace falta — modelando la ventana real (extracción de
818segundos + compilación), dos builds sobre el mismo árbol **sin** lock dan la firma exacta del apagón
819de KDE del 22/07:
820
821```
822SIN lock:  A: ROTO — .zwrap/cc AUSENTE, 0/400 fuentes      B: OK
823CON lock:  A: OK                                            B: OK
824```
825
826Más dos tests en el crate, con su control (dos nombres distintos NO se bloquean) y la comprobación de
827que el fichero de lock vive **fuera** de `sources/`, donde el propio `remove_dir_all` lo barrería.
828
829**Y no mueve ningún hash**: el artefacto de la prueba salió `b3:dd6b538af774…`, idéntico al que el
830grafo ya registraba. La batería del workspace pasa entera salvo un test de `swm` que ya estaba roto
831antes (de otro agente).
832
833## Lo que habilita, y lo que NO decide
834
835`JOBS=1` en el worker y el `-P2` de `build-farm.sh` estaban puestos **por esta carrera**. Ya no hay
836carrera. **Subir el paralelismo es otra decisión y no se toma acá**: el worker son 6 cores y 16 G, y
837esta clase de máquina ya provocó un `global_oom`. Lo que cambia es que el límite pasa a ser RAM y
838disco —medibles, con watchdog— en vez de la corrupción silenciosa de un árbol compartido. Y el
839`flock` de `CLAUDE.md` §1 sigue haciendo falta por otra razón: serializa contra los scripts de la
840granja, que es control de RECURSOS.
841
842---
843
844# Paso 7 — MEDIDO: `muon` es viable, pero hoy es NO-GO por dos defectos nombrados (2026-09-22)
845
846El paso 7 del plan pedía explícitamente **medir 10 antes de adoptar**, porque «muon no cubre el 100 %
847de meson». Se midió. El resultado no es el que la frase sugería: **el problema no es la cobertura del
848lenguaje meson — es una bandera del enlazador.**
849
850## Cómo se midió
851
852- `muon 0.6.0` (tag, commit `1c825e41e2266be1bead999f7f65d9c763a7ca18`) construido acá: **una sola
853  compilación C** de `src/amalgam.c` (6,7 s con el `cc` del lab) da el `muon-bootstrap`, y ése
854  configura y compila el muon completo en **4,7 s / 156 objetivos**, usando su `samu` embebido. Dice
855  **«meson compatibility version 1.7.99»**. Como receta sería trivial.
856- **Muestra SISTEMÁTICA, no elegida a dedo**: de las 107 recetas que declaran `meson`, ordenadas
857  alfabéticamente, una cada 10,7 ⇒ 10 repartidas por todo el alfabeto. Salieron
858  `accountsservice dbus-shared geocode-glib graphene iso-codes libgweather mesa pango-shared swaybg
859  wl-clipboard` — incluida **`mesa`**, que es el proyecto meson más retorcido del corpus.
860- Fuente bajada de la URL/commit que pinea cada receta, y `muon setup` en el árbol.
861
862## El hallazgo transversal: UNA bandera rompe TODAS las sondas del compilador
863
864Primera corrida: 9 de 10 fallan. Dos con un síntoma que no era de deps —dbus: «Could not find a
86564-bit integer type»; graphene: «No aligned malloc function could be found»— y la causa estaba dos
866capas más abajo:
867
868```
869dbg executing cc … '-Wl,--fatal-warnings'
870dbg compiler stderr: 'error: unsupported linker arg: --fatal-warnings'
871c compiler: sizeof long long: -1
872```
873
874muon añade `-Wl,--fatal-warnings` a las sondas que **compilan y ENLAZAN**
875(`src/functions/compiler.c:210`), y **zig lo rechaza de plano**. Entonces `cc.sizeof()` devuelve
876**-1** y `cc.has_function()` devuelve **false para todo** — y los proyectos que ramifican sobre eso
877abortan diciendo cualquier cosa menos la verdad.
878
879⚠ **Y mi primer «control» no era un control**: puse `CC=$(command -v gcc || command -v cc)` y en esta
880jaula no hay gcc, así que `cc` resolvió al MISMO envoltorio de zig. Daba idéntico, y por un momento
881pareció que el defecto era de muon en cualquier compilador. Lo que lo aisló fue sacar muon del medio:
882
883```
884/work/sergio/bin/cc x.c -o x -Wl,--fatal-warnings   ⇒ error: unsupported linker arg
885/work/sergio/bin/cc x.c -o x                        ⇒ compila y enlaza OK
886```
887
888**La causa raíz está en el propio muon, y su código lo sabe.** `src/script/runtime/toolchains.meson`
889detecta `zig ld` y lo trata como `lld`, que hereda de `ld` (GNU), con este TODO textual: «we need to
890support a way for the compiler to return a list of linkers to check, and then we can specify zig ld
891as its own linker type». Intenté justamente eso —registrar `zig-ld` con `fatal_warnings: []`— y **no
892toma efecto**: el camino real es «linker: lld (passed-through)», que no pasa por el `detect` del
893registro. Es trabajo aguas arriba, no un parche de una línea.
894
895**Lo que SÍ prueba que la bandera es todo el problema**: con un `cc` que la descarta, las mismas
896sondas dan los valores correctos (`sizeof(long long) = 8`, `aligned_alloc = true`).
897
898⚠ **Y ese envoltorio NO es la solución**, aunque funcione: el lab **no entra en `hash_inputs`**
899([[lab-fuera-de-hash-inputs]]), así que filtrar la bandera ahí haría que dos labs sellaran bytes
900distintos en la MISMA dirección del store. El arreglo tiene que estar donde el hash lo cubra.
901
902## La muestra, con la bandera filtrada (o sea: lo que muon puede de verdad)
903
904| receta | resultado | clase |
905|---|---|---|
906| `dbus-shared` | **setup complete** | ✓ |
907| `iso-codes` | **setup complete** | ✓ |
908| `geocode-glib` | `program not found` (módulo gnome) | dep del entorno |
909| `libgweather` | módulo python `gi` ausente | dep del entorno |
910| `mesa` | pkg-config `vdpau` ausente | dep del entorno |
911| `pango-shared` | pkg-config `gobject-introspection` ausente | dep del entorno |
912| `swaybg` · `wl-clipboard` | pkg-config `wayland-client` ausente | dep del entorno |
913| `accountsservice` | su script de versión exige un clone git | artefacto de MI arnés |
914| **`graphene`** | **`conf.set10('HAVE_MEMALIGN', 1)` ⇒ «expected type bool, got int»** | **GAP de muon** |
915
916**Cobertura del lenguaje: 9 de 10 sin un solo hueco de sintaxis** — `mesa` incluida, que llega hasta
917resolver dependencias. El único gap real es de TIPOS, no de una función que falte: meson acepta un
918`int` en `configuration_data.set10()` y muon 0.6.0 exige `bool`.
919
920## Veredicto: NO-GO hoy, y el gate son dos defectos, no una campaña
921
9221. **`zig ld` tratado como GNU `ld`** ⇒ todas las sondas de enlace rotas. Es el TODO de muon; hay que
923   hacerlo aguas arriba (o parchear el camino «passed-through», que es donde de verdad se decide).
9242. **`conf.set10()` rechaza `int`.** Extrapolando la muestra, ~10 de las 107 tendrían algo de esta
925   clase; cada uno es un arreglo chico aguas arriba o un parche por receta.
926
927Mientras 1 no esté, **el swap re-hashearía 107 recetas para obtener binarios configurados con sondas
928que mienten** — que es peor que no hacerlo. Y el premio tampoco es el que parecía: con el paso 1 hecho
929`python3` ya NO viaja en las imágenes, así que lo que compra el cambio es **la cadena de build**, no
930bytes de imagen.
931
932**No se commiteó `recipes/muon.toml` a propósito:** una receta sin sellar ensucia el grafo con un nodo
933`never` que el drenaje cuenta como deuda. Cuando el gate se cierre, la receta es de quince líneas y
934los comandos están medidos acá: `sh bootstrap.sh build-boot` → `build-boot/muon-bootstrap setup -Dlibcurl=disabled
935-Dlibarchive=disabled -Dlibpkgconf=disabled build` → `muon-bootstrap -C build samu`.
936
937---
938
939# 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)
940
941El paso 8 pedía «receta que publique el mismo SONAME, swap en UN consumidor, y comparar. Si ahí anda,
942la campaña es una decisión de calendario; si no, costó una receta». **Anda.** Y la comparación
943contra la `zlib` del corpus, con datos reales, dice cosas que no se podían suponer.
944
945## Sellado
946
947```
948recipes/zlib-rs.toml  v0.6.8 · commit 0254e07204884bfc902d6ebb68c8b703d0ea49cf (tag v0.6.8)
949b3:61fb18d1f2850c5c3152925d06c0bd19256caaaff97e1e3877f4f3bc72278d7e   (el hash del dry-run)
950publica: usr/lib/{libz.a, libz.so.1, libz.so} · usr/include/{zlib.h, zconf.h} · pkgconfig/zlib.pc
951```
952
953Con los NOMBRES de zlib, para que un consumidor que hoy pide `-lz` y `#include <zlib.h>` no cambie una
954línea. Un consumidor compilado contra el **artefacto sellado** (header y lib suyos) corre y da los
955mismos bytes que el build de scratch — o sea que es determinista.
956
957⚠ **El build necesitó un parche de dos líneas y medio**, y el porqué vale más que el parche: el crate
958que publica la ABI de C (`libz-rs-sys-cdylib`) está **excluido del workspace** por upstream, y
959compilarlo desde su subdirectorio no sirve acá porque el vendoreo del lab corre en la RAÍZ. Meterlo al
960workspace sin más tampoco: el crate **aborta a propósito** con `compile_error!("panic=\"abort\" is
961mandatory because unwinding to C is undefined behavior")`, porque el `[profile.release]` de un miembro
962se ignora. El parche sube el crate a `members`, pone `panic = "abort"` en el profile de la raíz **y
963actualiza `Cargo.lock`** (8 líneas), porque el vendoreo corre con `--locked`.
964
965## Lo medido, con el corpus Silesia (15,7 MB) y el mismo binario contra las dos libs
966
967| nivel | zlib 1.3.1 | zlib-rs 0.6.8 | tamaño | tiempo |
968|---|---|---|---|---|
969| 1 | 7.050.606 B · 226,6 ms | 8.526.731 B · **95,1 ms** | **+20,9 %** | **2,4× más rápido** |
970| **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** |
971| 9 | 6.371.670 B · 1496,3 ms | **6.358.939 B** · **634,9 ms** | **−0,2 %** | **2,4× más rápido** |
972
973Round-trip correcto en los tres niveles. Al nivel por defecto cuesta **0,7 % de tamaño** y devuelve
974**2,3× de velocidad**; al 9 es más chica *y* más rápida; al 1 comprime bastante peor.
975
976⚠ La primera corrida la hice con una entrada SINTÉTICA y daba +84 % al nivel 5. No era representativa:
977con datos reales el número es +0,7 %. Queda escrito porque el dato falso era mío y estuvo a punto de
978decidir el paso al revés.
979
980## Las tres reservas, que son lo que cambia la forma de la campaña
981
9821. **NO es idéntica byte a byte.** El `fnv` de la salida difiere en los tres niveles. Cualquier
983   artefacto que EMBEBA datos comprimidos —páginas de manual gzipeadas, PNG, `.tar.gz`, un
984   initramfs— **cambia de contenido**. No es no-determinismo (es determinista y distinto), pero la
985   campaña de las 122 no es «reconstruir y obtener lo mismo»: **produce artefactos distintos**.
9862. **`gzprintf`/`gzvprintf` no existen.** Medido comparando símbolos: de los 101 públicos de `libz.a`
987   faltan esos dos, y su feature en upstream **pide Rust nightly** (`c_variadic`). El resto que falta
988   es interno de zlib (`_tr_*`, `inflate_fast`, `inflate_table`, `zcalloc`, `z_errmsg`). Un consumidor
989   que llame `gzprintf` **no enlaza** ⇒ hay que mirarlo receta por receta.
9903. **El consumidor necesita `-lunwind`.** Es Rust adentro de C: el `staticlib` arrastra el backtrace
991   de std y sin el unwinder el enlace muere con `undefined symbol: _Unwind_Resume`. Está en el
992   `zlib.pc` como `Libs.private`, pero un `-lz` a mano no alcanza. Es la perilla que el corpus ya
993   conocía ([[lab-cargo-vendor-poliglota]]).
994
995Y un dato de tamaño para que nadie se sorprenda: el artefacto pasa de **537 K a 13 M** (`libz.a` de
996422 K a 11 M), porque un `staticlib` de Rust trae su mundo. En el binario final sólo entra lo que se
997usa, pero el store lo paga.
998
999## Veredicto
1000
1001**GO como variante, y la campaña es una decisión de calendario Y DE CONTENIDO** — que es más de lo
1002que el plan suponía. La receta queda sellada en el catálogo con 0 consumidores y en ningún perfil: el
1003siguiente paso natural es swapear UN consumidor real (`lld21` pide `libz.so.1`; `pigz` ejercita
1004deflate a fondo) y correr su prueba, ahora que el artefacto existe y el enlace está caracterizado.
1005
1006## Paso 8 bis — el swap en un consumidor REAL: `pigz` sobre `zlib-rs`, sellado y verificado
1007
1008La lib andando contra un programa de prueba propio no decide una campaña de 122 recetas. Lo que
1009decide es si un consumidor **de verdad**, con su Makefile y su uso de la API, compila y corre **sin
1010tocar su fuente**. `recipes/pigz-zlibrs.toml`: `b3:3a485edc…`, sellada.
1011
1012**pigz es el consumidor duro, y por eso se eligió**: no usa el `compress2` de manual sino los rincones
1013de zlib — `inflateBack`, `inflateBackInit_`, `inflateBackEnd`, `deflatePending`, `deflatePrime`,
1014`crc32_combine`, `deflateSetHeader`. Comprobado que zlib-rs exporta **los siete** antes de intentar el
1015build. Y viaja en 6 perfiles.
1016
1017**Lo único que cambió respecto de `recipes/pigz.toml`:** la dep (`zlib-rs` en vez de `zlib`) y
1018`LIBS="… -lz -lunwind"`. El `-lunwind` va en `LIBS` y NO en `LDFLAGS` porque el Makefile de pigz pone
1019`$(LDFLAGS)` antes de los objetos y `$(LIBS)` al final (línea 15): en enlace estático el orden decide
1020y el unwinder tiene que ir DESPUÉS de quien lo pide. Fuente, banderas y fases, idénticas — la
1021comparación vale porque lo único distinto es la lib.
1022
1023### Medido, con el corpus Silesia (15,7 MB) y un solo hilo para que el tiempo sea comparable
1024
1025| | `pigz` (zlib 1.3.1) | `pigz-zlibrs` |
1026|---|---|---|
1027| comprimir `-6` | 6.410.662 B · **0,726 s** | 6.455.201 B · **0,326 s** |
1028| descomprimir (ejercita `inflateBack`) | 0,065 s | **0,059 s** |
1029| tamaño del binario | 1.062.672 B | **3.971.304 B** |
1030
1031⇒ **+0,69 % de tamaño comprimido, 2,23× más rápido comprimiendo**, y descompresión un pelo más
1032rápida. El precio está en el binario: **+2,9 MB** (×3,7), que es el mundo de Rust que el `staticlib`
1033arrastra.
1034
1035### Interoperabilidad, en las tres direcciones que importan
1036
1037```
1038canónico lee el .gz de zlib-rs   ⇒ OK      (sha256 del original recuperado)
1039zlib-rs  lee el .gz del canónico ⇒ OK
1040el `gzip` del corpus lee los dos ⇒ OK      ← tercera implementación, independiente
1041```
1042
1043O sea: el formato es el mismo y el CRC cierra. Lo que difiere es **qué bytes elige el compresor**, que
1044es la reserva nº 1 del paso 8 y sigue en pie.
1045
1046### Lo que esto cambia en el veredicto
1047
1048El swap en un consumidor real **no costó ni una línea de su fuente**, sólo la bandera del unwinder. Con
1049eso, de las tres reservas del paso 8 quedan dos: la salida no es byte-idéntica (decisión de contenido)
1050y `gzprintf` no existe (hay que mirar receta por receta). El tamaño del binario se suma como tercera:
1051para una imagen con muchos consumidores de zlib, +2,9 MB por binario no es gratis.
1052
1053⚠ `scripts/yupana.py` gana el sufijo `-zlibrs` en `SUFIJOS_VARIANTE`: sin eso el invariante (c) marca
1054`pigz`/`pigz-zlibrs` como «colisión-fuente ACCIONABLE», que es un falso positivo — y un guardián con
1055falsos positivos se empieza a ignorar. Comprobado: `yupana duplicados` sigue en «ninguna».

plan-que-sigue-tras-busybox.md

Plan — con qué conviene hacer lo de busybox, MEDIDO

Fecha: 2026-09-22 · Pregunta: «botado busybox, ¿con qué conviene hacer lo mismo? ¿Hay que rusterizar o soberanizar algo vital, hay opciones modernas a cosas tradicionales, o hay una arquitectura mejor?»

Instrumento (todo reproducible, nada escrito a mano):

# grafo y clases: los 950 nodos con su clase, hash, perfiles y dependientes
python3 -c 'import json;json.load(open("docs/state/build-state.json"))'
# aristas y membresía: la MISMA función que usan el grafo, la hidratación y el repo firmado
python3 -c 'import sys;sys.path.insert(0,"scripts");import yupana;yupana.cargar_colas()'
# bytes: tamaño real de cada artefacto sellado
cd /store && du -sk --apparent-size *          # 1.435 artefactos, 59,8 GiB
# runtime real: quién pide qué SONAME
readelf -d <artefacto>/usr/bin/<bin>

El hallazgo que ordena todo, y no es una pieza de userland

La clausura que define una imagen es build ∪ runtime. Entonces cada imagen de takana lleva adentro la cadena de compilación: llvm21, go, python3, binutils, cmake, perl, nasm, bison, flex, m4, meson, wasi-sdk, nodejs.

Es exactamente la forma del caso busybox, y por eso vale la pena verlo dos veces: busybox nunca estuvo declarado en targets.toml —llegaba a los 7 perfiles por la clausura, porque 24 recetas lo pedían en [deps] build—, y sacarlo no costó escribir una línea de userland (paso 6 de plan-botar-busybox.md). Lo que ese paso arregló para un nombre, esto lo arregla para la clase entera.

La definición está en scripts/yupana.py:98 (_deps) y en scripts/build-state.py:128, y en las dos hay un comentario que explica por qué es la unión: firefox declaraba runtime = ["gcc-libs"], nadie leía ese campo, y la imagen se armaba sin libstdc++.so.6. El arreglo fue correcto y la unión es lo que hay que afinar, no lo que hay que deshacer — ver el riesgo más abajo.

Lo que cuesta, en nodos y en bytes

Clausura de hoy contra «raíces + aristas de runtime», medido sobre los artefactos sellados:

perfilnodos hoysólo-runtimeGiB hoyGiB sólo-runtimegrasa
base85481,350,7147 %
cli1521032,571,5639 %
servidor1851324,562,0954 %
escritorio-kde44416313,055,7656 %
escritorio-gnome3441589,633,8660 %
escritorio-cosmic2951578,484,3449 %
escritorio-sway2811437,904,1148 %
escritorio-mirada41170,790,2075 %

Y los caminos, que es lo que lo hace concreto:

en la imagenpesaentra porqué es
llvm211.527 MiBservidor → rust → llvm21libs estáticas + headers de LLVM
python3209 MiBbase → iputils → python3ping se construye con meson
go222 MiBcli → fzf → goel toolchain de Go
binutils127 MiBbase → bwrap → binutilsensamblador y linker
perl84 MiBbase → git → perllos configure de OpenSSL/curl
cmake94 MiBservidor → rust → cmakegenerador de builds
nodejs86 MiBescritorio-kde → atuq → firefox → nodejsel JS del build de Gecko

llvm21 es el caso de libro y se verificó en el binario, no en la receta:

readelf -d /store/…-rust/usr/bin/rustc   ⇒ NEEDED librustc_driver-*.so, libc.so   (NO libLLVM)
readelf -d /store/…-lld21/usr/bin/lld    ⇒ NEEDED libz.so.1, libgcc_s.so.1, libc.so
recipes/llvm21.toml:75-76                ⇒ -DLLVM_BUILD_LLVM_DYLIB=OFF -DLLVM_LINK_LLVM_DYLIB=OFF

O sea: 1,5 G de LLVM viajan en la imagen del servidor para que nadie los abra. Y servidor es el perfil donde compilar sí es deliberado (targets.toml:1725-1745, «la caja que sirve el repo y construye, sí»): incluso ahí, el compilador es rust + lld21, no llvm21.

Por qué es barato: CERO reconstrucciones

Esto no toca ninguna receta ⇒ no toca ningún ArtifactHash ⇒ no invalida nada. Se cambia cómo se proyecta, no qué se construye. Es lo que lo pone primero: todo lo demás de este documento cuesta builds.

Por qué es seguro: la ambigüedad es ENUMERABLE

El miedo legítimo es la cicatriz de gcc-libs: si una lib dinámica se cae de la imagen, el binario sella, reproduce y no arranca. Medido sobre el cierre de servidor:

de 185 nodos, sólo 28 contienen algún .so. 144 no traen ni uno (todo el corpus es link = static), y 13 no están en el store de este hub.

Los 28: alsa-lib binutils bzip2-shared cmus dbus elfutils-libdw ffmpeg gcc-libs gmp go libffi-shared libnl libudev-zero llvm21 mold mpc mpfr musl-fts musl-shared ncurses-shared perl python3 readline-shared sqlite-shared util-linux xz-shared zlib-shared zstd.

Entonces la regla correcta no es «usá sólo las aristas de runtime» (eso es lo que rompió firefox). Es:

cierre de imagen = raíces ∪ deps de runtime declaradas ∪ proveedores de los SONAME OBSERVADOS

y el instrumento para la tercera parte ya existe y ya se corre: scripts/vigia-sonames.py contesta «¿qué .so pide esta imagen que ningún artefacto de su cierre provee?». Hoy es un guardián a posteriori; acá pasa a ser la definición.

Dónde se toca, y el criterio de terminado

Terminado cuando: hydrate-profile.py servidor no proyecta llvm21 python3 perl binutils cmake go, vigia-sonames.py da cero huecos en los 8 perfiles, y product-boot-test.sh arranca. La cifra a batir: 4,56 → 2,09 GiB en servidor y 13,05 → 5,76 en KDE.

⚠ Un detalle que sale de paso y no es de este frente: el kernel no está en ningún perfil (linux* → perfiles: []); lo pasa un KERNEL= de script. Lo que sí viaja en los 7 es linux-headers. La imagen declara los headers del kernel y no el kernel.


Los tres grupos, porque mezclarlos es lo que cuesta el trabajo

Igual que los tres frentes de busybox, acá la diferencia que importa es cuántas reconstrucciones paga cada cosa — [deps] build entra en hash_inputs, [deps] runtime no:

grupoqué esrebuilds
1 · proyeccióncómo se arma la imagen; ninguna receta cambia0
2 · hojasrecetas con 0 consumidores: nadie las declara0 (sólo ellas)
3 · campañastocar una herramienta de build que N recetas declaranN

Grupo 2 · Las hojas: cambio moderno, coste cero

Todas verificadas con yupana: 0 consumidores, o sea que tocarlas no invalida a nadie.

2.1 · sudo y doas en los 7 perfiles — y ninguno de los dos puede funcionar

Dos herramientas para lo mismo, declaradas juntas en base (targets.toml:58) y heredadas por los otros 6 perfiles. sudo = 12,1 MiB, doas = 0,1 MiB. Pero el hallazgo es otro:

/store/…-sudo/usr/bin/sudo   → -r-xr-xr-x   (sin setuid)
/store/…-doas/usr/bin/doas   → -r-xr-xr-x   (sin setuid)
grep -rn "S_ISUID\|4755" crates/   → 0 hits que pongan el bit

Y las recetas dicen tres cosas distintas:

recetaqué afirma¿cierto?
recipes/sudo.toml:57«el paquete lo re-aplica al hidratar»no
recipes/doas.toml:38«el binario setuid es cosa de la hidratación»no
recipes/shadow.toml:15«el modelo de artefactos no puede ni expresar ni garantizar un setuid»sí

Es la familia de [[needed-colgante-libstdcxx]] y [[subcomando-sin-driver]]: sella, reproduce y no corre. Hoy es LATENTE, y por eso nadie lo vio: el instalador sólo pone contraseña de root (takana-live-install.sh) y los [[user]] de las 7 recetas que los declaran son cuentas de servicio, que no escalan. Se vuelve real el día que la imagen tenga un usuario humano no-root — y entonces sudo, doas, su y passwd fallan los cuatro.

Orden correcto, y no es «poner sudo-rs»:

  1. Decidir quién pone el bit (el modelo de artefactos, la hidratación o el .swm). Es la conversación que shadow.toml:18 ya pidió y que sigue abierta.
  2. Elegir UNA. Si la respuesta es «la chica», doas ya ganó. Si es «la compatible con los runbooks», el reemplazo moderno es sudo-rs (Rust, auditado, drop-in de sudo) y de paso saca un setuid en C del producto.
  3. Corregir las dos recetas que afirman algo falso, gane quien gane.

2.2 · chrony → ntpd-rs

La hora es vital y el modo de fallo es traicionero: sin NTP, TLS y las firmas fallan con errores que dicen «firma inválida» y no «la hora» — está escrito en el propio targets.toml. chrony es C, 0 consumidores, sólo en servidor. ntpd-rs es el reemplazo Rust hecho exactamente para este nicho (cliente + servidor, NTS). Receta Cargo con commit pineado, el patrón de uutils.

⚠ Verificar antes de prometer: que cubra el modo cliente con el mismo contrato de arranque, y que su [[service]] encaje en arje (SDD 30). El criterio es arjectl status + reloj convergido en QEMU, no que compile.

2.3 · gnupg — mirar si hay que reemplazarlo o borrarlo

gnupg es raíz de base (targets.toml:60) ⇒ viaja en los 7 perfiles, 39,1 MiB, 0 consumidores, y en todo el repo lo invoca un solo script de prueba (scripts/product-base-system-boot-test.sh). La firma de takana no lo usa: es ed25519 propio (takana keygen release, trust/). Entonces son dos preguntas distintas:

⚠ libgcrypt (18 consumidores) y libgpg-error (20) se quedan con o sin gnupg.


Grupo 3 · Las campañas: buen cambio, factura grande

3.1 · meson → muon: matar el intérprete de Python del sandbox

datovalor
recetas que declaran meson107
de esas, que declaran también python3107 (todas)
consumidores de python3 en total302
python3 en el store209,0 MiB
meson3,9 MiB

El precedente ya está sentado y es del propio repo: samurai (C, 0,6 MiB, 256 consumidores) reemplazó a ninja. muon es su hermano exacto: implementación en C de meson.build, sin Python, pensada para correr con samurai. Es el cambio que convierte «ping arrastra 209 MiB de intérprete» en un no-problema del lado del build, además del lado de la imagen.

⚠ Cuesta 107 recetas re-selladas (y sus consumidores): deps.build está en hash_inputs. yupana radio meson antes de tocar nada. Conviene sólo pegado a otra invalidación grande, no como cambio suelto. ⚠ Y se mide primero, no se adopta: muon no cubre el 100 % de meson. El criterio de decisión es cuántas de las 107 configuran con muon sin tocar el meson.build; con una muestra de 10 ya se sabe si el frente vale.

3.2 · zlib → zlib-rs: la lib C más consumida del corpus

zlib tiene 122 consumidores (+83 de zlib-shared) — más que ninguna otra librería del catálogo, y es un clásico de CVEs de memoria. zlib-rs publica la misma ABI C (libz.a / libz.so.1), así que en teoría es un swap sin migrar llamadores, que es justo lo que no pasa con ripgrep/sd/fd.

⚠ 122 recetas re-selladas: es la campaña más cara de este documento. Y ⚠ hay que verificar su estado upstream antes de prometerlo — la misma cautela que el plan de busybox aplicó a uutils/util-linux.

Cómo se decide sin pagar la campaña: receta zlib-rs publicando el mismo SONAME, swap en un consumidor dinámico (lld21 pide libz.so.1), y comparar. Si ahí anda, la campaña es una decisión de calendario; si no, costó una receta.

3.3 · Lo que NO es campaña aunque se parezca

openssl: 55 consumidores, 44,1 MiB. 9 son recetas Rust (cargo-generate, cargo-outdated, cargo-udeps, cargo-cache, cosmic-settings-daemon, hurl, monolith, nu, rink) que podrían usar rustls por feature. Pero los otros 34 son C y openssl seguiría viajando igual ⇒ el esfuerzo no compra bytes ni superficie. Es trabajo del día que openssl se caiga sola.


Correcciones al plan de busybox que salen de esta medición

plan-botar-busybox.md cifró 23 applets de trabajo real. Dos de las ocho piezas ya están hechas y una no hace falta:

piezaappletsestado hoy
psmisckillall killall5 pstree fuser✅ sealed, en 7 perfiles (commit 4357b29c)
bcbc dc✅ sealed, en 7 perfiles (commit 4357b29c)
awkawk✅ resuelto escribiendo: akllay, Rust, 3.531 líneas (86915f26)
kmodinsmod rmmod lsmod modprobe depmod modinfono hace falta: no hay módulos

El detalle de kmod, que es el que cambia la cuenta: los cuatro kernels que arman imágenes llevan -d MODULES — linux-generic.toml:69, linux-metal, linux-metal-dual, linux-metal-kexec. El único que conserva los módulos del defconfig es recipes/linux.toml, y ése no arma ninguna imagen (el SDD 28 ya lo descartó: apaga SCSI y no ve el disco de hcloud). ADR 0018 lo dice del otro lado: «un único bzImage, sin módulos ⇒ una sola firma».

⇒ los 6 applets de kmod son clase BORRAR, no clase escribir. Vuelven el día que exista un módulo fuera del árbol (el tester NVIDIA es el candidato), y ese día kmod es una receta, no seis applets. Con eso el trabajo restante del censo baja de 23 a 11 (grep 3, sed 1, gestor de /dev 3, getty 2, nslookup 1), y de esos getty ya se sabe que es ADR y no escritura.


Lo único del producto que no sale de una receta: el cargador de arranque

recipes/grub*.toml                       ⇒ no existe
scripts/iso-image.sh:37                  ⇒ GRUB_LIB="${GRUB_LIB:-/usr/lib/grub/i386-pc}"
scripts/install-image.sh:49              ⇒ idem  ·  exige grub-mkimage, grub-bios-setup

O sea que el GRUB que arranca una imagen de takana es el de la caja que la construyó, y su versión no entra en ningún hash. Es la rama BIOS/El-Torito; la rama UEFI ya se escapó por EFI-stub (EFI_STUB=1, metal-iso.sh:104, y el ADR 0018 con vendor path propio + reconciliador de NVRAM, implementado y verificado en metal).

La decisión es chica y está sin tomar: o grub entra al corpus como receta, o la rama BIOS se retira y todo el arranque queda EFI-stub, que es además la arquitectura moderna (UKI: un PE firmable, sin gestor intermedio). Lo que no conviene es lo de hoy, que es depender de un binario ajeno sin decirlo. ⚠ Y el ADR 0018 deja abierta la de Secure Boot, que es la que lo ata.


Arquitectura: los tres pendientes, por lo que cuestan HOY

  1. ADR 0012 · el árbol de fuentes compartido — PENDIENTE desde 2026-07-22, y es el que más paga. Es lo que obliga a JOBS=1 y a envolver todo en flock: la granja compila de a uno en una caja de 6 cores. Medido en su día: al invalidarse libdrm, ~93 de 205 recetas KDE murieron con el wrapper de zig barrido por el fetch de otra. Resolverlo (caché inmutable direccionada por contenido + copia privada por build, con reflink/overlay) es la única de estas tres que multiplica la velocidad del frente entero. Todo lo demás de este documento se construye más rápido después de esto.
  2. Las 28 recetas *-shared. zlib/zlib-shared, pcre2/pcre2-shared, glib/glib-shared… 168,4 MiB de artefactos y, sobre todo, 28 builds y 28 puntos de mantenimiento duplicados para emitir dos formas del mismo código. La arquitectura correcta es una receta con varias salidas (estática / dinámica / headers), que además es lo que haría al vigía de sonames innecesario en su caso más común. ⚠ Re-hashea; va después del §1, que es el que hace que la distinción importe.
  3. Los dos linajes de imagen (tres listas a mano vs. clausura del perfil). Hoy conviven y divergen en silencio: es la misma forma que los hydrate-*.sh contra targets.toml ([[hidratar-desde-el-perfil-no-el-script]]) y que el sshd que entra por product-userland-from-repo.sh y no por el manifiesto — lo dice el propio targets.toml.

Lo que NO conviene tocar, y por qué (para que no se proponga en tres semanas)

candidatopor qué no
opensshno hay reemplazo Rust drop-in de un servidor SSH. Es el acceso a la caja: un experimento acá se paga con una máquina inalcanzable
linux-pam5 consumidores, todos greeters de escritorio. No hay reemplazo; la deuda está contenida y nombrada
cups / bluez291,8 y 35,5 MiB en los 4 escritorios, ya decidido a conciencia (targets.toml: viajan como paquete y NO se arrancan como servicio). Es el punto 8 de PUBLICABLE, no un olvido
NetworkManager50,3 MiB, pero los applets de los 4 escritorios le hablan por D-Bus. Es migración de llamadores, no borrado — la lección de ifconfig del censo de busybox
el demonio de dbus124 consumidores son clientes de libdbus; cambiar el demonio (busd/dbus-broker) es posible y no urge
forkear ripgrep/sd/fdya cerrado en plan-botar-busybox.md: se usan como librerías, no se forkean
deduplicar work/sourcesel árbol por receta ES el aislamiento del ADR 0012. Deduplicar de paso re-crea la carrera

Orden de ataque

#unidadrebuildsgana
1cierre de imagen build vs runtime (+ el vigía como definición)0−2,5 GiB en servidor, −7,3 en KDE, y el toolchain fuera del producto
2decidir el setuid y quedarse con una de sudo/doas0cierra un «sella y no corre» antes de que sea visible
3kmod a BORRAR en el censo de busybox (23 → 11 applets)0no se escribe lo que nadie necesita
4la rama BIOS: receta de grub o retirarla0ningún binario ajeno sin hash en el arranque
5chrony → ntpd-rs0una hoja C vital menos, con drop-in Rust
6ADR 0012—la granja deja de compilar de a uno
7meson → muon (medir 10 antes)107un intérprete menos en el sandbox
8zlib-rs en UN consumidor, para decidir1la lib C más consumida del corpus, con la misma ABI

Los cinco primeros no cuestan una sola reconstrucción. Eso es lo que los pone antes: la lección de uutils/brush/foot —trabajo hecho y no cobrado— aplicada al calendario.


Paso 1 — HECHO: el toolchain sale de las imágenes (2026-09-22)

Cero reconstrucciones, como estaba previsto: no se tocó ninguna receta. Lo que cambió es la proyección, declarada en [solo-build] de docs/state/targets.toml (44 entradas, cada una con su motivo) y aplicada por yupana.membresia_imagen().

La regla que se implementó, y la que se descartó

Se poda por NODO, no por tipo de ARISTA. Podar las aristas de build es lo que dejó a firefox sin libstdc++.so.6; acá una receta de la lista no entra a la imagen y no se explora desde ella, y todo lo demás sigue entrando por donde entraba. Consecuencia buscada: qtbase, glib o harfbuzz llegan por aristas de build de apps que sí viajan y se quedan. Decidir sobre cada librería es otra unidad de trabajo.

La válvula: una raíz declarada gana. Por eso servidor sigue llevando rust y lld21, y cli sigue llevando mold — «la caja que sirve el repo y construye, sí».

Medido en disco, no calculado — hidratando de verdad con --closure build|image

hydrate-profile.py ganó --closure build para poder medir el ANTES: sin control, un «−X MiB» es una afirmación sin testigo.

perfilficheros antes → despuéstamaño antes → después
base15.743 → 8.2281,4 G → 871 M (−38 %)
servidor40.235 → 11.5164,9 G → 2,6 G (−47 %)

⚠ Los dos lados se hidrataron con los mismos 12 nodos ausentes de este hub (chrony, cronie, rust, nftables… están en el store del worker): la comparación es válida, el absoluto es cota inferior. Y ⚠ du de los dos árboles en UN solo comando miente: proyectan por hardlink y du deduplica entre sus argumentos — daba 45 M para el segundo. Se miden por separado.

Nodos por perfil (build = qué hay que construir, sigue igual; imagen = qué viaja):

perfilbuildimagenfuera
base856817
cli15213220
servidor18516520
escritorio-mirada412318
escritorio-kde44340934
escritorio-gnome33930732
escritorio-cosmic29526035
escritorio-sway28124833

El base hidratado ya no tiene python3, perl, cmake, meson, binutils, nasm, bison, flex, m4, pkgconf, go ni rustc — comprobado buscándolos en el árbol, no en la lista. Lo único que queda con ese nombre es usr/share/cmake, que son ficheros de datos que instala otra receta, no el programa.

El instrumento que faltaba: scripts/vigia-interpretes.py

El vigía de sonames dio 0 huecos en los 8 perfiles con el toolchain fuera (--imagen --fail, rc=0). Pero un .so no es la única dependencia de ejecución: un #! también, y de eso no había instrumento. El guardián nuevo compara cada script del cierre de imagen contra lo que la imagen provee, con dos tablas —POR_CONTRATO (andamiaje de build horneado dentro de una librería) y POR_DECISION (lo que se aceptó perder)— y falla con --fail ante cualquier caso no declarado.

Encontró tres cosas que ningún soname delataba, y las tres cambiaron la decisión:

hallazgoqué eraqué se hizo
gnome-tweaks es un programa Python enterosin python3 la app no abrepython3 declarado en escritorio-gnome
gdctl de mutter (configurar salidas)idemidem
el kconf_update de plasma-workspacemigra la config al ACTUALIZAR; si no corre, no migra y nada lo dicepython3 declarado en escritorio-kde

O sea: python3 (209 MiB) viaja en las dos imágenes grandes —donde es el 2 % de 10-13 G— y no en base, cli, servidor, sway, cosmic ni mirada, donde en base era el 17 %.

Lo que queda inerte a propósito, contado y nombrado (• en la salida del vigía): los 10 scripts perl de git (send-email, svn, cvs*, gitweb.cgi), los 4 de lm-sensors (sensors-detect…), los diagnósticos de libinput, routel, rrsync, lsusb.py, los filtros de aerc y helper-mux de squid. Un #! que no resuelve falla ruidoso y nombra al intérprete; por eso la decisión es defendible y no una fuga silenciosa. Revertir cualquiera es una línea en el paquetes del perfil.

⚠ Lo que no se midió: un binario que llame al intérprete con system() sin #!. No hay instrumento para eso; lo caza el arranque, y por eso el criterio de terminado incluye scripts/product-boot-test.sh en QEMU, que esta jaula no puede correr.

Lo que NO cambió, a propósito

Guardado contra regresiones

scripts/targets.py --selftest gana 6 casos con roturas a propósito sobre la poda (control sin poda, poda transitiva, la válvula, podar algo inalcanzable, y que ninguna entrada de la lista sea raíz de todos los perfiles). Y se corrigió un comentario de vigia-sonames.py que afirmaba que «la hidratación de la imagen NO las instala: va por lista explícita» — era del OTRO linaje (bootstrap product), y por creerlo nadie miró los 1.527 MiB de LLVM que sí se instalaban.


Paso 2 — HECHO: el bit de privilegio, y doas como único camino (2026-09-22)

El SDD 28 §5.11 dejó esto «anotado, sin decidir» el 21/09 con tres preguntas. Están contestadas, y la respuesta es un mecanismo con guardián, no un chmod en un script.

El hueco medido: 26 binarios, todos inertes

Cruzando el cierre de IMAGEN de los 8 perfiles contra los nombres que en cualquier distro son setuid o setgid, y mirando el modo real del artefacto:

lo traebinariosmodo hoyperfiles
shadowpasswd chage chfn chsh expiry gpasswd newgrp sg su login newuidmap newgidmap5557
util-linuxmount umount sulogin wall5557
linux-pamunix_chkpwd5555 (los escritorios)
dbusdbus-daemon-launch-helper5557
fuse3fusermount35553
iputilsping5557
opensshssh-agent5551
sudo / doassudo sudoedit / doas5557

Ninguno tiene el bit y grep -rn S_ISUID crates/ daba cero: no había nadie que lo pusiera. El más caro de la lista no es sudo: es unix_chkpwd, que es con lo que PAM verifica una contraseña — sin él los greeters de los cuatro escritorios no pueden autenticar a nadie.

Sigue siendo LATENTE, y por eso nadie lo chocó: el instalador sólo pone contraseña de root y los [[user]] de las recetas son cuentas de servicio, que no escalan.

1 · ¿Quién lo pone? El ensamblado, no el artefacto — y son dos hechos, no una preferencia

⇒ Se declara en la receta ([[setuid]], con why obligatorio) y lo aplica takana setuid --apply <rootfs>, que rompe el enlace duro antes del chmod. Y la mitad buena del diseño sale de ahí: la copia la crea quien ensambla, así que queda a su nombre — y el unshare -r de install-image.sh mapea ese uid a 0, o sea setuid ROOT en la imagen, sin un chown en ningún lado.

Cero reconstrucciones, verificado: el campo está fuera de hash_inputs (como license, [[service]] y [[user]]) y el hash de doas sigue siendo b3:28bc42c2…, sellado.

2 · ¿Quién lo verifica? Las dos direcciones, y la segunda es la que vale

✓ 4755 usr/bin/doas               (doas)  [enlace duro roto: el store queda intacto]
✓ ningún setuid/setgid sin declarar en <rootfs>

Probado con roturas a propósito sobre un rootfs de verdad, no sólo en tests: un setuid colado ⇒ rc=1; una declaración que apunta a una ruta inexistente ⇒ rc=1. Más 6 tests en crates/takana-core/src/setuid.rs, incluido el que justifica el módulo entero: tras aplicar el bit el artefacto del store sigue en 0555 y el del rootfs es 4755.

3 · ¿Qué significa que el hash no lo cubra?

Que el privilegio no es propiedad del artefacto: es propiedad de la imagen. El ArtifactHash sigue afirmando lo mismo que antes —estos bytes, con este bit de ejecución— y no se mueve. Lo que cambia es que el privilegio gana su propio registro (la receta, versionada en git) y su propia verificación (las dos direcciones de arriba). Lo que no se puede hacer es guardarlo en el store y creer que el hash lo protege.

La elección: doas, y sudo sale de las imágenes

Decisión del usuario con lo medido delante:

sudodoas
tamaño13 M134 KB
config que instala/etc/sudoers + /etc/sudo.confninguna
lo que autoriza tal cualroot ALL=(ALL:ALL) ALL — a nadie másnada hasta que exista doas.conf

Viajaban los dos en los 7 perfiles y ninguno podía funcionar. Ahora sudo no está en ninguna imagen (su receta se queda en el catálogo, que no es la imagen) y doas lleva su [[setuid]].

⚠ Falta el /etc/doas.conf: sin él, doas rechaza todo. No se inventó una regla acá — quién puede escalar es una decisión de la imagen (¿grupo wheel?, ¿qué usuario humano?) y hoy las imágenes no tienen usuario humano. Es el mismo hueco que el /etc/shells del §5.11 y es la unidad siguiente; declarar el bit ahora es lo que no depende de esa decisión.

Dos comentarios que afirmaban algo falso, corregidos

recetadecíarealidad
recipes/sudo.toml:57«el paquete lo re-aplica al hidratar»nadie lo re-aplicaba
recipes/doas.toml:38«el binario setuid es cosa de la hidratación»no había hidratación que lo hiciera
recipes/shadow.toml:15«el modelo no puede ni expresar ni garantizar un setuid»la única que lo decía bien

Lo que el guardián encontró en su PRIMERA corrida, y no era del setuid

El aviso de dueño saltó con doas y destapó otra cosa: el store tiene artefactos de dos uid distintos. Visto desde el anfitrión, …-doas/usr/bin/doas es del uid 1000 (el usuario del worker, que llega con la cosecha) y …-bash/bin/bash es del 1001 (el hub). Dentro de la jaula el 1000 no está mapeado y se ve nobody.

Medido sobre el rootfs de base recién hidratado: 5.984 ficheros de sergio y 1.159 de nobody. Como unshare -r sólo mapea a 0 el uid que corre, esos 1.159 salen a la imagen con dueño 65534 — y nadie lo había medido. Para el setuid no importa (romper el enlace normaliza el dueño), pero es su propia unidad de trabajo y queda anotada acá.

Lo que queda, dicho para que no se lea como cubierto


Paso 3 — HECHO: el censo de busybox deja de pedir trabajo hecho (2026-09-22)

El detalle está en docs/plan-botar-busybox.md §7. En corto, y todo con rc comprobado:

De 23 applets de trabajo real quedan 3, y dos son el ADR de getty. El paso 3 del plan decía «23 → 11»; medido, es 23 → 3.


Paso 4 — HECHO: las herramientas del ensamblado salen del store; el gestor de arranque es el único hueco (2026-09-22)

El paso 4 estaba planteado como una disyuntiva: «o grub entra al corpus como receta, o la rama BIOS se retira». Medir la cerró de un lado y abrió algo más grande del otro.

La rama BIOS no se puede retirar, y está medido en el repo

Hetzner Cloud arranca por BIOS — «medido en gioser: no existe /sys/firmware/efi; GPT + GRUB» (SDD 28 §3.1).

O sea que la imagen del servidor de producción es justamente la que necesita la rama BIOS. La disyuntiva se resuelve por el otro lado: el gestor de arranque tiene que entrar al corpus.

Lo que apareció al mirar: el pipeline de imagen estaba roto en las TRES máquinas

Los scripts tomaban sus herramientas del PATH del host. Medido en el hub, en su anfitrión (raiz) y en el worker dev.gioser.net:

grub-mkimage AUSENTE · grub-bios-setup AUSENTE · cpio AUSENTE · mformat AUSENTE · xorriso AUSENTE

Ninguna imagen se podía armar en ninguna de las tres, y sólo se veía al intentarlo, con un falta cpio que se lee como un descuido de la máquina. Y lo absurdo: seis de las siete son nuestras y están selladas — util-linux (sfdisk), e2fsprogs (mke2fs), libarchive (bsdcpio), gzip, mtools, xorriso, más dosfstools. Todas estáticas musl; corren tal cual en el host, comprobado ejecutándolas.

Lo hecho

scripts/lib/herramientas-desde-store.sh — resolvedor único: la herramienta sale del artefacto sellado por hash vigente, con el host como CAÍDA ruidosa (un binario del host no tiene hash ⇒ la imagen deja de ser reproducible por ahí, y eso se dice en el log) y abortando con las dos rutas nombradas si no está en ninguno.

⚠ Por hash, no por fecha ni por el primer glob: iso-image.sh resolvía xorriso con ls store/*-xorriso/…|head -1 y install-image-efi.sh mtools con ls -dt …|head -1. Con dos artefactos del mismo paquete conviviendo —lo normal tras cualquier re-hash— el más reciente puede ser el viejo. Es la lección que hydrate-profile.py y libs-compartidas-desde-store.sh ya habían pagado. Y el store por defecto se detecta: en este hub el ./store del clon está vacío y el de verdad es /store, así que un default mudo manda a diagnosticar al lugar equivocado.

Cableados: install-image.sh, install-image-efi.sh, iso-image.sh, metal-usb-sdboot.sh.

scripts/vigia-ensamblado.sh — el guardián: de dónde sale cada herramienta.

✓ sfdisk util-linux · ✓ mke2fs e2fsprogs · ✓ bsdcpio libarchive · ✓ gzip · ✓ mformat/mcopy mtools
✓ xorriso · ✓ mkfs.vfat dosfstools
✗ grub (no hay receta) — grub-mkimage AUSENTE, grub-bios-setup AUSENTE
== resumen: 8 sellada(s) · 0 del host (sin hash) · 2 ausente(s)

La prueba: una imagen EFI armada EN EL HUB, que antes era imposible

ROOTFS=<product-rootfs> KERNEL=<linux-metal/bzImage> STAGE=/store/.efi-stage \
  sh scripts/install-image-efi.sh        ⇒ rc=0
==> imagen EFI creada: 297M en disco (sparse), 754M virtuales

Todas las herramientas del store (con su hash en el log), tabla GPT verificada con el sfdisk sellado (ESP 48M + root 512M + store 128M + estado 64M) y la ESP con las dos rutas del kernel más el initramfs, verificada por el propio script con mdir.

⚠ Y el primer intento falló con EXDEV, que es el otro arreglo de este paso: el staging enlaza en duro desde el rootfs y un enlace no cruza un montaje aunque sea el mismo disco ([[hardlink-cruza-mount-exdev]]). El fallo llegaba como una pared de cp: Invalid cross-device link —cientos de líneas— sin que ninguna dijera la causa. Ahora hay sonda_hardlink, FUNCIONAL (intenta un enlace de verdad, porque store y work/out son bind-mounts del mismo dispositivo y comparar st_dev no dispara), y dice qué montaje es cada uno:

✗ EXDEV: no se puede ENLAZAR de '/store/…-product-rootfs' a '/work/sergio/work/.efi-stage'.
  origen  está en: /store       destino está en: /work/sergio
  Poné STAGE= (y IMG=) bajo el MISMO montaje que el rootfs.

Es la misma sonda que hydrate-profile.py ya tenía; install-image.sh sólo tenía el comentario.

Lo que queda, y es UNA receta


Paso 5 — HECHO: la hora la da ntpd-rs, en Rust (2026-09-22)

chrony era la hoja C más barata de cambiar del corpus: 0 consumidores, declarada sólo en servidor. Tocarla no invalida a nadie, y lo que se gana es concreto: el demonio que ajusta el reloj del sistema como root deja de ser C.

Sellado y verificado

recipes/ntpd-rs.toml  v1.9.0 · commit 46ec9bb4d5b6cb24f814f5543d85b9138afb4cba (tag v1.9.0)
b3:44e1e0079eb7d33ee37261db9cdc3e3b5432c7d08ca8ee0617a59b19c6eb4a84   ⇒ el hash del dry-run,
                                                                        idéntico al del sellado
artefacto: 5,1 M · ntp-daemon y ntp-ctl ESTÁTICOS, 0 NEEDED · los dos contestan `--version`
ntp-ctl validate contra el ejemplo que viaja  ⇒  "Config looks good"

Se construyen dos de los tres binarios: el demonio y ntp-ctl, que es el equivalente de chronyc — sin él la caja no se puede inspeccionar, y eso sería una regresión frente a chrony. Como cargo rustc -- exige UN target y ntpd produce tres, la fase compile itera, calcada de recipes/cargo-edit.toml (que existe por lo mismo): +crt-static no puede ir en RUSTFLAGS porque alcanza a los proc-macros y rustc aborta con «cannot produce proc-macro».

Lo que NO se gana, medido antes de prometerlo: NTS sigue apagado

recipes/chrony.toml apaga NTS porque necesita nettle o gnutls y ninguna está en el corpus. Acá el muro es el mismo un piso más arriba: el TLS de NTS lo hace rustls, y rustls necesita un proveedor de criptografía compilado — aws-lc-rs o ring — y los dos son C (aws-lc-sys arrastra cmake + asm; leído en el Cargo.lock del tag). Por eso el build va con --no-default-features --features rustcrypto: la criptografía del protocolo (AES-SIV, MD5) sí queda pura-Rust —ntp-proto la declara proveedor de primera clase y aborta con compile_error! si no hay ninguno— pero el NTS-KE queda sin TLS. ⇒ NTP clásico, igual que chrony hoy. Subir NTS pide un proveedor de rustls, no esta receta.

El label cambia con el paquete

servicios pasa de "chronyd" a "ntpd-rs". Un label es el nombre del SERVICIO, no de la función: dejar chronyd apuntando a ntpd-rs haría que arjectl status chronyd mostrara algo que no es chrony. La card guarda el mismo contrato que la de chrony —sale 78 nombrando /etc/ntpd-rs/ntp.toml, con el ejemplo en /usr/share/doc/ntpd-rs/ntp.toml.default— porque a qué servidores NTP se pregunta es decisión del sitio. chrony se queda en el CATÁLOGO, que no es la imagen: su receta documenta media docena de lecciones sobre un configure que prueba el entorno, y borrarla sería tirar eso.

Y el bloqueante que apareció: el git del corpus SÍ trae los helpers

El primer intento de build murió en el fetch:

Cloning into bare repository '/work/repos/ntpd-rs.git'...
git: 'remote-https' is not a git command.

Y la causa no es la que este repo tenía escrita. CLAUDE.md §2 dice que el git del corpus «no trae los helpers remote-http/remote-https». Los trae — están en su artefacto, medidos:

ls …-git/usr/libexec/git-core | grep -c remote-http   ⇒ 2
git --exec-path                                       ⇒ /usr/libexec/git-core   (ruta COMPILADA)
ls /usr/libexec/git-core | grep -c remote-http        ⇒ 0   (el libexec de la IMAGEN, vacío)

git es un multiplexor que busca sus treinta ayudantes en un directorio horneado al build: la ruta FHS. Corrido desde el store, mira el /usr/libexec/git-core de la jaula, que no tiene ninguno. Lo único que faltaba era GIT_EXEC_PATH. Con eso, el MISMO binario clona por https y empuja al espejo de GitHub — comprobado con ls-remote y con push --dry-run, que llegó hasta el rechazo por non-fast-forward, o sea que habló TLS y autenticó.

Arreglado donde se genera (scripts/servidor/jaula-herramientas.sh: git sale del bucle y lleva su export GIT_EXEC_PATH="$b/usr/libexec/git-core") y en el envoltorio vivo /work/sergio/bin/git. ⇒ El push doble desde la jaula deja de estar roto, que era una molestia diaria documentada.


Paso 6 — HECHO: el ADR 0012, decidido e implementado (2026-09-22)

Estaba PENDIENTE desde el 2026-07-22 con una nota que decía «no implementar sin resolver el dilema». El dilema se resolvió midiendo las tres cosas que el propio ADR pedía, y la primera resultó estar mal planteada — verlo es la mitad de la decisión.

1 · «¿Cuánto serializaría un lock por árbol?» — nada, y el ADR no lo había mirado

El ADR descartaba a medias la opción A porque «el grafo es un diamante que converge ⇒ puede degenerar en serializar casi todo». Eso supone que un consumidor toca el árbol de su dep. No lo toca:

1. store.has(h)              → si está, vuelve sin tocar NINGÚN árbol
2. materialize_build_deps()  → construye las deps (recursivo) y TERMINA
3. fetch → patches → vendor → compilar → sellar   ← recién acá se toca el árbol PROPIO

2 · «¿Cuánto cuesta re-extraer y re-vendorear?» — 2,4 %, pero el disco duele

Medido en el build real de ntpd-rs (el del paso 5), con las marcas de tiempo de su propio log: fetch 4,0 s + cargo vendor 5,4 s contra 6 min 25 s de compilación. El tiempo no era el problema de la opción B; el disco sí: ese árbol pesa 707 MiB, y B lo multiplica por build concurrente.

3 · «¿Espacio pico de la caché + copia?» — 12,4 GiB en 46 árboles

Con los mayores en 2,4 G (boveda, shuma-pregunta) y 2,5 G (llama-cpp). La opción C duplica lo que esté en vuelo y no ahorra el vendoreo (que muta ⇒ va en la copia): compra los 4 s del extract al precio de duplicar el disco.

La decisión: opción A, precisada

Lock exclusivo por nombre de receta, tomado en fetch y sostenido por RAII hasta el seal (fetch::GuardaArbol), más re-chequeo de la tienda al obtenerlo: el que esperó sale por cache hit en vez de reconstruir. El grano es el nombre —y no el árbol— porque el mirror git (repos/<nombre>.git) también es estado compartido y su clave es sólo el nombre.

⚠ El fd no se hereda, y eso es el punto fino: Rust abre con O_CLOEXEC, así que ni tar, ni git, ni bwrap, ni un nieto fugado se quedan con el lock — que es exactamente el modo de fallo que CLAUDE.md documenta para el flock(1) de alrededor (hora y media de granja parada, en silencio).

Verificado, con control

Dos builds concurrentes de la misma receta, con el árbol borrado antes para forzar la extracción:

A: fetch … → sealed
B: «árbol: esperando el lock» → «lock obtenido tras esperar» (1,27 s)
   → «caché: otro build lo selló mientras esperábamos el árbol»
los dos rc=0 · UN artefacto

Y el control, que es lo que prueba que el lock hace falta — modelando la ventana real (extracción de segundos + compilación), dos builds sobre el mismo árbol sin lock dan la firma exacta del apagón de KDE del 22/07:

SIN lock:  A: ROTO — .zwrap/cc AUSENTE, 0/400 fuentes      B: OK
CON lock:  A: OK                                            B: OK

Más dos tests en el crate, con su control (dos nombres distintos NO se bloquean) y la comprobación de que el fichero de lock vive fuera de sources/, donde el propio remove_dir_all lo barrería.

Y no mueve ningún hash: el artefacto de la prueba salió b3:dd6b538af774…, idéntico al que el grafo ya registraba. La batería del workspace pasa entera salvo un test de swm que ya estaba roto antes (de otro agente).

Lo que habilita, y lo que NO decide

JOBS=1 en el worker y el -P2 de build-farm.sh estaban puestos por esta carrera. Ya no hay carrera. Subir el paralelismo es otra decisión y no se toma acá: el worker son 6 cores y 16 G, y esta clase de máquina ya provocó un global_oom. Lo que cambia es que el límite pasa a ser RAM y disco —medibles, con watchdog— en vez de la corrupción silenciosa de un árbol compartido. Y el flock de CLAUDE.md §1 sigue haciendo falta por otra razón: serializa contra los scripts de la granja, que es control de RECURSOS.


Paso 7 — MEDIDO: muon es viable, pero hoy es NO-GO por dos defectos nombrados (2026-09-22)

El paso 7 del plan pedía explícitamente medir 10 antes de adoptar, porque «muon no cubre el 100 % de meson». Se midió. El resultado no es el que la frase sugería: el problema no es la cobertura del lenguaje meson — es una bandera del enlazador.

Cómo se midió

El hallazgo transversal: UNA bandera rompe TODAS las sondas del compilador

Primera corrida: 9 de 10 fallan. Dos con un síntoma que no era de deps —dbus: «Could not find a 64-bit integer type»; graphene: «No aligned malloc function could be found»— y la causa estaba dos capas más abajo:

dbg executing cc … '-Wl,--fatal-warnings'
dbg compiler stderr: 'error: unsupported linker arg: --fatal-warnings'
c compiler: sizeof long long: -1

muon añade -Wl,--fatal-warnings a las sondas que compilan y ENLAZAN (src/functions/compiler.c:210), y zig lo rechaza de plano. Entonces cc.sizeof() devuelve -1 y cc.has_function() devuelve false para todo — y los proyectos que ramifican sobre eso abortan diciendo cualquier cosa menos la verdad.

⚠ Y mi primer «control» no era un control: puse CC=$(command -v gcc || command -v cc) y en esta jaula no hay gcc, así que cc resolvió al MISMO envoltorio de zig. Daba idéntico, y por un momento pareció que el defecto era de muon en cualquier compilador. Lo que lo aisló fue sacar muon del medio:

/work/sergio/bin/cc x.c -o x -Wl,--fatal-warnings   ⇒ error: unsupported linker arg
/work/sergio/bin/cc x.c -o x                        ⇒ compila y enlaza OK

La causa raíz está en el propio muon, y su código lo sabe. src/script/runtime/toolchains.meson detecta zig ld y lo trata como lld, que hereda de ld (GNU), con este TODO textual: «we need to support a way for the compiler to return a list of linkers to check, and then we can specify zig ld as its own linker type». Intenté justamente eso —registrar zig-ld con fatal_warnings: []— y no toma efecto: el camino real es «linker: lld (passed-through)», que no pasa por el detect del registro. Es trabajo aguas arriba, no un parche de una línea.

Lo que SÍ prueba que la bandera es todo el problema: con un cc que la descarta, las mismas sondas dan los valores correctos (sizeof(long long) = 8, aligned_alloc = true).

⚠ Y ese envoltorio NO es la solución, aunque funcione: el lab no entra en hash_inputs ([[lab-fuera-de-hash-inputs]]), así que filtrar la bandera ahí haría que dos labs sellaran bytes distintos en la MISMA dirección del store. El arreglo tiene que estar donde el hash lo cubra.

La muestra, con la bandera filtrada (o sea: lo que muon puede de verdad)

recetaresultadoclase
dbus-sharedsetup complete✓
iso-codessetup complete✓
geocode-glibprogram not found (módulo gnome)dep del entorno
libgweathermódulo python gi ausentedep del entorno
mesapkg-config vdpau ausentedep del entorno
pango-sharedpkg-config gobject-introspection ausentedep del entorno
swaybg · wl-clipboardpkg-config wayland-client ausentedep del entorno
accountsservicesu script de versión exige un clone gitartefacto de MI arnés
grapheneconf.set10('HAVE_MEMALIGN', 1) ⇒ «expected type bool, got int»GAP de muon

Cobertura del lenguaje: 9 de 10 sin un solo hueco de sintaxis — mesa incluida, que llega hasta resolver dependencias. El único gap real es de TIPOS, no de una función que falte: meson acepta un int en configuration_data.set10() y muon 0.6.0 exige bool.

Veredicto: NO-GO hoy, y el gate son dos defectos, no una campaña

  1. zig ld tratado como GNU ld ⇒ todas las sondas de enlace rotas. Es el TODO de muon; hay que hacerlo aguas arriba (o parchear el camino «passed-through», que es donde de verdad se decide).
  2. conf.set10() rechaza int. Extrapolando la muestra, ~10 de las 107 tendrían algo de esta clase; cada uno es un arreglo chico aguas arriba o un parche por receta.

Mientras 1 no esté, el swap re-hashearía 107 recetas para obtener binarios configurados con sondas que mienten — que es peor que no hacerlo. Y el premio tampoco es el que parecía: con el paso 1 hecho python3 ya NO viaja en las imágenes, así que lo que compra el cambio es la cadena de build, no bytes de imagen.

No se commiteó recipes/muon.toml a propósito: una receta sin sellar ensucia el grafo con un nodo 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

nivelzlib 1.3.1zlib-rs 0.6.8tamañotiempo
17.050.606 B · 226,6 ms8.526.731 B · 95,1 ms+20,9 %2,4× más rápido
6 (por defecto)6.409.962 B · 760,4 ms6.457.822 B · 335,4 ms+0,7 %2,3× más rápido
96.371.670 B · 1496,3 ms6.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.

Paso 8 bis — el swap en un consumidor REAL: pigz sobre zlib-rs, sellado y verificado

La lib andando contra un programa de prueba propio no decide una campaña de 122 recetas. Lo que decide es si un consumidor de verdad, con su Makefile y su uso de la API, compila y corre sin tocar su fuente. recipes/pigz-zlibrs.toml: b3:3a485edc…, sellada.

pigz es el consumidor duro, y por eso se eligió: no usa el compress2 de manual sino los rincones de zlib — inflateBack, inflateBackInit_, inflateBackEnd, deflatePending, deflatePrime, crc32_combine, deflateSetHeader. Comprobado que zlib-rs exporta los siete antes de intentar el build. Y viaja en 6 perfiles.

Lo único que cambió respecto de recipes/pigz.toml: la dep (zlib-rs en vez de zlib) y LIBS="… -lz -lunwind". El -lunwind va en LIBS y NO en LDFLAGS porque el Makefile de pigz pone $(LDFLAGS) antes de los objetos y $(LIBS) al final (línea 15): en enlace estático el orden decide y el unwinder tiene que ir DESPUÉS de quien lo pide. Fuente, banderas y fases, idénticas — la comparación vale porque lo único distinto es la lib.

Medido, con el corpus Silesia (15,7 MB) y un solo hilo para que el tiempo sea comparable

pigz (zlib 1.3.1)pigz-zlibrs
comprimir -66.410.662 B · 0,726 s6.455.201 B · 0,326 s
descomprimir (ejercita inflateBack)0,065 s0,059 s
tamaño del binario1.062.672 B3.971.304 B

⇒ +0,69 % de tamaño comprimido, 2,23× más rápido comprimiendo, y descompresión un pelo más rápida. El precio está en el binario: +2,9 MB (×3,7), que es el mundo de Rust que el staticlib arrastra.

Interoperabilidad, en las tres direcciones que importan

canónico lee el .gz de zlib-rs   ⇒ OK      (sha256 del original recuperado)
zlib-rs  lee el .gz del canónico ⇒ OK
el `gzip` del corpus lee los dos ⇒ OK      ← tercera implementación, independiente

O sea: el formato es el mismo y el CRC cierra. Lo que difiere es qué bytes elige el compresor, que es la reserva nº 1 del paso 8 y sigue en pie.

Lo que esto cambia en el veredicto

El swap en un consumidor real no costó ni una línea de su fuente, sólo la bandera del unwinder. Con eso, de las tres reservas del paso 8 quedan dos: la salida no es byte-idéntica (decisión de contenido) y gzprintf no existe (hay que mirar receta por receta). El tamaño del binario se suma como tercera: para una imagen con muchos consumidores de zlib, +2,9 MB por binario no es gratis.

⚠ scripts/yupana.py gana el sufijo -zlibrs en SUFIJOS_VARIANTE: sin eso el invariante (c) marca pigz/pigz-zlibrs como «colisión-fuente ACCIONABLE», que es un falso positivo — y un guardián con falsos positivos se empieza a ignorar. Comprobado: yupana duplicados sigue en «ninguna».