1# Plan — con qué conviene hacer lo de busybox, MEDIDO
2
3**Fecha:** 2026-09-22 · **Pregunta:** «botado busybox, ¿con qué conviene hacer lo mismo? ¿Hay que
4rusterizar o soberanizar algo vital, hay opciones modernas a cosas tradicionales, o hay una
5arquitectura mejor?»
6
7**Instrumento** (todo reproducible, nada escrito a mano):
8
9```sh
10# grafo y clases: los 950 nodos con su clase, hash, perfiles y dependientes
11python3 -c 'import json;json.load(open("docs/state/build-state.json"))'
12# aristas y membresía: la MISMA función que usan el grafo, la hidratación y el repo firmado
13python3 -c 'import sys;sys.path.insert(0,"scripts");import yupana;yupana.cargar_colas()'
14# bytes: tamaño real de cada artefacto sellado
15cd /store && du -sk --apparent-size * # 1.435 artefactos, 59,8 GiB
16# runtime real: quién pide qué SONAME
17readelf -d <artefacto>/usr/bin/<bin>
18```
19
20## El hallazgo que ordena todo, y no es una pieza de userland
21
22> **La clausura que define una imagen es `build ∪ runtime`. Entonces cada imagen de takana lleva
23> adentro la cadena de compilación: `llvm21`, `go`, `python3`, `binutils`, `cmake`, `perl`, `nasm`,
24> `bison`, `flex`, `m4`, `meson`, `wasi-sdk`, `nodejs`.**
25
26Es **exactamente la forma del caso busybox**, y por eso vale la pena verlo dos veces: `busybox`
27nunca estuvo declarado en `targets.toml` —llegaba a los 7 perfiles por la clausura, porque 24
28recetas lo pedían en `[deps] build`—, y sacarlo no costó escribir una línea de userland (paso 6 de
29`plan-botar-busybox.md`). Lo que ese paso arregló **para un nombre**, esto lo arregla **para la
30clase entera**.
31
32La definición está en `scripts/yupana.py:98` (`_deps`) y en `scripts/build-state.py:128`, y en las
33dos hay un comentario que explica por qué es la unión: `firefox` declaraba `runtime = ["gcc-libs"]`,
34nadie leía ese campo, y la imagen se armaba sin `libstdc++.so.6`. **El arreglo fue correcto y la
35unión es lo que hay que afinar, no lo que hay que deshacer** — ver el riesgo más abajo.
36
37### Lo que cuesta, en nodos y en bytes
38
39Clausura de hoy contra «raíces + aristas de runtime», medido sobre los artefactos sellados:
40
41| perfil | nodos hoy | sólo-runtime | GiB hoy | GiB sólo-runtime | grasa |
42|---|---|---|---|---|---|
43| `base` | 85 | 48 | **1,35** | 0,71 | **47 %** |
44| `cli` | 152 | 103 | **2,57** | 1,56 | 39 % |
45| `servidor` | 185 | 132 | **4,56** | 2,09 | **54 %** |
46| `escritorio-kde` | 444 | 163 | **13,05** | 5,76 | **56 %** |
47| `escritorio-gnome` | 344 | 158 | 9,63 | 3,86 | 60 % |
48| `escritorio-cosmic` | 295 | 157 | 8,48 | 4,34 | 49 % |
49| `escritorio-sway` | 281 | 143 | 7,90 | 4,11 | 48 % |
50| `escritorio-mirada` | 41 | 17 | 0,79 | 0,20 | 75 % |
51
52Y los caminos, que es lo que lo hace concreto:
53
54| en la imagen | pesa | entra por | qué es |
55|---|---|---|---|
56| `llvm21` | **1.527 MiB** | `servidor` → `rust` → `llvm21` | libs estáticas + headers de LLVM |
57| `python3` | 209 MiB | **`base` → `iputils` → `python3`** | `ping` se construye con meson |
58| `go` | 222 MiB | `cli` → `fzf` → `go` | el toolchain de Go |
59| `binutils` | 127 MiB | `base` → `bwrap` → `binutils` | ensamblador y linker |
60| `perl` | 84 MiB | `base` → `git` → `perl` | los `configure` de OpenSSL/curl |
61| `cmake` | 94 MiB | `servidor` → `rust` → `cmake` | generador de builds |
62| `nodejs` | 86 MiB | `escritorio-kde` → `atuq` → `firefox` → `nodejs` | el JS del build de Gecko |
63
64`llvm21` es el caso de libro y se verificó **en el binario, no en la receta**:
65
66```
67readelf -d /store/…-rust/usr/bin/rustc ⇒ NEEDED librustc_driver-*.so, libc.so (NO libLLVM)
68readelf -d /store/…-lld21/usr/bin/lld ⇒ NEEDED libz.so.1, libgcc_s.so.1, libc.so
69recipes/llvm21.toml:75-76 ⇒ -DLLVM_BUILD_LLVM_DYLIB=OFF -DLLVM_LINK_LLVM_DYLIB=OFF
70```
71
72O sea: 1,5 G de LLVM viajan en la imagen del servidor para que **nadie** los abra. Y `servidor` es
73el perfil donde compilar **sí** es deliberado (`targets.toml:1725-1745`, «la caja que sirve el repo
74y construye, sí»): incluso ahí, el compilador es `rust` + `lld21`, no `llvm21`.
75
76### Por qué es barato: **CERO reconstrucciones**
77
78Esto no toca ninguna receta ⇒ no toca ningún `ArtifactHash` ⇒ no invalida nada. Se cambia **cómo se
79proyecta**, no qué se construye. Es lo que lo pone primero: todo lo demás de este documento cuesta
80builds.
81
82### Por qué es seguro: la ambigüedad es ENUMERABLE
83
84El miedo legítimo es la cicatriz de `gcc-libs`: si una lib dinámica se cae de la imagen, el binario
85sella, reproduce y no arranca. Medido sobre el cierre de `servidor`:
86
87> **de 185 nodos, sólo 28 contienen algún `.so`.** 144 no traen ni uno (todo el corpus es
88> `link = static`), y 13 no están en el store de este hub.
89
90Los 28: `alsa-lib binutils bzip2-shared cmus dbus elfutils-libdw ffmpeg gcc-libs gmp go
91libffi-shared libnl libudev-zero llvm21 mold mpc mpfr musl-fts musl-shared ncurses-shared perl
92python3 readline-shared sqlite-shared util-linux xz-shared zlib-shared zstd`.
93
94Entonces la regla correcta **no** es «usá sólo las aristas de runtime» (eso es lo que rompió
95firefox). Es:
96
97```
98cierre de imagen = raíces ∪ deps de runtime declaradas ∪ proveedores de los SONAME OBSERVADOS
99```
100
101y el instrumento para la tercera parte **ya existe y ya se corre**: `scripts/vigia-sonames.py`
102contesta «¿qué `.so` pide esta imagen que ningún artefacto de su cierre provee?». Hoy es un guardián
103*a posteriori*; acá pasa a ser la **definición**.
104
105### Dónde se toca, y el criterio de terminado
106
107- `scripts/yupana.py` — una tercera vista además de `cargar_colas()` y `cargar_colas(solo_build=True)`:
108 la de **imagen**. Las otras dos no se tocan: `radio` necesita la de build (re-hasheo) y el grafo de
109 estado necesita la unión (cota de «qué hay que construir»).
110- `scripts/hydrate-profile.py` — hoy «proyecta al FHS el cierre ENTERO».
111- `scripts/repo-perfil.sh` y `scripts/build-state.py` — consumen la misma membresía.
112- ⚠ **Hay DOS linajes de imagen y este es el que no está curado.** El otro —`takana bootstrap
113 product`— se arma de tres listas a mano (`STAGE1_COMPONENTS` 4, `USERLAND_COMPONENTS` 5,
114 `SERVICE_COMPONENTS` 2) y por eso `product-image.sh` puede anunciarse «lean, SIN toolchain». La
115 imagen del servidor es **producto + hidratación del perfil encima** (SDD 28 §«Receta del
116 ensamblado»), y la segunda mitad es la que trae los 2,46 GiB. Unificar los linajes es su propia
117 unidad de trabajo; **no hace falta para cobrar esto.**
118
119**Terminado cuando:** `hydrate-profile.py servidor` no proyecta `llvm21 python3 perl binutils cmake
120go`, `vigia-sonames.py` da **cero** huecos en los 8 perfiles, y `product-boot-test.sh` arranca. La
121cifra a batir: **4,56 → 2,09 GiB** en `servidor` y **13,05 → 5,76** en KDE.
122
123⚠ **Un detalle que sale de paso y no es de este frente:** el **kernel no está en ningún perfil**
124(`linux*` → `perfiles: []`); lo pasa un `KERNEL=` de script. Lo que sí viaja en los 7 es
125`linux-headers`. La imagen declara los headers del kernel y no el kernel.
126
127---
128
129## Los tres grupos, porque mezclarlos es lo que cuesta el trabajo
130
131Igual que los tres frentes de busybox, acá la diferencia que importa es **cuántas reconstrucciones
132paga cada cosa** — `[deps] build` entra en `hash_inputs`, `[deps] runtime` no:
133
134| grupo | qué es | rebuilds |
135|---|---|---|
136| **1 · proyección** | cómo se arma la imagen; ninguna receta cambia | **0** |
137| **2 · hojas** | recetas con **0 consumidores**: nadie las declara | **0** (sólo ellas) |
138| **3 · campañas** | tocar una herramienta de build que N recetas declaran | **N** |
139
140---
141
142## Grupo 2 · Las hojas: cambio moderno, coste cero
143
144Todas verificadas con `yupana`: **0 consumidores**, o sea que tocarlas no invalida a nadie.
145
146### 2.1 · `sudo` **y** `doas` en los 7 perfiles — y ninguno de los dos puede funcionar
147
148Dos herramientas para lo mismo, declaradas juntas en `base` (`targets.toml:58`) y heredadas por los
149otros 6 perfiles. `sudo` = 12,1 MiB, `doas` = 0,1 MiB. Pero el hallazgo es otro:
150
151```
152/store/…-sudo/usr/bin/sudo → -r-xr-xr-x (sin setuid)
153/store/…-doas/usr/bin/doas → -r-xr-xr-x (sin setuid)
154grep -rn "S_ISUID\|4755" crates/ → 0 hits que pongan el bit
155```
156
157Y las recetas dicen tres cosas distintas:
158
159| receta | qué afirma | ¿cierto? |
160|---|---|---|
161| `recipes/sudo.toml:57` | «el paquete lo re-aplica al hidratar» | **no** |
162| `recipes/doas.toml:38` | «el binario setuid es cosa de la hidratación» | **no** |
163| `recipes/shadow.toml:15` | «el modelo de artefactos no puede ni expresar ni garantizar un setuid» | **sí** |
164
165Es la familia de [[needed-colgante-libstdcxx]] y [[subcomando-sin-driver]]: sella, reproduce y no
166corre. **Hoy es LATENTE**, y por eso nadie lo vio: el instalador sólo pone contraseña de root
167(`takana-live-install.sh`) y los `[[user]]` de las 7 recetas que los declaran son cuentas de servicio, que no
168escalan. Se vuelve real el día que la imagen tenga un usuario humano no-root — y entonces `sudo`,
169`doas`, `su` y `passwd` fallan los cuatro.
170
171**Orden correcto, y no es «poner sudo-rs»:**
1721. **Decidir quién pone el bit** (el modelo de artefactos, la hidratación o el `.swm`). Es la
173 conversación que `shadow.toml:18` ya pidió y que sigue abierta.
1742. **Elegir UNA.** Si la respuesta es «la chica», `doas` ya ganó. Si es «la compatible con los
175 runbooks», el reemplazo moderno es **`sudo-rs`** (Rust, auditado, drop-in de `sudo`) y de paso
176 saca un setuid en C del producto.
1773. Corregir las dos recetas que afirman algo falso, gane quien gane.
178
179### 2.2 · `chrony` → `ntpd-rs`
180
181La hora es vital y el modo de fallo es traicionero: sin NTP, TLS y las firmas fallan con errores que
182dicen «firma inválida» y no «la hora» — está escrito en el propio `targets.toml`. `chrony` es C, 0
183consumidores, sólo en `servidor`. `ntpd-rs` es el reemplazo Rust hecho exactamente para este nicho
184(cliente + servidor, NTS). Receta Cargo con commit pineado, el patrón de `uutils`.
185
186⚠ Verificar antes de prometer: que cubra el modo *cliente* con el mismo contrato de arranque, y que
187su `[[service]]` encaje en arje (SDD 30). El criterio es `arjectl status` + reloj convergido en
188QEMU, no que compile.
189
190### 2.3 · `gnupg` — mirar si hay que reemplazarlo o **borrarlo**
191
192`gnupg` es raíz de `base` (`targets.toml:60`) ⇒ viaja en los 7 perfiles, 39,1 MiB, **0 consumidores**, y en todo el repo lo invoca **un
193solo script de prueba** (`scripts/product-base-system-boot-test.sh`). La firma de takana **no lo
194usa**: es ed25519 propio (`takana keygen release`, `trust/`). Entonces son dos preguntas distintas:
195
196- Como **herramienta del usuario**: el reemplazo moderno es `sequoia` (`sq`, Rust). ⚠ No confundir
197 con `recipes/sq.toml`, que es otro `sq` (neilotoole, Go, consultas a datos).
198- Como **infraestructura**: no lo es. Si nadie lo pide, es la clase «105 BORRAR» del censo de
199 busybox, no la clase «reemplazar».
200
201⚠ `libgcrypt` (18 consumidores) y `libgpg-error` (20) **se quedan** con o sin gnupg.
202
203---
204
205## Grupo 3 · Las campañas: buen cambio, factura grande
206
207### 3.1 · `meson` → `muon`: matar el intérprete de Python del sandbox
208
209| dato | valor |
210|---|---|
211| recetas que declaran `meson` | **107** |
212| de esas, que declaran también `python3` | **107 (todas)** |
213| consumidores de `python3` en total | 302 |
214| `python3` en el store | **209,0 MiB** |
215| `meson` | 3,9 MiB |
216
217El precedente ya está sentado y es del propio repo: **`samurai`** (C, 0,6 MiB, 256 consumidores)
218reemplazó a ninja. `muon` es su hermano exacto: implementación en C de `meson.build`, sin Python,
219pensada para correr con samurai. Es el cambio que convierte «`ping` arrastra 209 MiB de intérprete»
220en un no-problema del lado del build, además del lado de la imagen.
221
222⚠ **Cuesta 107 recetas re-selladas** (y sus consumidores): `deps.build` está en `hash_inputs`.
223`yupana radio meson` antes de tocar nada. Conviene **sólo pegado a otra invalidación grande**, no
224como cambio suelto.
225⚠ Y se mide primero, no se adopta: muon **no cubre el 100 %** de meson. El criterio de decisión es
226cuántas de las 107 configuran con muon sin tocar el `meson.build`; con una muestra de 10 ya se sabe
227si el frente vale.
228
229### 3.2 · `zlib` → `zlib-rs`: la lib C más consumida del corpus
230
231`zlib` tiene **122 consumidores** (+83 de `zlib-shared`) — más que ninguna otra librería del
232catálogo, y es un clásico de CVEs de memoria. `zlib-rs` publica la **misma ABI C** (`libz.a` /
233`libz.so.1`), así que en teoría es un swap sin migrar llamadores, que es justo lo que no pasa con
234`ripgrep`/`sd`/`fd`.
235
236⚠ 122 recetas re-selladas: es **la campaña más cara de este documento**. Y ⚠ hay que verificar su
237estado upstream antes de prometerlo — la misma cautela que el plan de busybox aplicó a
238`uutils/util-linux`.
239
240**Cómo se decide sin pagar la campaña:** receta `zlib-rs` publicando el mismo SONAME, swap en **un**
241consumidor dinámico (`lld21` pide `libz.so.1`), y comparar. Si ahí anda, la campaña es una decisión
242de calendario; si no, costó una receta.
243
244### 3.3 · Lo que NO es campaña aunque se parezca
245
246`openssl`: 55 consumidores, 44,1 MiB. **9 son recetas Rust** (`cargo-generate`, `cargo-outdated`,
247`cargo-udeps`, `cargo-cache`, `cosmic-settings-daemon`, `hurl`, `monolith`, `nu`, `rink`) que podrían
248usar `rustls` por *feature*. Pero los otros 34 son C y openssl seguiría viajando igual ⇒ **el
249esfuerzo no compra bytes ni superficie**. Es trabajo del día que openssl se caiga sola.
250
251---
252
253## Correcciones al plan de busybox que salen de esta medición
254
255`plan-botar-busybox.md` cifró **23 applets de trabajo real**. Dos de las ocho piezas ya están
256hechas y una **no hace falta**:
257
258| pieza | applets | estado hoy |
259|---|---|---|
260| `psmisc` | `killall killall5 pstree fuser` | ✅ `sealed`, en 7 perfiles (commit `4357b29c`) |
261| `bc` | `bc dc` | ✅ `sealed`, en 7 perfiles (commit `4357b29c`) |
262| `awk` | `awk` | ✅ resuelto **escribiendo**: `akllay`, Rust, 3.531 líneas (`86915f26`) |
263| **`kmod`** | `insmod rmmod lsmod modprobe depmod modinfo` | **no hace falta: no hay módulos** |
264
265El detalle de `kmod`, que es el que cambia la cuenta: **los cuatro kernels que arman imágenes llevan
266`-d MODULES`** — `linux-generic.toml:69`, `linux-metal`, `linux-metal-dual`, `linux-metal-kexec`. El
267único que conserva los módulos del `defconfig` es `recipes/linux.toml`, y ése **no arma ninguna
268imagen** (el SDD 28 ya lo descartó: apaga SCSI y no ve el disco de hcloud). ADR 0018 lo dice del
269otro lado: «un único bzImage, sin módulos ⇒ una sola firma».
270
271⇒ **los 6 applets de `kmod` son clase BORRAR, no clase escribir.** Vuelven el día que exista un
272módulo fuera del árbol (el tester NVIDIA es el candidato), y ese día `kmod` es una receta, no seis
273applets. Con eso el trabajo restante del censo baja de 23 a **11** (`grep` 3, `sed` 1, gestor de
274`/dev` 3, `getty` 2, `nslookup` 1), y de esos `getty` **ya se sabe que es ADR** y no escritura.
275
276---
277
278## Lo único del producto que no sale de una receta: el cargador de arranque
279
280```
281recipes/grub*.toml ⇒ no existe
282scripts/iso-image.sh:37 ⇒ GRUB_LIB="${GRUB_LIB:-/usr/lib/grub/i386-pc}"
283scripts/install-image.sh:49 ⇒ idem · exige grub-mkimage, grub-bios-setup
284```
285
286O sea que **el GRUB que arranca una imagen de takana es el de la caja que la construyó**, y su
287versión no entra en ningún hash. Es la rama BIOS/El-Torito; la rama UEFI **ya se escapó** por
288EFI-stub (`EFI_STUB=1`, `metal-iso.sh:104`, y el ADR 0018 con vendor path propio + reconciliador de
289NVRAM, implementado y verificado en metal).
290
291La decisión es chica y está sin tomar: **o `grub` entra al corpus como receta, o la rama BIOS se
292retira** y todo el arranque queda EFI-stub, que es además la arquitectura moderna (UKI: un PE
293firmable, sin gestor intermedio). Lo que no conviene es lo de hoy, que es depender de un binario
294ajeno sin decirlo. ⚠ Y el ADR 0018 deja abierta la de Secure Boot, que es la que lo ata.
295
296---
297
298## Arquitectura: los tres pendientes, por lo que cuestan HOY
299
3001. **ADR 0012 · el árbol de fuentes compartido — PENDIENTE desde 2026-07-22, y es el que más paga.**
301 Es lo que obliga a `JOBS=1` y a envolver todo en `flock`: la granja compila **de a uno** en una
302 caja de 6 cores. Medido en su día: al invalidarse `libdrm`, ~93 de 205 recetas KDE murieron con el
303 wrapper de zig barrido por el fetch de otra. Resolverlo (caché inmutable direccionada por
304 contenido + copia privada por build, con reflink/overlay) es **la única de estas tres que
305 multiplica la velocidad del frente entero**. Todo lo demás de este documento se construye más
306 rápido después de esto.
3072. **Las 28 recetas `*-shared`.** `zlib`/`zlib-shared`, `pcre2`/`pcre2-shared`, `glib`/`glib-shared`…
308 168,4 MiB de artefactos y, sobre todo, **28 builds y 28 puntos de mantenimiento duplicados** para
309 emitir dos formas del mismo código. La arquitectura correcta es una receta con **varias salidas**
310 (estática / dinámica / headers), que además es lo que haría al vigía de sonames innecesario en su
311 caso más común. ⚠ Re-hashea; va después del §1, que es el que hace que la distinción importe.
3123. **Los dos linajes de imagen** (tres listas a mano vs. clausura del perfil). Hoy conviven y
313 divergen en silencio: es la misma forma que los `hydrate-*.sh` contra `targets.toml`
314 ([[hidratar-desde-el-perfil-no-el-script]]) y que el `sshd` que entra por
315 `product-userland-from-repo.sh` y no por el manifiesto — lo dice el propio `targets.toml`.
316
317---
318
319## Lo que NO conviene tocar, y por qué (para que no se proponga en tres semanas)
320
321| candidato | por qué no |
322|---|---|
323| `openssh` | no hay reemplazo Rust drop-in de un servidor SSH. Es el acceso a la caja: un experimento acá se paga con una máquina inalcanzable |
324| `linux-pam` | 5 consumidores, todos greeters de escritorio. No hay reemplazo; la deuda está contenida y nombrada |
325| `cups` / `bluez` | 291,8 y 35,5 MiB en los 4 escritorios, **ya decidido a conciencia** (`targets.toml`: viajan como paquete y NO se arrancan como servicio). Es el punto 8 de PUBLICABLE, no un olvido |
326| `NetworkManager` | 50,3 MiB, pero los applets de los 4 escritorios le hablan **por D-Bus**. Es migración de llamadores, no borrado — la lección de `ifconfig` del censo de busybox |
327| el demonio de `dbus` | 124 consumidores son **clientes** de libdbus; cambiar el demonio (busd/dbus-broker) es posible y no urge |
328| forkear `ripgrep`/`sd`/`fd` | ya cerrado en `plan-botar-busybox.md`: se usan como **librerías**, no se forkean |
329| deduplicar `work/sources` | el árbol por receta ES el aislamiento del ADR 0012. Deduplicar *de paso* re-crea la carrera |
330
331---
332
333## Orden de ataque
334
335| # | unidad | rebuilds | gana |
336|---|---|---|---|
337| 1 | cierre de imagen `build` vs `runtime` (+ el vigía como definición) | **0** | −2,5 GiB en servidor, −7,3 en KDE, y el toolchain fuera del producto |
338| 2 | decidir el setuid y quedarse con **una** de `sudo`/`doas` | 0 | cierra un «sella y no corre» antes de que sea visible |
339| 3 | `kmod` a BORRAR en el censo de busybox (23 → 11 applets) | 0 | no se escribe lo que nadie necesita |
340| 4 | la rama BIOS: receta de `grub` o retirarla | 0 | ningún binario ajeno sin hash en el arranque |
341| 5 | `chrony` → `ntpd-rs` | 0 | una hoja C vital menos, con drop-in Rust |
342| 6 | **ADR 0012** | — | la granja deja de compilar de a uno |
343| 7 | `meson` → `muon` (medir 10 antes) | 107 | un intérprete menos en el sandbox |
344| 8 | `zlib-rs` en UN consumidor, para decidir | 1 | la lib C más consumida del corpus, con la misma ABI |
345
346Los cinco primeros **no cuestan una sola reconstrucción**. Eso es lo que los pone antes: la lección
347de `uutils`/`brush`/`foot` —trabajo hecho y no cobrado— aplicada al calendario.
348
349---
350
351# Paso 1 — HECHO: el toolchain sale de las imágenes (2026-09-22)
352
353**Cero reconstrucciones, como estaba previsto: no se tocó ninguna receta.** Lo que cambió es la
354proyección, declarada en `[solo-build]` de `docs/state/targets.toml` (44 entradas, cada una con su
355motivo) y aplicada por `yupana.membresia_imagen()`.
356
357## La regla que se implementó, y la que se descartó
358
359**Se poda por NODO, no por tipo de ARISTA.** Podar las aristas de build es lo que dejó a firefox sin
360`libstdc++.so.6`; acá una receta de la lista no entra a la imagen y no se explora desde ella, y todo
361lo demás sigue entrando por donde entraba. Consecuencia buscada: `qtbase`, `glib` o `harfbuzz`
362llegan por aristas de build de apps que sí viajan y **se quedan**. Decidir sobre cada librería es
363otra unidad de trabajo.
364
365**La válvula: una raíz declarada gana.** Por eso `servidor` sigue llevando `rust` y `lld21`, y `cli`
366sigue llevando `mold` — «la caja que sirve el repo y construye, sí».
367
368## Medido en disco, no calculado — hidratando de verdad con `--closure build|image`
369
370`hydrate-profile.py` ganó `--closure build` para poder medir el ANTES: sin control, un «−X MiB» es
371una afirmación sin testigo.
372
373| perfil | ficheros antes → después | tamaño antes → después |
374|---|---|---|
375| `base` | 15.743 → **8.228** | 1,4 G → **871 M** (−38 %) |
376| `servidor` | 40.235 → **11.516** | 4,9 G → **2,6 G** (−47 %) |
377
378⚠ Los dos lados se hidrataron con los **mismos 12 nodos ausentes** de este hub (`chrony`, `cronie`,
379`rust`, `nftables`… están en el store del worker): la comparación es válida, el absoluto es cota
380inferior. Y ⚠ **`du` de los dos árboles en UN solo comando miente**: proyectan por hardlink y `du`
381deduplica entre sus argumentos — daba 45 M para el segundo. Se miden por separado.
382
383Nodos por perfil (`build` = qué hay que construir, sigue igual; `imagen` = qué viaja):
384
385| perfil | build | imagen | fuera |
386|---|---|---|---|
387| `base` | 85 | **68** | 17 |
388| `cli` | 152 | **132** | 20 |
389| `servidor` | 185 | **165** | 20 |
390| `escritorio-mirada` | 41 | **23** | 18 |
391| `escritorio-kde` | 443 | **409** | 34 |
392| `escritorio-gnome` | 339 | **307** | 32 |
393| `escritorio-cosmic` | 295 | **260** | 35 |
394| `escritorio-sway` | 281 | **248** | 33 |
395
396El `base` hidratado ya no tiene `python3`, `perl`, `cmake`, `meson`, `binutils`, `nasm`, `bison`,
397`flex`, `m4`, `pkgconf`, `go` ni `rustc` — comprobado buscándolos en el árbol, no en la lista. Lo
398único que queda con ese nombre es `usr/share/cmake`, que son ficheros de datos que instala otra
399receta, no el programa.
400
401## El instrumento que faltaba: `scripts/vigia-interpretes.py`
402
403El vigía de sonames dio **0 huecos en los 8 perfiles** con el toolchain fuera (`--imagen --fail`,
404`rc=0`). Pero **un `.so` no es la única dependencia de ejecución: un `#!` también**, y de eso no
405había instrumento. El guardián nuevo compara cada script del cierre de imagen contra lo que la
406imagen provee, con dos tablas —`POR_CONTRATO` (andamiaje de build horneado dentro de una librería) y
407`POR_DECISION` (lo que se aceptó perder)— y falla con `--fail` ante cualquier caso no declarado.
408
409**Encontró tres cosas que ningún soname delataba, y las tres cambiaron la decisión:**
410
411| hallazgo | qué era | qué se hizo |
412|---|---|---|
413| `gnome-tweaks` es un **programa Python entero** | sin `python3` la app no abre | `python3` declarado en `escritorio-gnome` |
414| `gdctl` de mutter (configurar salidas) | idem | idem |
415| el `kconf_update` de plasma-workspace | **migra la config al ACTUALIZAR**; si no corre, no migra y nada lo dice | `python3` declarado en `escritorio-kde` |
416
417O sea: `python3` (209 MiB) viaja en las dos imágenes grandes —donde es el 2 % de 10-13 G— y **no**
418en `base`, `cli`, `servidor`, `sway`, `cosmic` ni `mirada`, donde en `base` era el 17 %.
419
420Lo que queda inerte a propósito, contado y nombrado (`•` en la salida del vigía): los 10 scripts
421perl de `git` (`send-email`, `svn`, `cvs*`, `gitweb.cgi`), los 4 de `lm-sensors`
422(`sensors-detect`…), los diagnósticos de `libinput`, `routel`, `rrsync`, `lsusb.py`, los filtros de
423`aerc` y `helper-mux` de squid. Un `#!` que no resuelve **falla ruidoso y nombra al intérprete**;
424por eso la decisión es defendible y no una fuga silenciosa. Revertir cualquiera es una línea en el
425`paquetes` del perfil.
426
427⚠ **Lo que no se midió:** un binario que llame al intérprete con `system()` sin `#!`. No hay
428instrumento para eso; lo caza el arranque, y por eso el criterio de terminado incluye
429`scripts/product-boot-test.sh` en QEMU, que esta jaula no puede correr.
430
431## Lo que NO cambió, a propósito
432
433- **`build-state.py` sigue contando `clausura` = `build ∪ runtime`** — es «qué hay que construir» y
434 para eso el toolchain hace falta. Ahora informa `imagen` al lado, en el mismo renglón.
435- **`repo-perfil.sh` sigue publicando el cierre entero**: un repositorio no es una imagen. Que se
436 pueda instalar `cmake` desde el repo es correcto; que venga horneado en el rootfs, no.
437- **Las recetas**: ninguna. `[solo-build]` vive en el manifiesto y no en el `.toml` justamente para
438 no entrar en `hash_inputs`.
439
440## Guardado contra regresiones
441
442`scripts/targets.py --selftest` gana 6 casos con roturas a propósito sobre la poda (control sin
443poda, poda transitiva, la válvula, podar algo inalcanzable, y que ninguna entrada de la lista sea
444raíz de todos los perfiles). Y se corrigió un comentario de `vigia-sonames.py` que afirmaba que «la
445hidratación de la imagen NO las instala: va por lista explícita» — era del OTRO linaje
446(`bootstrap product`), y por creerlo nadie miró los 1.527 MiB de LLVM que sí se instalaban.
447
448---
449
450# Paso 2 — HECHO: el bit de privilegio, y `doas` como único camino (2026-09-22)
451
452El SDD 28 §5.11 dejó esto «anotado, sin decidir» el 21/09 con tres preguntas. Están contestadas, y
453la respuesta es un mecanismo con guardián, no un `chmod` en un script.
454
455## El hueco medido: 26 binarios, todos inertes
456
457Cruzando el cierre de IMAGEN de los 8 perfiles contra los nombres que en cualquier distro son setuid
458o setgid, y mirando el modo real del artefacto:
459
460| lo trae | binarios | modo hoy | perfiles |
461|---|---|---|---|
462| `shadow` | `passwd chage chfn chsh expiry gpasswd newgrp sg su login newuidmap newgidmap` | `555` | 7 |
463| `util-linux` | `mount umount sulogin wall` | `555` | 7 |
464| `linux-pam` | **`unix_chkpwd`** | `555` | 5 (los escritorios) |
465| `dbus` | `dbus-daemon-launch-helper` | `555` | 7 |
466| `fuse3` | `fusermount3` | `555` | 3 |
467| `iputils` | `ping` | `555` | 7 |
468| `openssh` | `ssh-agent` | `555` | 1 |
469| `sudo` / `doas` | `sudo sudoedit` / `doas` | `555` | 7 |
470
471**Ninguno tiene el bit** y `grep -rn S_ISUID crates/` daba **cero**: no había nadie que lo pusiera.
472El más caro de la lista no es `sudo`: es **`unix_chkpwd`**, que es con lo que PAM verifica una
473contraseña — sin él los greeters de los cuatro escritorios no pueden autenticar a nadie.
474
475Sigue siendo **LATENTE**, y por eso nadie lo chocó: el instalador sólo pone contraseña de root y los
476`[[user]]` de las recetas son cuentas de servicio, que no escalan.
477
478## 1 · ¿Quién lo pone? El ensamblado, no el artefacto — y son dos hechos, no una preferencia
479
480- **El store no puede hashearlo.** `ArtifactHash::of_tree` sólo mira el bit de EJECUCIÓN
481 (`mode & 0o111`, `hash.rs:79`). Dos artefactos que difieran sólo en el setuid tienen el MISMO
482 hash ⇒ dejarlo ahí es privilegio no cubierto por el contrato que cubre todo lo demás.
483- **La hidratación es por HARDLINK** (ADR 0005). El fichero del rootfs *es* el inodo del store: un
484 `chmod u+s` en una imagen de pruebas dejaría setuid el artefacto que comparten todas las demás.
485
486⇒ Se declara en la receta (`[[setuid]]`, con `why` **obligatorio**) y lo aplica `takana setuid
487--apply <rootfs>`, que **rompe el enlace duro** antes del `chmod`. Y la mitad buena del diseño sale
488de ahí: la copia la crea quien ensambla, así que **queda a su nombre** — y el `unshare -r` de
489`install-image.sh` mapea ese uid a 0, o sea setuid ROOT en la imagen, sin un `chown` en ningún lado.
490
491**Cero reconstrucciones**, verificado: el campo está fuera de `hash_inputs` (como `license`,
492`[[service]]` y `[[user]]`) y el hash de `doas` sigue siendo `b3:28bc42c2…`, sellado.
493
494## 2 · ¿Quién lo verifica? Las dos direcciones, y la segunda es la que vale
495
496```
497✓ 4755 usr/bin/doas (doas) [enlace duro roto: el store queda intacto]
498✓ ningún setuid/setgid sin declarar en <rootfs>
499```
500
501- *lo declarado está*: cada ruta existe y queda con el modo pedido (una declaración que apunta a un
502 binario que la imagen no trae **falla**; es la trampa de [[catalogo-no-es-imagen]]).
503- **lo que está, está declarado**: ningún fichero del rootfs tiene el bit sin declararlo. Eso
504 convierte la lista en un invariante, y acá es barato justamente porque el rootfs sale de artefactos
505 inmutables: un bit que aparece sin declaración es, por construcción, alguien pisando la imagen.
506
507Probado con roturas a propósito sobre un rootfs de verdad, no sólo en tests: un setuid colado ⇒
508`rc=1`; una declaración que apunta a una ruta inexistente ⇒ `rc=1`. Más 6 tests en
509`crates/takana-core/src/setuid.rs`, incluido el que justifica el módulo entero: tras aplicar el bit
510**el artefacto del store sigue en `0555`** y el del rootfs es `4755`.
511
512## 3 · ¿Qué significa que el hash no lo cubra?
513
514Que **el privilegio no es propiedad del artefacto: es propiedad de la imagen.** El `ArtifactHash`
515sigue afirmando lo mismo que antes —estos bytes, con este bit de ejecución— y no se mueve. Lo que
516cambia es que el privilegio gana su propio registro (la receta, versionada en git) y su propia
517verificación (las dos direcciones de arriba). Lo que no se puede hacer es guardarlo en el store y
518creer que el hash lo protege.
519
520## La elección: `doas`, y `sudo` sale de las imágenes
521
522Decisión del usuario con lo medido delante:
523
524| | `sudo` | `doas` |
525|---|---|---|
526| tamaño | **13 M** | **134 KB** |
527| config que instala | `/etc/sudoers` + `/etc/sudo.conf` | ninguna |
528| lo que autoriza tal cual | `root ALL=(ALL:ALL) ALL` — **a nadie más** | nada hasta que exista `doas.conf` |
529
530Viajaban **los dos** en los 7 perfiles y ninguno podía funcionar. Ahora `sudo` no está en ninguna
531imagen (su receta se queda en el catálogo, que no es la imagen) y `doas` lleva su `[[setuid]]`.
532
533⚠ **Falta el `/etc/doas.conf`: sin él, `doas` rechaza todo.** No se inventó una regla acá — quién
534puede escalar es una decisión de la imagen (¿grupo `wheel`?, ¿qué usuario humano?) y **hoy las
535imágenes no tienen usuario humano**. Es el mismo hueco que el `/etc/shells` del §5.11 y es la unidad
536siguiente; declarar el bit ahora es lo que no depende de esa decisión.
537
538## Dos comentarios que afirmaban algo falso, corregidos
539
540| receta | decía | realidad |
541|---|---|---|
542| `recipes/sudo.toml:57` | «el paquete lo re-aplica al hidratar» | nadie lo re-aplicaba |
543| `recipes/doas.toml:38` | «el binario setuid es cosa de la hidratación» | no había hidratación que lo hiciera |
544| `recipes/shadow.toml:15` | «el modelo no puede ni expresar ni garantizar un setuid» | **la única que lo decía bien** |
545
546## Lo que el guardián encontró en su PRIMERA corrida, y no era del setuid
547
548El aviso de dueño saltó con `doas` y destapó otra cosa: **el store tiene artefactos de dos uid
549distintos.** Visto desde el anfitrión, `…-doas/usr/bin/doas` es del **uid 1000** (el usuario del
550worker, que llega con la cosecha) y `…-bash/bin/bash` es del **1001** (el hub). Dentro de la jaula el
5511000 no está mapeado y se ve `nobody`.
552
553Medido sobre el rootfs de `base` recién hidratado: **5.984 ficheros de `sergio` y 1.159 de `nobody`**.
554Como `unshare -r` sólo mapea a 0 el uid que corre, **esos 1.159 salen a la imagen con dueño 65534** —
555y nadie lo había medido. Para el setuid no importa (romper el enlace normaliza el dueño), pero es su
556propia unidad de trabajo y queda anotada acá.
557
558## Lo que queda, dicho para que no se lea como cubierto
559
560- **Los otros 25 binarios del inventario.** El mecanismo está; declararlos es una decisión de
561 superficie de ataque, una línea por binario, y **no se tomó de paso**. El orden por lo que
562 desbloquea: `unix_chkpwd` (sin él no hay login gráfico), `passwd`+`chage`+`chfn`+`chsh`+`gpasswd`,
563 `mount`/`umount`, `fusermount3`, `ping`, `newuidmap`/`newgidmap`.
564- **El camino del `.swm`**: `Swm::from_recipe` lleva `deps`, `evidence` y `slots`, pero **no**
565 `[[service]]`, `[[user]]` ni `[[setuid]]` ⇒ un `takana install` de un paquete no pone el bit. Es el
566 mismo hueco que las otras dos declaraciones ya tenían; no lo empeora, pero hay que nombrarlo.
567- **`product-rootfs`**: el otro linaje (`takana bootstrap product`) no pasa por `hydrate-profile.py`,
568 así que tampoco aplica el bit. Cuando el frente C de busybox lo toque, la llamada es una línea.
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.
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.
| perfil | ficheros antes → después | tamaño antes → después |
base | 15.743 → 8.228 | 1,4 G → 871 M (−38 %) |
servidor | 40.235 → 11.516 | 4,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):
| perfil | build | imagen | fuera |
base | 85 | 68 | 17 |
cli | 152 | 132 | 20 |
servidor | 185 | 165 | 20 |
escritorio-mirada | 41 | 23 | 18 |
escritorio-kde | 443 | 409 | 34 |
escritorio-gnome | 339 | 307 | 32 |
escritorio-cosmic | 295 | 260 | 35 |
escritorio-sway | 281 | 248 | 33 |
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:
| hallazgo | qué era | qué se hizo |
gnome-tweaks es un programa Python entero | sin python3 la app no abre | python3 declarado en escritorio-gnome |
gdctl de mutter (configurar salidas) | idem | idem |
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 |
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
build-state.py sigue contando clausura = build ∪ runtime — es «qué hay que construir» y
para eso el toolchain hace falta. Ahora informa imagen al lado, en el mismo renglón.
repo-perfil.sh sigue publicando el cierre entero: un repositorio no es una imagen. Que se
pueda instalar cmake desde el repo es correcto; que venga horneado en el rootfs, no.
- Las recetas: ninguna.
[solo-build] vive en el manifiesto y no en el .toml justamente para
no entrar en hash_inputs.
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 trae | binarios | modo hoy | perfiles |
shadow | passwd chage chfn chsh expiry gpasswd newgrp sg su login newuidmap newgidmap | 555 | 7 |
util-linux | mount umount sulogin wall | 555 | 7 |
linux-pam | unix_chkpwd | 555 | 5 (los escritorios) |
dbus | dbus-daemon-launch-helper | 555 | 7 |
fuse3 | fusermount3 | 555 | 3 |
iputils | ping | 555 | 7 |
openssh | ssh-agent | 555 | 1 |
sudo / doas | sudo sudoedit / doas | 555 | 7 |
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
- El store no puede hashearlo.
ArtifactHash::of_tree sólo mira el bit de EJECUCIÓN
(mode & 0o111, hash.rs:79). Dos artefactos que difieran sólo en el setuid tienen el MISMO
hash ⇒ dejarlo ahí es privilegio no cubierto por el contrato que cubre todo lo demás.
- La hidratación es por HARDLINK (ADR 0005). El fichero del rootfs es el inodo del store: un
chmod u+s en una imagen de pruebas dejaría setuid el artefacto que comparten todas las demás.
⇒ 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>
- lo declarado está: cada ruta existe y queda con el modo pedido (una declaración que apunta a un
binario que la imagen no trae falla; es la trampa de [[catalogo-no-es-imagen]]).
- lo que está, está declarado: ningún fichero del rootfs tiene el bit sin declararlo. Eso
convierte la lista en un invariante, y acá es barato justamente porque el rootfs sale de artefactos
inmutables: un bit que aparece sin declaración es, por construcción, alguien pisando la imagen.
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:
| sudo | doas |
| tamaño | 13 M | 134 KB |
| config que instala | /etc/sudoers + /etc/sudo.conf | ninguna |
| lo que autoriza tal cual | root ALL=(ALL:ALL) ALL — a nadie más | nada 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
| receta | decía | realidad |
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
- Los otros 25 binarios del inventario. El mecanismo está; declararlos es una decisión de
superficie de ataque, una línea por binario, y no se tomó de paso. El orden por lo que
desbloquea:
unix_chkpwd (sin él no hay login gráfico), passwd+chage+chfn+chsh+gpasswd,
mount/umount, fusermount3, ping, newuidmap/newgidmap.
- El camino del
.swm: Swm::from_recipe lleva deps, evidence y slots, pero no
[[service]], [[user]] ni [[setuid]] ⇒ un takana install de un paquete no pone el bit. Es el
mismo hueco que las otras dos declaraciones ya tenían; no lo empeora, pero hay que nombrarlo.
product-rootfs: el otro linaje (takana bootstrap product) no pasa por hydrate-profile.py,
así que tampoco aplica el bit. Cuando el frente C de busybox lo toque, la llamada es una línea.