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…

⎇ main docs/plan-que-sigue-tras-busybox.md Markdown · 1256 lines · 71.9 KB History raw

paso 9: a gnupg no le sobraba nada, le FALTABA pinentry — y `git commit -S` no andaba en ninguna imagen e52abccc2 · 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✅ **RESPONDIDO el 2026-09-22 en el paso 9, y la respuesta es NINGUNA DE LAS DOS: se completa.**
193Lo de abajo se deja como estaba porque el razonamiento es el que había, pero sus dos premisas
194están medidas y son falsas: (a) los «0 consumidores» son 0 **por build** — por runtime lo ejecutan
195`git` (7 perfiles), `lazygit` (6) y `gcr` (gnome, con la ruta horneada); (b) no lo invoca «un solo
196script de prueba»: lo invoca git cada vez que alguien firma un commit. Y lo que viajaba no podía
197usar ninguna clave con contraseña porque **faltaba `pinentry`**. Ver paso 9.
198
199`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
200solo script de prueba** (`scripts/product-base-system-boot-test.sh`). La firma de takana **no lo
201usa**: es ed25519 propio (`takana keygen release`, `trust/`). Entonces son dos preguntas distintas:
202
203- Como **herramienta del usuario**: el reemplazo moderno es `sequoia` (`sq`, Rust). ⚠ No confundir
204  con `recipes/sq.toml`, que es otro `sq` (neilotoole, Go, consultas a datos).
205- Como **infraestructura**: no lo es. Si nadie lo pide, es la clase «105 BORRAR» del censo de
206  busybox, no la clase «reemplazar».
207
208⚠ `libgcrypt` (18 consumidores) y `libgpg-error` (20) **se quedan** con o sin gnupg.
209
210---
211
212## Grupo 3 · Las campañas: buen cambio, factura grande
213
214### 3.1 · `meson` → `muon`: matar el intérprete de Python del sandbox
215
216| dato | valor |
217|---|---|
218| recetas que declaran `meson` | **107** |
219| de esas, que declaran también `python3` | **107 (todas)** |
220| consumidores de `python3` en total | 302 |
221| `python3` en el store | **209,0 MiB** |
222| `meson` | 3,9 MiB |
223
224El precedente ya está sentado y es del propio repo: **`samurai`** (C, 0,6 MiB, 256 consumidores)
225reemplazó a ninja. `muon` es su hermano exacto: implementación en C de `meson.build`, sin Python,
226pensada para correr con samurai. Es el cambio que convierte «`ping` arrastra 209 MiB de intérprete»
227en un no-problema del lado del build, además del lado de la imagen.
228
229⚠ **Cuesta 107 recetas re-selladas** (y sus consumidores): `deps.build` está en `hash_inputs`.
230`yupana radio meson` antes de tocar nada. Conviene **sólo pegado a otra invalidación grande**, no
231como cambio suelto.
232⚠ Y se mide primero, no se adopta: muon **no cubre el 100 %** de meson. El criterio de decisión es
233cuántas de las 107 configuran con muon sin tocar el `meson.build`; con una muestra de 10 ya se sabe
234si el frente vale.
235
236### 3.2 · `zlib` → `zlib-rs`: la lib C más consumida del corpus
237
238`zlib` tiene **122 consumidores** (+83 de `zlib-shared`) — más que ninguna otra librería del
239catálogo, y es un clásico de CVEs de memoria. `zlib-rs` publica la **misma ABI C** (`libz.a` /
240`libz.so.1`), así que en teoría es un swap sin migrar llamadores, que es justo lo que no pasa con
241`ripgrep`/`sd`/`fd`.
242
243⚠ 122 recetas re-selladas: es **la campaña más cara de este documento**. Y ⚠ hay que verificar su
244estado upstream antes de prometerlo — la misma cautela que el plan de busybox aplicó a
245`uutils/util-linux`.
246
247**Cómo se decide sin pagar la campaña:** receta `zlib-rs` publicando el mismo SONAME, swap en **un**
248consumidor dinámico (`lld21` pide `libz.so.1`), y comparar. Si ahí anda, la campaña es una decisión
249de calendario; si no, costó una receta.
250
251### 3.3 · Lo que NO es campaña aunque se parezca
252
253`openssl`: 55 consumidores, 44,1 MiB. **9 son recetas Rust** (`cargo-generate`, `cargo-outdated`,
254`cargo-udeps`, `cargo-cache`, `cosmic-settings-daemon`, `hurl`, `monolith`, `nu`, `rink`) que podrían
255usar `rustls` por *feature*. Pero los otros 34 son C y openssl seguiría viajando igual ⇒ **el
256esfuerzo no compra bytes ni superficie**. Es trabajo del día que openssl se caiga sola.
257
258---
259
260## Correcciones al plan de busybox que salen de esta medición
261
262`plan-botar-busybox.md` cifró **23 applets de trabajo real**. Dos de las ocho piezas ya están
263hechas y una **no hace falta**:
264
265| pieza | applets | estado hoy |
266|---|---|---|
267| `psmisc` | `killall killall5 pstree fuser` | ✅ `sealed`, en 7 perfiles (commit `4357b29c`) |
268| `bc` | `bc dc` | ✅ `sealed`, en 7 perfiles (commit `4357b29c`) |
269| `awk` | `awk` | ✅ resuelto **escribiendo**: `akllay`, Rust, 3.531 líneas (`86915f26`) |
270| **`kmod`** | `insmod rmmod lsmod modprobe depmod modinfo` | **no hace falta: no hay módulos** |
271
272El detalle de `kmod`, que es el que cambia la cuenta: **los cuatro kernels que arman imágenes llevan
273`-d MODULES`** — `linux-generic.toml:69`, `linux-metal`, `linux-metal-dual`, `linux-metal-kexec`. El
274único que conserva los módulos del `defconfig` es `recipes/linux.toml`, y ése **no arma ninguna
275imagen** (el SDD 28 ya lo descartó: apaga SCSI y no ve el disco de hcloud). ADR 0018 lo dice del
276otro lado: «un único bzImage, sin módulos ⇒ una sola firma».
277
278⇒ **los 6 applets de `kmod` son clase BORRAR, no clase escribir.** Vuelven el día que exista un
279módulo fuera del árbol (el tester NVIDIA es el candidato), y ese día `kmod` es una receta, no seis
280applets. Con eso el trabajo restante del censo baja de 23 a **11** (`grep` 3, `sed` 1, gestor de
281`/dev` 3, `getty` 2, `nslookup` 1), y de esos `getty` **ya se sabe que es ADR** y no escritura.
282
283---
284
285## Lo único del producto que no sale de una receta: el cargador de arranque
286
287```
288recipes/grub*.toml                       ⇒ no existe
289scripts/iso-image.sh:37                  ⇒ GRUB_LIB="${GRUB_LIB:-/usr/lib/grub/i386-pc}"
290scripts/install-image.sh:49              ⇒ idem  ·  exige grub-mkimage, grub-bios-setup
291```
292
293O sea que **el GRUB que arranca una imagen de takana es el de la caja que la construyó**, y su
294versión no entra en ningún hash. Es la rama BIOS/El-Torito; la rama UEFI **ya se escapó** por
295EFI-stub (`EFI_STUB=1`, `metal-iso.sh:104`, y el ADR 0018 con vendor path propio + reconciliador de
296NVRAM, implementado y verificado en metal).
297
298La decisión es chica y está sin tomar: **o `grub` entra al corpus como receta, o la rama BIOS se
299retira** y todo el arranque queda EFI-stub, que es además la arquitectura moderna (UKI: un PE
300firmable, sin gestor intermedio). Lo que no conviene es lo de hoy, que es depender de un binario
301ajeno sin decirlo. ⚠ Y el ADR 0018 deja abierta la de Secure Boot, que es la que lo ata.
302
303---
304
305## Arquitectura: los tres pendientes, por lo que cuestan HOY
306
3071. **ADR 0012 · el árbol de fuentes compartido — PENDIENTE desde 2026-07-22, y es el que más paga.**
308   Es lo que obliga a `JOBS=1` y a envolver todo en `flock`: la granja compila **de a uno** en una
309   caja de 6 cores. Medido en su día: al invalidarse `libdrm`, ~93 de 205 recetas KDE murieron con el
310   wrapper de zig barrido por el fetch de otra. Resolverlo (caché inmutable direccionada por
311   contenido + copia privada por build, con reflink/overlay) es **la única de estas tres que
312   multiplica la velocidad del frente entero**. Todo lo demás de este documento se construye más
313   rápido después de esto.
3142. **Las 28 recetas `*-shared`.** `zlib`/`zlib-shared`, `pcre2`/`pcre2-shared`, `glib`/`glib-shared`…
315   168,4 MiB de artefactos y, sobre todo, **28 builds y 28 puntos de mantenimiento duplicados** para
316   emitir dos formas del mismo código. La arquitectura correcta es una receta con **varias salidas**
317   (estática / dinámica / headers), que además es lo que haría al vigía de sonames innecesario en su
318   caso más común. ⚠ Re-hashea; va después del §1, que es el que hace que la distinción importe.
3193. **Los dos linajes de imagen** (tres listas a mano vs. clausura del perfil). Hoy conviven y
320   divergen en silencio: es la misma forma que los `hydrate-*.sh` contra `targets.toml`
321   ([[hidratar-desde-el-perfil-no-el-script]]) y que el `sshd` que entra por
322   `product-userland-from-repo.sh` y no por el manifiesto — lo dice el propio `targets.toml`.
323
324---
325
326## Lo que NO conviene tocar, y por qué (para que no se proponga en tres semanas)
327
328| candidato | por qué no |
329|---|---|
330| `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 |
331| `linux-pam` | 5 consumidores, todos greeters de escritorio. No hay reemplazo; la deuda está contenida y nombrada |
332| `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 |
333| `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 |
334| el demonio de `dbus` | 124 consumidores son **clientes** de libdbus; cambiar el demonio (busd/dbus-broker) es posible y no urge |
335| forkear `ripgrep`/`sd`/`fd` | ya cerrado en `plan-botar-busybox.md`: se usan como **librerías**, no se forkean |
336| deduplicar `work/sources` | el árbol por receta ES el aislamiento del ADR 0012. Deduplicar *de paso* re-crea la carrera |
337
338---
339
340## Orden de ataque
341
342| # | unidad | rebuilds | gana |
343|---|---|---|---|
344| 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 |
345| 2 | decidir el setuid y quedarse con **una** de `sudo`/`doas` | 0 | cierra un «sella y no corre» antes de que sea visible |
346| 3 | `kmod` a BORRAR en el censo de busybox (23 → 11 applets) | 0 | no se escribe lo que nadie necesita |
347| 4 | la rama BIOS: receta de `grub` o retirarla | 0 | ningún binario ajeno sin hash en el arranque |
348| 5 | `chrony` → `ntpd-rs` | 0 | una hoja C vital menos, con drop-in Rust |
349| 6 | **ADR 0012** | — | la granja deja de compilar de a uno |
350| 7 | `meson` → `muon` (medir 10 antes) | 107 | un intérprete menos en el sandbox |
351| 8 | `zlib-rs` en UN consumidor, para decidir | 1 | la lib C más consumida del corpus, con la misma ABI |
352| 9 | `gnupg`: ¿reemplazar o borrar? | 0 | **respondido: ninguna de las dos** — le faltaba `pinentry` y `git commit -S` no funcionaba en ninguna imagen |
353
354Los cinco primeros **no cuestan una sola reconstrucción**. Eso es lo que los pone antes: la lección
355de `uutils`/`brush`/`foot` —trabajo hecho y no cobrado— aplicada al calendario.
356
357---
358
359# Paso 1 — HECHO: el toolchain sale de las imágenes (2026-09-22)
360
361**Cero reconstrucciones, como estaba previsto: no se tocó ninguna receta.** Lo que cambió es la
362proyección, declarada en `[solo-build]` de `docs/state/targets.toml` (44 entradas, cada una con su
363motivo) y aplicada por `yupana.membresia_imagen()`.
364
365## La regla que se implementó, y la que se descartó
366
367**Se poda por NODO, no por tipo de ARISTA.** Podar las aristas de build es lo que dejó a firefox sin
368`libstdc++.so.6`; acá una receta de la lista no entra a la imagen y no se explora desde ella, y todo
369lo demás sigue entrando por donde entraba. Consecuencia buscada: `qtbase`, `glib` o `harfbuzz`
370llegan por aristas de build de apps que sí viajan y **se quedan**. Decidir sobre cada librería es
371otra unidad de trabajo.
372
373**La válvula: una raíz declarada gana.** Por eso `servidor` sigue llevando `rust` y `lld21`, y `cli`
374sigue llevando `mold` — «la caja que sirve el repo y construye, sí».
375
376## Medido en disco, no calculado — hidratando de verdad con `--closure build|image`
377
378`hydrate-profile.py` ganó `--closure build` para poder medir el ANTES: sin control, un «−X MiB» es
379una afirmación sin testigo.
380
381| perfil | ficheros antes → después | tamaño antes → después |
382|---|---|---|
383| `base` | 15.743 → **8.228** | 1,4 G → **871 M** (−38 %) |
384| `servidor` | 40.235 → **11.516** | 4,9 G → **2,6 G** (−47 %) |
385
386⚠ Los dos lados se hidrataron con los **mismos 12 nodos ausentes** de este hub (`chrony`, `cronie`,
387`rust`, `nftables`… están en el store del worker): la comparación es válida, el absoluto es cota
388inferior. Y ⚠ **`du` de los dos árboles en UN solo comando miente**: proyectan por hardlink y `du`
389deduplica entre sus argumentos — daba 45 M para el segundo. Se miden por separado.
390
391Nodos por perfil (`build` = qué hay que construir, sigue igual; `imagen` = qué viaja):
392
393| perfil | build | imagen | fuera |
394|---|---|---|---|
395| `base` | 85 | **68** | 17 |
396| `cli` | 152 | **132** | 20 |
397| `servidor` | 185 | **165** | 20 |
398| `escritorio-mirada` | 41 | **23** | 18 |
399| `escritorio-kde` | 443 | **409** | 34 |
400| `escritorio-gnome` | 339 | **307** | 32 |
401| `escritorio-cosmic` | 295 | **260** | 35 |
402| `escritorio-sway` | 281 | **248** | 33 |
403
404El `base` hidratado ya no tiene `python3`, `perl`, `cmake`, `meson`, `binutils`, `nasm`, `bison`,
405`flex`, `m4`, `pkgconf`, `go` ni `rustc` — comprobado buscándolos en el árbol, no en la lista. Lo
406único que queda con ese nombre es `usr/share/cmake`, que son ficheros de datos que instala otra
407receta, no el programa.
408
409## El instrumento que faltaba: `scripts/vigia-interpretes.py`
410
411El vigía de sonames dio **0 huecos en los 8 perfiles** con el toolchain fuera (`--imagen --fail`,
412`rc=0`). Pero **un `.so` no es la única dependencia de ejecución: un `#!` también**, y de eso no
413había instrumento. El guardián nuevo compara cada script del cierre de imagen contra lo que la
414imagen provee, con dos tablas —`POR_CONTRATO` (andamiaje de build horneado dentro de una librería) y
415`POR_DECISION` (lo que se aceptó perder)— y falla con `--fail` ante cualquier caso no declarado.
416
417**Encontró tres cosas que ningún soname delataba, y las tres cambiaron la decisión:**
418
419| hallazgo | qué era | qué se hizo |
420|---|---|---|
421| `gnome-tweaks` es un **programa Python entero** | sin `python3` la app no abre | `python3` declarado en `escritorio-gnome` |
422| `gdctl` de mutter (configurar salidas) | idem | idem |
423| 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` |
424
425O sea: `python3` (209 MiB) viaja en las dos imágenes grandes —donde es el 2 % de 10-13 G— y **no**
426en `base`, `cli`, `servidor`, `sway`, `cosmic` ni `mirada`, donde en `base` era el 17 %.
427
428Lo que queda inerte a propósito, contado y nombrado (`•` en la salida del vigía): los 10 scripts
429perl de `git` (`send-email`, `svn`, `cvs*`, `gitweb.cgi`), los 4 de `lm-sensors`
430(`sensors-detect`…), los diagnósticos de `libinput`, `routel`, `rrsync`, `lsusb.py`, los filtros de
431`aerc` y `helper-mux` de squid. Un `#!` que no resuelve **falla ruidoso y nombra al intérprete**;
432por eso la decisión es defendible y no una fuga silenciosa. Revertir cualquiera es una línea en el
433`paquetes` del perfil.
434
435⚠ **Lo que no se midió:** un binario que llame al intérprete con `system()` sin `#!`. No hay
436instrumento para eso; lo caza el arranque, y por eso el criterio de terminado incluye
437`scripts/product-boot-test.sh` en QEMU, que esta jaula no puede correr.
438
439## Lo que NO cambió, a propósito
440
441- **`build-state.py` sigue contando `clausura` = `build ∪ runtime`** — es «qué hay que construir» y
442  para eso el toolchain hace falta. Ahora informa `imagen` al lado, en el mismo renglón.
443- **`repo-perfil.sh` sigue publicando el cierre entero**: un repositorio no es una imagen. Que se
444  pueda instalar `cmake` desde el repo es correcto; que venga horneado en el rootfs, no.
445- **Las recetas**: ninguna. `[solo-build]` vive en el manifiesto y no en el `.toml` justamente para
446  no entrar en `hash_inputs`.
447
448## Guardado contra regresiones
449
450`scripts/targets.py --selftest` gana 6 casos con roturas a propósito sobre la poda (control sin
451poda, poda transitiva, la válvula, podar algo inalcanzable, y que ninguna entrada de la lista sea
452raíz de todos los perfiles). Y se corrigió un comentario de `vigia-sonames.py` que afirmaba que «la
453hidratación de la imagen NO las instala: va por lista explícita» — era del OTRO linaje
454(`bootstrap product`), y por creerlo nadie miró los 1.527 MiB de LLVM que sí se instalaban.
455
456---
457
458# Paso 2 — HECHO: el bit de privilegio, y `doas` como único camino (2026-09-22)
459
460El SDD 28 §5.11 dejó esto «anotado, sin decidir» el 21/09 con tres preguntas. Están contestadas, y
461la respuesta es un mecanismo con guardián, no un `chmod` en un script.
462
463## El hueco medido: 26 binarios, todos inertes
464
465Cruzando el cierre de IMAGEN de los 8 perfiles contra los nombres que en cualquier distro son setuid
466o setgid, y mirando el modo real del artefacto:
467
468| lo trae | binarios | modo hoy | perfiles |
469|---|---|---|---|
470| `shadow` | `passwd chage chfn chsh expiry gpasswd newgrp sg su login newuidmap newgidmap` | `555` | 7 |
471| `util-linux` | `mount umount sulogin wall` | `555` | 7 |
472| `linux-pam` | **`unix_chkpwd`** | `555` | 5 (los escritorios) |
473| `dbus` | `dbus-daemon-launch-helper` | `555` | 7 |
474| `fuse3` | `fusermount3` | `555` | 3 |
475| `iputils` | `ping` | `555` | 7 |
476| `openssh` | `ssh-agent` | `555` | 1 |
477| `sudo` / `doas` | `sudo sudoedit` / `doas` | `555` | 7 |
478
479**Ninguno tiene el bit** y `grep -rn S_ISUID crates/` daba **cero**: no había nadie que lo pusiera.
480El más caro de la lista no es `sudo`: es **`unix_chkpwd`**, que es con lo que PAM verifica una
481contraseña — sin él los greeters de los cuatro escritorios no pueden autenticar a nadie.
482
483Sigue siendo **LATENTE**, y por eso nadie lo chocó: el instalador sólo pone contraseña de root y los
484`[[user]]` de las recetas son cuentas de servicio, que no escalan.
485
486## 1 · ¿Quién lo pone? El ensamblado, no el artefacto — y son dos hechos, no una preferencia
487
488- **El store no puede hashearlo.** `ArtifactHash::of_tree` sólo mira el bit de EJECUCIÓN
489  (`mode & 0o111`, `hash.rs:79`). Dos artefactos que difieran sólo en el setuid tienen el MISMO
490  hash ⇒ dejarlo ahí es privilegio no cubierto por el contrato que cubre todo lo demás.
491- **La hidratación es por HARDLINK** (ADR 0005). El fichero del rootfs *es* el inodo del store: un
492  `chmod u+s` en una imagen de pruebas dejaría setuid el artefacto que comparten todas las demás.
493
494⇒ Se declara en la receta (`[[setuid]]`, con `why` **obligatorio**) y lo aplica `takana setuid
495--apply <rootfs>`, que **rompe el enlace duro** antes del `chmod`. Y la mitad buena del diseño sale
496de ahí: la copia la crea quien ensambla, así que **queda a su nombre** — y el `unshare -r` de
497`install-image.sh` mapea ese uid a 0, o sea setuid ROOT en la imagen, sin un `chown` en ningún lado.
498
499**Cero reconstrucciones**, verificado: el campo está fuera de `hash_inputs` (como `license`,
500`[[service]]` y `[[user]]`) y el hash de `doas` sigue siendo `b3:28bc42c2…`, sellado.
501
502## 2 · ¿Quién lo verifica? Las dos direcciones, y la segunda es la que vale
503
504```
505✓ 4755 usr/bin/doas               (doas)  [enlace duro roto: el store queda intacto]
506✓ ningún setuid/setgid sin declarar en <rootfs>
507```
508
509- *lo declarado está*: cada ruta existe y queda con el modo pedido (una declaración que apunta a un
510  binario que la imagen no trae **falla**; es la trampa de [[catalogo-no-es-imagen]]).
511- **lo que está, está declarado**: ningún fichero del rootfs tiene el bit sin declararlo. Eso
512  convierte la lista en un invariante, y acá es barato justamente porque el rootfs sale de artefactos
513  inmutables: un bit que aparece sin declaración es, por construcción, alguien pisando la imagen.
514
515Probado con roturas a propósito sobre un rootfs de verdad, no sólo en tests: un setuid colado ⇒
516`rc=1`; una declaración que apunta a una ruta inexistente ⇒ `rc=1`. Más 6 tests en
517`crates/takana-core/src/setuid.rs`, incluido el que justifica el módulo entero: tras aplicar el bit
518**el artefacto del store sigue en `0555`** y el del rootfs es `4755`.
519
520## 3 · ¿Qué significa que el hash no lo cubra?
521
522Que **el privilegio no es propiedad del artefacto: es propiedad de la imagen.** El `ArtifactHash`
523sigue afirmando lo mismo que antes —estos bytes, con este bit de ejecución— y no se mueve. Lo que
524cambia es que el privilegio gana su propio registro (la receta, versionada en git) y su propia
525verificación (las dos direcciones de arriba). Lo que no se puede hacer es guardarlo en el store y
526creer que el hash lo protege.
527
528## La elección: `doas`, y `sudo` sale de las imágenes
529
530Decisión del usuario con lo medido delante:
531
532| | `sudo` | `doas` |
533|---|---|---|
534| tamaño | **13 M** | **134 KB** |
535| config que instala | `/etc/sudoers` + `/etc/sudo.conf` | ninguna |
536| lo que autoriza tal cual | `root ALL=(ALL:ALL) ALL` — **a nadie más** | nada hasta que exista `doas.conf` |
537
538Viajaban **los dos** en los 7 perfiles y ninguno podía funcionar. Ahora `sudo` no está en ninguna
539imagen (su receta se queda en el catálogo, que no es la imagen) y `doas` lleva su `[[setuid]]`.
540
541⚠ **Falta el `/etc/doas.conf`: sin él, `doas` rechaza todo.** No se inventó una regla acá — quién
542puede escalar es una decisión de la imagen (¿grupo `wheel`?, ¿qué usuario humano?) y **hoy las
543imágenes no tienen usuario humano**. Es el mismo hueco que el `/etc/shells` del §5.11 y es la unidad
544siguiente; declarar el bit ahora es lo que no depende de esa decisión.
545
546## Dos comentarios que afirmaban algo falso, corregidos
547
548| receta | decía | realidad |
549|---|---|---|
550| `recipes/sudo.toml:57` | «el paquete lo re-aplica al hidratar» | nadie lo re-aplicaba |
551| `recipes/doas.toml:38` | «el binario setuid es cosa de la hidratación» | no había hidratación que lo hiciera |
552| `recipes/shadow.toml:15` | «el modelo no puede ni expresar ni garantizar un setuid» | **la única que lo decía bien** |
553
554## Lo que el guardián encontró en su PRIMERA corrida, y no era del setuid
555
556El aviso de dueño saltó con `doas` y destapó otra cosa: **el store tiene artefactos de dos uid
557distintos.** Visto desde el anfitrión, `…-doas/usr/bin/doas` es del **uid 1000** (el usuario del
558worker, que llega con la cosecha) y `…-bash/bin/bash` es del **1001** (el hub). Dentro de la jaula el
5591000 no está mapeado y se ve `nobody`.
560
561Medido sobre el rootfs de `base` recién hidratado: **5.984 ficheros de `sergio` y 1.159 de `nobody`**.
562Como `unshare -r` sólo mapea a 0 el uid que corre, **esos 1.159 salen a la imagen con dueño 65534** —
563y nadie lo había medido. Para el setuid no importa (romper el enlace normaliza el dueño), pero es su
564propia unidad de trabajo y queda anotada acá.
565
566## Lo que queda, dicho para que no se lea como cubierto
567
568- **Los otros 25 binarios del inventario.** El mecanismo está; declararlos es una decisión de
569  superficie de ataque, una línea por binario, y **no se tomó de paso**. El orden por lo que
570  desbloquea: `unix_chkpwd` (sin él no hay login gráfico), `passwd`+`chage`+`chfn`+`chsh`+`gpasswd`,
571  `mount`/`umount`, `fusermount3`, `ping`, `newuidmap`/`newgidmap`.
572- **El camino del `.swm`**: `Swm::from_recipe` lleva `deps`, `evidence` y `slots`, pero **no**
573  `[[service]]`, `[[user]]` ni `[[setuid]]` ⇒ un `takana install` de un paquete no pone el bit. Es el
574  mismo hueco que las otras dos declaraciones ya tenían; no lo empeora, pero hay que nombrarlo.
575- **`product-rootfs`**: el otro linaje (`takana bootstrap product`) no pasa por `hydrate-profile.py`,
576  así que tampoco aplica el bit. Cuando el frente C de busybox lo toque, la llamada es una línea.
577
578---
579
580# Paso 3 — HECHO: el censo de busybox deja de pedir trabajo hecho (2026-09-22)
581
582El detalle está en `docs/plan-botar-busybox.md` §7. En corto, y todo con `rc` comprobado:
583
584- **`--auditar-destinos`** nuevo en `scripts/busybox-censo-proveedores.py`: el `destino` y el
585  `estado_del_dueño` dejan de ser juicio. Regla mecánica: *si un paquete que viaja en la imagen
586  provee el applet, ése es el destino*. **293 celdas estaban mal** (22 de `destino`, 271 de estado).
587  `BORRAR` nunca se reescribe: es una decisión, y medir no la contradice.
588- **`kmod` (6) y el gestor de `/dev` (3) pasan a BORRAR**, medido en los `/boot/config-*` que
589  publican los artefactos: 5 de los 6 kernels sellados traen MODULES off y **ningún artefacto del
590  store publica `lib/modules`**. De paso, el vigía afirmaba «los cuatro kernels, cero `=m`» y son
591  seis, con 13 módulos en el sexto — la conclusión aguanta, la afirmación no.
592- **`killall5`** prometía `psmisc`, que no lo trae: lo cubre `arje-zero` por función. A BORRAR.
593- **El vigía no veía las cadenas crudas de Rust**, o sea los guiones del producto: decía «nadie los
594  invoca» con `insmod` retirado y `lib.rs:1388` invocándolo. Arreglado, con el mapa de líneas reales
595  y una sexta regla anti-ruido para el `/toolchain/`. Probado con una rotura a propósito.
596
597**De 23 applets de trabajo real quedan 3**, y dos son el ADR de `getty`. El paso 3 del plan decía
598«23 → 11»; medido, es **23 → 3**.
599
600---
601
602# Paso 4 — HECHO: las herramientas del ensamblado salen del store; el gestor de arranque es el único hueco (2026-09-22)
603
604El paso 4 estaba planteado como una disyuntiva: «o `grub` entra al corpus como receta, o la rama BIOS
605se retira». **Medir la cerró de un lado y abrió algo más grande del otro.**
606
607## La rama BIOS no se puede retirar, y está medido en el repo
608
609> **Hetzner Cloud arranca por BIOS** — «medido en gioser: no existe `/sys/firmware/efi`; GPT + GRUB»
610> (SDD 28 §3.1).
611
612O sea que la imagen del **servidor de producción** es justamente la que necesita la rama BIOS. La
613disyuntiva se resuelve por el otro lado: el gestor de arranque tiene que entrar al corpus.
614
615## Lo que apareció al mirar: el pipeline de imagen estaba roto en las TRES máquinas
616
617Los scripts tomaban sus herramientas del `PATH` del host. Medido en el hub, en su anfitrión (`raiz`) y
618en el worker `dev.gioser.net`:
619
620```
621grub-mkimage AUSENTE · grub-bios-setup AUSENTE · cpio AUSENTE · mformat AUSENTE · xorriso AUSENTE
622```
623
624**Ninguna imagen se podía armar en ninguna de las tres**, y sólo se veía al intentarlo, con un
625`falta cpio` que se lee como un descuido de la máquina. Y lo absurdo: **seis de las siete son
626nuestras y están selladas** — `util-linux` (sfdisk), `e2fsprogs` (mke2fs), `libarchive` (bsdcpio),
627`gzip`, `mtools`, `xorriso`, más `dosfstools`. Todas estáticas musl; **corren tal cual en el host**,
628comprobado ejecutándolas.
629
630## Lo hecho
631
632**`scripts/lib/herramientas-desde-store.sh`** — resolvedor único: la herramienta sale del artefacto
633sellado **por hash vigente**, con el host como CAÍDA ruidosa (un binario del host no tiene hash ⇒ la
634imagen deja de ser reproducible por ahí, y eso se dice en el log) y abortando con las dos rutas
635nombradas si no está en ninguno.
636
637⚠ **Por hash, no por fecha ni por el primer glob**: `iso-image.sh` resolvía xorriso con
638`ls store/*-xorriso/…|head -1` y `install-image-efi.sh` mtools con `ls -dt …|head -1`. Con dos
639artefactos del mismo paquete conviviendo —lo normal tras cualquier re-hash— el más reciente puede ser
640el viejo. Es la lección que `hydrate-profile.py` y `libs-compartidas-desde-store.sh` ya habían pagado.
641Y el store por defecto se **detecta**: en este hub el `./store` del clon está vacío y el de verdad es
642`/store`, así que un default mudo manda a diagnosticar al lugar equivocado.
643
644Cableados: `install-image.sh`, `install-image-efi.sh`, `iso-image.sh`, `metal-usb-sdboot.sh`.
645
646**`scripts/vigia-ensamblado.sh`** — el guardián: de dónde sale cada herramienta.
647
648```
649✓ sfdisk util-linux · ✓ mke2fs e2fsprogs · ✓ bsdcpio libarchive · ✓ gzip · ✓ mformat/mcopy mtools
650✓ xorriso · ✓ mkfs.vfat dosfstools
651✗ grub (no hay receta) — grub-mkimage AUSENTE, grub-bios-setup AUSENTE
652== resumen: 8 sellada(s) · 0 del host (sin hash) · 2 ausente(s)
653```
654
655## La prueba: una imagen EFI armada EN EL HUB, que antes era imposible
656
657```
658ROOTFS=<product-rootfs> KERNEL=<linux-metal/bzImage> STAGE=/store/.efi-stage \
659  sh scripts/install-image-efi.sh        ⇒ rc=0
660==> imagen EFI creada: 297M en disco (sparse), 754M virtuales
661```
662
663Todas las herramientas del store (con su hash en el log), tabla GPT verificada con el `sfdisk`
664sellado (ESP 48M + root 512M + store 128M + estado 64M) y la ESP con las dos rutas del kernel más el
665initramfs, verificada por el propio script con `mdir`.
666
667⚠ **Y el primer intento falló con EXDEV**, que es el otro arreglo de este paso: el staging **enlaza en
668duro** desde el rootfs y un enlace no cruza un montaje aunque sea el mismo disco
669([[hardlink-cruza-mount-exdev]]). El fallo llegaba como una **pared de `cp: Invalid cross-device
670link`** —cientos de líneas— sin que ninguna dijera la causa. Ahora hay `sonda_hardlink`, FUNCIONAL
671(intenta un enlace de verdad, porque `store` y `work/out` son bind-mounts del mismo dispositivo y
672comparar `st_dev` no dispara), y dice qué montaje es cada uno:
673
674```
675✗ EXDEV: no se puede ENLAZAR de '/store/…-product-rootfs' a '/work/sergio/work/.efi-stage'.
676  origen  está en: /store       destino está en: /work/sergio
677  Poné STAGE= (y IMG=) bajo el MISMO montaje que el rootfs.
678```
679
680Es la misma sonda que `hydrate-profile.py` ya tenía; `install-image.sh` sólo tenía el comentario.
681
682## Lo que queda, y es UNA receta
683
684- **El gestor de arranque BIOS.** Dos salidas, ninguna gratis: **`grub`** (lo que los scripts ya
685  hablan) o **`limine`**, que cubre BIOS **y** UEFI de una y es mucho más chico. Es un build de
686  granja, no un cambio de script, y por eso no entra en este paso.
687- **`metal-usb-sdboot.sh` arranca con un `systemd-bootx64.efi` sacado de un rootfs de Arch del home
688  del usuario** — otro binario ajeno, sin hash y fuera del store, en el camino del metal. Anotado y
689  no tocado: esa ruta se usa para bringup con menú, y cambiarla por EFI-stub (que ya existe) es su
690  propia decisión.
691- Para cualquier objetivo **UEFI la cadena ya es soberana y no lleva gestor**: el bzImage *es* el
692  binario EFI (ADR 0018), y ahora además se puede armar acá.
693
694---
695
696# Paso 5 — HECHO: la hora la da `ntpd-rs`, en Rust (2026-09-22)
697
698`chrony` era la hoja C **más barata de cambiar** del corpus: 0 consumidores, declarada sólo en
699`servidor`. Tocarla no invalida a nadie, y lo que se gana es concreto: el demonio que **ajusta el
700reloj del sistema como root** deja de ser C.
701
702## Sellado y verificado
703
704```
705recipes/ntpd-rs.toml  v1.9.0 · commit 46ec9bb4d5b6cb24f814f5543d85b9138afb4cba (tag v1.9.0)
706b3:44e1e0079eb7d33ee37261db9cdc3e3b5432c7d08ca8ee0617a59b19c6eb4a84   ⇒ el hash del dry-run,
707                                                                        idéntico al del sellado
708artefacto: 5,1 M · ntp-daemon y ntp-ctl ESTÁTICOS, 0 NEEDED · los dos contestan `--version`
709ntp-ctl validate contra el ejemplo que viaja  ⇒  "Config looks good"
710```
711
712Se construyen **dos** de los tres binarios: el demonio y `ntp-ctl`, que es el equivalente de
713`chronyc` — sin él la caja no se puede inspeccionar, y eso sería una regresión frente a chrony. Como
714`cargo rustc --` exige UN target y `ntpd` produce tres, la fase `compile` itera, calcada de
715`recipes/cargo-edit.toml` (que existe por lo mismo): `+crt-static` no puede ir en `RUSTFLAGS` porque
716alcanza a los proc-macros y rustc aborta con «cannot produce proc-macro».
717
718## Lo que NO se gana, medido antes de prometerlo: NTS sigue apagado
719
720`recipes/chrony.toml` apaga NTS porque necesita nettle o gnutls y **ninguna está en el corpus**. Acá
721el muro es el mismo un piso más arriba: el TLS de NTS lo hace `rustls`, y rustls necesita un
722*proveedor* de criptografía compilado — `aws-lc-rs` o `ring` — **y los dos son C** (`aws-lc-sys`
723arrastra cmake + asm; leído en el `Cargo.lock` del tag). Por eso el build va con
724`--no-default-features --features rustcrypto`: la criptografía del **protocolo** (AES-SIV, MD5) sí
725queda pura-Rust —`ntp-proto` la declara proveedor de primera clase y aborta con `compile_error!` si no
726hay ninguno— pero el NTS-KE queda sin TLS. ⇒ **NTP clásico, igual que chrony hoy.** Subir NTS pide un
727proveedor de rustls, no esta receta.
728
729## El label cambia con el paquete
730
731`servicios` pasa de `"chronyd"` a `"ntpd-rs"`. Un label es el nombre del SERVICIO, no de la función:
732dejar `chronyd` apuntando a ntpd-rs haría que `arjectl status chronyd` mostrara algo que no es chrony.
733La card guarda el mismo contrato que la de chrony —sale **78** nombrando `/etc/ntpd-rs/ntp.toml`, con
734el ejemplo en `/usr/share/doc/ntpd-rs/ntp.toml.default`— porque a qué servidores NTP se pregunta es
735decisión del sitio. `chrony` se queda en el CATÁLOGO, que no es la imagen: su receta documenta media
736docena de lecciones sobre un `configure` que prueba el entorno, y borrarla sería tirar eso.
737
738## Y el bloqueante que apareció: el `git` del corpus SÍ trae los helpers
739
740El primer intento de build murió en el `fetch`:
741
742```
743Cloning into bare repository '/work/repos/ntpd-rs.git'...
744git: 'remote-https' is not a git command.
745```
746
747**Y la causa no es la que este repo tenía escrita.** `CLAUDE.md` §2 dice que el git del corpus «no trae
748los helpers `remote-http`/`remote-https`». **Los trae** — están en su artefacto, medidos:
749
750```
751ls …-git/usr/libexec/git-core | grep -c remote-http   ⇒ 2
752git --exec-path                                       ⇒ /usr/libexec/git-core   (ruta COMPILADA)
753ls /usr/libexec/git-core | grep -c remote-http        ⇒ 0   (el libexec de la IMAGEN, vacío)
754```
755
756git es un multiplexor que busca sus treinta ayudantes en un directorio **horneado al build**: la ruta
757FHS. Corrido desde el store, mira el `/usr/libexec/git-core` de la jaula, que no tiene ninguno. Lo
758único que faltaba era `GIT_EXEC_PATH`. Con eso, el MISMO binario clona por https **y empuja al espejo
759de GitHub** — comprobado con `ls-remote` y con `push --dry-run`, que llegó hasta el rechazo por
760non-fast-forward, o sea que habló TLS y autenticó.
761
762Arreglado donde se genera (`scripts/servidor/jaula-herramientas.sh`: `git` sale del bucle y lleva su
763`export GIT_EXEC_PATH="$b/usr/libexec/git-core"`) y en el envoltorio vivo `/work/sergio/bin/git`.
764⇒ **El push doble desde la jaula deja de estar roto**, que era una molestia diaria documentada.
765
766---
767
768# Paso 6 — HECHO: el ADR 0012, decidido e implementado (2026-09-22)
769
770Estaba PENDIENTE desde el 2026-07-22 con una nota que decía «no implementar sin resolver el dilema».
771El dilema se resolvió **midiendo las tres cosas que el propio ADR pedía**, y la primera resultó estar
772mal planteada — verlo es la mitad de la decisión.
773
774## 1 · «¿Cuánto serializaría un lock por árbol?» — nada, y el ADR no lo había mirado
775
776El ADR descartaba a medias la opción A porque «el grafo es un diamante que converge ⇒ puede degenerar
777en serializar casi todo». Eso supone que un consumidor toca el árbol de su dep. **No lo toca:**
778
779```
7801. store.has(h)              → si está, vuelve sin tocar NINGÚN árbol
7812. materialize_build_deps()  → construye las deps (recursivo) y TERMINA
7823. fetch → patches → vendor → compilar → sellar   ← recién acá se toca el árbol PROPIO
783```
784
785- Un consumidor recibe el **artefacto sellado** de su dep como capa overlay, no su árbol ⇒ dos
786  recetas distintas **nunca comparten su árbol propio** y no se serializan entre sí.
787- Los locks **no se anidan**: al tomar el lock del árbol propio, las deps ya están construidas y sus
788  locks liberados ⇒ **no puede haber deadlock**, sin ordenar nada ni detectar ciclos.
789
790## 2 · «¿Cuánto cuesta re-extraer y re-vendorear?» — 2,4 %, pero el disco duele
791
792Medido en el build real de `ntpd-rs` (el del paso 5), con las marcas de tiempo de su propio log:
793fetch **4,0 s** + `cargo vendor` **5,4 s** contra **6 min 25 s** de compilación. El tiempo no era el
794problema de la opción B; el disco sí: ese árbol pesa **707 MiB**, y B lo multiplica por build
795concurrente.
796
797## 3 · «¿Espacio pico de la caché + copia?» — 12,4 GiB en 46 árboles
798
799Con los mayores en 2,4 G (`boveda`, `shuma-pregunta`) y 2,5 G (`llama-cpp`). La opción C duplica lo
800que esté en vuelo y **no ahorra el vendoreo** (que muta ⇒ va en la copia): compra los 4 s del extract
801al precio de duplicar el disco.
802
803## La decisión: opción A, precisada
804
805Lock exclusivo **por nombre de receta**, tomado en `fetch` y sostenido por RAII hasta el `seal`
806(`fetch::GuardaArbol`), más **re-chequeo de la tienda** al obtenerlo: el que esperó sale por cache hit
807en vez de reconstruir. El grano es el nombre —y no el árbol— porque el mirror git
808(`repos/<nombre>.git`) también es estado compartido y su clave es sólo el nombre.
809
810⚠ **El fd no se hereda**, y eso es el punto fino: Rust abre con `O_CLOEXEC`, así que ni `tar`, ni
811`git`, ni `bwrap`, ni un nieto fugado se quedan con el lock — que es exactamente el modo de fallo que
812`CLAUDE.md` documenta para el `flock(1)` de alrededor (hora y media de granja parada, en silencio).
813
814## Verificado, con control
815
816Dos builds concurrentes de la misma receta, con el árbol borrado antes para forzar la extracción:
817
818```
819A: fetch … → sealed
820B: «árbol: esperando el lock» → «lock obtenido tras esperar» (1,27 s)
821   → «caché: otro build lo selló mientras esperábamos el árbol»
822los dos rc=0 · UN artefacto
823```
824
825Y el control, que es lo que prueba que el lock hace falta — modelando la ventana real (extracción de
826segundos + compilación), dos builds sobre el mismo árbol **sin** lock dan la firma exacta del apagón
827de KDE del 22/07:
828
829```
830SIN lock:  A: ROTO — .zwrap/cc AUSENTE, 0/400 fuentes      B: OK
831CON lock:  A: OK                                            B: OK
832```
833
834Más dos tests en el crate, con su control (dos nombres distintos NO se bloquean) y la comprobación de
835que el fichero de lock vive **fuera** de `sources/`, donde el propio `remove_dir_all` lo barrería.
836
837**Y no mueve ningún hash**: el artefacto de la prueba salió `b3:dd6b538af774…`, idéntico al que el
838grafo ya registraba. La batería del workspace pasa entera salvo un test de `swm` que ya estaba roto
839antes (de otro agente).
840
841## Lo que habilita, y lo que NO decide
842
843`JOBS=1` en el worker y el `-P2` de `build-farm.sh` estaban puestos **por esta carrera**. Ya no hay
844carrera. **Subir el paralelismo es otra decisión y no se toma acá**: el worker son 6 cores y 16 G, y
845esta clase de máquina ya provocó un `global_oom`. Lo que cambia es que el límite pasa a ser RAM y
846disco —medibles, con watchdog— en vez de la corrupción silenciosa de un árbol compartido. Y el
847`flock` de `CLAUDE.md` §1 sigue haciendo falta por otra razón: serializa contra los scripts de la
848granja, que es control de RECURSOS.
849
850---
851
852# Paso 7 — MEDIDO: `muon` es viable, pero hoy es NO-GO por dos defectos nombrados (2026-09-22)
853
854El paso 7 del plan pedía explícitamente **medir 10 antes de adoptar**, porque «muon no cubre el 100 %
855de meson». Se midió. El resultado no es el que la frase sugería: **el problema no es la cobertura del
856lenguaje meson — es una bandera del enlazador.**
857
858## Cómo se midió
859
860- `muon 0.6.0` (tag, commit `1c825e41e2266be1bead999f7f65d9c763a7ca18`) construido acá: **una sola
861  compilación C** de `src/amalgam.c` (6,7 s con el `cc` del lab) da el `muon-bootstrap`, y ése
862  configura y compila el muon completo en **4,7 s / 156 objetivos**, usando su `samu` embebido. Dice
863  **«meson compatibility version 1.7.99»**. Como receta sería trivial.
864- **Muestra SISTEMÁTICA, no elegida a dedo**: de las 107 recetas que declaran `meson`, ordenadas
865  alfabéticamente, una cada 10,7 ⇒ 10 repartidas por todo el alfabeto. Salieron
866  `accountsservice dbus-shared geocode-glib graphene iso-codes libgweather mesa pango-shared swaybg
867  wl-clipboard` — incluida **`mesa`**, que es el proyecto meson más retorcido del corpus.
868- Fuente bajada de la URL/commit que pinea cada receta, y `muon setup` en el árbol.
869
870## El hallazgo transversal: UNA bandera rompe TODAS las sondas del compilador
871
872Primera corrida: 9 de 10 fallan. Dos con un síntoma que no era de deps —dbus: «Could not find a
87364-bit integer type»; graphene: «No aligned malloc function could be found»— y la causa estaba dos
874capas más abajo:
875
876```
877dbg executing cc … '-Wl,--fatal-warnings'
878dbg compiler stderr: 'error: unsupported linker arg: --fatal-warnings'
879c compiler: sizeof long long: -1
880```
881
882muon añade `-Wl,--fatal-warnings` a las sondas que **compilan y ENLAZAN**
883(`src/functions/compiler.c:210`), y **zig lo rechaza de plano**. Entonces `cc.sizeof()` devuelve
884**-1** y `cc.has_function()` devuelve **false para todo** — y los proyectos que ramifican sobre eso
885abortan diciendo cualquier cosa menos la verdad.
886
887⚠ **Y mi primer «control» no era un control**: puse `CC=$(command -v gcc || command -v cc)` y en esta
888jaula no hay gcc, así que `cc` resolvió al MISMO envoltorio de zig. Daba idéntico, y por un momento
889pareció que el defecto era de muon en cualquier compilador. Lo que lo aisló fue sacar muon del medio:
890
891```
892/work/sergio/bin/cc x.c -o x -Wl,--fatal-warnings   ⇒ error: unsupported linker arg
893/work/sergio/bin/cc x.c -o x                        ⇒ compila y enlaza OK
894```
895
896**La causa raíz está en el propio muon, y su código lo sabe.** `src/script/runtime/toolchains.meson`
897detecta `zig ld` y lo trata como `lld`, que hereda de `ld` (GNU), con este TODO textual: «we need to
898support a way for the compiler to return a list of linkers to check, and then we can specify zig ld
899as its own linker type». Intenté justamente eso —registrar `zig-ld` con `fatal_warnings: []`— y **no
900toma efecto**: el camino real es «linker: lld (passed-through)», que no pasa por el `detect` del
901registro. Es trabajo aguas arriba, no un parche de una línea.
902
903**Lo que SÍ prueba que la bandera es todo el problema**: con un `cc` que la descarta, las mismas
904sondas dan los valores correctos (`sizeof(long long) = 8`, `aligned_alloc = true`).
905
906⚠ **Y ese envoltorio NO es la solución**, aunque funcione: el lab **no entra en `hash_inputs`**
907([[lab-fuera-de-hash-inputs]]), así que filtrar la bandera ahí haría que dos labs sellaran bytes
908distintos en la MISMA dirección del store. El arreglo tiene que estar donde el hash lo cubra.
909
910## La muestra, con la bandera filtrada (o sea: lo que muon puede de verdad)
911
912| receta | resultado | clase |
913|---|---|---|
914| `dbus-shared` | **setup complete** | ✓ |
915| `iso-codes` | **setup complete** | ✓ |
916| `geocode-glib` | `program not found` (módulo gnome) | dep del entorno |
917| `libgweather` | módulo python `gi` ausente | dep del entorno |
918| `mesa` | pkg-config `vdpau` ausente | dep del entorno |
919| `pango-shared` | pkg-config `gobject-introspection` ausente | dep del entorno |
920| `swaybg` · `wl-clipboard` | pkg-config `wayland-client` ausente | dep del entorno |
921| `accountsservice` | su script de versión exige un clone git | artefacto de MI arnés |
922| **`graphene`** | **`conf.set10('HAVE_MEMALIGN', 1)` ⇒ «expected type bool, got int»** | **GAP de muon** |
923
924**Cobertura del lenguaje: 9 de 10 sin un solo hueco de sintaxis** — `mesa` incluida, que llega hasta
925resolver dependencias. El único gap real es de TIPOS, no de una función que falte: meson acepta un
926`int` en `configuration_data.set10()` y muon 0.6.0 exige `bool`.
927
928## Veredicto: NO-GO hoy, y el gate son dos defectos, no una campaña
929
9301. **`zig ld` tratado como GNU `ld`** ⇒ todas las sondas de enlace rotas. Es el TODO de muon; hay que
931   hacerlo aguas arriba (o parchear el camino «passed-through», que es donde de verdad se decide).
9322. **`conf.set10()` rechaza `int`.** Extrapolando la muestra, ~10 de las 107 tendrían algo de esta
933   clase; cada uno es un arreglo chico aguas arriba o un parche por receta.
934
935Mientras 1 no esté, **el swap re-hashearía 107 recetas para obtener binarios configurados con sondas
936que mienten** — que es peor que no hacerlo. Y el premio tampoco es el que parecía: con el paso 1 hecho
937`python3` ya NO viaja en las imágenes, así que lo que compra el cambio es **la cadena de build**, no
938bytes de imagen.
939
940**No se commiteó `recipes/muon.toml` a propósito:** una receta sin sellar ensucia el grafo con un nodo
941`never` que el drenaje cuenta como deuda. Cuando el gate se cierre, la receta es de quince líneas y
942los comandos están medidos acá: `sh bootstrap.sh build-boot` → `build-boot/muon-bootstrap setup -Dlibcurl=disabled
943-Dlibarchive=disabled -Dlibpkgconf=disabled build` → `muon-bootstrap -C build samu`.
944
945---
946
947# 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)
948
949El paso 8 pedía «receta que publique el mismo SONAME, swap en UN consumidor, y comparar. Si ahí anda,
950la campaña es una decisión de calendario; si no, costó una receta». **Anda.** Y la comparación
951contra la `zlib` del corpus, con datos reales, dice cosas que no se podían suponer.
952
953## Sellado
954
955```
956recipes/zlib-rs.toml  v0.6.8 · commit 0254e07204884bfc902d6ebb68c8b703d0ea49cf (tag v0.6.8)
957b3:61fb18d1f2850c5c3152925d06c0bd19256caaaff97e1e3877f4f3bc72278d7e   (el hash del dry-run)
958publica: usr/lib/{libz.a, libz.so.1, libz.so} · usr/include/{zlib.h, zconf.h} · pkgconfig/zlib.pc
959```
960
961Con los NOMBRES de zlib, para que un consumidor que hoy pide `-lz` y `#include <zlib.h>` no cambie una
962línea. Un consumidor compilado contra el **artefacto sellado** (header y lib suyos) corre y da los
963mismos bytes que el build de scratch — o sea que es determinista.
964
965⚠ **El build necesitó un parche de dos líneas y medio**, y el porqué vale más que el parche: el crate
966que publica la ABI de C (`libz-rs-sys-cdylib`) está **excluido del workspace** por upstream, y
967compilarlo desde su subdirectorio no sirve acá porque el vendoreo del lab corre en la RAÍZ. Meterlo al
968workspace sin más tampoco: el crate **aborta a propósito** con `compile_error!("panic=\"abort\" is
969mandatory because unwinding to C is undefined behavior")`, porque el `[profile.release]` de un miembro
970se ignora. El parche sube el crate a `members`, pone `panic = "abort"` en el profile de la raíz **y
971actualiza `Cargo.lock`** (8 líneas), porque el vendoreo corre con `--locked`.
972
973## Lo medido, con el corpus Silesia (15,7 MB) y el mismo binario contra las dos libs
974
975| nivel | zlib 1.3.1 | zlib-rs 0.6.8 | tamaño | tiempo |
976|---|---|---|---|---|
977| 1 | 7.050.606 B · 226,6 ms | 8.526.731 B · **95,1 ms** | **+20,9 %** | **2,4× más rápido** |
978| **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** |
979| 9 | 6.371.670 B · 1496,3 ms | **6.358.939 B** · **634,9 ms** | **−0,2 %** | **2,4× más rápido** |
980
981Round-trip correcto en los tres niveles. Al nivel por defecto cuesta **0,7 % de tamaño** y devuelve
982**2,3× de velocidad**; al 9 es más chica *y* más rápida; al 1 comprime bastante peor.
983
984⚠ La primera corrida la hice con una entrada SINTÉTICA y daba +84 % al nivel 5. No era representativa:
985con datos reales el número es +0,7 %. Queda escrito porque el dato falso era mío y estuvo a punto de
986decidir el paso al revés.
987
988## Las tres reservas, que son lo que cambia la forma de la campaña
989
9901. **NO es idéntica byte a byte.** El `fnv` de la salida difiere en los tres niveles. Cualquier
991   artefacto que EMBEBA datos comprimidos —páginas de manual gzipeadas, PNG, `.tar.gz`, un
992   initramfs— **cambia de contenido**. No es no-determinismo (es determinista y distinto), pero la
993   campaña de las 122 no es «reconstruir y obtener lo mismo»: **produce artefactos distintos**.
9942. **`gzprintf`/`gzvprintf` no existen.** Medido comparando símbolos: de los 101 públicos de `libz.a`
995   faltan esos dos, y su feature en upstream **pide Rust nightly** (`c_variadic`). El resto que falta
996   es interno de zlib (`_tr_*`, `inflate_fast`, `inflate_table`, `zcalloc`, `z_errmsg`). Un consumidor
997   que llame `gzprintf` **no enlaza** ⇒ hay que mirarlo receta por receta.
9983. **El consumidor necesita `-lunwind`.** Es Rust adentro de C: el `staticlib` arrastra el backtrace
999   de std y sin el unwinder el enlace muere con `undefined symbol: _Unwind_Resume`. Está en el
1000   `zlib.pc` como `Libs.private`, pero un `-lz` a mano no alcanza. Es la perilla que el corpus ya
1001   conocía ([[lab-cargo-vendor-poliglota]]).
1002
1003Y un dato de tamaño para que nadie se sorprenda: el artefacto pasa de **537 K a 13 M** (`libz.a` de
1004422 K a 11 M), porque un `staticlib` de Rust trae su mundo. En el binario final sólo entra lo que se
1005usa, pero el store lo paga.
1006
1007## Veredicto
1008
1009**GO como variante, y la campaña es una decisión de calendario Y DE CONTENIDO** — que es más de lo
1010que el plan suponía. La receta queda sellada en el catálogo con 0 consumidores y en ningún perfil: el
1011siguiente paso natural es swapear UN consumidor real (`lld21` pide `libz.so.1`; `pigz` ejercita
1012deflate a fondo) y correr su prueba, ahora que el artefacto existe y el enlace está caracterizado.
1013
1014## Paso 8 bis — el swap en un consumidor REAL: `pigz` sobre `zlib-rs`, sellado y verificado
1015
1016La lib andando contra un programa de prueba propio no decide una campaña de 122 recetas. Lo que
1017decide es si un consumidor **de verdad**, con su Makefile y su uso de la API, compila y corre **sin
1018tocar su fuente**. `recipes/pigz-zlibrs.toml`: `b3:3a485edc…`, sellada.
1019
1020**pigz es el consumidor duro, y por eso se eligió**: no usa el `compress2` de manual sino los rincones
1021de zlib — `inflateBack`, `inflateBackInit_`, `inflateBackEnd`, `deflatePending`, `deflatePrime`,
1022`crc32_combine`, `deflateSetHeader`. Comprobado que zlib-rs exporta **los siete** antes de intentar el
1023build. Y viaja en 6 perfiles.
1024
1025**Lo único que cambió respecto de `recipes/pigz.toml`:** la dep (`zlib-rs` en vez de `zlib`) y
1026`LIBS="… -lz -lunwind"`. El `-lunwind` va en `LIBS` y NO en `LDFLAGS` porque el Makefile de pigz pone
1027`$(LDFLAGS)` antes de los objetos y `$(LIBS)` al final (línea 15): en enlace estático el orden decide
1028y el unwinder tiene que ir DESPUÉS de quien lo pide. Fuente, banderas y fases, idénticas — la
1029comparación vale porque lo único distinto es la lib.
1030
1031### Medido, con el corpus Silesia (15,7 MB) y un solo hilo para que el tiempo sea comparable
1032
1033| | `pigz` (zlib 1.3.1) | `pigz-zlibrs` |
1034|---|---|---|
1035| comprimir `-6` | 6.410.662 B · **0,726 s** | 6.455.201 B · **0,326 s** |
1036| descomprimir (ejercita `inflateBack`) | 0,065 s | **0,059 s** |
1037| tamaño del binario | 1.062.672 B | **3.971.304 B** |
1038
1039⇒ **+0,69 % de tamaño comprimido, 2,23× más rápido comprimiendo**, y descompresión un pelo más
1040rápida. El precio está en el binario: **+2,9 MB** (×3,7), que es el mundo de Rust que el `staticlib`
1041arrastra.
1042
1043### Interoperabilidad, en las tres direcciones que importan
1044
1045```
1046canónico lee el .gz de zlib-rs   ⇒ OK      (sha256 del original recuperado)
1047zlib-rs  lee el .gz del canónico ⇒ OK
1048el `gzip` del corpus lee los dos ⇒ OK      ← tercera implementación, independiente
1049```
1050
1051O sea: el formato es el mismo y el CRC cierra. Lo que difiere es **qué bytes elige el compresor**, que
1052es la reserva nº 1 del paso 8 y sigue en pie.
1053
1054### Lo que esto cambia en el veredicto
1055
1056El swap en un consumidor real **no costó ni una línea de su fuente**, sólo la bandera del unwinder. Con
1057eso, de las tres reservas del paso 8 quedan dos: la salida no es byte-idéntica (decisión de contenido)
1058y `gzprintf` no existe (hay que mirar receta por receta). El tamaño del binario se suma como tercera:
1059para una imagen con muchos consumidores de zlib, +2,9 MB por binario no es gratis.
1060
1061⚠ `scripts/yupana.py` gana el sufijo `-zlibrs` en `SUFIJOS_VARIANTE`: sin eso el invariante (c) marca
1062`pigz`/`pigz-zlibrs` como «colisión-fuente ACCIONABLE», que es un falso positivo — y un guardián con
1063falsos positivos se empieza a ignorar. Comprobado: `yupana duplicados` sigue en «ninguna».
1064
1065## Paso 8 ter — el consumidor DINÁMICO: `lld21` sobre `zlib-rs`, y el defecto que sólo este caso podía mostrar
1066
1067`pigz-zlibrs` probó el enlace estático. Faltaba el otro, que es el que falla callado: **lld enlaza
1068`libz.so.1` dinámicamente** (`link = "dynamic"` en su receta, por una razón medida que está escrita
1069allá), así que acá no se prueba sólo compilar — se prueba **qué `NEEDED` queda grabado y si el
1070cargador lo resuelve**. `recipes/lld21-zlibrs.toml`: `b3:45eed481…`, sellada, ~6 min de build (sólo el
1071subproyecto lld contra el `llvm21` ya sellado).
1072
1073### El defecto: la `.so` de zlib-rs NO declaraba SONAME
1074
1075```
1076readelf -d  zlib-rs/usr/lib/libz.so.1   ⇒ NEEDED libgcc_s.so.1, NEEDED libc.so … y NINGÚN SONAME
1077readelf -d  zlib-shared/usr/lib/libz.so.1 ⇒ SONAME libz.so.1
1078```
1079
1080Un consumidor enlazado contra una `.so` sin soname graba en su `NEEDED` **la ruta con la que se
1081enlazó**, no el nombre de la librería ⇒ el binario sella, reproduce y **no corre**
1082([[needed-colgante-libstdcxx]]). El caso estático no podía mostrarlo: ahí el `.a` entra al binario y
1083no hay `NEEDED` que grabar.
1084
1085Arreglado en `recipes/zlib-rs.toml` con `-C link-arg=-Wl,-soname,libz.so.1` —en el compile, donde el
1086hash lo cubre—, y re-sellado. `yupana radio zlib-rs` dijo **1 dependiente** (pigz-zlibrs), que se
1087reconstruyó en el acto: la disciplina de medir el radio antes de tocar, aplicada a mi propio cambio.
1088
1089### Con el soname puesto, el swap es transparente
1090
1091| | `lld21` (zlib 1.3.1) | `lld21-zlibrs` |
1092|---|---|---|
1093| `NEEDED` | `libz.so.1` · `libgcc_s.so.1` · `libc.so` | **idénticos** |
1094| binario | 96.628.688 B | 96.628.672 B |
1095| `--version` con su libz cargada | `LLD 21.1.2` | `LLD 21.1.2` |
1096
1097⚠ **Y en el caso dinámico NO hace falta `-lunwind`**: la `.so` de zlib-rs trae `NEEDED libgcc_s.so.1`,
1098que es quien provee los `_Unwind_*` en ejecución. La reserva del unwinder era **del enlace estático**.
1099
1100### La prueba funcional: lld leyendo Y escribiendo `ELFCOMPRESS_ZLIB`
1101
1102No alcanza un `--version`: lld sólo usa zlib para las secciones de depuración comprimidas (por eso su
1103receta enciende `LLVM_ENABLE_ZLIB`, tras un error real con los `crt*` de rust). Así que se le dio de
1104comer exactamente eso — un objeto compilado con `-gz=zlib` — y se le pidió comprimir la salida:
1105
1106```
1107lld -flavor gnu -r cz.o -o r.o --compress-debug-sections=zlib
1108  canónico  salida 5312 B · 3 secciones comprimidas · el readelf de binutils la DESCOMPRIME OK
1109  zlib-rs   salida 5328 B · 3 secciones comprimidas · el readelf de binutils la DESCOMPRIME OK
1110```
1111
1112O sea: **inflate del input y deflate del output, los dos dentro de lld**, y una TERCERA
1113implementación (el `readelf` de binutils) lee el resultado. Y el dato más fino:
1114
1115```
1116sha256 del `--debug-dump=info` descomprimido:  canónico == zlib-rs
1117```
1118
1119**El contenido descomprimido es idéntico; lo único que difiere es el stream deflate** (+16 B). Que es
1120la reserva nº 1 dicha con precisión: zlib-rs no cambia los datos, cambia su empaquetado.
1121
1122### El veredicto, cerrado
1123
1124Los dos casos de enlace están probados con consumidores reales del corpus, sin tocar una línea de sus
1125fuentes. Lo que queda para decidir la campaña de las 122 no es técnico sino de política:
1126
11271. **La salida comprimida no es byte-idéntica** ⇒ los artefactos que embeben datos comprimidos cambian
1128   de contenido. Es determinista y el contenido descomprimido es el mismo, pero el `ArtifactHash`
1129   cambia y con él lo que el usuario descarga.
11302. **`gzprintf` no existe** (pide nightly) ⇒ hay que mirar receta por receta.
11313. **El tamaño**: +2,9 MB en el binario estático de pigz; en el dinámico, **cero** (la `.so` es 1,7 M
1132   contra 108 K de la de zlib, y se comparte).
1133
1134⇒ Y de ahí sale la recomendación concreta, que antes de medir no se podía dar: **empezar por los
1135consumidores DINÁMICOS**, donde el coste en bytes es una sola librería compartida y el unwinder no
1136hace falta. Los estáticos pagan 2,9 MB cada uno.
1137
1138---
1139
1140# Paso 9 — HECHO: `gnupg` no se borra ni se reemplaza, se COMPLETA — le faltaba `pinentry` (2026-09-22)
1141
1142El paso 2.3 de este plan preguntaba si `gnupg` se reemplaza por sequoia o se borra, con esta premisa:
1143«raíz de `base` ⇒ los 7 perfiles, 39,1 MiB, **0 consumidores**, y en todo el repo lo invoca un solo
1144script de prueba». **Las dos mitades de esa premisa son falsas, y medirlas cambió la respuesta.**
1145
1146## 1 · Los «0 consumidores» son 0 por BUILD, y tres por RUNTIME
1147
1148`yupana radio gnupg` dice 0 dependientes directos y 0 transitivos — y es correcto: **nadie lo enlaza**.
1149Pero a gpg no se lo enlaza, se lo EJECUTA, y eso el grafo de build no lo puede ver. Barrido del store
1150entero (66 G, todo ejecutable o `.so`, buscando `/usr/bin/gpg`, `gpgconf`, `gpg-agent`, `gpg --`):
1151**147 ficheros, 13 paquetes distintos**. Filtrados por membresía de imagen:
1152
1153| paquete | perfiles | cómo lo usa |
1154|---|---|---|
1155| `git` | **los 7** | `commit -S`, `tag -s`, `verify-commit` — por `gpg.program` |
1156| `lazygit` | 6 | no llama a gpg: pone `commit.gpgSign`/`tag.gpgSign` y deja que git lo haga |
1157| `gcr` | escritorio-gnome | **`/usr/bin/gpg` HORNEADO en la `.so`** (`-Dgpg_path=`), con sus 19 símbolos `_gcr_gnupg_process_*`/`_gcr_gnupg_importer_*`: es el importador de claves PGP del escritorio |
1158
1159Los otros diez (`gh`, `terragrunt`, `chezmoi`, `sops`, `gopass`, `cosign`, `dnscontrol`, `infracost`,
1160`kluctl`, `terramate`, `gocron`) están en el catálogo y en **ninguna** imagen, así que no deciden nada
1161hoy. Es la lección de `ifconfig` del censo de busybox otra vez, y vale escribirla como regla: **para un
1162binario que se EJECUTA, `radio` es un piso, no el número.** El sweep del store es el complemento.
1163
1164## 2 · Y lo que viajaba estaba a medias inerte: sin `pinentry` no hay clave con contraseña
1165
1166El script de prueba (`product-base-system-boot-test.sh`) corría `gpg --version` y daba verde. Batería
1167completa contra los artefactos SELLADOS (gnupg `f610ba6a…`, gpg-agent propio, `GNUPGHOME` limpio):
1168
1169| prueba | resultado |
1170|---|---|
1171| `--version`, generar clave ed25519, listarla | ✅ |
1172| firmar y verificar (`gpg --verify` **y** `gpgv` con su keyring) | ✅ |
1173| cifrado simétrico y descifrado (sha256 del original recuperado) | ✅ |
1174| cifrado asimétrico con subclave cv25519 y descifrado | ✅ |
1175| firmar con clave **con contraseña** | ❌ `gpg: signing failed: No pinentry` |
1176| `git commit -S` con esa misma clave | ❌ `fatal: failed to write commit object` |
1177
1178**`pinentry` no existía en el catálogo** (`ls recipes/ | grep pinentry` ⇒ nada), y gpg-agent lo busca
1179por RUTA —`bindir/pinentry`, o sea `/usr/bin/pinentry`, comprobado contra el binario sellado— así que
1180no hay configuración que lo salve. Y toda clave que un usuario trae de otra máquina tiene contraseña.
1181
1182⇒ **39,1 MiB en los 7 perfiles, sellados y reproducibles, con la operación más común rota.** Es
1183exactamente la forma del bit setuid del paso 2: presente, íntegro, y sin poder hacer su trabajo.
1184
1185## 3 · La decisión: completarlo. `recipes/pinentry.toml`, 1,1 MiB
1186
1187`b3:18161b35d6cdcd0b0bbbea6bef14e86b7c10389aa37ed683436562d52041a451`, sellada, hash del dry-run
1188idéntico al del sellado. `pinentry-curses` (777 K) + `pinentry-tty` (334 K), los dos estáticos musl, y
1189el `/usr/bin/pinentry → pinentry-curses` que crea el `install-exec-local` de upstream. Frontends
1190gráficas apagadas **explícitamente** una por una: casi todas tienen default `maybe` y auto-detectar es
1191lo que mete una dep sin declararla. `libassuan` del corpus es API 3 y pinentry pide API 2 — **no hace
1192falta parche**, su `m4/libassuan.m4:194` acepta `want=2 got=3`; comprobado antes de autorar.
1193
1194Con la receta sellada, y con un agente NUEVO (sin contraseña en caché):
1195
1196```
1197gpg --detach-sign  con la clave protegida   ⇒ SALIDA=0   · firma verificada: Good signature
1198git commit -S      con la clave protegida   ⇒ SALIDA=0   · [master 24061dd] · Good signature
1199```
1200
1201Y la prueba que vale, porque no usa NADA del anfitrión: el mismo escenario dentro de un `bwrap` cuyo
1202`/` es un rootfs hidratado **sólo** con artefactos del corpus (gnupg + pinentry + git + busybox). Ahí
1203`/usr/bin/pinentry` es el del store, que es la ruta que gpg-agent tiene horneada.
1204
1205`pinentry` entra a `base` como RAÍZ —nadie lo alcanza por deps de build, es un hueco de runtime puro,
1206igual que `bash-completion`— y con eso llega a los 7 perfiles. **Cero reconstrucciones**: `yupana radio
1207pinentry` ⇒ 0 dependientes.
1208
1209## 4 · El guardián, medido en los dos sentidos
1210
1211La sonda vieja (`gpg --version`) no podía ver este defecto. La nueva, ya en
1212`product-base-system-boot-test.sh`, firma con una clave protegida y **lee el error**, que es
1213determinista y distingue las dos situaciones sin necesitar terminal (el test corre por ssh):
1214
1215```
1216con pinentry   ⇒ gpg: signing failed: Not a tty      ← lo encontró y lo lanzó   ⇒ pinentry=ok
1217sin pinentry   ⇒ gpg: signing failed: No pinentry    ← falta                    ⇒ pinentry=AUSENTE
1218```
1219
1220Comprobado en los dos sentidos con el rootfs de bwrap y un control al que se le BORRAN los tres
1221binarios de pinentry. La sonda es **condición de aprobado**, no una línea informativa más:
1222`pinentry=ok` o el test falla. De paso, el contador `$hyd/26` pasa a derivarse de la lista — el 26
1223escrito a mano envejecía a cada cambio y «25/26» se leía como fallo cuando el viejo era el contador
1224([[umbral-a-mano-envejece]]).
1225
1226## 5 · La corrección al paso 2.3: sequoia no es el reemplazo, y medirlo cuesta cinco minutos
1227
1228El plan proponía `sequoia` (`sq`) como «el reemplazo moderno». **Con los consumidores medidos, no lo
1229es**: ninguno quiere una librería OpenPGP ni una CLI nueva — `git` ejecuta lo que diga `gpg.program`
1230hablando el protocolo de opciones y `--status-fd` de gpg, y `gcr` tiene la ruta `/usr/bin/gpg`
1231horneada en el binario. Reemplazar significaría una CLI **compatible con gpg** (`gpg-sq` de sequoia),
1232no `sq`. Sigue siendo posible y ahora tiene un criterio de aceptación concreto: pasar esta misma
1233batería, incluida la sonda de pinentry. La alternativa de borrar a `gnupg` tiene hoy su factura
1234nombrada: se lleva `git commit -S` en los 7 perfiles y el importador de claves de GNOME.
1235
1236⚠ **`git` tiene un segundo camino que acá no está disponible:** `gpg.format = ssh` firma con
1237`ssh-keygen` desde git 2.34 — pero `openssh` sólo viaja en el perfil `servidor`, así que en `base`,
1238`cli` y los 4 escritorios el único camino de firma es gpg.
1239
1240## 6 · Lo que queda dicho y NO resuelto
1241
1242- **`cap_ipc_lock`**: el `install-exec-local` de pinentry se la pone a sus binarios con `setcap`. Una
1243  capability no puede viajar en el artefacto —igual que el setuid del paso 2, la pone el ensamblado— y
1244  sin ella pinentry avisa «insecure memory» y funciona. Deuda nombrada. (En la receta el hook queda
1245  como no-op limpio con `SETCAP=true`: nadie define `SETCAP`, ni `configure.ac` ni el `Makefile.in`, y
1246  hoy se salvaba por un `|| true` tras ejecutar un comando inexistente.)
1247- **Sin `dirmngr`** (apagado en `recipes/gnupg.toml`): no hay servidor de claves ni WKD. Medido:
1248  `gpg --recv-keys` ⇒ `error running '/usr/bin/dirmngr': probably not installed`. Una clave ajena hay
1249  que traerla por fichero.
1250- **Sin `scdaemon`**: no hay tarjeta ni YubiKey. Medido: `gpg --card-status` ⇒ `OpenPGP card not
1251  available: Not supported`.
1252- **Sin `gpgtar`** (`--disable-gpgtar`), que es lo que `gpg --encrypt-files` usa para directorios.
1253- ⚠ **El `BASE_SYSTEM` del boot-test sigue trayendo `sudo`, que SALIÓ de `base` en el paso 2**, y su
1254  chequeo local exige `usr/bin/sudo`. Es la deriva de [[hidratar-desde-el-perfil-no-el-script]]: la
1255  lista está copiada de `targets.toml` en vez de leída de ahí. No se barre de paso — cambia lo que el
1256  test verifica—, pero queda anotado acá y no en la cabeza de nadie.

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

✅ RESPONDIDO el 2026-09-22 en el paso 9, y la respuesta es NINGUNA DE LAS DOS: se completa. Lo de abajo se deja como estaba porque el razonamiento es el que había, pero sus dos premisas están medidas y son falsas: (a) los «0 consumidores» son 0 por build — por runtime lo ejecutan git (7 perfiles), lazygit (6) y gcr (gnome, con la ruta horneada); (b) no lo invoca «un solo script de prueba»: lo invoca git cada vez que alguien firma un commit. Y lo que viajaba no podía usar ninguna clave con contraseña porque faltaba pinentry. Ver paso 9.

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
9gnupg: ¿reemplazar o borrar?0respondido: ninguna de las dos — le faltaba pinentry y git commit -S no funcionaba en ninguna imagen

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».

Paso 8 ter — el consumidor DINÁMICO: lld21 sobre zlib-rs, y el defecto que sólo este caso podía mostrar

pigz-zlibrs probó el enlace estático. Faltaba el otro, que es el que falla callado: lld enlaza libz.so.1 dinámicamente (link = "dynamic" en su receta, por una razón medida que está escrita allá), así que acá no se prueba sólo compilar — se prueba qué NEEDED queda grabado y si el cargador lo resuelve. recipes/lld21-zlibrs.toml: b3:45eed481…, sellada, ~6 min de build (sólo el subproyecto lld contra el llvm21 ya sellado).

El defecto: la .so de zlib-rs NO declaraba SONAME

readelf -d  zlib-rs/usr/lib/libz.so.1   ⇒ NEEDED libgcc_s.so.1, NEEDED libc.so … y NINGÚN SONAME
readelf -d  zlib-shared/usr/lib/libz.so.1 ⇒ SONAME libz.so.1

Un consumidor enlazado contra una .so sin soname graba en su NEEDED la ruta con la que se enlazó, no el nombre de la librería ⇒ el binario sella, reproduce y no corre ([[needed-colgante-libstdcxx]]). El caso estático no podía mostrarlo: ahí el .a entra al binario y no hay NEEDED que grabar.

Arreglado en recipes/zlib-rs.toml con -C link-arg=-Wl,-soname,libz.so.1 —en el compile, donde el hash lo cubre—, y re-sellado. yupana radio zlib-rs dijo 1 dependiente (pigz-zlibrs), que se reconstruyó en el acto: la disciplina de medir el radio antes de tocar, aplicada a mi propio cambio.

Con el soname puesto, el swap es transparente

lld21 (zlib 1.3.1)lld21-zlibrs
NEEDEDlibz.so.1 · libgcc_s.so.1 · libc.soidénticos
binario96.628.688 B96.628.672 B
--version con su libz cargadaLLD 21.1.2LLD 21.1.2

⚠ Y en el caso dinámico NO hace falta -lunwind: la .so de zlib-rs trae NEEDED libgcc_s.so.1, que es quien provee los _Unwind_* en ejecución. La reserva del unwinder era del enlace estático.

La prueba funcional: lld leyendo Y escribiendo ELFCOMPRESS_ZLIB

No alcanza un --version: lld sólo usa zlib para las secciones de depuración comprimidas (por eso su receta enciende LLVM_ENABLE_ZLIB, tras un error real con los crt* de rust). Así que se le dio de comer exactamente eso — un objeto compilado con -gz=zlib — y se le pidió comprimir la salida:

lld -flavor gnu -r cz.o -o r.o --compress-debug-sections=zlib
  canónico  salida 5312 B · 3 secciones comprimidas · el readelf de binutils la DESCOMPRIME OK
  zlib-rs   salida 5328 B · 3 secciones comprimidas · el readelf de binutils la DESCOMPRIME OK

O sea: inflate del input y deflate del output, los dos dentro de lld, y una TERCERA implementación (el readelf de binutils) lee el resultado. Y el dato más fino:

sha256 del `--debug-dump=info` descomprimido:  canónico == zlib-rs

El contenido descomprimido es idéntico; lo único que difiere es el stream deflate (+16 B). Que es la reserva nº 1 dicha con precisión: zlib-rs no cambia los datos, cambia su empaquetado.

El veredicto, cerrado

Los dos casos de enlace están probados con consumidores reales del corpus, sin tocar una línea de sus fuentes. Lo que queda para decidir la campaña de las 122 no es técnico sino de política:

  1. La salida comprimida no es byte-idéntica ⇒ los artefactos que embeben datos comprimidos cambian de contenido. Es determinista y el contenido descomprimido es el mismo, pero el ArtifactHash cambia y con él lo que el usuario descarga.
  2. gzprintf no existe (pide nightly) ⇒ hay que mirar receta por receta.
  3. El tamaño: +2,9 MB en el binario estático de pigz; en el dinámico, cero (la .so es 1,7 M contra 108 K de la de zlib, y se comparte).

⇒ Y de ahí sale la recomendación concreta, que antes de medir no se podía dar: empezar por los consumidores DINÁMICOS, donde el coste en bytes es una sola librería compartida y el unwinder no hace falta. Los estáticos pagan 2,9 MB cada uno.


Paso 9 — HECHO: gnupg no se borra ni se reemplaza, se COMPLETA — le faltaba pinentry (2026-09-22)

El paso 2.3 de este plan preguntaba si gnupg se reemplaza por sequoia o se borra, con esta premisa: «raíz de base ⇒ los 7 perfiles, 39,1 MiB, 0 consumidores, y en todo el repo lo invoca un solo script de prueba». Las dos mitades de esa premisa son falsas, y medirlas cambió la respuesta.

1 · Los «0 consumidores» son 0 por BUILD, y tres por RUNTIME

yupana radio gnupg dice 0 dependientes directos y 0 transitivos — y es correcto: nadie lo enlaza. Pero a gpg no se lo enlaza, se lo EJECUTA, y eso el grafo de build no lo puede ver. Barrido del store entero (66 G, todo ejecutable o .so, buscando /usr/bin/gpg, gpgconf, gpg-agent, gpg --): 147 ficheros, 13 paquetes distintos. Filtrados por membresía de imagen:

paqueteperfilescómo lo usa
gitlos 7commit -S, tag -s, verify-commit — por gpg.program
lazygit6no llama a gpg: pone commit.gpgSign/tag.gpgSign y deja que git lo haga
gcrescritorio-gnome/usr/bin/gpg HORNEADO en la .so (-Dgpg_path=), con sus 19 símbolos _gcr_gnupg_process_*/_gcr_gnupg_importer_*: es el importador de claves PGP del escritorio

Los otros diez (gh, terragrunt, chezmoi, sops, gopass, cosign, dnscontrol, infracost, kluctl, terramate, gocron) están en el catálogo y en ninguna imagen, así que no deciden nada hoy. Es la lección de ifconfig del censo de busybox otra vez, y vale escribirla como regla: para un binario que se EJECUTA, radio es un piso, no el número. El sweep del store es el complemento.

2 · Y lo que viajaba estaba a medias inerte: sin pinentry no hay clave con contraseña

El script de prueba (product-base-system-boot-test.sh) corría gpg --version y daba verde. Batería completa contra los artefactos SELLADOS (gnupg f610ba6a…, gpg-agent propio, GNUPGHOME limpio):

pruebaresultado
--version, generar clave ed25519, listarla✅
firmar y verificar (gpg --verify y gpgv con su keyring)✅
cifrado simétrico y descifrado (sha256 del original recuperado)✅
cifrado asimétrico con subclave cv25519 y descifrado✅
firmar con clave con contraseña❌ gpg: signing failed: No pinentry
git commit -S con esa misma clave❌ fatal: failed to write commit object

pinentry no existía en el catálogo (ls recipes/ | grep pinentry ⇒ nada), y gpg-agent lo busca por RUTA —bindir/pinentry, o sea /usr/bin/pinentry, comprobado contra el binario sellado— así que no hay configuración que lo salve. Y toda clave que un usuario trae de otra máquina tiene contraseña.

⇒ 39,1 MiB en los 7 perfiles, sellados y reproducibles, con la operación más común rota. Es exactamente la forma del bit setuid del paso 2: presente, íntegro, y sin poder hacer su trabajo.

3 · La decisión: completarlo. recipes/pinentry.toml, 1,1 MiB

b3:18161b35d6cdcd0b0bbbea6bef14e86b7c10389aa37ed683436562d52041a451, sellada, hash del dry-run idéntico al del sellado. pinentry-curses (777 K) + pinentry-tty (334 K), los dos estáticos musl, y el /usr/bin/pinentry → pinentry-curses que crea el install-exec-local de upstream. Frontends gráficas apagadas explícitamente una por una: casi todas tienen default maybe y auto-detectar es lo que mete una dep sin declararla. libassuan del corpus es API 3 y pinentry pide API 2 — no hace falta parche, su m4/libassuan.m4:194 acepta want=2 got=3; comprobado antes de autorar.

Con la receta sellada, y con un agente NUEVO (sin contraseña en caché):

gpg --detach-sign  con la clave protegida   ⇒ SALIDA=0   · firma verificada: Good signature
git commit -S      con la clave protegida   ⇒ SALIDA=0   · [master 24061dd] · Good signature

Y la prueba que vale, porque no usa NADA del anfitrión: el mismo escenario dentro de un bwrap cuyo / es un rootfs hidratado sólo con artefactos del corpus (gnupg + pinentry + git + busybox). Ahí /usr/bin/pinentry es el del store, que es la ruta que gpg-agent tiene horneada.

pinentry entra a base como RAÍZ —nadie lo alcanza por deps de build, es un hueco de runtime puro, igual que bash-completion— y con eso llega a los 7 perfiles. Cero reconstrucciones: yupana radio pinentry ⇒ 0 dependientes.

4 · El guardián, medido en los dos sentidos

La sonda vieja (gpg --version) no podía ver este defecto. La nueva, ya en product-base-system-boot-test.sh, firma con una clave protegida y lee el error, que es determinista y distingue las dos situaciones sin necesitar terminal (el test corre por ssh):

con pinentry   ⇒ gpg: signing failed: Not a tty      ← lo encontró y lo lanzó   ⇒ pinentry=ok
sin pinentry   ⇒ gpg: signing failed: No pinentry    ← falta                    ⇒ pinentry=AUSENTE

Comprobado en los dos sentidos con el rootfs de bwrap y un control al que se le BORRAN los tres binarios de pinentry. La sonda es condición de aprobado, no una línea informativa más: pinentry=ok o el test falla. De paso, el contador $hyd/26 pasa a derivarse de la lista — el 26 escrito a mano envejecía a cada cambio y «25/26» se leía como fallo cuando el viejo era el contador ([[umbral-a-mano-envejece]]).

5 · La corrección al paso 2.3: sequoia no es el reemplazo, y medirlo cuesta cinco minutos

El plan proponía sequoia (sq) como «el reemplazo moderno». Con los consumidores medidos, no lo es: ninguno quiere una librería OpenPGP ni una CLI nueva — git ejecuta lo que diga gpg.program hablando el protocolo de opciones y --status-fd de gpg, y gcr tiene la ruta /usr/bin/gpg horneada en el binario. Reemplazar significaría una CLI compatible con gpg (gpg-sq de sequoia), no sq. Sigue siendo posible y ahora tiene un criterio de aceptación concreto: pasar esta misma batería, incluida la sonda de pinentry. La alternativa de borrar a gnupg tiene hoy su factura nombrada: se lleva git commit -S en los 7 perfiles y el importador de claves de GNOME.

⚠ git tiene un segundo camino que acá no está disponible: gpg.format = ssh firma con ssh-keygen desde git 2.34 — pero openssh sólo viaja en el perfil servidor, así que en base, cli y los 4 escritorios el único camino de firma es gpg.

6 · Lo que queda dicho y NO resuelto