Files
takana/docs/adr/0014-distribucion-multiorigen.md
T
SergioandClaude Opus 5 8b2ff3cfd4 digest: blake3 pelado, no length-prefijado — el mismo archivo tenía dos hex
Lo destapó escribir la contraparte del ADR 0014 para churay: churay direcciona sus blobs
con blake3(bytes) pelado y hammer iba a sellar el digest con of_inputs(&[bytes]), los dos
bajo el prefijo `b3:`. El mismo archivo con dos hex distintos, y un CAS compartido que no
falla ruidosamente: cada lado busca un nombre distinto para los mismos bytes.

Con una sola entrada el length-prefijado no desambigua ninguna concatenación — sólo hace
que el nombre deje de ser verificable por un tercero con b3sum en la mano.

`ArtifactHash::of_bytes` YA EXISTÍA y ya era blake3 pelado: es la convención de of_file y
la del expected_hash de un .swm, así que of_inputs era además la pieza fuera de sitio
dentro del propio repo. of_inputs se queda para lo que fue escrito: hashear una LISTA.

Coste: una línea, porque ningún índice publicado lleva todavía el campo. Es el argumento
del ADR aplicado a sí mismo — decidir antes de que haya usuarios. La corrección queda en
el ADR, no reescrita en silencio.

Guardián: digest_es_blake3_pelado_y_no_length_prefijado, con vector fijo de b3sum y un
assert_ne contra of_inputs que nombra la consecuencia.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CK6HpSoHcN9M4GBpqRSusR
2026-09-01 19:10:07 +00:00

13 KiB

ADR 0014 — Distribución multi-origen: el origen no necesita confianza

  • Estado: ACEPTADO — implementado en crates/hammer-build/src/fetch.rs, crates/hammer-core/src/repo.rs y crates/hammer-cli/src/main.rs (RepoSource).
  • Fecha: 2026-09-01
  • Frontera: bases_de_mirror / rotar_bases, PackageEntry::digest, repo::verify_digest, RepoSource::{parse,read_index,materialize}.
  • Continúa: ADR 0013 (mirror de fuentes) y SDD 19 §3 (infraestructura pública).

Contexto

Todavía no hay usuarios. Ése es exactamente el momento de decidir esto: el protocolo de distribución toca el formato del índice y el comportamiento de clientes ya instalados en máquinas ajenas. Cambiarlo después de la primera descarga pública significa romper a alguien o arrastrar dos formatos para siempre; cambiarlo hoy no cuesta nada.

La pregunta que lo dispara es la correcta: no quiero apoyarme en un solo servidorcito. Y hoy nos apoyamos exactamente en eso, en tres capas a la vez:

capa qué sirve orígenes soportados antes de este ADR
fuentes tarball {sha256}.tar 1 (HAMMER_MIRROR)
fuentes git {commit}.bundle 1 (HAMMER_MIRROR_GIT)
repo de paquetes index.json + .swm 1 (--repo <URL>)

El ADR 0013 existe precisamente para no depender de 79 servidores ajenos — y lo resolvió creando una dependencia de uno nuestro. Es el mismo punto único de fallo con nuestro nombre encima.

La medida: qué habría que servir, y cuánto pesa

qué tamaño naturaleza
tarballs de fuentes (work/tarballs, 393 objetos) 2,9 G inmutable, direccionado por sha256
bundles git de fuentes (571 commits) ~2,3 G inmutable, direccionado por commit
artefactos / paquetes 36 G en el hub; ~51 G el espejo publicable tras el split de debug (SDD 23) inmutable, direccionado por BLAKE3
imágenes de instalación unos pocos G por release inmutable
índice firmado del repo KB mutable, y lo único que debe ser fresco y auténtico

Es decir: el 99,99% de los bytes es inmutable y verificable por contenido, y el 0,01% restante es lo único que necesita confianza. Esa asimetría es la decisión entera.

Decisión

1. La partición: raíz chica y firmada, cuerpo grande y sin confianza

  • Raíz — el index.json firmado. Debe ser fresca y auténtica. Es de kilobytes, así que puede vivir en sitios que no escalan pero sí se controlan: el propio git (que ya se espeja a gitea y a GitHub, ver scripts/espejo-setup.sh), o cualquier host.
  • Cuerpo — tarballs, bundles, .swm, artefactos, imágenes. Decenas de gigas, y cero necesidad de confianza: el nombre del objeto ES su verificación, o su hash está anclado en la raíz firmada.

⇒ El cuerpo puede servirse desde cualquier host, incluso uno hostil. Eso convierte «conseguir espejos» de un problema de infraestructura y de contratos a un problema de a quién pedírselo.

2. Todo camino de descarga acepta una LISTA de orígenes, no uno

HAMMER_MIRROR y HAMMER_MIRROR_GIT pasan a ser listas separadas por comas. --repo acepta https://a,https://b. Añadir orígenes no re-hashea nada: la URL nunca estuvo en hash_inputs (ADR 0013 §1), y para el repo el digest no es un input de build sino un campo del índice.

3. Las fuentes ROTAN el orden; el repo lo respeta

Distinto a propósito, y la razón importa:

  • Fuentes — es la granja moliendo el corpus sin nadie mirando, cientos de objetos por tanda. Si se probara siempre en orden, el segundo origen no se tocaría jamás hasta la emergencia: sería un espejo que nadie prueba, la lección literal del ADR 0013 §2 un nivel más arriba. Se rota con una semilla determinista tomada del propio hash del objeto: el mismo objeto sale siempre del mismo origen (un fallo se reproduce y se depura), pero objetos distintos se reparten entre todos, así que una tanda del corpus ejercita la lista entera. Tras el elegido se recorren los demás.
  • Repo — es una persona instalando un paquete. El orden es una preferencia suya (el espejo más cercano primero) y la previsibilidad vale más que el reparto; además son un puñado de ficheros, no una tanda. Se prueban en el orden que escribió.

4. Ausencia ⇒ seguir. Contenido distinto ⇒ ABORTAR

La distinción que hace que una lista de orígenes sea una mejora y no un agujero:

  • Un origen que no tiene el objeto (404, host caído, DNS) es normal: se pasa al siguiente en silencio. Un espejo puede ir por detrás.
  • Un origen que devuelve otro contenido del que la raíz firmada declara es manipulación o corrupción. Se aborta ahí mismo, no se cae al siguiente origen.

Caer al siguiente sería la tentación natural («total, del bueno lo bajo igual») y es justo el patrón que este repo ya pagó caro: un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue bien (CLAUDE.md §3). El paquete acabaría instalado desde el espejo bueno y nadie se enteraría nunca de que uno de los espejos miente.

5. El índice firmado ancla el digest de cada .swm — el hueco que había

Antes de este ADR la cadena estaba rota en el último eslabón, y no era evidente. La firma del índice cubre la lista de entradas: nombre, versión, file, deps. Pero file es una ruta, no un contenido: quien sirviera los bytes podía devolver otro .swm bajo el mismo nombre y la firma del índice seguía casando perfectamente.

Había red aguas abajo — apply reconstruye desde fuente y compara contra expected_hash — pero no alcanza: expected_hash es opcional, y el .swm se lee mucho antes de llegar ahí (el gate de colisiones de ficheros de install ya decide con su contenido). El comentario de download.rs decía que el patch_url «se cubre indirectamente»; indirectamente no es una cadena de confianza.

Se cierra con PackageEntry::digest (BLAKE3, b3:…), sellado al publicar y verificado antes de escribir un byte. La cadena queda: clave raíz → firma del índice → digest → bytes.

CORRECCIÓN (mismo día). Este párrafo decía que el digest usaba «la misma función que sella artefactos», o sea of_inputs(&[bytes]) — length-prefijado. Estaba mal, y la razón importa. Con una sola entrada el length-prefijado no desambigua ninguna concatenación: sólo hace que b3:<hex> deje de ser el hash que obtiene un tercero con b3sum sobre el mismo archivo.

Lo destapó escribir la contraparte de este ADR para churay (02_ruway/churay/SDD-DISTRIBUCION.md §5 en tawasuyu): churay direcciona sus blobs con blake3(bytes) pelado y hammer iba a hacerlo length-prefijado, bajo el mismo prefijo b3: ⇒ el mismo archivo con dos hex distintos, y un CAS compartido que no falla ruidosamente sino que busca nombres distintos para los mismos bytes.

Ahora usa ArtifactHash::of_bytes — que ya existía en hammer y ya era blake3 pelado; es la convención de of_file y la del expected_hash de un .swm, así que of_inputs era además la pieza fuera de sitio dentro del propio repo. Coste del cambio: una línea, porque ningún índice publicado llevaba todavía el campo. Es el argumento de este ADR aplicado a sí mismo — decidir antes de que haya usuarios. Hay guardián: digest_es_blake3_pelado_y_no_length_prefijado.

El campo es Option con skip_serializing_if, así que un índice firmado antes de este cambio serializa idéntico y su firma sigue siendo válida (hay test). Un índice sin digests no bloquea, pero dice cuántos paquetes no verificó: un índice viejo servido desde un espejo ajeno no es seguro, y el silencio lo haría parecer verificado.

6. Cloudflare R2 como primer origen público — instancia, no arquitectura

El criterio no es el precio del almacenamiento (51 G no cuestan nada en ningún sitio): es el egress, que es lo que quiebra a los proyectos que reparten binarios. R2 no cobra salida.

Órdenes de magnitud, a verificar el día de contratar — no son cotizaciones:

origen almacenamiento egress
Storage Box BX11 (ya pagado) €3,20/mes por 1 TiB sin cargo, pero no es un CDN
Cloudflare R2 ~US$0,015/GB-mes ⇒ <US$1/mes por 51 G 0
Backblaze B2 + Cloudflare ~US$6/TB-mes 0 vía Bandwidth Alliance
Hetzner Object Storage ~€6/mes (1 TB + 1 TB) incluido hasta el TB

Lo que decide este ADR no es «usamos R2»: es que R2 sea una entrada más de una lista. Si mañana cierra la cuenta, se añade otra base a la lista y no cambia una línea de código ni se re-hashea un artefacto. La condición de no depender de un servidorcito no se cumple contratando un proveedor grande — se cumple pudiendo perder cualquiera de ellos sin enterarse.

Regla que se deriva: nunca menos de dos orígenes en dos proveedores distintos. Hoy el repo, el respaldo y el mirror de fuentes están los tres en Hetzner y en la misma cuenta — riesgo que el ADR 0013 ya dejó anotado sin resolver, y que R2 resuelve por ser de otra casa.

7. Lo que NUNCA sale hacia el CDN

La clave raíz de firma, que es offline y es lo único no delegable de todo el lanzamiento (SDD 19 §3.2, SDD 20). Un CDN sirve bytes públicos; firmar es lo contrario de publicar. Si la clave estuviera en el sitio desde donde se sirve, los N espejos dejarían de ser «orígenes sin confianza» y pasarían a ser N copias de la autoridad.

Verificado de punta a punta, no por inspección

Dos servidores HTTP locales con el mismo index.json (mismo nombre de paquete, mismo file, mismo digest) y .swm distintos: el bueno y una copia con un solo byte cambiado — el mismo tamaño, el mismo nombre de fichero. Es exactamente lo que puede hacer un espejo que no controlamos.

escenario resultado qué prueba
1. sólo el origen bueno pasa el digest, baja, carga el .swm, y muere después en el config_edit (el paquete sintético toca un /etc/network.conf que no existe) la descarga y la verificación se cruzan de verdad
2. sólo el origen manipulado Error: digest de 'demo' no coincide … el origen entregó un .swm distinto del que se firmó, exit 1 el byte cambiado se detecta
3. origen muerto primero (:9999), bueno detrás llega al mismo punto que (1), y reporta bajados 1 .swm de http://127.0.0.1:8801 la lista hace failover ante ausencia
4. origen manipulado primero, bueno detrás aborta con el mismo error que (2), exit 1 NO cae al siguiente ante contenido distinto — §4

(3) y (4) son el par que importa: sin el segundo, la lista sería un mecanismo para tapar espejos mentirosos en vez de para sobrevivir a espejos caídos.

Además, el mensaje de (3) nombra el origen que sirvió de verdad, no la lista entera. Decir «bajado de <lista>» cuando sólo uno respondió es una media verdad que esconde justo el dato con el que se depura un espejo caído — el mismo error de diagnóstico que el ADR 0013 documenta en el poblador de bundles.

Consecuencias

  • Cero re-hasheos. La URL no está en hash_inputs y digest no es un input de build.
  • Cero roturas de firmas existentes. Verificado por test: sin digest, PackageEntry serializa idéntico a antes.
  • Un espejo nuevo pasa a ser un rsync plano sin índice, sin coordinación y sin confianza. La barrera de entrada para que un tercero nos espeje es más baja que la de una distro clásica, y eso es una consecuencia directa del direccionamiento por contenido, no un favor que pedimos.
  • Aparece deuda de vigilancia (ver abajo): más orígenes = más sitios que pueden pudrirse en silencio.

Lo que este ADR NO decide

  • El vigía de los espejos. scripts/fuentes/fuentes-vigia.sh vigila upstream, no nuestros orígenes. Con una lista de N, hace falta comprobar que cada uno tiene lo que dice tener — si no, volvemos a la métrica que envejece hacia el optimismo. Es el trabajo pendiente más claro que deja este documento.
  • Qué origen es la verdad al publicar (cómo se propaga un release de uno a los demás, y qué pasa si la propagación se corta a media tanda: un espejo con índice nuevo y .swm viejo).
  • Las imágenes de instalación: son ficheros grandes de un solo tiro y el reparto natural es torrent con webseed, que no comparte mecánica con esto.
  • La rotación de claves de firma y el procedimiento ante filtración — sigue siendo de SDD 19 §3.2, y sin él la firma sigue siendo teatro por muchos orígenes que haya.
  • La concentración en github.com (64% del corpus como fuente). Este ADR no la toca; sigue pendiente desde el 0013.