# 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): ```sh # 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 /usr/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»:** 1. **Decidir quién pone el bit** (el modelo de artefactos, la hidratación o el `.swm`). Es la conversación que `shadow.toml:18` ya pidió y que sigue abierta. 2. **Elegir UNA.** Si la respuesta es «la chica», `doas` ya ganó. Si es «la compatible con los runbooks», el reemplazo moderno es **`sudo-rs`** (Rust, auditado, drop-in de `sudo`) y de paso saca un setuid en C del producto. 3. Corregir las dos recetas que afirman algo falso, gane quien gane. ### 2.2 · `chrony` → `ntpd-rs` La hora es vital y el modo de fallo es traicionero: sin NTP, TLS y las firmas fallan con errores que dicen «firma inválida» y no «la hora» — está escrito en el propio `targets.toml`. `chrony` es C, 0 consumidores, sólo en `servidor`. `ntpd-rs` es el reemplazo Rust hecho exactamente para este nicho (cliente + servidor, NTS). Receta Cargo con commit pineado, el patrón de `uutils`. ⚠ Verificar antes de prometer: que cubra el modo *cliente* con el mismo contrato de arranque, y que su `[[service]]` encaje en arje (SDD 30). El criterio es `arjectl status` + reloj convergido en QEMU, no que compile. ### 2.3 · `gnupg` — mirar si hay que reemplazarlo o **borrarlo** ✅ **RESPONDIDO el 2026-09-22 en el paso 9, y la respuesta es NINGUNA DE LAS DOS: se completa.** Lo de abajo se deja como estaba porque el razonamiento es el que había, pero sus dos premisas están medidas y son falsas: (a) los «0 consumidores» son 0 **por build** — por runtime lo ejecutan `git` (7 perfiles), `lazygit` (6) y `gcr` (gnome, con la ruta horneada); (b) no lo invoca «un solo script de prueba»: lo invoca git cada vez que alguien firma un commit. Y lo que viajaba no podía usar ninguna clave con contraseña porque **faltaba `pinentry`**. Ver paso 9. `gnupg` es raíz de `base` (`targets.toml:60`) ⇒ viaja en los 7 perfiles, 39,1 MiB, **0 consumidores**, y en todo el repo lo invoca **un solo script de prueba** (`scripts/product-base-system-boot-test.sh`). La firma de takana **no lo usa**: es ed25519 propio (`takana keygen release`, `trust/`). Entonces son dos preguntas distintas: - 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 1. **ADR 0012 · el árbol de fuentes compartido — PENDIENTE desde 2026-07-22, y es el que más paga.** Es lo que obliga a `JOBS=1` y a envolver todo en `flock`: la granja compila **de a uno** en una caja de 6 cores. Medido en su día: al invalidarse `libdrm`, ~93 de 205 recetas KDE murieron con el wrapper de zig barrido por el fetch de otra. Resolverlo (caché inmutable direccionada por contenido + copia privada por build, con reflink/overlay) es **la única de estas tres que multiplica la velocidad del frente entero**. Todo lo demás de este documento se construye más rápido después de esto. 2. **Las 28 recetas `*-shared`.** `zlib`/`zlib-shared`, `pcre2`/`pcre2-shared`, `glib`/`glib-shared`… 168,4 MiB de artefactos y, sobre todo, **28 builds y 28 puntos de mantenimiento duplicados** para emitir dos formas del mismo código. La arquitectura correcta es una receta con **varias salidas** (estática / dinámica / headers), que además es lo que haría al vigía de sonames innecesario en su caso más común. ⚠ Re-hashea; va después del §1, que es el que hace que la distinción importe. 3. **Los dos linajes de imagen** (tres listas a mano vs. clausura del perfil). Hoy conviven y divergen en silencio: es la misma forma que los `hydrate-*.sh` contra `targets.toml` ([[hidratar-desde-el-perfil-no-el-script]]) y que el `sshd` que entra por `product-userland-from-repo.sh` y no por el manifiesto — lo dice el propio `targets.toml`. --- ## Lo que NO conviene tocar, y por qué (para que no se proponga en tres semanas) | 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 | | 9 | `gnupg`: ¿reemplazar o borrar? | 0 | **respondido: ninguna de las dos** — le faltaba `pinentry` y `git commit -S` no funcionaba en ninguna imagen | Los cinco primeros **no cuestan una sola reconstrucción**. Eso es lo que los pone antes: la lección de `uutils`/`brush`/`foot` —trabajo hecho y no cobrado— aplicada al calendario. --- # Paso 1 — HECHO: el toolchain sale de las imágenes (2026-09-22) **Cero reconstrucciones, como estaba previsto: no se tocó ninguna receta.** Lo que cambió es la proyección, declarada en `[solo-build]` de `docs/state/targets.toml` (44 entradas, cada una con su motivo) y aplicada por `yupana.membresia_imagen()`. ## La regla que se implementó, y la que se descartó **Se poda por NODO, no por tipo de ARISTA.** Podar las aristas de build es lo que dejó a firefox sin `libstdc++.so.6`; acá una receta de la lista no entra a la imagen y no se explora desde ella, y todo lo demás sigue entrando por donde entraba. Consecuencia buscada: `qtbase`, `glib` o `harfbuzz` llegan por aristas de build de apps que sí viajan y **se quedan**. Decidir sobre cada librería es otra unidad de trabajo. **La válvula: una raíz declarada gana.** Por eso `servidor` sigue llevando `rust` y `lld21`, y `cli` sigue llevando `mold` — «la caja que sirve el repo y construye, sí». ## Medido en disco, no calculado — hidratando de verdad con `--closure build|image` `hydrate-profile.py` ganó `--closure build` para poder medir el ANTES: sin control, un «−X MiB» es una afirmación sin testigo. | 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 `, 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 ``` - *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= KERNEL= 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/.git`) también es estado compartido y su clave es sólo el nombre. ⚠ **El fd no se hereda**, y eso es el punto fino: Rust abre con `O_CLOEXEC`, así que ni `tar`, ni `git`, ni `bwrap`, ni un nieto fugado se quedan con el lock — que es exactamente el modo de fallo que `CLAUDE.md` documenta para el `flock(1)` de alrededor (hora y media de granja parada, en silencio). ## Verificado, con control Dos builds concurrentes de la misma receta, con el árbol borrado antes para forzar la extracción: ``` A: fetch … → sealed B: «árbol: esperando el lock» → «lock obtenido tras esperar» (1,27 s) → «caché: otro build lo selló mientras esperábamos el árbol» los dos rc=0 · UN artefacto ``` Y el control, que es lo que prueba que el lock hace falta — modelando la ventana real (extracción de segundos + compilación), dos builds sobre el mismo árbol **sin** lock dan la firma exacta del apagón de KDE del 22/07: ``` SIN lock: A: ROTO — .zwrap/cc AUSENTE, 0/400 fuentes B: OK CON lock: A: OK B: OK ``` Más dos tests en el crate, con su control (dos nombres distintos NO se bloquean) y la comprobación de que el fichero de lock vive **fuera** de `sources/`, donde el propio `remove_dir_all` lo barrería. **Y no mueve ningún hash**: el artefacto de la prueba salió `b3:dd6b538af774…`, idéntico al que el grafo ya registraba. La batería del workspace pasa entera salvo un test de `swm` que ya estaba roto antes (de otro agente). ## Lo que habilita, y lo que NO decide `JOBS=1` en el worker y el `-P2` de `build-farm.sh` estaban puestos **por esta carrera**. Ya no hay carrera. **Subir el paralelismo es otra decisión y no se toma acá**: el worker son 6 cores y 16 G, y esta clase de máquina ya provocó un `global_oom`. Lo que cambia es que el límite pasa a ser RAM y disco —medibles, con watchdog— en vez de la corrupción silenciosa de un árbol compartido. Y el `flock` de `CLAUDE.md` §1 sigue haciendo falta por otra razón: serializa contra los scripts de la granja, que es control de RECURSOS. --- # Paso 7 — MEDIDO: `muon` es viable, pero hoy es NO-GO por dos defectos nombrados (2026-09-22) El paso 7 del plan pedía explícitamente **medir 10 antes de adoptar**, porque «muon no cubre el 100 % de meson». Se midió. El resultado no es el que la frase sugería: **el problema no es la cobertura del lenguaje meson — es una bandera del enlazador.** ## Cómo se midió - `muon 0.6.0` (tag, commit `1c825e41e2266be1bead999f7f65d9c763a7ca18`) construido acá: **una sola compilación C** de `src/amalgam.c` (6,7 s con el `cc` del lab) da el `muon-bootstrap`, y ése configura y compila el muon completo en **4,7 s / 156 objetivos**, usando su `samu` embebido. Dice **«meson compatibility version 1.7.99»**. Como receta sería trivial. - **Muestra SISTEMÁTICA, no elegida a dedo**: de las 107 recetas que declaran `meson`, ordenadas alfabéticamente, una cada 10,7 ⇒ 10 repartidas por todo el alfabeto. Salieron `accountsservice dbus-shared geocode-glib graphene iso-codes libgweather mesa pango-shared swaybg wl-clipboard` — incluida **`mesa`**, que es el proyecto meson más retorcido del corpus. - Fuente bajada de la URL/commit que pinea cada receta, y `muon setup` en el árbol. ## El hallazgo transversal: UNA bandera rompe TODAS las sondas del compilador Primera corrida: 9 de 10 fallan. Dos con un síntoma que no era de deps —dbus: «Could not find a 64-bit integer type»; graphene: «No aligned malloc function could be found»— y la causa estaba dos capas más abajo: ``` dbg executing cc … '-Wl,--fatal-warnings' dbg compiler stderr: 'error: unsupported linker arg: --fatal-warnings' c compiler: sizeof long long: -1 ``` muon añade `-Wl,--fatal-warnings` a las sondas que **compilan y ENLAZAN** (`src/functions/compiler.c:210`), y **zig lo rechaza de plano**. Entonces `cc.sizeof()` devuelve **-1** y `cc.has_function()` devuelve **false para todo** — y los proyectos que ramifican sobre eso abortan diciendo cualquier cosa menos la verdad. ⚠ **Y mi primer «control» no era un control**: puse `CC=$(command -v gcc || command -v cc)` y en esta jaula no hay gcc, así que `cc` resolvió al MISMO envoltorio de zig. Daba idéntico, y por un momento pareció que el defecto era de muon en cualquier compilador. Lo que lo aisló fue sacar muon del medio: ``` /work/sergio/bin/cc x.c -o x -Wl,--fatal-warnings ⇒ error: unsupported linker arg /work/sergio/bin/cc x.c -o x ⇒ compila y enlaza OK ``` **La causa raíz está en el propio muon, y su código lo sabe.** `src/script/runtime/toolchains.meson` detecta `zig ld` y lo trata como `lld`, que hereda de `ld` (GNU), con este TODO textual: «we need to support a way for the compiler to return a list of linkers to check, and then we can specify zig ld as its own linker type». Intenté justamente eso —registrar `zig-ld` con `fatal_warnings: []`— y **no toma efecto**: el camino real es «linker: lld (passed-through)», que no pasa por el `detect` del registro. Es trabajo aguas arriba, no un parche de una línea. **Lo que SÍ prueba que la bandera es todo el problema**: con un `cc` que la descarta, las mismas sondas dan los valores correctos (`sizeof(long long) = 8`, `aligned_alloc = true`). ⚠ **Y ese envoltorio NO es la solución**, aunque funcione: el lab **no entra en `hash_inputs`** ([[lab-fuera-de-hash-inputs]]), así que filtrar la bandera ahí haría que dos labs sellaran bytes distintos en la MISMA dirección del store. El arreglo tiene que estar donde el hash lo cubra. ## La muestra, con la bandera filtrada (o sea: lo que muon puede de verdad) | receta | resultado | clase | |---|---|---| | `dbus-shared` | **setup complete** | ✓ | | `iso-codes` | **setup complete** | ✓ | | `geocode-glib` | `program not found` (módulo gnome) | dep del entorno | | `libgweather` | módulo python `gi` ausente | dep del entorno | | `mesa` | pkg-config `vdpau` ausente | dep del entorno | | `pango-shared` | pkg-config `gobject-introspection` ausente | dep del entorno | | `swaybg` · `wl-clipboard` | pkg-config `wayland-client` ausente | dep del entorno | | `accountsservice` | su script de versión exige un clone git | artefacto de MI arnés | | **`graphene`** | **`conf.set10('HAVE_MEMALIGN', 1)` ⇒ «expected type bool, got int»** | **GAP de muon** | **Cobertura del lenguaje: 9 de 10 sin un solo hueco de sintaxis** — `mesa` incluida, que llega hasta resolver dependencias. El único gap real es de TIPOS, no de una función que falte: meson acepta un `int` en `configuration_data.set10()` y muon 0.6.0 exige `bool`. ## Veredicto: NO-GO hoy, y el gate son dos defectos, no una campaña 1. **`zig ld` tratado como GNU `ld`** ⇒ todas las sondas de enlace rotas. Es el TODO de muon; hay que hacerlo aguas arriba (o parchear el camino «passed-through», que es donde de verdad se decide). 2. **`conf.set10()` rechaza `int`.** Extrapolando la muestra, ~10 de las 107 tendrían algo de esta clase; cada uno es un arreglo chico aguas arriba o un parche por receta. Mientras 1 no esté, **el swap re-hashearía 107 recetas para obtener binarios configurados con sondas que mienten** — que es peor que no hacerlo. Y el premio tampoco es el que parecía: con el paso 1 hecho `python3` ya NO viaja en las imágenes, así que lo que compra el cambio es **la cadena de build**, no bytes de imagen. **No se commiteó `recipes/muon.toml` a propósito:** una receta sin sellar ensucia el grafo con un nodo `never` que el drenaje cuenta como deuda. Cuando el gate se cierre, la receta es de quince líneas y los comandos están medidos acá: `sh bootstrap.sh build-boot` → `build-boot/muon-bootstrap setup -Dlibcurl=disabled -Dlibarchive=disabled -Dlibpkgconf=disabled build` → `muon-bootstrap -C build samu`. --- # Paso 8 — MEDIDO Y SELLADO: `zlib-rs` funciona como drop-in, con tres reservas que cambian la forma de la campaña (2026-09-22) El paso 8 pedía «receta que publique el mismo SONAME, swap en UN consumidor, y comparar. Si ahí anda, la campaña es una decisión de calendario; si no, costó una receta». **Anda.** Y la comparación contra la `zlib` del corpus, con datos reales, dice cosas que no se podían suponer. ## Sellado ``` recipes/zlib-rs.toml v0.6.8 · commit 0254e07204884bfc902d6ebb68c8b703d0ea49cf (tag v0.6.8) b3:61fb18d1f2850c5c3152925d06c0bd19256caaaff97e1e3877f4f3bc72278d7e (el hash del dry-run) publica: usr/lib/{libz.a, libz.so.1, libz.so} · usr/include/{zlib.h, zconf.h} · pkgconfig/zlib.pc ``` Con los NOMBRES de zlib, para que un consumidor que hoy pide `-lz` y `#include ` no cambie una línea. Un consumidor compilado contra el **artefacto sellado** (header y lib suyos) corre y da los mismos bytes que el build de scratch — o sea que es determinista. ⚠ **El build necesitó un parche de dos líneas y medio**, y el porqué vale más que el parche: el crate que publica la ABI de C (`libz-rs-sys-cdylib`) está **excluido del workspace** por upstream, y compilarlo desde su subdirectorio no sirve acá porque el vendoreo del lab corre en la RAÍZ. Meterlo al workspace sin más tampoco: el crate **aborta a propósito** con `compile_error!("panic=\"abort\" is mandatory because unwinding to C is undefined behavior")`, porque el `[profile.release]` de un miembro se ignora. El parche sube el crate a `members`, pone `panic = "abort"` en el profile de la raíz **y actualiza `Cargo.lock`** (8 líneas), porque el vendoreo corre con `--locked`. ## Lo medido, con el corpus Silesia (15,7 MB) y el mismo binario contra las dos libs | nivel | zlib 1.3.1 | zlib-rs 0.6.8 | tamaño | tiempo | |---|---|---|---|---| | 1 | 7.050.606 B · 226,6 ms | 8.526.731 B · **95,1 ms** | **+20,9 %** | **2,4× más rápido** | | **6 (por defecto)** | 6.409.962 B · 760,4 ms | 6.457.822 B · **335,4 ms** | **+0,7 %** | **2,3× más rápido** | | 9 | 6.371.670 B · 1496,3 ms | **6.358.939 B** · **634,9 ms** | **−0,2 %** | **2,4× más rápido** | Round-trip correcto en los tres niveles. Al nivel por defecto cuesta **0,7 % de tamaño** y devuelve **2,3× de velocidad**; al 9 es más chica *y* más rápida; al 1 comprime bastante peor. ⚠ La primera corrida la hice con una entrada SINTÉTICA y daba +84 % al nivel 5. No era representativa: con datos reales el número es +0,7 %. Queda escrito porque el dato falso era mío y estuvo a punto de decidir el paso al revés. ## Las tres reservas, que son lo que cambia la forma de la campaña 1. **NO es idéntica byte a byte.** El `fnv` de la salida difiere en los tres niveles. Cualquier artefacto que EMBEBA datos comprimidos —páginas de manual gzipeadas, PNG, `.tar.gz`, un initramfs— **cambia de contenido**. No es no-determinismo (es determinista y distinto), pero la campaña de las 122 no es «reconstruir y obtener lo mismo»: **produce artefactos distintos**. 2. **`gzprintf`/`gzvprintf` no existen.** Medido comparando símbolos: de los 101 públicos de `libz.a` faltan esos dos, y su feature en upstream **pide Rust nightly** (`c_variadic`). El resto que falta es interno de zlib (`_tr_*`, `inflate_fast`, `inflate_table`, `zcalloc`, `z_errmsg`). Un consumidor que llame `gzprintf` **no enlaza** ⇒ hay que mirarlo receta por receta. 3. **El consumidor necesita `-lunwind`.** Es Rust adentro de C: el `staticlib` arrastra el backtrace de std y sin el unwinder el enlace muere con `undefined symbol: _Unwind_Resume`. Está en el `zlib.pc` como `Libs.private`, pero un `-lz` a mano no alcanza. Es la perilla que el corpus ya conocía ([[lab-cargo-vendor-poliglota]]). Y un dato de tamaño para que nadie se sorprenda: el artefacto pasa de **537 K a 13 M** (`libz.a` de 422 K a 11 M), porque un `staticlib` de Rust trae su mundo. En el binario final sólo entra lo que se usa, pero el store lo paga. ## Veredicto **GO como variante, y la campaña es una decisión de calendario Y DE CONTENIDO** — que es más de lo que el plan suponía. La receta queda sellada en el catálogo con 0 consumidores y en ningún perfil: el siguiente paso natural es swapear UN consumidor real (`lld21` pide `libz.so.1`; `pigz` ejercita deflate a fondo) y correr su prueba, ahora que el artefacto existe y el enlace está caracterizado. ## Paso 8 bis — el swap en un consumidor REAL: `pigz` sobre `zlib-rs`, sellado y verificado La lib andando contra un programa de prueba propio no decide una campaña de 122 recetas. Lo que decide es si un consumidor **de verdad**, con su Makefile y su uso de la API, compila y corre **sin tocar su fuente**. `recipes/pigz-zlibrs.toml`: `b3:3a485edc…`, sellada. **pigz es el consumidor duro, y por eso se eligió**: no usa el `compress2` de manual sino los rincones de zlib — `inflateBack`, `inflateBackInit_`, `inflateBackEnd`, `deflatePending`, `deflatePrime`, `crc32_combine`, `deflateSetHeader`. Comprobado que zlib-rs exporta **los siete** antes de intentar el build. Y viaja en 6 perfiles. **Lo único que cambió respecto de `recipes/pigz.toml`:** la dep (`zlib-rs` en vez de `zlib`) y `LIBS="… -lz -lunwind"`. El `-lunwind` va en `LIBS` y NO en `LDFLAGS` porque el Makefile de pigz pone `$(LDFLAGS)` antes de los objetos y `$(LIBS)` al final (línea 15): en enlace estático el orden decide y el unwinder tiene que ir DESPUÉS de quien lo pide. Fuente, banderas y fases, idénticas — la comparación vale porque lo único distinto es la lib. ### Medido, con el corpus Silesia (15,7 MB) y un solo hilo para que el tiempo sea comparable | | `pigz` (zlib 1.3.1) | `pigz-zlibrs` | |---|---|---| | comprimir `-6` | 6.410.662 B · **0,726 s** | 6.455.201 B · **0,326 s** | | descomprimir (ejercita `inflateBack`) | 0,065 s | **0,059 s** | | tamaño del binario | 1.062.672 B | **3.971.304 B** | ⇒ **+0,69 % de tamaño comprimido, 2,23× más rápido comprimiendo**, y descompresión un pelo más rápida. El precio está en el binario: **+2,9 MB** (×3,7), que es el mundo de Rust que el `staticlib` arrastra. ### Interoperabilidad, en las tres direcciones que importan ``` canónico lee el .gz de zlib-rs ⇒ OK (sha256 del original recuperado) zlib-rs lee el .gz del canónico ⇒ OK el `gzip` del corpus lee los dos ⇒ OK ← tercera implementación, independiente ``` O sea: el formato es el mismo y el CRC cierra. Lo que difiere es **qué bytes elige el compresor**, que es la reserva nº 1 del paso 8 y sigue en pie. ### Lo que esto cambia en el veredicto El swap en un consumidor real **no costó ni una línea de su fuente**, sólo la bandera del unwinder. Con eso, de las tres reservas del paso 8 quedan dos: la salida no es byte-idéntica (decisión de contenido) y `gzprintf` no existe (hay que mirar receta por receta). El tamaño del binario se suma como tercera: para una imagen con muchos consumidores de zlib, +2,9 MB por binario no es gratis. ⚠ `scripts/yupana.py` gana el sufijo `-zlibrs` en `SUFIJOS_VARIANTE`: sin eso el invariante (c) marca `pigz`/`pigz-zlibrs` como «colisión-fuente ACCIONABLE», que es un falso positivo — y un guardián con falsos positivos se empieza a ignorar. Comprobado: `yupana duplicados` sigue en «ninguna». ## Paso 8 ter — el consumidor DINÁMICO: `lld21` sobre `zlib-rs`, y el defecto que sólo este caso podía mostrar `pigz-zlibrs` probó el enlace estático. Faltaba el otro, que es el que falla callado: **lld enlaza `libz.so.1` dinámicamente** (`link = "dynamic"` en su receta, por una razón medida que está escrita allá), así que acá no se prueba sólo compilar — se prueba **qué `NEEDED` queda grabado y si el cargador lo resuelve**. `recipes/lld21-zlibrs.toml`: `b3:45eed481…`, sellada, ~6 min de build (sólo el subproyecto lld contra el `llvm21` ya sellado). ### El defecto: la `.so` de zlib-rs NO declaraba SONAME ``` readelf -d zlib-rs/usr/lib/libz.so.1 ⇒ NEEDED libgcc_s.so.1, NEEDED libc.so … y NINGÚN SONAME readelf -d zlib-shared/usr/lib/libz.so.1 ⇒ SONAME libz.so.1 ``` Un consumidor enlazado contra una `.so` sin soname graba en su `NEEDED` **la ruta con la que se enlazó**, no el nombre de la librería ⇒ el binario sella, reproduce y **no corre** ([[needed-colgante-libstdcxx]]). El caso estático no podía mostrarlo: ahí el `.a` entra al binario y no hay `NEEDED` que grabar. Arreglado en `recipes/zlib-rs.toml` con `-C link-arg=-Wl,-soname,libz.so.1` —en el compile, donde el hash lo cubre—, y re-sellado. `yupana radio zlib-rs` dijo **1 dependiente** (pigz-zlibrs), que se reconstruyó en el acto: la disciplina de medir el radio antes de tocar, aplicada a mi propio cambio. ### Con el soname puesto, el swap es transparente | | `lld21` (zlib 1.3.1) | `lld21-zlibrs` | |---|---|---| | `NEEDED` | `libz.so.1` · `libgcc_s.so.1` · `libc.so` | **idénticos** | | binario | 96.628.688 B | 96.628.672 B | | `--version` con su libz cargada | `LLD 21.1.2` | `LLD 21.1.2` | ⚠ **Y en el caso dinámico NO hace falta `-lunwind`**: la `.so` de zlib-rs trae `NEEDED libgcc_s.so.1`, que es quien provee los `_Unwind_*` en ejecución. La reserva del unwinder era **del enlace estático**. ### La prueba funcional: lld leyendo Y escribiendo `ELFCOMPRESS_ZLIB` No alcanza un `--version`: lld sólo usa zlib para las secciones de depuración comprimidas (por eso su receta enciende `LLVM_ENABLE_ZLIB`, tras un error real con los `crt*` de rust). Así que se le dio de comer exactamente eso — un objeto compilado con `-gz=zlib` — y se le pidió comprimir la salida: ``` lld -flavor gnu -r cz.o -o r.o --compress-debug-sections=zlib canónico salida 5312 B · 3 secciones comprimidas · el readelf de binutils la DESCOMPRIME OK zlib-rs salida 5328 B · 3 secciones comprimidas · el readelf de binutils la DESCOMPRIME OK ``` O sea: **inflate del input y deflate del output, los dos dentro de lld**, y una TERCERA implementación (el `readelf` de binutils) lee el resultado. Y el dato más fino: ``` sha256 del `--debug-dump=info` descomprimido: canónico == zlib-rs ``` **El contenido descomprimido es idéntico; lo único que difiere es el stream deflate** (+16 B). Que es la reserva nº 1 dicha con precisión: zlib-rs no cambia los datos, cambia su empaquetado. ### El veredicto, cerrado Los dos casos de enlace están probados con consumidores reales del corpus, sin tocar una línea de sus fuentes. Lo que queda para decidir la campaña de las 122 no es técnico sino de política: 1. **La salida comprimida no es byte-idéntica** ⇒ los artefactos que embeben datos comprimidos cambian de contenido. Es determinista y el contenido descomprimido es el mismo, pero el `ArtifactHash` cambia y con él lo que el usuario descarga. 2. **`gzprintf` no existe** (pide nightly) ⇒ hay que mirar receta por receta. 3. **El tamaño**: +2,9 MB en el binario estático de pigz; en el dinámico, **cero** (la `.so` es 1,7 M contra 108 K de la de zlib, y se comparte). ⇒ Y de ahí sale la recomendación concreta, que antes de medir no se podía dar: **empezar por los consumidores DINÁMICOS**, donde el coste en bytes es una sola librería compartida y el unwinder no hace falta. Los estáticos pagan 2,9 MB cada uno. --- # Paso 9 — HECHO: `gnupg` no se borra ni se reemplaza, se COMPLETA — le faltaba `pinentry` (2026-09-22) El paso 2.3 de este plan preguntaba si `gnupg` se reemplaza por sequoia o se borra, con esta premisa: «raíz de `base` ⇒ los 7 perfiles, 39,1 MiB, **0 consumidores**, y en todo el repo lo invoca un solo script de prueba». **Las dos mitades de esa premisa son falsas, y medirlas cambió la respuesta.** ## 1 · Los «0 consumidores» son 0 por BUILD, y tres por RUNTIME `yupana radio gnupg` dice 0 dependientes directos y 0 transitivos — y es correcto: **nadie lo enlaza**. Pero a gpg no se lo enlaza, se lo EJECUTA, y eso el grafo de build no lo puede ver. Barrido del store entero (66 G, todo ejecutable o `.so`, buscando `/usr/bin/gpg`, `gpgconf`, `gpg-agent`, `gpg --`): **147 ficheros, 13 paquetes distintos**. Filtrados por membresía de imagen: | paquete | perfiles | cómo lo usa | |---|---|---| | `git` | **los 7** | `commit -S`, `tag -s`, `verify-commit` — por `gpg.program` | | `lazygit` | 6 | no llama a gpg: pone `commit.gpgSign`/`tag.gpgSign` y deja que git lo haga | | `gcr` | escritorio-gnome | **`/usr/bin/gpg` HORNEADO en la `.so`** (`-Dgpg_path=`), con sus 19 símbolos `_gcr_gnupg_process_*`/`_gcr_gnupg_importer_*`: es el importador de claves PGP del escritorio | Los otros diez (`gh`, `terragrunt`, `chezmoi`, `sops`, `gopass`, `cosign`, `dnscontrol`, `infracost`, `kluctl`, `terramate`, `gocron`) están en el catálogo y en **ninguna** imagen, así que no deciden nada hoy. Es la lección de `ifconfig` del censo de busybox otra vez, y vale escribirla como regla: **para un binario que se EJECUTA, `radio` es un piso, no el número.** El sweep del store es el complemento. ## 2 · Y lo que viajaba estaba a medias inerte: sin `pinentry` no hay clave con contraseña El script de prueba (`product-base-system-boot-test.sh`) corría `gpg --version` y daba verde. Batería completa contra los artefactos SELLADOS (gnupg `f610ba6a…`, gpg-agent propio, `GNUPGHOME` limpio): | prueba | resultado | |---|---| | `--version`, generar clave ed25519, listarla | ✅ | | firmar y verificar (`gpg --verify` **y** `gpgv` con su keyring) | ✅ | | cifrado simétrico y descifrado (sha256 del original recuperado) | ✅ | | cifrado asimétrico con subclave cv25519 y descifrado | ✅ | | firmar con clave **con contraseña** | ❌ `gpg: signing failed: No pinentry` | | `git commit -S` con esa misma clave | ❌ `fatal: failed to write commit object` | **`pinentry` no existía en el catálogo** (`ls recipes/ | grep pinentry` ⇒ nada), y gpg-agent lo busca por RUTA —`bindir/pinentry`, o sea `/usr/bin/pinentry`, comprobado contra el binario sellado— así que no hay configuración que lo salve. Y toda clave que un usuario trae de otra máquina tiene contraseña. ⇒ **39,1 MiB en los 7 perfiles, sellados y reproducibles, con la operación más común rota.** Es exactamente la forma del bit setuid del paso 2: presente, íntegro, y sin poder hacer su trabajo. ## 3 · La decisión: completarlo. `recipes/pinentry.toml`, 1,1 MiB `b3:18161b35d6cdcd0b0bbbea6bef14e86b7c10389aa37ed683436562d52041a451`, sellada, hash del dry-run idéntico al del sellado. `pinentry-curses` (777 K) + `pinentry-tty` (334 K), los dos estáticos musl, y el `/usr/bin/pinentry → pinentry-curses` que crea el `install-exec-local` de upstream. Frontends gráficas apagadas **explícitamente** una por una: casi todas tienen default `maybe` y auto-detectar es lo que mete una dep sin declararla. `libassuan` del corpus es API 3 y pinentry pide API 2 — **no hace falta parche**, su `m4/libassuan.m4:194` acepta `want=2 got=3`; comprobado antes de autorar. Con la receta sellada, y con un agente NUEVO (sin contraseña en caché): ``` gpg --detach-sign con la clave protegida ⇒ SALIDA=0 · firma verificada: Good signature git commit -S con la clave protegida ⇒ SALIDA=0 · [master 24061dd] · Good signature ``` Y la prueba que vale, porque no usa NADA del anfitrión: el mismo escenario dentro de un `bwrap` cuyo `/` es un rootfs hidratado **sólo** con artefactos del corpus (gnupg + pinentry + git + busybox). Ahí `/usr/bin/pinentry` es el del store, que es la ruta que gpg-agent tiene horneada. `pinentry` entra a `base` como RAÍZ —nadie lo alcanza por deps de build, es un hueco de runtime puro, igual que `bash-completion`— y con eso llega a los 7 perfiles. **Cero reconstrucciones**: `yupana radio pinentry` ⇒ 0 dependientes. ## 4 · El guardián, medido en los dos sentidos La sonda vieja (`gpg --version`) no podía ver este defecto. La nueva, ya en `product-base-system-boot-test.sh`, firma con una clave protegida y **lee el error**, que es determinista y distingue las dos situaciones sin necesitar terminal (el test corre por ssh): ``` con pinentry ⇒ gpg: signing failed: Not a tty ← lo encontró y lo lanzó ⇒ pinentry=ok sin pinentry ⇒ gpg: signing failed: No pinentry ← falta ⇒ pinentry=AUSENTE ``` Comprobado en los dos sentidos con el rootfs de bwrap y un control al que se le BORRAN los tres binarios de pinentry. La sonda es **condición de aprobado**, no una línea informativa más: `pinentry=ok` o el test falla. De paso, el contador `$hyd/26` pasa a derivarse de la lista — el 26 escrito a mano envejecía a cada cambio y «25/26» se leía como fallo cuando el viejo era el contador ([[umbral-a-mano-envejece]]). ## 5 · La corrección al paso 2.3: sequoia no es el reemplazo, y medirlo cuesta cinco minutos El plan proponía `sequoia` (`sq`) como «el reemplazo moderno». **Con los consumidores medidos, no lo es**: ninguno quiere una librería OpenPGP ni una CLI nueva — `git` ejecuta lo que diga `gpg.program` hablando el protocolo de opciones y `--status-fd` de gpg, y `gcr` tiene la ruta `/usr/bin/gpg` horneada en el binario. Reemplazar significaría una CLI **compatible con gpg** (`gpg-sq` de sequoia), no `sq`. Sigue siendo posible y ahora tiene un criterio de aceptación concreto: pasar esta misma batería, incluida la sonda de pinentry. La alternativa de borrar a `gnupg` tiene hoy su factura nombrada: se lleva `git commit -S` en los 7 perfiles y el importador de claves de GNOME. ⚠ **`git` tiene un segundo camino que acá no está disponible:** `gpg.format = ssh` firma con `ssh-keygen` desde git 2.34 — pero `openssh` sólo viaja en el perfil `servidor`, así que en `base`, `cli` y los 4 escritorios el único camino de firma es gpg. ## 6 · Lo que queda dicho y NO resuelto - **`cap_ipc_lock`**: el `install-exec-local` de pinentry se la pone a sus binarios con `setcap`. Una capability no puede viajar en el artefacto —igual que el setuid del paso 2, la pone el ensamblado— y sin ella pinentry avisa «insecure memory» y funciona. Deuda nombrada. (En la receta el hook queda como no-op limpio con `SETCAP=true`: nadie define `SETCAP`, ni `configure.ac` ni el `Makefile.in`, y hoy se salvaba por un `|| true` tras ejecutar un comando inexistente.) - **Sin `dirmngr`** (apagado en `recipes/gnupg.toml`): no hay servidor de claves ni WKD. Medido: `gpg --recv-keys` ⇒ `error running '/usr/bin/dirmngr': probably not installed`. Una clave ajena hay que traerla por fichero. - **Sin `scdaemon`**: no hay tarjeta ni YubiKey. Medido: `gpg --card-status` ⇒ `OpenPGP card not available: Not supported`. - **Sin `gpgtar`** (`--disable-gpgtar`), que es lo que `gpg --encrypt-files` usa para directorios. - ⚠ **El `BASE_SYSTEM` del boot-test sigue trayendo `sudo`, que SALIÓ de `base` en el paso 2**, y su chequeo local exige `usr/bin/sudo`. Es la deriva de [[hidratar-desde-el-perfil-no-el-script]]: la lista está copiada de `targets.toml` en vez de leída de ahí. No se barre de paso — cambia lo que el test verifica—, pero queda anotado acá y no en la cabeza de nadie.