1# Plan — con qué conviene hacer lo de busybox, MEDIDO
2
3**Fecha:** 2026-09-22 · **Pregunta:** «botado busybox, ¿con qué conviene hacer lo mismo? ¿Hay que
4rusterizar o soberanizar algo vital, hay opciones modernas a cosas tradicionales, o hay una
5arquitectura mejor?»
6
7**Instrumento** (todo reproducible, nada escrito a mano):
8
9```sh
10# grafo y clases: los 950 nodos con su clase, hash, perfiles y dependientes
11python3 -c 'import json;json.load(open("docs/state/build-state.json"))'
12# aristas y membresía: la MISMA función que usan el grafo, la hidratación y el repo firmado
13python3 -c 'import sys;sys.path.insert(0,"scripts");import yupana;yupana.cargar_colas()'
14# bytes: tamaño real de cada artefacto sellado
15cd /store && du -sk --apparent-size * # 1.435 artefactos, 59,8 GiB
16# runtime real: quién pide qué SONAME
17readelf -d <artefacto>/usr/bin/<bin>
18```
19
20## El hallazgo que ordena todo, y no es una pieza de userland
21
22> **La clausura que define una imagen es `build ∪ runtime`. Entonces cada imagen de takana lleva
23> adentro la cadena de compilación: `llvm21`, `go`, `python3`, `binutils`, `cmake`, `perl`, `nasm`,
24> `bison`, `flex`, `m4`, `meson`, `wasi-sdk`, `nodejs`.**
25
26Es **exactamente la forma del caso busybox**, y por eso vale la pena verlo dos veces: `busybox`
27nunca estuvo declarado en `targets.toml` —llegaba a los 7 perfiles por la clausura, porque 24
28recetas lo pedían en `[deps] build`—, y sacarlo no costó escribir una línea de userland (paso 6 de
29`plan-botar-busybox.md`). Lo que ese paso arregló **para un nombre**, esto lo arregla **para la
30clase entera**.
31
32La definición está en `scripts/yupana.py:98` (`_deps`) y en `scripts/build-state.py:128`, y en las
33dos hay un comentario que explica por qué es la unión: `firefox` declaraba `runtime = ["gcc-libs"]`,
34nadie leía ese campo, y la imagen se armaba sin `libstdc++.so.6`. **El arreglo fue correcto y la
35unión es lo que hay que afinar, no lo que hay que deshacer** — ver el riesgo más abajo.
36
37### Lo que cuesta, en nodos y en bytes
38
39Clausura de hoy contra «raíces + aristas de runtime», medido sobre los artefactos sellados:
40
41| perfil | nodos hoy | sólo-runtime | GiB hoy | GiB sólo-runtime | grasa |
42|---|---|---|---|---|---|
43| `base` | 85 | 48 | **1,35** | 0,71 | **47 %** |
44| `cli` | 152 | 103 | **2,57** | 1,56 | 39 % |
45| `servidor` | 185 | 132 | **4,56** | 2,09 | **54 %** |
46| `escritorio-kde` | 444 | 163 | **13,05** | 5,76 | **56 %** |
47| `escritorio-gnome` | 344 | 158 | 9,63 | 3,86 | 60 % |
48| `escritorio-cosmic` | 295 | 157 | 8,48 | 4,34 | 49 % |
49| `escritorio-sway` | 281 | 143 | 7,90 | 4,11 | 48 % |
50| `escritorio-mirada` | 41 | 17 | 0,79 | 0,20 | 75 % |
51
52Y los caminos, que es lo que lo hace concreto:
53
54| en la imagen | pesa | entra por | qué es |
55|---|---|---|---|
56| `llvm21` | **1.527 MiB** | `servidor` → `rust` → `llvm21` | libs estáticas + headers de LLVM |
57| `python3` | 209 MiB | **`base` → `iputils` → `python3`** | `ping` se construye con meson |
58| `go` | 222 MiB | `cli` → `fzf` → `go` | el toolchain de Go |
59| `binutils` | 127 MiB | `base` → `bwrap` → `binutils` | ensamblador y linker |
60| `perl` | 84 MiB | `base` → `git` → `perl` | los `configure` de OpenSSL/curl |
61| `cmake` | 94 MiB | `servidor` → `rust` → `cmake` | generador de builds |
62| `nodejs` | 86 MiB | `escritorio-kde` → `atuq` → `firefox` → `nodejs` | el JS del build de Gecko |
63
64`llvm21` es el caso de libro y se verificó **en el binario, no en la receta**:
65
66```
67readelf -d /store/…-rust/usr/bin/rustc ⇒ NEEDED librustc_driver-*.so, libc.so (NO libLLVM)
68readelf -d /store/…-lld21/usr/bin/lld ⇒ NEEDED libz.so.1, libgcc_s.so.1, libc.so
69recipes/llvm21.toml:75-76 ⇒ -DLLVM_BUILD_LLVM_DYLIB=OFF -DLLVM_LINK_LLVM_DYLIB=OFF
70```
71
72O sea: 1,5 G de LLVM viajan en la imagen del servidor para que **nadie** los abra. Y `servidor` es
73el perfil donde compilar **sí** es deliberado (`targets.toml:1725-1745`, «la caja que sirve el repo
74y construye, sí»): incluso ahí, el compilador es `rust` + `lld21`, no `llvm21`.
75
76### Por qué es barato: **CERO reconstrucciones**
77
78Esto no toca ninguna receta ⇒ no toca ningún `ArtifactHash` ⇒ no invalida nada. Se cambia **cómo se
79proyecta**, no qué se construye. Es lo que lo pone primero: todo lo demás de este documento cuesta
80builds.
81
82### Por qué es seguro: la ambigüedad es ENUMERABLE
83
84El miedo legítimo es la cicatriz de `gcc-libs`: si una lib dinámica se cae de la imagen, el binario
85sella, reproduce y no arranca. Medido sobre el cierre de `servidor`:
86
87> **de 185 nodos, sólo 28 contienen algún `.so`.** 144 no traen ni uno (todo el corpus es
88> `link = static`), y 13 no están en el store de este hub.
89
90Los 28: `alsa-lib binutils bzip2-shared cmus dbus elfutils-libdw ffmpeg gcc-libs gmp go
91libffi-shared libnl libudev-zero llvm21 mold mpc mpfr musl-fts musl-shared ncurses-shared perl
92python3 readline-shared sqlite-shared util-linux xz-shared zlib-shared zstd`.
93
94Entonces la regla correcta **no** es «usá sólo las aristas de runtime» (eso es lo que rompió
95firefox). Es:
96
97```
98cierre de imagen = raíces ∪ deps de runtime declaradas ∪ proveedores de los SONAME OBSERVADOS
99```
100
101y el instrumento para la tercera parte **ya existe y ya se corre**: `scripts/vigia-sonames.py`
102contesta «¿qué `.so` pide esta imagen que ningún artefacto de su cierre provee?». Hoy es un guardián
103*a posteriori*; acá pasa a ser la **definición**.
104
105### Dónde se toca, y el criterio de terminado
106
107- `scripts/yupana.py` — una tercera vista además de `cargar_colas()` y `cargar_colas(solo_build=True)`:
108 la de **imagen**. Las otras dos no se tocan: `radio` necesita la de build (re-hasheo) y el grafo de
109 estado necesita la unión (cota de «qué hay que construir»).
110- `scripts/hydrate-profile.py` — hoy «proyecta al FHS el cierre ENTERO».
111- `scripts/repo-perfil.sh` y `scripts/build-state.py` — consumen la misma membresía.
112- ⚠ **Hay DOS linajes de imagen y este es el que no está curado.** El otro —`takana bootstrap
113 product`— se arma de tres listas a mano (`STAGE1_COMPONENTS` 4, `USERLAND_COMPONENTS` 5,
114 `SERVICE_COMPONENTS` 2) y por eso `product-image.sh` puede anunciarse «lean, SIN toolchain». La
115 imagen del servidor es **producto + hidratación del perfil encima** (SDD 28 §«Receta del
116 ensamblado»), y la segunda mitad es la que trae los 2,46 GiB. Unificar los linajes es su propia
117 unidad de trabajo; **no hace falta para cobrar esto.**
118
119**Terminado cuando:** `hydrate-profile.py servidor` no proyecta `llvm21 python3 perl binutils cmake
120go`, `vigia-sonames.py` da **cero** huecos en los 8 perfiles, y `product-boot-test.sh` arranca. La
121cifra a batir: **4,56 → 2,09 GiB** en `servidor` y **13,05 → 5,76** en KDE.
122
123⚠ **Un detalle que sale de paso y no es de este frente:** el **kernel no está en ningún perfil**
124(`linux*` → `perfiles: []`); lo pasa un `KERNEL=` de script. Lo que sí viaja en los 7 es
125`linux-headers`. La imagen declara los headers del kernel y no el kernel.
126
127---
128
129## Los tres grupos, porque mezclarlos es lo que cuesta el trabajo
130
131Igual que los tres frentes de busybox, acá la diferencia que importa es **cuántas reconstrucciones
132paga cada cosa** — `[deps] build` entra en `hash_inputs`, `[deps] runtime` no:
133
134| grupo | qué es | rebuilds |
135|---|---|---|
136| **1 · proyección** | cómo se arma la imagen; ninguna receta cambia | **0** |
137| **2 · hojas** | recetas con **0 consumidores**: nadie las declara | **0** (sólo ellas) |
138| **3 · campañas** | tocar una herramienta de build que N recetas declaran | **N** |
139
140---
141
142## Grupo 2 · Las hojas: cambio moderno, coste cero
143
144Todas verificadas con `yupana`: **0 consumidores**, o sea que tocarlas no invalida a nadie.
145
146### 2.1 · `sudo` **y** `doas` en los 7 perfiles — y ninguno de los dos puede funcionar
147
148Dos herramientas para lo mismo, declaradas juntas en `base` (`targets.toml:58`) y heredadas por los
149otros 6 perfiles. `sudo` = 12,1 MiB, `doas` = 0,1 MiB. Pero el hallazgo es otro:
150
151```
152/store/…-sudo/usr/bin/sudo → -r-xr-xr-x (sin setuid)
153/store/…-doas/usr/bin/doas → -r-xr-xr-x (sin setuid)
154grep -rn "S_ISUID\|4755" crates/ → 0 hits que pongan el bit
155```
156
157Y las recetas dicen tres cosas distintas:
158
159| receta | qué afirma | ¿cierto? |
160|---|---|---|
161| `recipes/sudo.toml:57` | «el paquete lo re-aplica al hidratar» | **no** |
162| `recipes/doas.toml:38` | «el binario setuid es cosa de la hidratación» | **no** |
163| `recipes/shadow.toml:15` | «el modelo de artefactos no puede ni expresar ni garantizar un setuid» | **sí** |
164
165Es la familia de [[needed-colgante-libstdcxx]] y [[subcomando-sin-driver]]: sella, reproduce y no
166corre. **Hoy es LATENTE**, y por eso nadie lo vio: el instalador sólo pone contraseña de root
167(`takana-live-install.sh`) y los `[[user]]` de las 7 recetas que los declaran son cuentas de servicio, que no
168escalan. Se vuelve real el día que la imagen tenga un usuario humano no-root — y entonces `sudo`,
169`doas`, `su` y `passwd` fallan los cuatro.
170
171**Orden correcto, y no es «poner sudo-rs»:**
1721. **Decidir quién pone el bit** (el modelo de artefactos, la hidratación o el `.swm`). Es la
173 conversación que `shadow.toml:18` ya pidió y que sigue abierta.
1742. **Elegir UNA.** Si la respuesta es «la chica», `doas` ya ganó. Si es «la compatible con los
175 runbooks», el reemplazo moderno es **`sudo-rs`** (Rust, auditado, drop-in de `sudo`) y de paso
176 saca un setuid en C del producto.
1773. Corregir las dos recetas que afirman algo falso, gane quien gane.
178
179### 2.2 · `chrony` → `ntpd-rs`
180
181La hora es vital y el modo de fallo es traicionero: sin NTP, TLS y las firmas fallan con errores que
182dicen «firma inválida» y no «la hora» — está escrito en el propio `targets.toml`. `chrony` es C, 0
183consumidores, sólo en `servidor`. `ntpd-rs` es el reemplazo Rust hecho exactamente para este nicho
184(cliente + servidor, NTS). Receta Cargo con commit pineado, el patrón de `uutils`.
185
186⚠ Verificar antes de prometer: que cubra el modo *cliente* con el mismo contrato de arranque, y que
187su `[[service]]` encaje en arje (SDD 30). El criterio es `arjectl status` + reloj convergido en
188QEMU, no que compile.
189
190### 2.3 · `gnupg` — mirar si hay que reemplazarlo o **borrarlo**
191
192`gnupg` es raíz de `base` (`targets.toml:60`) ⇒ viaja en los 7 perfiles, 39,1 MiB, **0 consumidores**, y en todo el repo lo invoca **un
193solo script de prueba** (`scripts/product-base-system-boot-test.sh`). La firma de takana **no lo
194usa**: es ed25519 propio (`takana keygen release`, `trust/`). Entonces son dos preguntas distintas:
195
196- Como **herramienta del usuario**: el reemplazo moderno es `sequoia` (`sq`, Rust). ⚠ No confundir
197 con `recipes/sq.toml`, que es otro `sq` (neilotoole, Go, consultas a datos).
198- Como **infraestructura**: no lo es. Si nadie lo pide, es la clase «105 BORRAR» del censo de
199 busybox, no la clase «reemplazar».
200
201⚠ `libgcrypt` (18 consumidores) y `libgpg-error` (20) **se quedan** con o sin gnupg.
202
203---
204
205## Grupo 3 · Las campañas: buen cambio, factura grande
206
207### 3.1 · `meson` → `muon`: matar el intérprete de Python del sandbox
208
209| dato | valor |
210|---|---|
211| recetas que declaran `meson` | **107** |
212| de esas, que declaran también `python3` | **107 (todas)** |
213| consumidores de `python3` en total | 302 |
214| `python3` en el store | **209,0 MiB** |
215| `meson` | 3,9 MiB |
216
217El precedente ya está sentado y es del propio repo: **`samurai`** (C, 0,6 MiB, 256 consumidores)
218reemplazó a ninja. `muon` es su hermano exacto: implementación en C de `meson.build`, sin Python,
219pensada para correr con samurai. Es el cambio que convierte «`ping` arrastra 209 MiB de intérprete»
220en un no-problema del lado del build, además del lado de la imagen.
221
222⚠ **Cuesta 107 recetas re-selladas** (y sus consumidores): `deps.build` está en `hash_inputs`.
223`yupana radio meson` antes de tocar nada. Conviene **sólo pegado a otra invalidación grande**, no
224como cambio suelto.
225⚠ Y se mide primero, no se adopta: muon **no cubre el 100 %** de meson. El criterio de decisión es
226cuántas de las 107 configuran con muon sin tocar el `meson.build`; con una muestra de 10 ya se sabe
227si el frente vale.
228
229### 3.2 · `zlib` → `zlib-rs`: la lib C más consumida del corpus
230
231`zlib` tiene **122 consumidores** (+83 de `zlib-shared`) — más que ninguna otra librería del
232catálogo, y es un clásico de CVEs de memoria. `zlib-rs` publica la **misma ABI C** (`libz.a` /
233`libz.so.1`), así que en teoría es un swap sin migrar llamadores, que es justo lo que no pasa con
234`ripgrep`/`sd`/`fd`.
235
236⚠ 122 recetas re-selladas: es **la campaña más cara de este documento**. Y ⚠ hay que verificar su
237estado upstream antes de prometerlo — la misma cautela que el plan de busybox aplicó a
238`uutils/util-linux`.
239
240**Cómo se decide sin pagar la campaña:** receta `zlib-rs` publicando el mismo SONAME, swap en **un**
241consumidor dinámico (`lld21` pide `libz.so.1`), y comparar. Si ahí anda, la campaña es una decisión
242de calendario; si no, costó una receta.
243
244### 3.3 · Lo que NO es campaña aunque se parezca
245
246`openssl`: 55 consumidores, 44,1 MiB. **9 son recetas Rust** (`cargo-generate`, `cargo-outdated`,
247`cargo-udeps`, `cargo-cache`, `cosmic-settings-daemon`, `hurl`, `monolith`, `nu`, `rink`) que podrían
248usar `rustls` por *feature*. Pero los otros 34 son C y openssl seguiría viajando igual ⇒ **el
249esfuerzo no compra bytes ni superficie**. Es trabajo del día que openssl se caiga sola.
250
251---
252
253## Correcciones al plan de busybox que salen de esta medición
254
255`plan-botar-busybox.md` cifró **23 applets de trabajo real**. Dos de las ocho piezas ya están
256hechas y una **no hace falta**:
257
258| pieza | applets | estado hoy |
259|---|---|---|
260| `psmisc` | `killall killall5 pstree fuser` | ✅ `sealed`, en 7 perfiles (commit `4357b29c`) |
261| `bc` | `bc dc` | ✅ `sealed`, en 7 perfiles (commit `4357b29c`) |
262| `awk` | `awk` | ✅ resuelto **escribiendo**: `akllay`, Rust, 3.531 líneas (`86915f26`) |
263| **`kmod`** | `insmod rmmod lsmod modprobe depmod modinfo` | **no hace falta: no hay módulos** |
264
265El detalle de `kmod`, que es el que cambia la cuenta: **los cuatro kernels que arman imágenes llevan
266`-d MODULES`** — `linux-generic.toml:69`, `linux-metal`, `linux-metal-dual`, `linux-metal-kexec`. El
267único que conserva los módulos del `defconfig` es `recipes/linux.toml`, y ése **no arma ninguna
268imagen** (el SDD 28 ya lo descartó: apaga SCSI y no ve el disco de hcloud). ADR 0018 lo dice del
269otro lado: «un único bzImage, sin módulos ⇒ una sola firma».
270
271⇒ **los 6 applets de `kmod` son clase BORRAR, no clase escribir.** Vuelven el día que exista un
272módulo fuera del árbol (el tester NVIDIA es el candidato), y ese día `kmod` es una receta, no seis
273applets. Con eso el trabajo restante del censo baja de 23 a **11** (`grep` 3, `sed` 1, gestor de
274`/dev` 3, `getty` 2, `nslookup` 1), y de esos `getty` **ya se sabe que es ADR** y no escritura.
275
276---
277
278## Lo único del producto que no sale de una receta: el cargador de arranque
279
280```
281recipes/grub*.toml ⇒ no existe
282scripts/iso-image.sh:37 ⇒ GRUB_LIB="${GRUB_LIB:-/usr/lib/grub/i386-pc}"
283scripts/install-image.sh:49 ⇒ idem · exige grub-mkimage, grub-bios-setup
284```
285
286O sea que **el GRUB que arranca una imagen de takana es el de la caja que la construyó**, y su
287versión no entra en ningún hash. Es la rama BIOS/El-Torito; la rama UEFI **ya se escapó** por
288EFI-stub (`EFI_STUB=1`, `metal-iso.sh:104`, y el ADR 0018 con vendor path propio + reconciliador de
289NVRAM, implementado y verificado en metal).
290
291La decisión es chica y está sin tomar: **o `grub` entra al corpus como receta, o la rama BIOS se
292retira** y todo el arranque queda EFI-stub, que es además la arquitectura moderna (UKI: un PE
293firmable, sin gestor intermedio). Lo que no conviene es lo de hoy, que es depender de un binario
294ajeno sin decirlo. ⚠ Y el ADR 0018 deja abierta la de Secure Boot, que es la que lo ata.
295
296---
297
298## Arquitectura: los tres pendientes, por lo que cuestan HOY
299
3001. **ADR 0012 · el árbol de fuentes compartido — PENDIENTE desde 2026-07-22, y es el que más paga.**
301 Es lo que obliga a `JOBS=1` y a envolver todo en `flock`: la granja compila **de a uno** en una
302 caja de 6 cores. Medido en su día: al invalidarse `libdrm`, ~93 de 205 recetas KDE murieron con el
303 wrapper de zig barrido por el fetch de otra. Resolverlo (caché inmutable direccionada por
304 contenido + copia privada por build, con reflink/overlay) es **la única de estas tres que
305 multiplica la velocidad del frente entero**. Todo lo demás de este documento se construye más
306 rápido después de esto.
3072. **Las 28 recetas `*-shared`.** `zlib`/`zlib-shared`, `pcre2`/`pcre2-shared`, `glib`/`glib-shared`…
308 168,4 MiB de artefactos y, sobre todo, **28 builds y 28 puntos de mantenimiento duplicados** para
309 emitir dos formas del mismo código. La arquitectura correcta es una receta con **varias salidas**
310 (estática / dinámica / headers), que además es lo que haría al vigía de sonames innecesario en su
311 caso más común. ⚠ Re-hashea; va después del §1, que es el que hace que la distinción importe.
3123. **Los dos linajes de imagen** (tres listas a mano vs. clausura del perfil). Hoy conviven y
313 divergen en silencio: es la misma forma que los `hydrate-*.sh` contra `targets.toml`
314 ([[hidratar-desde-el-perfil-no-el-script]]) y que el `sshd` que entra por
315 `product-userland-from-repo.sh` y no por el manifiesto — lo dice el propio `targets.toml`.
316
317---
318
319## Lo que NO conviene tocar, y por qué (para que no se proponga en tres semanas)
320
321| candidato | por qué no |
322|---|---|
323| `openssh` | no hay reemplazo Rust drop-in de un servidor SSH. Es el acceso a la caja: un experimento acá se paga con una máquina inalcanzable |
324| `linux-pam` | 5 consumidores, todos greeters de escritorio. No hay reemplazo; la deuda está contenida y nombrada |
325| `cups` / `bluez` | 291,8 y 35,5 MiB en los 4 escritorios, **ya decidido a conciencia** (`targets.toml`: viajan como paquete y NO se arrancan como servicio). Es el punto 8 de PUBLICABLE, no un olvido |
326| `NetworkManager` | 50,3 MiB, pero los applets de los 4 escritorios le hablan **por D-Bus**. Es migración de llamadores, no borrado — la lección de `ifconfig` del censo de busybox |
327| el demonio de `dbus` | 124 consumidores son **clientes** de libdbus; cambiar el demonio (busd/dbus-broker) es posible y no urge |
328| forkear `ripgrep`/`sd`/`fd` | ya cerrado en `plan-botar-busybox.md`: se usan como **librerías**, no se forkean |
329| deduplicar `work/sources` | el árbol por receta ES el aislamiento del ADR 0012. Deduplicar *de paso* re-crea la carrera |
330
331---
332
333## Orden de ataque
334
335| # | unidad | rebuilds | gana |
336|---|---|---|---|
337| 1 | cierre de imagen `build` vs `runtime` (+ el vigía como definición) | **0** | −2,5 GiB en servidor, −7,3 en KDE, y el toolchain fuera del producto |
338| 2 | decidir el setuid y quedarse con **una** de `sudo`/`doas` | 0 | cierra un «sella y no corre» antes de que sea visible |
339| 3 | `kmod` a BORRAR en el censo de busybox (23 → 11 applets) | 0 | no se escribe lo que nadie necesita |
340| 4 | la rama BIOS: receta de `grub` o retirarla | 0 | ningún binario ajeno sin hash en el arranque |
341| 5 | `chrony` → `ntpd-rs` | 0 | una hoja C vital menos, con drop-in Rust |
342| 6 | **ADR 0012** | — | la granja deja de compilar de a uno |
343| 7 | `meson` → `muon` (medir 10 antes) | 107 | un intérprete menos en el sandbox |
344| 8 | `zlib-rs` en UN consumidor, para decidir | 1 | la lib C más consumida del corpus, con la misma ABI |
345
346Los cinco primeros **no cuestan una sola reconstrucción**. Eso es lo que los pone antes: la lección
347de `uutils`/`brush`/`foot` —trabajo hecho y no cobrado— aplicada al calendario.
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:
| perfil | nodos hoy | sólo-runtime | GiB hoy | GiB sólo-runtime | grasa |
base | 85 | 48 | 1,35 | 0,71 | 47 % |
cli | 152 | 103 | 2,57 | 1,56 | 39 % |
servidor | 185 | 132 | 4,56 | 2,09 | 54 % |
escritorio-kde | 444 | 163 | 13,05 | 5,76 | 56 % |
escritorio-gnome | 344 | 158 | 9,63 | 3,86 | 60 % |
escritorio-cosmic | 295 | 157 | 8,48 | 4,34 | 49 % |
escritorio-sway | 281 | 143 | 7,90 | 4,11 | 48 % |
escritorio-mirada | 41 | 17 | 0,79 | 0,20 | 75 % |
Y los caminos, que es lo que lo hace concreto:
| en la imagen | pesa | entra por | qué es |
llvm21 | 1.527 MiB | servidor → rust → llvm21 | libs estáticas + headers de LLVM |
python3 | 209 MiB | base → iputils → python3 | ping se construye con meson |
go | 222 MiB | cli → fzf → go | el toolchain de Go |
binutils | 127 MiB | base → bwrap → binutils | ensamblador y linker |
perl | 84 MiB | base → git → perl | los configure de OpenSSL/curl |
cmake | 94 MiB | servidor → rust → cmake | generador de builds |
nodejs | 86 MiB | escritorio-kde → atuq → firefox → nodejs | el 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
scripts/yupana.py — una tercera vista además de cargar_colas() y cargar_colas(solo_build=True):
la de imagen. Las otras dos no se tocan: radio necesita la de build (re-hasheo) y el grafo de
estado necesita la unión (cota de «qué hay que construir»).
scripts/hydrate-profile.py — hoy «proyecta al FHS el cierre ENTERO».
scripts/repo-perfil.sh y scripts/build-state.py — consumen la misma membresía.
- ⚠ Hay DOS linajes de imagen y este es el que no está curado. El otro —
takana bootstrap product— se arma de tres listas a mano (STAGE1_COMPONENTS 4, USERLAND_COMPONENTS 5,
SERVICE_COMPONENTS 2) y por eso product-image.sh puede anunciarse «lean, SIN toolchain». La
imagen del servidor es producto + hidratación del perfil encima (SDD 28 §«Receta del
ensamblado»), y la segunda mitad es la que trae los 2,46 GiB. Unificar los linajes es su propia
unidad de trabajo; no hace falta para cobrar esto.
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:
| grupo | qué es | rebuilds |
| 1 · proyección | cómo se arma la imagen; ninguna receta cambia | 0 |
| 2 · hojas | recetas con 0 consumidores: nadie las declara | 0 (sólo ellas) |
| 3 · campañas | tocar una herramienta de build que N recetas declaran | N |
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:
| receta | qué 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»:
- 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.
- 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.
- Corregir las dos recetas que afirman algo falso, gane quien gane.
2.2 · chrony → ntpd-rs
La hora es vital y el modo de fallo es traicionero: sin NTP, TLS y las firmas fallan con errores que
dicen «firma inválida» y no «la hora» — está escrito en el propio targets.toml. chrony es C, 0
consumidores, sólo en servidor. ntpd-rs es el reemplazo Rust hecho exactamente para este nicho
(cliente + servidor, NTS). Receta Cargo con commit pineado, el patrón de uutils.
⚠ Verificar antes de prometer: que cubra el modo cliente con el mismo contrato de arranque, y que
su [[service]] encaje en arje (SDD 30). El criterio es arjectl status + reloj convergido en
QEMU, no que compile.
2.3 · gnupg — mirar si hay que reemplazarlo o borrarlo
gnupg es raíz de base (targets.toml:60) ⇒ viaja en los 7 perfiles, 39,1 MiB, 0 consumidores, y en todo el repo lo invoca un
solo script de prueba (scripts/product-base-system-boot-test.sh). La firma de takana no lo
usa: es ed25519 propio (takana keygen release, trust/). Entonces son dos preguntas distintas:
- Como herramienta del usuario: el reemplazo moderno es
sequoia (sq, Rust). ⚠ No confundir
con recipes/sq.toml, que es otro sq (neilotoole, Go, consultas a datos).
- Como infraestructura: no lo es. Si nadie lo pide, es la clase «105 BORRAR» del censo de
busybox, no la clase «reemplazar».
⚠ 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
| dato | valor |
recetas que declaran meson | 107 |
de esas, que declaran también python3 | 107 (todas) |
consumidores de python3 en total | 302 |
python3 en el store | 209,0 MiB |
meson | 3,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:
| pieza | applets | estado hoy |
psmisc | killall killall5 pstree fuser | ✅ sealed, en 7 perfiles (commit 4357b29c) |
bc | bc dc | ✅ sealed, en 7 perfiles (commit 4357b29c) |
awk | awk | ✅ resuelto escribiendo: akllay, Rust, 3.531 líneas (86915f26) |
kmod | insmod rmmod lsmod modprobe depmod modinfo | no 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
- 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.
- 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.
- 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)
| candidato | por qué no |
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 |
linux-pam | 5 consumidores, todos greeters de escritorio. No hay reemplazo; la deuda está contenida y nombrada |
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 |
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 |
el demonio de dbus | 124 consumidores son clientes de libdbus; cambiar el demonio (busd/dbus-broker) es posible y no urge |
forkear ripgrep/sd/fd | ya cerrado en plan-botar-busybox.md: se usan como librerías, no se forkean |
deduplicar work/sources | el árbol por receta ES el aislamiento del ADR 0012. Deduplicar de paso re-crea la carrera |
Orden de ataque
| # | unidad | rebuilds | gana |
| 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 |
| 2 | decidir el setuid y quedarse con una de sudo/doas | 0 | cierra un «sella y no corre» antes de que sea visible |
| 3 | kmod a BORRAR en el censo de busybox (23 → 11 applets) | 0 | no se escribe lo que nadie necesita |
| 4 | la rama BIOS: receta de grub o retirarla | 0 | ningún binario ajeno sin hash en el arranque |
| 5 | chrony → ntpd-rs | 0 | una hoja C vital menos, con drop-in Rust |
| 6 | ADR 0012 | — | la granja deja de compilar de a uno |
| 7 | meson → muon (medir 10 antes) | 107 | un intérprete menos en el sandbox |
| 8 | zlib-rs en UN consumidor, para decidir | 1 | la lib C más consumida del corpus, con la misma ABI |
Los cinco primeros no cuestan una sola reconstrucción. Eso es lo que los pone antes: la lección
de uutils/brush/foot —trabajo hecho y no cobrado— aplicada al calendario.