From 927dee60cf5c0f1b292ec6ae6c69ed9414857f06 Mon Sep 17 00:00:00 2001 From: Sergio Date: Tue, 1 Sep 2026 18:44:50 +0000 Subject: [PATCH] =?UTF-8?q?fuentes:=20HAMMER=5FMIRROR=20es=20una=20LISTA?= =?UTF-8?q?=20=E2=80=94=20un=20espejo=20propio=20era=20el=20mismo=20punto?= =?UTF-8?q?=20=C3=BAnico=20de=20fallo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01CK6HpSoHcN9M4GBpqRSusR --- crates/hammer-build/src/fetch.rs | 134 ++++++++++++++++++++++++++----- scripts/fuentes/mirror-env.sh | 9 +++ 2 files changed, 123 insertions(+), 20 deletions(-) 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 { + 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 { + let bases: Vec = 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 = ["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://.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.