El watchdog barre work/sources/* cada 180 s con un suelo de 2 minutos, así que
el árbol de una receta que ACABA DE FALLAR se destruye antes de que nadie lo
mire. Medido hoy: waterfox murió a las 03:20:15 tras 36 minutos y 23.980 pasos,
enlazando libxul.so con un `collect2: error: ld returned 1 exit status` SIN
mensaje del linker delante — o sea justo el caso en que hace falta el objdir. A
las 03:22 work/sources/ estaba vacío. Reproducirlo cuesta otros 36 minutos.
Lo que más molesta es que la doctrina ya estaba escrita en el repo, en dos
sitios, y este watchdog la contradecía sin que nadie lo notara:
campana-deuda.sh:138 «NO se poda tras un ✗: el árbol de una receta que falló
es el post-mortem»
poda-fuentes.sh suelo de 24 h, «deja el post-mortem del día»
No se arregla subiendo el suelo a secas: este watchdog existe para que una tanda
Go no llene el disco vendoreando, y ahí los minutos importan de verdad (80 G en
una tanda, con el I/O-wait disparando el load). Se arregla siendo agresivo SÓLO
cuando el recurso escasea: con el disco por debajo de DISK_HIGH el suelo pasa a
SUELO_FRIO_MIN (120 min por defecto, configurable); en cuanto el disco aprieta
vuelve a los 2 minutos de siempre. Un árbol frío con disco al 37% no le hace
daño a nadie.
Probado en los dos sentidos: árbol de 5 min -> se borra con suelo 2, protegido
con suelo 120; árbol de 3 h -> se borra con los dos (ya no es post-mortem).
Ojo al leerlo: `df --output=pcent` da el porcentaje USADO, no el libre. La
primera versión de este parche llamaba `libre` a esa variable.
El bloque de rebuild que ya existía corre una sola vez, al arrancar el loop, y
este loop vive días — hoy el worker llevaba 8 de uptime. El source SÍ sigue
llegando fresco cada 30 min (la siembra rsync-ea el repo), así que el worker
acaba con FUENTE NUEVO y BINARIO VIEJO.
Eso es peor que el fósil de la golden que motivó el bloque original, porque nada
lo delata: la versión de hammer NO entra en el ArtifactHash, así que el build se
comporta distinto y build-state.json sigue en verde. Misma familia que el lab
fuera de hash_inputs, sin siquiera el aviso del lock.
Caso real que lo motiva, de hoy: el worker corría un hammer de las 04:54 y el
arreglo que materializa los submódulos git había entrado a las 15:11 (114aeba).
`waterfox` moría en CUATRO SEGUNDOS por waterfox/browser/locales/moz.build
inexistente, y el diagnóstico apuntaba a la receta, a los once parches de musl y
al --filter=blob:none: a todo menos al binario. Lo resolvió `strings` sobre los
dos binarios en un minuto — hub 2 cadenas de .gitmodules/gitlink, worker 0.
Recompilado el worker (23 s) y borrado el árbol de fuentes viejo, waterfox pasa
el punto donde moría y el submódulo se materializa.
Se comprueba por mtime y no se recompila a ciegas: cargo cacheado son ~24 s,
pero correrlo cada IDLE_SLEEP sin motivo es ruido en el log y CPU que le estamos
quitando a un build. Si nada cambió cuesta un `find`. Probado en los dos
sentidos (fuente más nuevo -> detecta; binario más nuevo -> no hace nada).
`llvm-nm --defined-only | grep -c w2c_` cuenta también los nombres C++
mangleados que llevan `w2c_` en el MEDIO —`_ZN5rlbox...PK16w2c_mem_capacity...`
son 913 de los 1547—, no sólo los símbolos de la jaula wasm. Anclado
(`grep -c " w2c_"`, o `awk '{print $3}' | grep -c "^w2c_"`) da 634, que es lo
que mide el guardián de recipes/firefox.toml y lo que el documento quería decir.
La conclusión no se mueve: la base sin RLBox da 0 con cualquiera de los dos
patrones, y el derivado hereda los mismos 634 que la base. Lo que se corrige es
la afirmación, que decía «símbolos w2c_*» y contaba otra cosa: un número sin su
patrón no es una medición.
Lo cazó hammer-03 al reproducir el número desde su lado.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgNtJEFetMYbXax5dVUjZZ
`firefox` selló con RLBox (b3:8116bdec) y `atuq` se reconstruyó encima en 2
segundos, que es exactamente lo que el §2 prometía del camino derivado: el
overlay no cambió una línea por un rebuild del motor.
Comprobado que la base nueva es la que quedó DENTRO del derivado, no sólo
declarada: el `libxul.so` de atuq trae 1547 símbolos `w2c_*` donde la base
anterior (352d7880) traía 0. Y la capa de v0.5 sigue entera sobre ella — los
cuatro contenedores, las dos extensiones y el mapa del proxy en un arranque
real.
De paso, una promesa del documento que convenía no creer sin medir: que si sube
firefox, atuq se reconstruye solo. Se midió en un catálogo de sonda (una copia
de atuq.toml + su árbol + firefox.toml, resuelto por el fallback al catálogo
padre): con el firefox idéntico al del corpus, atuq da el mismo hash; cambiando
una bandera de ese firefox copiado, atuq pasa a b3:60ade76a. La dep entra en
hash_inputs, así que un atuq viejo no puede quedarse tapando un motor nuevo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgNtJEFetMYbXax5dVUjZZ
Las cinco recetas se construyeron por separado en las dos máquinas —gioser (4c)
y el LXC dev.gioser.net (6c)— y los cinco artefactos salen idénticos comparando
el árbol completo, no sólo el ArtifactHash.
No es un trámite. El lab NO entra en hash_inputs, así que dos labs distintos
pueden sellar bytes distintos en la MISMA dirección del store y nada lo detecta:
la igualdad de hashes no probaba nada por sí sola. Esto dice que para esta
cadena los dos labs coinciden de verdad.
Sale gratis de haber construido en las dos máquinas por otro motivo (el hub no
tenía wasi-libcxx y sin él no podía correr scripts/test-guardianes-wasm.sh).
La v0.5 dejó dicho que faltaba probar que una petición hecha en «Banco» sale
por el proxy de «Banco». Se intentó y no se llegó; queda escrito el intento
porque el muro es reusable y el que venga después no tiene por qué volver a
descubrirlo.
El diseño de la prueba sirve y no necesita red ni servidor: la MISMA url a un
puerto vacío desde dos contenedores, y comparar los errores. `connectionFailure`
contra `proxyConnectFailure`. Que sean DISTINTOS es la prueba, porque el segundo
sólo aparece si Gecko habló con un proxy y el único que se lo pudo indicar es
nuestro proxy.onRequest mirando el cookieStoreId.
Lo que falta es abrir una pestaña EN un contenedor, y las tres puertas están
cerradas:
1. Desde la línea de comandos no existe el flag.
2. Marionette SÍ está en el artefacto y responde —se le habló con un cliente
propio de 60 líneas, el protocolo es `<longitud>:<json>`, abrió sesión y
aceptó comandos—, pero el contexto chrome exige `-remote-allow-system-access`
y en la jaula no lo tomó ni por bandera, ni con --remote-debugging-port, ni
por MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1, que es lo que RemoteAgent.sys.mjs lee
en su constructor. Comprobado que la variable llega al proceso.
3. Desde la extensión, `tabs.create({cookieStoreId})` exige el permiso
`cookies` (ext-tabs-base.js:getUserContextIdForCookieStoreId). Darle a la
extensión del proxy acceso a TODAS las cookies del usuario para poder
probarse a sí misma sería pagar con la superficie de ataque del producto una
comodidad del test. No se hace, y queda dicho por qué.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgNtJEFetMYbXax5dVUjZZ
Un guardián que nunca falló no se sabe si sirve, y uno que falla SIEMPRE se ve
idéntico a uno que funciona. Por eso el control con el sysroot intacto —que
tiene que PASAR— no es un extra: es la mitad de la prueba. El método lo aportó
la sesión hammer-f8 (scripts/test-atuq-politica.py hace lo mismo con el cruce
política<->XPI).
Le pone delante a la cadena las cuatro formas conocidas de romperla —sin la
sonda include/c++/v1, sin cstring, sin libc++.a, sin libc.a— y exige que las
cuatro maten, usando la invocación EXACTA del configure de firefox dentro del
lab. No construye nada, no toma work/.farm-build.lock y no escribe en el store:
trabaja sobre copias temporales de artefactos ya sellados, así que corre con la
granja a pleno.
Y el control se ganó el sueldo en su primera corrida: salió en ROJO. La causa no
era un guardián flojo sino este script, que resolvía el artefacto con
y agarraba el VIEJO —el de antes de que la
receta creara el directorio-sonda—. Misma trampa que el cache-hit que congela
regresiones: un artefacto anterior se hace pasar por el actual y la prueba mide
otra cosa. Sin el control se habría leído como «4 en verde, guardianes ok».
Ahora el artefacto se resuelve por de la receta, nunca por glob.
5 en verde, 0 en rojo.
Medido hoy, y contra mi propia metida de pata: el commit ff0b556 se llevó dentro
un rename y un borrado de otro agente HABIENDO usado `git add <ruta explícita>`
y `git commit` sin -a. O sea, cumpliendo la regla al pie de la letra.
La causa es que el índice es estado COMPARTIDO entre los agentes que trabajan el
árbol: `git commit` commitea el índice entero, no lo que uno acaba de añadir.
Comprobado en un repo de juguete en los dos sentidos:
git add mio.txt && git commit -m … -> arrastra el ajeno.txt que el otro tenía staged
git commit -m … -- mio.txt -> sólo mio.txt; lo del otro queda staged e intacto
La regla decía «sólo rutas explícitas» y esa frase apunta al `add`, que no es
donde está el peligro. Queda apuntando al `commit`, que es donde sí.
Lo levantó la sesión hammer-f8 al ver sus ficheros dentro de mi commit.
Es el único de la lista del SDD 26 §6 que no pasa por el host de native
messaging del §7: es API de Firefox y nada más. Cada contenedor —Personal,
Trabajo, Banco, Compras— puede salir por su propio proxy.
Tres piezas y ninguna alcanza sola: `atuq.cfg` prende los contenedores (vienen
apagados), `policies.json` los CREA con `Containers.Default` —el único
mecanismo que los pone en un perfil NUEVO— y deja la config de fábrica en
`3rdparty.Extensions`, y `extensions/proxy/` los enruta con `proxy.onRequest`,
que es lo único que ve el `cookieStoreId` de la petición.
Las cuatro se comprobaron DENTRO del artefacto de firefox antes de escribir una
línea, no en la documentación de Mozilla: `Containers` y `3rdparty` en el
`policies-schema.json` de browser/omni.ja, `cookieStoreId` en el
`schemas/proxy.json` de omni.ja, y `storage.managed` leyendo
`getExtensionPolicy(id)` en ext-storage.js. Es la regla del §2.sexies: la
pregunta no es si Mozilla lo tiene, es si NUESTRO build lo tiene.
Tres decisiones que valen más que el código:
1. La config se indexa por NOMBRE de contenedor y no por `cookieStoreId`: el id
depende del orden en que se crearon, así que la misma configuración aplicada
a otro perfil apuntaría a otro contenedor.
2. FAIL CLOSED. Un contenedor con proxy configurado que no se pudo honrar no
sale directo: va a un destino cerrado y el navegador muestra el error. Salir
directo sería una fuga silenciosa — la misma familia que el artefacto vacío
de la regla 3, el fallo que llega hasta el final diciendo que todo fue bien.
3. `proxyDNS` PRENDIDO por defecto: sin él la consulta DNS sale por la línea que
se quería evitar. Es la fuga clásica de esta configuración.
Y lo que no promete está arriba de todo en la página de opciones, no en un pie:
separación de tráfico, NO anonimato; para anonimato, Tor Browser. El §4 cumplido
donde el usuario lo lee.
De paso, una verdad que estaba escrita en dos sitios pasa a tener un dueño: el
id de cada extensión sale ahora del `manifest.json` y no de una constante de
rebrand.py, el nombre del XPI se deriva de él, y el `install_url` de la política
se cruza contra la ruta donde el fichero quedó escrito de verdad. El icono se
inyecta desde branding/icons/ en vez de estar copiado byte a byte dentro de cada
extensión. Agregar una tercera extensión es ahora un directorio.
PROBADO, corriendo el árbol en la misma jaula que atuq-nested.sh:
· captura de about:preferences#containers con los cuatro contenedores y sus
iconos, más el containers.json del perfil;
· extensions.json del perfil nombra las dos extensiones;
· `console.info: "atuq/proxy: 4 contenedor(es) enrutado(s)"` — leyó la config
de fábrica por storage.managed Y la casó con los contenedores de la política;
· scripts/test-atuq-politica.py: cinco formas de desincronizar política y XPI,
las cinco matan el build, y el control con la política intacta pasa.
NO probado y dicho por su nombre: que una petición hecha en «Banco» salga por el
proxy de «Banco». Pide automatizar la UI y queda pendiente.
Dos obstáculos del método, que valen para la próxima. La consola de una
extensión es CONTENIDO: `devtools.console.stdout.chrome` (que viene en true) no
la incluye, hace falta `...stdout.content`. Y un `moz-extension://` NO se abre
desde la línea de comandos —muere con `NS_NOINTERFACE [nsIFileURL.file]` y abre
la home en su lugar—, además de que `--screenshot` dispara al `load`, que puede
ocurrir antes de que arranquen las extensiones. Eso último destapó un fallo real
y arreglado: la página de opciones confundía «no hay contenedores» con «la API
no está» y mostraba un mensaje FALSO.
Verificado contra firefox b3:352d7880; se reconstruye contra el firefox con
RLBox cuando selle. La otra mitad de esta unidad —el rename de
recipes/atuq/extension/ a extensions/inicio/— entró sin querer en ff0b556, que
es de otro frente.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgNtJEFetMYbXax5dVUjZZ
Deja escrito en el documento que se lee para reanudar: el orden obligatorio de
las cinco recetas, por qué wasi-libc-headers pinea un commit viejo a propósito, y
las tres trampas ya pagadas (sesgo de clang en check-symbols, el wasi-libcxx que
faltaba, y el directorio vacío que es una sonda del compilador).
Y el criterio de guardián que sale de todo esto, que es lo más reusable:
«existe» no es «se encuentra».
Segunda muerte del firefox con RLBox, otra vez a los 6 segundos y con el mismo
mensaje engañoso, ya con wasi-libcxx sellado:
checking the wasm C compiler can find wasi headers... yes
checking the wasm C++ compiler can find wasi headers...
fatal error: 'cstring' file not found
El C pasaba todo y sólo fallaba el C++. Y `cstring` ESTABA en el artefacto, en
include/wasm32-wasip1/c++/v1/cstring. No era un fichero que faltara: era un
fichero que clang no buscaba.
CAUSA. El APKBUILD de Alpine hace un `mkdir -p .../include/c++/v1` que parece
ruido de empaquetado y yo descarté al transcribir. Es la SONDA por la que el
driver de clang decide que el sysroot tiene layout de libc++; sin ese directorio
no añade `include/<triple>/c++/v1` a la lista de búsqueda.
MEDIDO, no deducido. Sysroot fusionado (wasi-libc + wasi-libcxx) y la invocación
literal del configure de Mozilla, dentro del lab:
clang++ -std=gnu++20 --target=wasm32-wasip1 conftest.cpp --sysroot=$S -c
sin include/c++/v1 -> fatal error: 'cstring' file not found
con include/c++/v1 -> exit 0
Es el reverso exacto de la regla 3 del CLAUDE.md: allá un directorio vacío es un
artefacto mentiroso, acá un directorio vacío es una declaración dirigida al
compilador. Queda documentado en la receta para que nadie lo borre por limpieza.
Y la lección que deja el guardián nuevo: los tests de presencia que tenía la
receta habrían dado esto por bueno, porque "existe" no es "se encuentra". Así
que ahora la receta corre LA MISMA PRUEBA que el configure de firefox, contra un
sysroot fusionado con el de su dep. Si pasa acá no puede fallar allá; y si falla,
falla en el segundo 20 de esta receta —imprimiendo la lista de búsqueda de
clang— y no en el segundo 6 de un build de cuatro horas que además culpa a otro.
Re-hashea: wasi-libcxx b3:1361deae -> b3:838a9557, y con él firefox.
El firefox con RLBox murió en el configure a los SEIS SEGUNDOS:
/tmp/conftest.cpp:1:10: fatal error: 'cstring' file not found
ERROR: Cannot find wasi headers or problem with the wasm compiler.
El mensaje culpa a los headers de WASI y los headers de WASI estaban perfectos.
`<cstring>` es un header de C++ y `wasi-libc` sólo trae la C: las librerías que
RLBox enjaula no son todas C —graphite2 es C++— así que el sandbox necesita una
biblioteca estándar de C++ para wasm32. Mismo patrón que la lección de los
headers UAPI: el error nombra lo que buscó, no lo que falta.
El hueco estaba en el GRAFO antes de que el build lo encontrara, y se podía ver
sin construir nada: el `wasi-sdk` de Alpine declara
`depends="wasi-libc wasi-libcxx wasi-compiler-rt"` —TRES— y nosotros teníamos
dos. Queda anotado como método: cuando se porta una cadena ajena, la lista de
depends del paquete equivalente es una comprobación de completitud gratis.
La receta es el mismo tarball de LLVM 22.1.8 que `wasi-compiler-rt` (mismo
sha256) y repite su transcripción del toolchain file de wasi-sdk, por el mismo
motivo: 38 líneas de declaraciones puras no justifican una fuente de red y una
receta. Si se toca una, se toca la otra.
Dos detalles que son trampas y no adorno:
- Se construyen los DOS triples (wasip1 y wasip1-threads). `wasi-libc` ya publica
los dos y firefox pide el sysroot por nombre de triple, así que dejar uno sin
libc++ sería presente-a-medias, que es peor que ausente.
- cmake instala en el triple canónico (`wasm32-unknown-wasip1`) y clang busca en
el corto (`wasm32-wasip1`). Sin el renombre del install, libc++ queda al lado
del sitio donde se la busca: en el artefacto e invisible para el compilador.
El guardián exige exactamente lo que el configure no encontró —`cstring`,
`libc++.a` y `libc++abi.a`— para los dos triples.
Re-hashea: wasi-sdk b3:c8a587fa -> b3:f13634c6, firefox b3:98d6899a ->
b3:2880e28a.
Tercera repetición de la misma lección en esta receta, y por eso nace junto con
la bandera y no después: `--with-wasi-sysroot` es exactamente la clase de opción
que el configure acepta sin chistar y que puede no llegar al artefacto. Ya pasó
dos veces acá (MOZ_REQUIRE_SIGNING y MOZ_BUILD_DATE) y las dos veces el síntoma
apareció lejísimos, sin nombrar la bandera.
Acá el fallo sería el más caro de los tres: un firefox que dice tener enjaulados
los parsers de fuentes y medios —graphite, ogg, expat, woff2, o sea el código
que come entrada NO confiable de la red— y no los tiene. Una promesa falsa de
seguridad es peor que no ofrecerla (§4 del SDD 26, mismo criterio que el modelo
de adversario de qullqa).
El discriminante está MEDIDO, no supuesto: sobre el artefacto b3:352d7880 —el
último con RLBox apagado, 224 MB de libxul.so— los tres patrones (w2c_, rlbox,
wasm2c) dan CERO. Así que cualquiera apareciendo prueba que la jaula entró.
Se aceptan los tres y no sólo `w2c_` a propósito: el prefijo exacto es un
detalle de la versión de wasm2c, y un guardián que falla por un cambio de
convención de nombres tira 4 h de build por algo que no está roto. Lo que no
puede pasar desapercibido es que los tres sigan en cero.
El método de comprobar el artefacto y no la doc lo aportó la sesión hammer-f8.
Re-hashea: b3:dba8fbf9 -> b3:98d6899a. Entra en el build que arranca ahora, que
es justamente para lo que se agrega antes y no después.
Cierra la unidad 3.b del SDD 26. El flag que se va apagaba la jaula wasm que
envuelve a los parsers de fuentes y medios (graphite, ogg, expat, woff2), o sea
el código que come entrada NO confiable de la red. Estaba apagado por una razón
honesta —«el SDK de WASI no está en ninguna cola»— que dejó de ser cierta hace
un commit: la cadena wasi-libc-headers -> wasi-compiler-rt -> wasi-libc ->
wasi-sdk sella entera.
El flag nuevo es LITERALMENTE el de Alpine, verificado hoy trayendo su mozconfig
de community/firefox: `--with-wasi-sysroot=/usr/share/wasi-sysroot`, que es la
misma ruta donde instala nuestro wasi-libc.
Las deps van las TRES y no es redundancia: en hammer las deps NO son
transitivas —el bwrap apila como overlay sólo lo que está escrito en la lista—
así que declarar sólo `wasi-sdk` no arrastraría al `wasi-libc` que necesita, y
ése es el peor fallo posible de esta cadena: el sysroot no existiría y el error
saldría horas después, hablando de un header de wasm sin nombrar a ninguna
receta. Alpine declara wasi-compiler-rt + wasi-sdk porque su wasi-sdk YA trae el
sysroot dentro; el nuestro está partido en cuatro, así que la lista es distinta
a propósito.
Esto re-hashea firefox: b3:352d7880 -> b3:dba8fbf9. Es un rebuild largo y va a
la máquina que corresponde, no a gioser (4c/7,6 G sin swap).
Los cuatro eslabones que enciende RLBox en firefox, sellados de punta a punta:
wasi-libc-headers b3:54c6e96d (andamio, rompe el ciclo del bootstrap)
wasi-compiler-rt b3:a3fe63d1 (libclang_rt.builtins-wasm32.a)
wasi-libc b3:8e50b145 (libc.a 970.734 bytes, 32 archivos .a)
wasi-sdk b3:c8a587fa (el .cfg que hace el sysroot invisible)
Los dos primeros ya estaban construidos; los dos últimos costaron dos fallos, y
ninguno de los dos era de la receta:
1. `check-symbols` moría por SESGO DE CLANG, no por un artefacto malo. El lab
trae clang 22.1.8 y este commit de wasi-libc (2025-06-26) congeló su snapshot
de macros predefinidos contra un clang anterior, así que el diff completo era
UNA línea: `+#define __wasip1__ 1`. La mitad que importa —los símbolos
definidos e indefinidos de la libc recién construida— coincidía byte a byte.
Por eso se RECONCILIA `expected/*/predefined-macros.txt` en vez de saltar el
chequeo con `make no-check-symbols`, que era la salida fácil: ese target
existe y se lleva por delante también la comparación de símbolos, que es la
que vale. Así cualquier OTRA deriva sigue matando el build.
2. `make TARGET_TRIPLE=NOBUILD ... install` NO es un no-op. La regla `install`
depende de `finish`, que depende de `libc`, así que make no lee el triple
inexistente como «no construyas» sino como «construí para el triple NOBUILD»
y muere en `build/NOBUILD/.../crt1-command.o` con `invalid thread model
'single'` — un error que nombra un fichero que nadie pidió. Se copia
`sysroot/{lib,share,include}` a mano, que es literalmente lo que hace la
regla de upstream (`SYSROOT ?= $(CURDIR)/sysroot`).
Cada receta lleva su guardián de contenido, porque acá el modo de fallo caro no
es el ausente sino el vacío: un sysroot con headers y sin libc.a pasa por bueno
y revienta dentro del build de firefox, horas después y sin nombrar a nadie de
esta cadena. Verificado en verde: los builtins son objetos wasm de verdad
(magic 0061736d, no x86_64 nativos) y el .cfg de wasi-sdk apunta a un sysroot
que existe.
El segundo fallo lo diagnosticamos entre dos sesiones: hammer-f8 aportó el
`invalid thread model 'single'` que explicaba la línea que yo veía.
Queda para la próxima unidad el flip de recipes/firefox.toml:
--without-wasm-sandboxed-libraries -> --with-wasi-sysroot, que es un rebuild
largo y su propia unidad de trabajo.
La deuda de BuildID queda cerrada con la misma prueba que se le exigió al
derivado: apartar el artefacto como .ref, construir de nuevo y comparar.
56 entradas idénticas, 0 divergen. Antes de MOZ_BUILD_DATE esto no podía
salir bien, y build-state.json no lo veía porque el ArtifactHash es
input-addressed y no se mueve por la hora del build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011L4H7RabF2NCxCvFPJi37r
Una tanda volvió a llenar el libro de veredictos falsos: 47 recetas sanas anotadas como
NO-DETERMINISMO. Esta vez la cadena empezó antes de donde yo había mirado.
El `mv` que APARTA el artefacto falló (el directorio de apartado no estaba) y yo nunca comprobaba que
hubiera funcionado. A partir de ahí todo lo que sigue es basura: el artefacto se queda en su sitio,
`hammer why-differs` compara contra una ruta que no existe y devuelve `Error: store: no existe…`, y
mi código leía cualquier salida no-cero como «difieren». Un error de comparación disfrazado de
veredicto, 47 veces.
Es la MISMA lección que arreglé hace un rato para el build, en un segundo sitio que no miré:
**no poder medir no es un resultado negativo**. Dos guardas nuevas:
· el apartado se comprueba (`mv || SIN VEREDICTO`) y la receta se salta ruidosamente;
· `why-differs` se lee, no sólo su exit code: si dice `Error:`/`no existe`, es SIN VEREDICTO, se
restaura el artefacto y NO se anota nada.
Probadas las dos: con el apartadero sin permiso de escritura sale «no pude apartar el artefacto ⇒
SIN VEREDICTO», el artefacto queda intacto y el libro no crece.
Comprobado además que la tanda mala no perdió NADA: como el `mv` fallaba, los 47 artefactos nunca
salieron del store (`faltan: 0`). El daño fue sólo el registro, y está purgado — el libro queda con
51 verificaciones buenas.
La regla, ya por triplicado hoy: cuando un guardián no puede establecer algo, el estado es *sin
veredicto*, y eso no se escribe. Un libro que anota lo que no midió se cree igual que uno que sí.
18 recetas C verificadas de una, en unos nueve minutos, 15 REPRODUCEN y 3 eran DERIVA (ya al día).
Cero no-determinismos. Y `work/sources` se quedó en 4 KB toda la tanda gracias a la poda por
veredicto: antes una tanda así llevaba el disco del 95% al 98%.
Lo que hace sostenible a esta y no a la anterior es la SELECCIÓN. El filtro `MAX_SECONDS` sobre los
tiempos del worker no transfiere a Rust —`zola` se comió 40 minutos y no dio un solo veredicto— pero
en C sí: sin caché de cargo de por medio, un build de 40 s allá son 40 s acá. Lista explícita de
clase `c`, no muestreo ciego.
Tres que había clasificado como decisión legal y no lo eran. Las dos causas son distintas y las dos
me las estaba perdiendo por mirar sólo el árbol:
**ffmpeg → LGPL-2.1-or-later.** Su raíz trae cuatro COPYING.* y por eso el detector se planta, con
razón: el árbol solo no puede decidir. Pero el `LICENSE.md` del propio ffmpeg explica la regla —«In
combination the LGPL v2.1+ applies to FFmpeg» y «None of these parts are used by default, you have to
explicitly pass `--enable-gpl`… In this case, FFmpeg's license changes to GPL v2+»— y la respuesta la
da NUESTRA receta: no pasa `--enable-gpl` ni `--enable-version3`, y encima va con
`--disable-autodetect`. O sea que la licencia sale de cruzar el árbol con los flags, no de elegir uno
de los cuatro ficheros.
**llvm18 y clang18 → Apache-2.0 WITH LLVM-exception.** Acá el detector veía «tres licencias
distintas» en un solo `LICENSE.TXT` y se plantaba, pero el fichero ABRE diciendo «The LLVM Project is
under the Apache License v2.0 with LLVM Exceptions»: lo demás es la sección legada. La regla de
«varias licencias ⇒ decide un humano» es correcta como default y aquí el humano sólo tuvo que leer la
primera línea.
`licenses/` pasa de 48 a 49 textos: `LLVM-exception` hacía falta para poder declararla, que es el
lazo de siempre —no se declara lo que no se puede entregar—. El único sin texto canónico sigue siendo
`LicenseRef-qorpa-ajena-no-enumerable`, que es identificador nuestro y SPDX no publica.
97% (1135/1165); la cola baja de 27 a 24. Los 3 ArtifactHash, idénticos.
Un barrido dejaba un árbol extraído por receta en `work/sources` y no limpiaba hasta el final. En el
hub eso llevó el disco del 95% al 98% DOS VECES hoy, y un disco lleno no se lee como disco lleno: se
lee como recetas rotas. Con la poda por veredicto, `work/sources` se queda en 3 MB durante toda la
tanda en vez de crecer hasta 7,7 G.
No cuesta nada porque `fetch.rs` borra y re-extrae el árbol en CADA build por diseño: guardarlo entre
recetas de un barrido no ahorra un segundo.
Dos límites deliberados:
· Sólo se poda cuando el veredicto es SANO. Si algo falló o divergió, el árbol es justamente lo que
hace falta para mirarlo — un verificador que limpia la escena del problema que acaba de encontrar
sirve para poco. Es la misma razón por la que los ejemplares divergentes se conservan.
· Sólo el árbol de ESA receta. Las deps extraídas se quedan: son compartidas, y borrarlas mientras
otra cosa las usa es exactamente el ADR 0012.
Con esto la cobertura se puede levantar en el HUB, que es donde están los 1157 artefactos. El worker
sólo tiene los que construyó —el último barrido allá reportó 47 de 60 «sin artefacto»— así que como
máquina de cobertura no sirve por más disco que tenga.
`scripts/verificar-repro.sh` lo cazó en el worker: dos reconstrucciones seguidas, misma máquina y
mismo lab, dan `usr/bin/aichat` distintos. Es el primer no-determinismo REAL que el instrumento
encuentra —hasta hoy todo lo que había marcado era deriva— y salió porque el arreglo de esta tarde
conserva los dos ejemplares en `store/.divergen/` en vez de borrarlos.
`hammer why-differs` dijo `.rodata`. El primer byte que cambia (offset 1749687) cae dentro de un JSON
de *stopwords* por idioma embebido en el binario, y lo que difiere no es el contenido sino el ORDEN:
una build empieza por `"sl"` y la otra por `"fi"`.
De ahí a la causa: el crate es `stop-words` 0.8.1, transitivo vía bm25, y su `build.rs` arma un
`HashMap<String, Vec<String>>` y lo serializa con `serde_json::to_string`. El `HashMap` de Rust usa
`RandomState` —semilla ALEATORIA POR PROCESO— así que el orden de serialización cambia en cada
compilación y ese JSON se hornea en el binario. `BTreeMap` serializa en orden de clave. Es un arreglo
de una palabra que upstream no hizo.
Se parchea el crate vendorizado en una fase `configure` (el vendoreo ocurre ANTES de las fases, así
que el árbol ya está ahí) y se vacía la lista de ficheros de su `.cargo-checksum.json`, por la misma
razón y con la misma técnica que `recipes/firefox.toml`: cargo verifica el sha de cada fichero
vendorizado y no ofrece forma de recalcularlo.
Los `[ -f ]` fallan ruidoso a propósito. Si mañana la cadena de deps deja de traer stop-words, lo
peor sería que el parche se saltara en silencio y el binario volviera a ser aleatorio sin que nadie
se entere — que es exactamente cómo esto llegó hasta acá.
Re-hashea aichat (b3:c40a15bd → b3:7a515b0a), y eso es gratis: `yupana radio` da 0 dependientes y
ninguna imagen lo declara.
Las 33 que quedan sólo existían en la salida del script, o sea que se perdían al cerrar la terminal.
Para quien tiene que decidirlas —y son decisiones legales, no mecánicas— «qué falta y POR QUÉ no se
puede afirmar» es tan dato como los veredictos. `docs/licencias-pendientes.tsv` las deja listadas con
su motivo: cuáles traen varios ficheros de licencia que dicen cosas distintas (gmp, ffmpeg, clang18,
llvm18, los `gi-*`), cuáles tienen un LICENSE que no reconozco y con qué frase empieza (fuse3, lsof),
y cuáles no traen nada en el árbol (boost, pigz, sqlite-shared).
Al revés que `licencias-evidencia.tsv`, ésta se REGENERA entera en cada corrida. No es un registro de
lo que se probó sino una foto de lo que queda, y una pendiente resuelta tiene que DESAPARECER de acá
—si se acumulara, la lista de trabajo mentiría hacia arriba para siempre.
Corriendo una tanda con `MAX_SECONDS=45` se llenó el disco a mitad, los rebuilds empezaron a morir
por ENOSPC, y la rama que yo mismo había escrito para distinguir DERIVA de NO-DETERMINISMO metió
«falló» y «difiere» en la misma condición ⇒ **21 recetas sanas quedaron anotadas como
NO-DETERMINISMO** en un libro que pretende ser autoritativo. Un registro falso y persistente es peor
que no tener registro: el propósito del libro es que nadie tenga que volver a mirar, así que una
entrada equivocada se cree para siempre.
Es la trampa de siempre —un disco lleno se lee como receta rota, y la pista es que mueren varias
seguidas— y el script ORIGINAL ya la evitaba: «no es lo mismo «no reproduce» que «no construye
acá». Distinguirlos importa: mezclarlos inventaría un problema de determinismo». Lo rompí al añadir
la segunda reconstrucción. Restituido: si la 2ª no construye, se restaura el artefacto, se cuenta
como fallo y **no se anota nada** — sin veredicto es un estado legítimo.
Reparado además el daño: las 21 entradas falsas purgadas del libro (quedan 27 verificaciones
buenas), y `angle-grinder` repuesto desde `store/.divergen/`, donde lo había dejado la clasificación
errónea. Eso sí funcionó como debía: el ejemplar estaba guardado y entero, no perdido.
Y queda anotado en el propio script el límite del filtro por tiempo que provocó todo: el sidecar de
`store/.times/` lo escribe el WORKER, así que acota el build de esa receta EN LA MÁQUINA QUE LO
MIDIÓ. Una receta Rust de 40 s allá con la caché de cargo caliente son muchos minutos acá en frío
—`angle-grinder` y `amp` entraron por el filtro y se comieron el disco— y encima el número no cuenta
la CASCADA de deps que falten en el store local, que es la misma sorpresa que dio `gjs`. Con disco
justo conviene pasar las recetas a mano.
`verificar-repro.sh` sorteaba una muestra y se olvidaba. Sin registro no había forma de contestar la
pregunta que importa —¿qué fracción del corpus se comprobó alguna vez que reproduce?— y encima las
mismas recetas salían sorteadas una y otra vez mientras otras no se miraban jamás. La primera medida
con el libro puesto: **2 de 1157**. El invariante central del proyecto no se había verificado de
forma acumulativa prácticamente en nada, y eso no se veía porque cada corrida daba «✅ PUERTA
SUPERADA» sobre su propia muestra.
`docs/state/repro-verificado.tsv` va con la misma clave auto-invalidante que el libro de fuzz:
(receta, **ArtifactHash**). El hash resume la receta MÁS su cierre de deps, así que una entrada deja
de casar en cuanto algo aguas arriba cambia. Un «ya lo verifiqué» que sobreviviera a un re-hash sería
una mentira; éste no puede serlo.
Con eso el muestreo pasa a sortear entre lo NO verificado, así que corridas sucesivas ACUMULAN
cobertura en vez de repetir (con `TODO=1` se sortea entre todas, para re-verificar a propósito), y
`--coverage` da el número sin construir nada.
Verificadas en esta tanda: itstool, markupsafe, musl-obstack, hicolor-icon-theme, musl-fts, npth,
poppler-render-check, doas, libffi y packaging REPRODUCEN; `when` derivaba y quedó al día. Cero
no-determinismos. Van 11 de 1157.
⚠ Y una trampa que casi me come, anotada acá porque el próximo la va a pisar: elegí candidatos «por
artefacto chico» y la lista empezó con **nodejs**, cuyo artefacto pesa 0,2 MB y cuyo build son horas
de V8. El tamaño del artefacto NO dice nada del costo de construirlo. Para elegir muestra barata hay
que mirar tiempos de build, no bytes de salida.
Tres defectos, encontrados corriéndolo y pagando uno de ellos: perdí el artefacto de `aichat` al
interrumpir una corrida. Está reconstruyéndose en el worker.
**1. El apartadero estaba en `/tmp`.** Para verificar hay que sacar el artefacto de su sitio y dejar
que hammer lo reconstruya; eso iba a un `mktemp -d`. La cabecera prometía «un verificador que
destruye lo que verifica es peor que no tenerlo», y esa ubicación rompía la promesa en tres sitios a
la vez: `/tmp` es **tmpfs**, así que apartar copiaba el artefacto a RAM en una caja de 7,6 GiB sin
swap; al ser otro sistema de ficheros el `mv` no era un rename atómico sino copiar-y-borrar, o sea
que una interrupción a mitad lo dejaba en ninguna parte; y lo que sobreviviera moría al reiniciar.
Ahora va a `store/.verificar-repro`: el `mv` es instantáneo, no cuesta RAM ni disco, y lo apartado
SOBREVIVE a un kill — la corrida siguiente lo repone sola antes de empezar.
**2. Llamaba a `hammer build` sin tomar el `flock`.** Es la regla 1 del repo, y saltársela arriesga
el árbol compartido de `work/sources` de forma irreversible (ADR 0012). Lo toma el script y no quien
lo llama, por lo mismo que `poda-fuentes.sh`: así no hay forma de correrlo mal — y correrlo mal fue
exactamente lo que hice. Con `-E 3`, porque sin eso «no conseguí el lock» sale como exit 1, que es lo
que este script usa para «hay divergencias»: informar «el corpus no reproduce» cuando lo que pasaba
era que había otro build corriendo es peor que no correr.
**3. Confundía DERIVA con NO-DETERMINISMO y lo delegaba en la memoria de quien leía.** Que el
guardado difiera de una reconstrucción de hoy puede ser que el lab se movió (deriva, sano) o que las
mismas entradas dan salidas distintas (no-determinismo, bug). La cabecera decía «corré el script dos
veces y mirá la segunda» — con lo que la primera corrida informaba «DIVERGE» sobre cosas sanas y
entrenaba a no creerle. Ahora, ante una diferencia, reconstruye una segunda vez y compara las dos
reconstrucciones ENTRE SÍ: si coinciden es DERIVA, si no, NO-DETERMINISMO. El rebuild extra sólo se
paga cuando ya hubo una diferencia. Y cuando es no-determinismo de verdad, los dos ejemplares se
conservan en `store/.divergen/` en vez de borrarse: antes el guardián tiraba la única evidencia justo
en el caso que existe para encontrar.
Medido de paso, y es la parte buena: `libassuan`, `libksba`, `libffi` y `packaging` —artefactos del
21 de agosto— reproducen BIT A BIT con el lab de hoy. `scdoc` derivaba y ya está al día; `gron`,
`age` y `anew` reproducen. La rama de DERIVA se probó ensuciando a propósito una copia guardada:
clasifica bien y el store queda con la reconstrucción limpia.
El re-empaque determinista, la capa de configuración y el branding visible eran afirmaciones, no
mediciones. Quedan con su prueba al lado: why-differs entre dos builds de atuq (85 idénticas, 0
divergen), el testigo escrito como pref de usuario, y la captura de la pantalla real.
De la primera salió el contraste que importa: el DERIVADO reproducía y la BASE no. El BuildID de
firefox era la hora del build, y build-state.json no lo podía ver porque el hash es input-addressed.
Verde y mintiendo. Cerrado, con guardián.
La regla que sale del día entero: cuando la duda es «¿llegó al artefacto?», la respuesta no está en
el log ni en la receta — está dentro del artefacto.
`cargo test` del workspace fallaba en `el_contrato_del_repo_cierra_sobre_si_mismo`:
«proceso-por-descriptor no declara símbolos». No es un descuido del alta: `abda7d3` dio de alta pidfd
con `symbols = []` y dedicó ocho líneas a explicar por qué va vacío — pidfd no depende de ningún
`CONFIG_*`, es interfaz del core desde Linux 5.3. Lo que no se actualizó fue el invariante, así que
el dato quedó bien y el test quedó rojo. Dos días sin que nadie lo viera, que es lo que pasa cuando
una suite falla por algo que «ya se sabe»: deja de mirarse entera.
El invariante correcto no es «toda capacidad tiene símbolos» sino **«toda capacidad afirma algo
comprobable»**: sin símbolos vale, pero entonces hay que nombrar la INTERFAZ. Sin una ni la otra, la
capacidad no dice nada que se pueda verificar y lo más probable es que sea un campo a medias — que
es el caso que el assert original quería atrapar y sigue atrapando.
Hoy hay exactamente una capacidad así y declara `interface = ["pidfd_open(2)", "pidfd_send_signal(2)"]`.
Workspace: 541 tests en verde, 0 fallos.
El script decía «sin COPYING ni declaración en el árbol» para `lsof`, `when`, `wpa_supplicant`,
`perl-xml-parser` y `fuse3` — y las cinco SÍ tienen un COPYING o un LICENSE en la raíz. Lo que
pasaba es que ninguna de mis huellas reconocía su texto, que es un problema distinto y se acciona
distinto: uno pide buscar la licencia en otro sitio, el otro pide una huella nueva o leerla. Decir lo
mismo de los dos casos mandaba a mirar donde no era. Ahora el mensaje nombra el fichero y cita su
primera línea, y con eso tres se resolvieron leyéndolas:
· `wpa_supplicant` — su COPYING no es una licencia sino un aviso: «the project has chosen to use
only the BSD license option for future distribution», y remite al README. El README trae las tres
cláusulas clásicas ⇒ BSD-3-Clause. Sin seguir esa remisión no había veredicto posible.
· `when` — «under the same terms as Perl, or, at your option, under version 2 of the GPL». «Los
mismos términos que Perl» ES la disyunción Artistic-1.0-Perl OR GPL-1.0-or-later, y la opción de
GPL-2 ya queda cubierta por el `-or-later`.
· `perl-xml-parser` — su LICENSE es el texto íntegro de la Artistic License 2.0.
Ese último obligó a cerrar un lazo que yo mismo había dejado abierto: el script se niega a declarar
un SPDX cuyo TEXTO no tengamos en `licenses/`, porque la obligación legal es entregar el texto y no
el nombre. Pero `licencias-textos.sh` deriva qué textos bajar DE las licencias ya declaradas ⇒ una
licencia genuinamente nueva no podía entrar por ningún lado. El orden que lo resuelve es: declarar
con evidencia, bajar el texto, comprobar que llegó. `licenses/` pasa de 44 a 48 textos y el único que
queda sin texto canónico es `LicenseRef-qorpa-ajena-no-enumerable`, que es un identificador nuestro y
SPDX no publica.
Quedan 33. `lsof` (licencia propia de Purdue) y `fuse3` (aviso compuesto: LGPL para la librería, GPL
para las utilidades) siguen pendientes a propósito, y el resto son expresiones que decide una
persona: gmp, ffmpeg, clang18, llvm18 y los `gi-*` traen VARIOS ficheros de licencia que dicen cosas
distintas, y en ffmpeg la respuesta ni siquiera está en el árbol —la deciden los flags de la receta.
Los 3 ArtifactHash, idénticos antes y después.
Faltaban tres recetas cuya fuente es un repo, no un tarball: `fcft`, `foot` y `glab`. El commit
pineado cumple exactamente el mismo papel que el sha256 —es el árbol EXACTO que construimos— así que
la evidencia estaba igual de disponible, sólo que por otra puerta.
`miembros_de_git()` la abre sin clonar el árbol entero: `--depth 1 --filter=blob:limit=64k` sobre el
commit. Un COPYING nunca pasa de 64k y los fuentes donde vive la concesión tampoco; los que sí pasan
—binarios, assets— son justo los que no interesan. Un repo de cientos de megas se resuelve con unos
pocos. Las tres salieron del `license:` que el propio autor escribe en su `meson.build` (fcft, foot)
y del texto del LICENSE (glab).
Los repos por SSH se saltan diciéndolo: este script hace fetch anónimo y no tiene claves. Son
`hammerd` y `portal-probe`, y ya están resueltas por otra vía (su repo es este mismo).
`analizar()` pasa a consumir un iterable de `(ruta, bytes)` en vez de abrir el tar ella misma, así
que las dos fuentes comparten TODA la lógica de veredicto: la jerarquía de evidencia, la regla de
sólo-la-raíz, la unión de identificaciones y la exigencia de que la concesión hable de la misma
versión que el COPYING. Un criterio, dos puertas.
Y el script se renombra `licencias-tarball.py` → `licencias-fuente.py`, porque el nombre viejo pasó a
ser mentira en el momento en que aprendió a leer git. Un nombre que describe la implementación de
ayer manda a la gente a buscar en el sitio equivocado.
Los 3 ArtifactHash, idénticos antes y después.
Medido y probado hoy: `atuq` REPRODUCE bit a bit (`why-differs`: 85 entradas idénticas, 0 divergen),
lo que valida el re-empaque determinista del omni.ja, el XPI, los iconos copiados y el BuildID
derivado del contenido. El que NO reproducía era la BASE: el `BuildID` de firefox era la fecha y
hora del build (`20260905035306` en el artefacto sellado), así que dos construcciones con el mismo
ArtifactHash daban bytes distintos.
Es el agujero exacto que el invariante de reproducibilidad existe para cerrar, y `build-state.json`
no lo puede ver: el hash es input-addressed y no se mueve por esto. Verde y mintiendo.
El valor sale de `SOURCE_DATE_EPOCH`, que el sandbox de hammer YA fija a 1 para todas las recetas —
mejor que una constante escrita a mano, porque usa el mecanismo de hermeticidad que ya existe en vez
de inventar un segundo. Alpine hace lo mismo en su APKBUILD. BuildID esperado: 19700101000001.
Va en LAS TRES FASES, por la lección que costó 50 minutos esta misma tarde con MOZ_REQUIRE_SIGNING:
son shells distintos y `mach build` re-ejecuta el configure cuando le parece.
Y con su GUARDIÁN, hermano del de la firma: la fase install compara el BuildID del artefacto contra
el que SOURCE_DATE_EPOCH obliga, y falla el build si no coinciden. Los dos fallos de esta familia
—flag que el configure acepta y el artefacto ignora— son mudos y viajan lejos; la única defensa es
comprobarlos donde importa, que es dentro del artefacto.
`vigia-parches.py --all` sobre las 99 aplicaciones de parche del catálogo: **0 FALLA**, y 14 líneas
FUZZ (8 pares parche/fuente distintos). FUZZ significa que `patch` metió el cambio ADIVINANDO dónde
porque el contexto no casaba, y si cayó en el sitio correcto sólo lo dice el diff. Nadie los había
mirado. Los miré todos:
· **libxml2 / CVE-2026-6732** — el que más importaba, un parche de seguridad con fuzz 2 en sus dos
hunks. Cayó bien: los dos dentro de `xmlParseReference()`, las 4 llamadas pasan `ctxt->userData`
y no sobrevive ningún `sax->characters(ctxt,` en el fichero. El fuzz era por un offset de 226
líneas, no por el sitio.
· **doas / rowhammer** — el otro sensible, toca la decisión de privilegio. Cae dentro de
`checkconfig()` y queda `rv=permit(...); if(rv==0)→permit`, coherente con el `if(rv!=0)→EPERM`
de `main()`. Y el fuzz lo causa un parche ANTERIOR de la propia cadena (el `#ifdef DOAS_CONFDIR`
que inserta `configuration-directory.patch`), no un cambio de upstream — que es una causa que no
se me habría ocurrido sin abrirlo.
· **wayland**, **mesa** (×3), **cairo** (×2), **firefox** (time64 y fix-rust-target): todos en su
sitio, cada uno comprobado contra lo que el propio parche declara querer.
· **gnupg / 0001-include-unistd** — hallazgo: el parche está OBSOLETO. Añade `#include <unistd.h>`
y upstream YA lo trae dos líneas más abajo, así que sólo lo duplica. Inocuo (el header tiene
guardas) y por eso el fuzz 2: cambió el contexto porque upstream lo incorporó. Quitarlo re-hashea
gnupg, así que se paga cuando se re-selle por otro motivo.
Y para que esto no se repregunte en cada corrida —lo que vuelve ruido al vigía, y así es como se
pierde el FALLA del día que aparezca— los veredictos van a `docs/state/fuzz-verificado.tsv` con un
cuarto estado, FUZZ✓.
La clave del libro NO es (receta, parche) sino (receta, parche, HUELLA), donde la huella resume el
texto del parche MÁS el pin de la fuente. Tocá el parche o subí la versión y la huella cambia, la
entrada deja de casar y el vigía vuelve a preguntar. Es lo contrario de una lista de excepciones: no
hay forma de silenciar algo y que siga silenciado cuando cambió. Y el vigía imprime la línea lista
para pegar debajo de cada FUZZ sin verificar, porque calcular la huella a mano es justo la fricción
que hace que nadie lo anote.
Los dos FUZZ de waterfox quedan FUERA del libro a propósito: no los verifiqué en esta ronda y
waterfox está fuera de alcance. Van a seguir saliendo como FUZZ, que es lo honesto — el libro dice
lo que se miró, no lo que se supone.