gioser

sergio/takana

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

TOML 815Rust 56Shell 37Markdown 291229 Sutichaykuna

⎇ 0523ea066509b320d491ce444b9650bb1c7c9dfe docs/plan-que-sigue-tras-busybox.md Markdown · 568 siq'ikuna · 32.0 KB Ñawpa willay ch'uya

paso 2: el bit de privilegio se declara y se verifica — y `doas` queda solo (SDD 28 §5.11) 0523ea066 · Sergio ·

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

plan-que-sigue-tras-busybox.md

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

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

Instrumento (todo reproducible, nada escrito a mano):

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

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

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

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

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

Lo que cuesta, en nodos y en bytes

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

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

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

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

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

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

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

Por qué es barato: CERO reconstrucciones

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

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

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

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

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

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

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

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

Dónde se toca, y el criterio de terminado

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

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


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

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

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

Grupo 2 · Las hojas: cambio moderno, coste cero

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

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

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

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

Y las recetas dicen tres cosas distintas:

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

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

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

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

2.2 · chrony → ntpd-rs

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

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

2.3 · gnupg — mirar si hay que reemplazarlo o borrarlo

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

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


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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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


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

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

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

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


Arquitectura: los tres pendientes, por lo que cuestan HOY

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

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

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

Orden de ataque

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Lo que NO cambió, a propósito

Guardado contra regresiones

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


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

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

El hueco medido: 26 binarios, todos inertes

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

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

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

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

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

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

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

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

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

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

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

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

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

Decisión del usuario con lo medido delante:

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

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

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

Dos comentarios que afirmaban algo falso, corregidos

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

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

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

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

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