> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
fuentes: HAMMER_MIRROR es una LISTA — un espejo propio era el mismo punto único de fallo
El ADR 0013 existe para no depender de 79 servidores ajenos, y lo resolvió creando una
dependencia de UNO nuestro. Las dos variables aceptan ahora varios orígenes separados por
comas y se prueban todos antes de caer a upstream.
El orden se ROTA con una semilla determinista tomada del hash del propio objeto: el mismo
objeto sale siempre del mismo origen (un fallo se reproduce), pero objetos distintos se
reparten ⇒ una tanda del corpus ejercita la lista entera. Con orden fijo, el segundo origen
no se tocaría jamás hasta la emergencia — que es literalmente «un espejo que nadie prueba»,
la lección del 0013 un nivel más arriba.
La parte pura sale a `rotar_bases` para poder probarla sin tocar el entorno del proceso
(que es global y haría los tests dependientes del orden en que corren). 3 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CK6HpSoHcN9M4GBpqRSusR
diff --git a/crates/hammer-build/src/fetch.rs b/crates/hammer-build/src/fetch.rs
index 828923fb..816e2035 100644
--- a/crates/hammer-build/src/fetch.rs
+++ b/crates/hammer-build/src/fetch.rs
@@ -103,6 +103,61 @@ fn fetch_git(
Ok(work_tree)
}
+/// Los orígenes declarados en `var` (**lista** separada por comas), **rotados** por `clave`.
+///
+/// # Por qué una lista y no una URL (ADR 0014)
+///
+/// El ADR 0013 dejó `HAMMER_MIRROR` con un solo valor y con eso el mecanismo que existe para no
+/// depender de 79 servidores ajenos pasaba a depender de **uno nuestro**: el mismo fallo, con
+/// nuestro nombre encima. La identidad del objeto es su `sha256`/`commit`, así que un origen de más
+/// no añade confianza que verificar — sólo un sitio más donde mirar. Añadirlos es gratis y no
+/// re-hashea nada (la URL nunca entró en `hash_inputs`).
+///
+/// # Por qué ROTA y no prueba siempre en orden
+///
+/// Es la lección del propio ADR 0013 aplicada un nivel más arriba: *un espejo que sólo se usa
+/// cuando el otro falla es un espejo que nadie prueba*, y se descubre roto el día que hace falta.
+/// Con orden fijo, el segundo origen no se toca jamás hasta la emergencia. El reparto es
+/// **determinista por contenido** — el mismo objeto sale siempre del mismo origen, así que 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 como respaldo.
+fn bases_de_mirror(var: &str, clave: &str) -> Vec<String> {
+ match std::env::var(var) {
+ Ok(raw) => rotar_bases(&raw, clave),
+ Err(_) => Vec::new(),
+ }
+}
+
+/// La parte pura de [`bases_de_mirror`]: parsea la lista y la rota. Separada del `env::var` para
+/// poder probarla sin tocar el entorno del proceso (que es global y hace los tests dependientes
+/// del orden en que corren).
+fn rotar_bases(raw: &str, clave: &str) -> Vec<String> {
+ let bases: Vec<String> = raw
+ .split(',')
+ .map(|s| s.trim().trim_end_matches('/'))
+ .filter(|s| !s.is_empty())
+ .map(str::to_string)
+ .collect();
+ if bases.len() < 2 {
+ return bases;
+ }
+ // Los dos primeros bytes de la clave hex bastan para repartir y no pueden panicar con una clave
+ // corta o vacía (a diferencia de `clave[..2]`).
+ let semilla = clave
+ .as_bytes()
+ .iter()
+ .take(2)
+ .fold(0usize, |a, b| a.wrapping_mul(256).wrapping_add(*b as usize));
+ let inicio = semilla % bases.len();
+ bases
+ .iter()
+ .cycle()
+ .skip(inicio)
+ .take(bases.len())
+ .cloned()
+ .collect()
+}
+
/// Intenta poblar `destino` (repo bare) con `commit` desde el mirror de fuentes git (ADR 0013).
/// Devuelve `true` si el commit quedó disponible. **Nunca es un error**: si no hay mirror, si el
/// objeto no está o si algo falla, devuelve `false` y el llamador va al remoto como siempre.
@@ -131,13 +186,16 @@ fn fetch_git(
/// La verificación sigue siendo criptográfica: git comprueba cada objeto contra su SHA al
/// desempaquetar, y el commit de la receta es la identidad. Un bundle alterado no pasa.
fn hidratar_desde_bundle(commit: &str, destino: &Path) -> bool {
- let Ok(base) = std::env::var("HAMMER_MIRROR_GIT") else {
- return false;
- };
- let base = base.trim_end_matches('/');
- if base.is_empty() {
- return false;
- }
+ // `HAMMER_MIRROR_GIT` es una LISTA (ADR 0014): se prueban todos los orígenes antes de rendirse
+ // y caer a upstream. Un bundle que no está en uno puede estar en otro, y la verificación la
+ // hace git contra el SHA venga de donde venga.
+ bases_de_mirror("HAMMER_MIRROR_GIT", commit)
+ .iter()
+ .any(|base| hidratar_desde_bundle_en(base, commit, destino))
+}
+
+/// Un solo intento contra `base`. Devuelve `true` si el commit quedó disponible en `destino`.
+fn hidratar_desde_bundle_en(base: &str, commit: &str, destino: &Path) -> bool {
let tmp = destino.with_extension("bundle.partial");
if let Some(padre) = tmp.parent() {
let _ = std::fs::create_dir_all(padre);
@@ -285,20 +343,19 @@ fn fetch_tarball(
/// Un mirror silencioso **con** vigía separado detecta el bit-rot; uno silencioso **sin** vigía lo
/// entierra, que es como el grafo de wlr pasó 17 días anunciando un 121/121 que ya era falso.
///
-/// `HAMMER_MIRROR` es un prefijo de URL que curl entienda (`file://`, `https://`, `sftp://`…); el
-/// objeto se busca por contenido en `{HAMMER_MIRROR}/{sha256}.tar`. `HAMMER_MIRROR_KEY`, si está,
-/// se pasa como `--key` (Storage Box por sftp). Sin `HAMMER_MIRROR` el comportamiento es el de
-/// siempre: derecho a upstream.
+/// `HAMMER_MIRROR` es una **lista** de prefijos de URL separada por comas, de cualquier esquema que
+/// curl entienda (`file://`, `https://`, `sftp://`…); el objeto se busca por contenido en
+/// `{base}/{sha256}.tar` en cada uno, en el orden que dicta [`bases_de_mirror`].
+/// `HAMMER_MIRROR_KEY`, si está, se pasa como `--key` (Storage Box por sftp). Sin `HAMMER_MIRROR`
+/// el comportamiento es el de siempre: derecho a upstream.
fn fetch_con_mirror(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> {
- if let Ok(base) = std::env::var("HAMMER_MIRROR") {
- let base = base.trim_end_matches('/');
- if !base.is_empty() {
- let espejo = format!("{base}/{sha256}.tar");
- // Un fallo acá NO es un error: es el caso normal la primera vez que se ve una fuente.
- // Se ignora en silencio y se sigue a upstream.
- if download_verify(&espejo, sha256, dst).is_ok() {
- return Ok(());
- }
+ for base in bases_de_mirror("HAMMER_MIRROR", sha256) {
+ let espejo = format!("{base}/{sha256}.tar");
+ // Un fallo acá NO es un error: es el caso normal la primera vez que se ve una fuente, y
+ // también el caso normal cuando un origen de la lista todavía no tiene ese objeto. Se
+ // ignora en silencio y se sigue al siguiente origen, y al final a upstream.
+ if download_verify(&espejo, sha256, dst).is_ok() {
+ return Ok(());
}
}
download_verify(url, sha256, dst)
@@ -506,6 +563,43 @@ pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> {
mod tests {
use super::*;
+ #[test]
+ fn rotar_bases_lista_vacia_o_unica() {
+ assert!(rotar_bases("", "abcd").is_empty());
+ assert!(rotar_bases(" , ,", "abcd").is_empty());
+ // Un solo origen: se devuelve tal cual, sin barra final.
+ assert_eq!(rotar_bases("https://a/", "abcd"), vec!["https://a"]);
+ }
+
+ #[test]
+ fn rotar_bases_reparte_pero_es_determinista() {
+ let lista = "https://a, https://b ,https://c/";
+ // Determinista: la misma clave da siempre el mismo orden ⇒ un fallo se reproduce.
+ assert_eq!(rotar_bases(lista, "00ff"), rotar_bases(lista, "00ff"));
+ // Y siempre están LOS TRES: el resto de la lista queda como respaldo, no se descarta.
+ for clave in ["00", "01", "02", "ff00"] {
+ let r = rotar_bases(lista, clave);
+ assert_eq!(r.len(), 3, "clave {clave}");
+ for b in ["https://a", "https://b", "https://c"] {
+ assert!(r.contains(&b.to_string()), "falta {b} con clave {clave}");
+ }
+ }
+ // Reparte: claves distintas eligen primeros distintos, que es lo que ejercita todos los
+ // orígenes en una tanda del corpus en vez de martillar siempre el primero.
+ let primeros: std::collections::HashSet<String> = ["00", "01", "02"]
+ .iter()
+ .map(|c| rotar_bases(lista, c)[0].clone())
+ .collect();
+ assert_eq!(primeros.len(), 3);
+ }
+
+ #[test]
+ fn rotar_bases_no_panica_con_clave_corta() {
+ // `clave[..2]` habría panicado acá; el fold sobre bytes no.
+ assert_eq!(rotar_bases("https://a,https://b", "").len(), 2);
+ assert_eq!(rotar_bases("https://a,https://b", "0").len(), 2);
+ }
+
#[test]
fn vendor_cargo_deps_runs_and_writes_config() {
// Crate sin deps de crates.io: `cargo vendor` sale 0 con config vacío y sin red.
diff --git a/scripts/fuentes/mirror-env.sh b/scripts/fuentes/mirror-env.sh
index edba354b..1a912c0b 100644
--- a/scripts/fuentes/mirror-env.sh
+++ b/scripts/fuentes/mirror-env.sh
@@ -8,6 +8,15 @@
# que apunta a una máquina concreta convertiría un fallo de red en un fallo de hammer para cualquiera
# que clone el repo.
#
+# LISTA, no una URL (ADR 0014): las dos variables aceptan varios orígenes separados por comas y se
+# prueban todos antes de caer a upstream. Con uno solo, el mecanismo que existe para no depender de
+# 79 servidores ajenos dependía de UNO nuestro. Para añadir el espejo público:
+#
+# export HAMMER_MIRROR="sftp://…/hammer/fuentes,https://<bucket>.r2.dev/fuentes"
+#
+# El orden se ROTA por el hash de cada objeto, así que todos los orígenes se ejercitan en una tanda
+# del corpus — un espejo que sólo se usa cuando el otro falla es un espejo que nadie prueba.
+#
# La ruta lleva `/~/` a propósito. curl interpreta lo que sigue al host en una URL `sftp://` como
# ruta ABSOLUTA del servidor, así que `…:23/hammer/fuentes` busca en la raíz y devuelve
# «(78) Could not open remote file for reading» aunque el fichero exista. `/~/` lo ancla al home.