diff --git a/docs/adr/0013-mirror-de-fuentes.md b/docs/adr/0013-mirror-de-fuentes.md index a7ab1e7a..cbc9b4fe 100644 --- a/docs/adr/0013-mirror-de-fuentes.md +++ b/docs/adr/0013-mirror-de-fuentes.md @@ -1,6 +1,9 @@ # ADR 0013 — Mirror de fuentes: la URL es transporte, el `sha256` es la identidad - **Estado:** ACEPTADO — implementado en `crates/hammer-build/src/fetch.rs` y `scripts/fuentes/`. + **Ampliado por [ADR 0014](0014-distribucion-multiorigen.md)**: `HAMMER_MIRROR` y + `HAMMER_MIRROR_GIT` son LISTAS de orígenes, no una URL — un solo espejo propio reproducía + el punto único de fallo que este documento quería quitar. - **Fecha:** 2026-08-26 - **Frontera:** `fetch_tarball` / `fetch_git`, `scripts/fuentes/mirror-poblar.sh`, `scripts/fuentes/fuentes-vigia.sh`. diff --git a/docs/adr/0014-distribucion-multiorigen.md b/docs/adr/0014-distribucion-multiorigen.md new file mode 100644 index 00000000..f6c49281 --- /dev/null +++ b/docs/adr/0014-distribucion-multiorigen.md @@ -0,0 +1,188 @@ +# 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](0013-mirror-de-fuentes.md) (mirror de fuentes) y + [SDD 19 §3](../19-lanzamiento-publico.md) (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 `) | + +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](../23-plan-rehasheo.md)) | 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:…`, la misma función que sella artefactos — no una +segunda convención de hash conviviendo con la primera), sellado al publicar y verificado antes de +escribir un byte. La cadena queda: **clave raíz → firma del índice → `digest` → bytes**. + +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 ⇒ **» 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](../19-lanzamiento-publico.md), 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.