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