gioser

sergio/takana

> 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…

TOML 188Rust 55Shell 30Markdown 281211 Definitions

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

927dee60cf5c0f1b292ec6ae6c69ed9414857f06 · by Sergio · (1 month ago) · parent a2c2c4d96 · browse the tree at this commit

Semantic delta · 8

2 files · +123 −20

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.