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 1576Shell 205Rust 111Markdown 993774 Definitions

⎇ 54fa4a941c1650de708229862bfd9a748d073663 crates/hammer-build/src/fetch.rs Rust · 721 lines · 31.8 KB History Who touched what raw

hammer: `source.dir` — el modo de fuente que faltaba para las recetas DERIVADAS 54fa4a941 · Sergio ·

1//! Fase `fetch`: materializa el árbol fuente de la receta en un directorio trabajable.
2//!
3//! Dos modos, decididos por la receta (ver `hammer_core::SourceKind`):
4//!
5//! * **Git** — mirror local en `<work_root>/repos/<name>.git` + `git archive` del commit
6//!   fijado al árbol trabajable. El mirror se reusa entre builds; el árbol es descartable.
7//! * **Tarball** — descarga con `curl`, verificación de sha256, extracción con `tar`. Se
8//!   cachean los archivos descargados en `<work_root>/tarballs/`.
9//!
10//! La red sólo se permite aquí, FUERA del sandbox de build. Después del fetch, el árbol del
11//! source se copia a un dir descartable y se aplican los patches; el sandbox sólo ve esa
12//! copia (sin red).
13
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16
17use hammer_core::{Recipe, SourceKind};
18
19/// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta.
20pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result<PathBuf> {
21    match recipe.source.kind()? {
22        SourceKind::Git { repo, commit } => fetch_git(recipe, repo, commit, work_root),
23        SourceKind::Tarball { url, sha256 } => fetch_tarball(recipe, url, sha256, work_root),
24        SourceKind::Dir { dir } => fetch_dir(recipe, dir, work_root),
25    }
26}
27
28/// Materializa el árbol de una receta DERIVADA: copia `<dir-de-la-receta>/<dir>` al workspace.
29///
30/// No hay red, ni caché que invalidar, ni mirror: la fuente ya está en el disco. Se COPIA y no se
31/// bindea porque las fases mutan el árbol (parches, vendoreo, ficheros generados) y el original vive
32/// en el repo git — un build que ensucie el working tree del repo sería una forma silenciosa de que
33/// dos recetas se pisen, que es justo lo que el ADR 0012 documenta para las fuentes compartidas.
34///
35/// El nombre del workspace lleva el hash del CONTENIDO y no el de la ruta: editar el overlay cambia
36/// de directorio, así que un árbol viejo nunca se reusa por accidente.
37fn fetch_dir(recipe: &Recipe, dir: &str, work_root: &Path) -> hammer_core::Result<PathBuf> {
38    let origen = recipe.base_dir.join(dir);
39    if !origen.is_dir() {
40        return Err(hammer_core::Error::Other(anyhow::anyhow!(
41            "source.dir apunta a {} y no es un directorio",
42            origen.display()
43        )));
44    }
45    let h = hammer_core::ArtifactHash::of_tree(&origen)
46        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("hash del árbol {}: {e}", origen.display())))?;
47    let bare = h.as_str().strip_prefix("b3:").unwrap_or(h.as_str());
48    let sources_dir = work_root.join("sources");
49    std::fs::create_dir_all(&sources_dir)?;
50    let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &bare[..16]));
51    if work_tree.is_dir() {
52        std::fs::remove_dir_all(&work_tree)?;
53    }
54    std::fs::create_dir_all(&work_tree)?;
55    let st = Command::new("cp")
56        .arg("-a")
57        .arg(format!("{}/.", origen.display()))
58        .arg(&work_tree)
59        .status()
60        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn cp -a del source.dir: {e}")))?;
61    if !st.success() {
62        return Err(hammer_core::Error::Other(anyhow::anyhow!(
63            "cp -a de {} falló",
64            origen.display()
65        )));
66    }
67    Ok(work_tree)
68}
69
70fn fetch_git(
71    recipe: &Recipe,
72    repo: &str,
73    commit: &str,
74    work_root: &Path,
75) -> hammer_core::Result<PathBuf> {
76    let repos_dir = work_root.join("repos");
77    let sources_dir = work_root.join("sources");
78    std::fs::create_dir_all(&repos_dir)?;
79    std::fs::create_dir_all(&sources_dir)?;
80
81    let mirror = repos_dir.join(format!("{}.git", recipe.name));
82
83    // Mirror propio de fuentes git (ADR 0013). Se intenta ANTES que el remoto, por la misma razón
84    // que en los tarballs: git verifica cada objeto por su SHA al desempaquetar, así que no hay
85    // diferencia de contenido posible entre las dos rutas — y un mirror que sólo se usa cuando
86    // upstream falla es un mirror que nadie prueba.
87    if !mirror.is_dir() || run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() {
88        let _ = hidratar_desde_bundle(commit, &mirror);
89    }
90
91    if !mirror.is_dir() {
92        run_git(
93            &[
94                "clone",
95                "--mirror",
96                "--filter=blob:none",
97                repo,
98                mirror.to_str().unwrap(),
99            ],
100            None,
101        )?;
102    }
103
104    // Asegura que tenemos el commit pedido. En un mirror existente puede faltar.
105    if run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() {
106        run_git(&["fetch", "--all", "--tags"], Some(&mirror))?;
107        run_git(&["cat-file", "-e", commit], Some(&mirror)).map_err(|_| {
108            hammer_core::Error::Other(anyhow::anyhow!(
109                "commit {commit} no existe en {repo} tras fetch"
110            ))
111        })?;
112    }
113
114    // Materializa un árbol trabajable mediante `git archive` → tar → extract.
115    // Esto evita el coste de un worktree completo y deja sólo los archivos del commit.
116    let work_tree = sources_dir.join(format!("{}-{}", recipe.name, commit));
117    if work_tree.is_dir() {
118        std::fs::remove_dir_all(&work_tree)?;
119    }
120    std::fs::create_dir_all(&work_tree)?;
121
122    let archive = Command::new("git")
123        .arg("-C")
124        .arg(&mirror)
125        .args(["archive", "--format=tar", commit])
126        .stdout(Stdio::piped())
127        .stderr(Stdio::piped())
128        .spawn()
129        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git archive: {e}")))?;
130
131    let tar_status = Command::new("tar")
132        .args(["-x", "-C"])
133        .arg(&work_tree)
134        .stdin(archive.stdout.unwrap())
135        .stdout(Stdio::inherit())
136        .stderr(Stdio::inherit())
137        .status()
138        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x: {e}")))?;
139
140    if !tar_status.success() {
141        return Err(hammer_core::Error::Other(anyhow::anyhow!(
142            "git archive | tar -x falló (tar exit {:?})",
143            tar_status.code()
144        )));
145    }
146    Ok(work_tree)
147}
148
149/// Los orígenes declarados en `var` (**lista** separada por comas), **rotados** por `clave`.
150///
151/// # Por qué una lista y no una URL (ADR 0014)
152///
153/// El ADR 0013 dejó `HAMMER_MIRROR` con un solo valor y con eso el mecanismo que existe para no
154/// depender de 79 servidores ajenos pasaba a depender de **uno nuestro**: el mismo fallo, con
155/// nuestro nombre encima. La identidad del objeto es su `sha256`/`commit`, así que un origen de más
156/// no añade confianza que verificar — sólo un sitio más donde mirar. Añadirlos es gratis y no
157/// re-hashea nada (la URL nunca entró en `hash_inputs`).
158///
159/// # Por qué ROTA y no prueba siempre en orden
160///
161/// Es la lección del propio ADR 0013 aplicada un nivel más arriba: *un espejo que sólo se usa
162/// cuando el otro falla es un espejo que nadie prueba*, y se descubre roto el día que hace falta.
163/// Con orden fijo, el segundo origen no se toca jamás hasta la emergencia. El reparto es
164/// **determinista por contenido** — el mismo objeto sale siempre del mismo origen, así que un fallo
165/// se reproduce y se depura — pero objetos distintos se reparten entre todos, así que una tanda del
166/// corpus **ejercita la lista entera**. Tras el elegido se recorren los demás como respaldo.
167fn bases_de_mirror(var: &str, clave: &str) -> Vec<String> {
168    match std::env::var(var) {
169        Ok(raw) => rotar_bases(&raw, clave),
170        Err(_) => Vec::new(),
171    }
172}
173
174/// La parte pura de [`bases_de_mirror`]: parsea la lista y la rota. Separada del `env::var` para
175/// poder probarla sin tocar el entorno del proceso (que es global y hace los tests dependientes
176/// del orden en que corren).
177fn rotar_bases(raw: &str, clave: &str) -> Vec<String> {
178    let bases: Vec<String> = raw
179        .split(',')
180        .map(|s| s.trim().trim_end_matches('/'))
181        .filter(|s| !s.is_empty())
182        .map(str::to_string)
183        .collect();
184    if bases.len() < 2 {
185        return bases;
186    }
187    // Los dos primeros bytes de la clave hex bastan para repartir y no pueden panicar con una clave
188    // corta o vacía (a diferencia de `clave[..2]`).
189    let semilla = clave
190        .as_bytes()
191        .iter()
192        .take(2)
193        .fold(0usize, |a, b| a.wrapping_mul(256).wrapping_add(*b as usize));
194    let inicio = semilla % bases.len();
195    bases
196        .iter()
197        .cycle()
198        .skip(inicio)
199        .take(bases.len())
200        .cloned()
201        .collect()
202}
203
204/// Intenta poblar `destino` (repo bare) con `commit` desde el mirror de fuentes git (ADR 0013).
205/// Devuelve `true` si el commit quedó disponible. **Nunca es un error**: si no hay mirror, si el
206/// objeto no está o si algo falla, devuelve `false` y el llamador va al remoto como siempre.
207///
208/// # Por qué un bundle SHALLOW y no un clon completo
209///
210/// El bundle se genera desde un clon `--depth 1` del commit exacto: para `act` son 8,9 MB en vez
211/// del repositorio entero. Hammer no necesita la historia — lo único que hace con el repo es
212/// `git archive <commit> | tar -x`, o sea materializar UN árbol.
213///
214/// # El detalle que hace que esto funcione: el fichero `shallow`
215///
216/// Un bundle hecho desde un repo shallow **no lleva la frontera de historia**, así que al
217/// desempaquetarlo git intenta recorrer los padres y aborta:
218///
219/// ```text
220/// fatal: Failed to traverse parents of commit 4f41128…
221/// error: … did not send all necessary objects
222/// ```
223///
224/// El mensaje suena a bundle incompleto y **no lo es**: los objetos del commit llegan enteros (de
225/// hecho `git archive` ya funciona pese al error). Lo que falta es decirle a git dónde termina la
226/// historia. Se arregla escribiendo el propio commit en `<destino>/shallow` ANTES del fetch — y no
227/// hay que transportar nada, porque la frontera de un clon `--depth 1` es exactamente ese commit.
228///
229/// La verificación sigue siendo criptográfica: git comprueba cada objeto contra su SHA al
230/// desempaquetar, y el commit de la receta es la identidad. Un bundle alterado no pasa.
231fn hidratar_desde_bundle(commit: &str, destino: &Path) -> bool {
232    // `HAMMER_MIRROR_GIT` es una LISTA (ADR 0014): se prueban todos los orígenes antes de rendirse
233    // y caer a upstream. Un bundle que no está en uno puede estar en otro, y la verificación la
234    // hace git contra el SHA venga de donde venga.
235    bases_de_mirror("HAMMER_MIRROR_GIT", commit)
236        .iter()
237        .any(|base| hidratar_desde_bundle_en(base, commit, destino))
238}
239
240/// Un solo intento contra `base`. Devuelve `true` si el commit quedó disponible en `destino`.
241fn hidratar_desde_bundle_en(base: &str, commit: &str, destino: &Path) -> bool {
242    let tmp = destino.with_extension("bundle.partial");
243    if let Some(padre) = tmp.parent() {
244        let _ = std::fs::create_dir_all(padre);
245    }
246    let _ = std::fs::remove_file(&tmp);
247    if descargar(&format!("{base}/{commit}.bundle"), &tmp).is_err() {
248        let _ = std::fs::remove_file(&tmp);
249        return false;
250    }
251    if !destino.is_dir() && run_git(&["init", "--bare", destino.to_str().unwrap()], None).is_err() {
252        let _ = std::fs::remove_file(&tmp);
253        return false;
254    }
255    // La frontera de historia. Sin esto el fetch aborta aunque los objetos estén completos.
256    //
257    // La frontera se LEE DEL BUNDLE, no se supone. `git bundle list-heads` imprime la lista de refs
258    // sin necesitar los objetos, así que sirve justo antes del fetch. Suponerla igual a `commit`
259    // estaba mal: el campo `commit` de una receta puede pinear un OBJETO TAG anotado (así están
260    // diffutils, findutils-xargs y kustomize), y el fichero `shallow` sólo admite SHAs de commit.
261    // El poblador deja siempre `refs/heads/hammer` en el commit ya pelado.
262    let Some(frontera) = frontera_del_bundle(&tmp) else {
263        let _ = std::fs::remove_file(&tmp);
264        return false;
265    };
266    let _ = std::fs::write(destino.join("shallow"), format!("{frontera}\n"));
267    // ⚠ RUTA ABSOLUTA, obligatoria. `run_git` invoca `git -C <destino> …`, y git resuelve una ruta
268    // relativa de bundle DENTRO de ese `-C` — o sea buscaría `<destino>/work/repos/x.bundle.partial`.
269    // El fallo es mudo aquí (se traga y se cae al remoto), así que el síntoma aparece lejos: el build
270    // muere diciendo «commit … no existe en <repo> tras fetch», culpando a upstream de un error de
271    // ruta local.
272    let bundle_abs = std::fs::canonicalize(&tmp).unwrap_or_else(|_| tmp.clone());
273    // `refs/*:refs/*` y no un ref concreto: cuando el pin es un tag anotado el bundle trae DOS refs
274    // (el commit pelado y el objeto tag), y hace falta el objeto tag para que el `cat-file -e` de
275    // abajo — y el `git archive <commit>` de después — encuentren lo que la receta pide.
276    let ok = run_git(
277        &["fetch", bundle_abs.to_str().unwrap(), "refs/*:refs/*"],
278        Some(destino),
279    )
280    .is_ok()
281        && run_git(&["cat-file", "-e", commit], Some(destino)).is_ok();
282    let _ = std::fs::remove_file(&tmp);
283    ok
284}
285
286/// SHA del commit que `refs/heads/hammer` señala dentro del bundle, leído sin desempaquetarlo.
287fn frontera_del_bundle(bundle: &Path) -> Option<String> {
288    let abs = std::fs::canonicalize(bundle).ok()?;
289    let salida = Command::new("git")
290        .args(["bundle", "list-heads"])
291        .arg(&abs)
292        .output()
293        .ok()?;
294    if !salida.status.success() {
295        return None;
296    }
297    String::from_utf8_lossy(&salida.stdout)
298        .lines()
299        .find_map(|l| {
300            let (sha, refname) = l.split_once(char::is_whitespace)?;
301            (refname.trim() == "refs/heads/hammer").then(|| sha.trim().to_string())
302        })
303}
304
305fn fetch_tarball(
306    recipe: &Recipe,
307    url: &str,
308    sha256: &str,
309    work_root: &Path,
310) -> hammer_core::Result<PathBuf> {
311    let tarballs_dir = work_root.join("tarballs");
312    let sources_dir = work_root.join("sources");
313    std::fs::create_dir_all(&tarballs_dir)?;
314    std::fs::create_dir_all(&sources_dir)?;
315
316    // Nombre por hash: dos URLs distintas con el mismo contenido se cachean una vez, y
317    // cambiar la URL sin cambiar el sha (mirror) no invalida la caché.
318    let cached = tarballs_dir.join(format!("{sha256}.tar"));
319    if !cached.is_file() {
320        fetch_con_mirror(url, sha256, &cached)?;
321    } else {
322        verify_sha256(&cached, sha256)?;
323    }
324
325    // ⚠ CARRERA CONOCIDA, SIN RESOLVER — ver docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md
326    // El path es determinista y NO lleva nada del constructor ⇒ dos builds de la misma receta (la
327    // misma dep alcanzada por dos caminos) resuelven al mismo directorio: uno borra mientras el otro
328    // extrae y el árbol queda roto con "Directory not empty" (os error 39) hasta borrarlo a mano.
329    // Mitigado SÓLO entre campana-deuda.sh y farm-worker-loop.sh con un lock de script (commit
330    // 1b436a7); el `xargs -P2` de build-farm.sh sigue expuesto.
331    // NO "arreglar" con un lock alrededor de esta extracción: parece correcto y no lo es, porque un
332    // segundo proceso puede borrar un árbol que un bwrap ya está usando para compilar. Este árbol es
333    // caché y workspace mutable a la vez (lib.rs lo parchea y vendorea in situ); el ADR 0012 plantea
334    // las tres salidas y qué medir para elegir.
335    let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &sha256[..16]));
336    if work_tree.is_dir() {
337        std::fs::remove_dir_all(&work_tree)?;
338    }
339    std::fs::create_dir_all(&work_tree)?;
340
341    // `tar` autodetecta el compresor por la cabecera; el sufijo .tar del nombre es solo
342    // identificador interno (no afecta a la detección).
343    let strip = recipe.source.strip_components.to_string();
344    let status = Command::new("tar")
345        .arg("-x")
346        // `--no-same-owner`: extrae como el usuario actual (no preserva el uid del tarball). Si el
347        // fetch corre como root (granja), `tar` por defecto preservaría el owner original → ficheros
348        // de un uid que el sandbox (userns) no puede chmod/escribir → configure de autotools falla
349        // ("Permission denied" creando Makefile, "Operation not permitted" en chmod). Inocuo para el
350        // of_tree (es el árbol de FUENTES, no el output). Destraba libs C como oniguruma.
351        .arg("--no-same-owner")
352        .arg("-f")
353        .arg(&cached)
354        .arg("--strip-components")
355        .arg(&strip)
356        .arg("-C")
357        .arg(&work_tree)
358        .stdout(Stdio::inherit())
359        .stderr(Stdio::inherit())
360        .status()
361        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x tarball: {e}")))?;
362    if !status.success() {
363        return Err(hammer_core::Error::Other(anyhow::anyhow!(
364            "tar -x falló (exit {:?})",
365            status.code()
366        )));
367    }
368    Ok(work_tree)
369}
370
371/// Baja el tarball probando el **mirror propio antes que upstream** (ADR 0013).
372///
373/// # Por qué el mirror va primero y no como rescate
374///
375/// El `sha256` se verifica igual venga de donde venga, así que **no hay diferencia de contenido
376/// posible** entre las dos rutas: preferir upstream «por pureza» no compra nada y paga latencia más
377/// un tercero en el camino. Además un mirror que sólo se usa cuando upstream falla es un mirror que
378/// **nadie prueba**, y se descubre roto justo el día en que hace falta.
379///
380/// # Por qué NO avisa cuando cae al mirror
381///
382/// Tentación evidente y **equivocada**: si `hammer build` avisara en cada descarga, el aviso sería
383/// ruido en 561 recetas y nadie lo leería. Peor: haría creer que el bit-rot está vigilado *acá*,
384/// cuando construir y vigilar upstream son dos trabajos distintos. La vigilancia vive en
385/// `scripts/fuentes/fuentes-vigia.sh`, la corre el latido y escribe `docs/state/fuentes-vigia.json`.
386/// Un mirror silencioso **con** vigía separado detecta el bit-rot; uno silencioso **sin** vigía lo
387/// entierra, que es como el grafo de wlr pasó 17 días anunciando un 121/121 que ya era falso.
388///
389/// `HAMMER_MIRROR` es una **lista** de prefijos de URL separada por comas, de cualquier esquema que
390/// curl entienda (`file://`, `https://`, `sftp://`…); el objeto se busca por contenido en
391/// `{base}/{sha256}.tar` en cada uno, en el orden que dicta [`bases_de_mirror`].
392/// `HAMMER_MIRROR_KEY`, si está, se pasa como `--key` (Storage Box por sftp). Sin `HAMMER_MIRROR`
393/// el comportamiento es el de siempre: derecho a upstream.
394fn fetch_con_mirror(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> {
395    for base in bases_de_mirror("HAMMER_MIRROR", sha256) {
396        let espejo = format!("{base}/{sha256}.tar");
397        // Un fallo acá NO es un error: es el caso normal la primera vez que se ve una fuente, y
398        // también el caso normal cuando un origen de la lista todavía no tiene ese objeto. Se
399        // ignora en silencio y se sigue al siguiente origen, y al final a upstream.
400        if download_verify(&espejo, sha256, dst).is_ok() {
401            return Ok(());
402        }
403    }
404    download_verify(url, sha256, dst)
405}
406
407/// Descarga `url` a `dst` y verifica el sha256. Si la verificación falla, borra el archivo
408/// para que la próxima corrida lo vuelva a intentar (no envenenamos la caché).
409/// Baja `url` a `dst` con curl. Sin verificar nada: quien llama decide cómo se comprueba —
410/// `download_verify` por sha256 (tarballs), `hidratar_desde_bundle` delegando en git (bundles).
411fn descargar(url: &str, dst: &Path) -> hammer_core::Result<()> {
412    let mut cmd = Command::new("curl");
413    cmd.args(["-fL", "--retry", "3", "--connect-timeout", "30"]);
414    // Clave para el mirror por sftp (Storage Box). Inocua para http/file.
415    if let Ok(key) = std::env::var("HAMMER_MIRROR_KEY") {
416        if !key.is_empty() {
417            cmd.arg("--key").arg(&key);
418        }
419    }
420    let status = cmd
421        .arg("-o")
422        .arg(dst)
423        .arg(url)
424        .stdout(Stdio::inherit())
425        .stderr(Stdio::inherit())
426        .status()
427        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?;
428    if !status.success() {
429        return Err(hammer_core::Error::Other(anyhow::anyhow!(
430            "curl {url} falló (exit {:?})",
431            status.code()
432        )));
433    }
434    Ok(())
435}
436
437fn download_verify(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> {
438    let tmp = dst.with_extension("tar.partial");
439    if let Err(e) = descargar(url, &tmp) {
440        let _ = std::fs::remove_file(&tmp);
441        return Err(e);
442    }
443    if let Err(e) = verify_sha256(&tmp, sha256) {
444        let _ = std::fs::remove_file(&tmp);
445        return Err(e);
446    }
447    std::fs::rename(&tmp, dst)?;
448    Ok(())
449}
450
451fn verify_sha256(path: &Path, expected: &str) -> hammer_core::Result<()> {
452    use sha2::{Digest, Sha256};
453    let mut f = std::fs::File::open(path)?;
454    let mut hasher = Sha256::new();
455    std::io::copy(&mut f, &mut hasher)?;
456    let got = hex::encode(hasher.finalize());
457    if !got.eq_ignore_ascii_case(expected) {
458        return Err(hammer_core::Error::Other(anyhow::anyhow!(
459            "sha256 mismatch en {}: esperado {expected}, obtenido {got}",
460            path.display()
461        )));
462    }
463    Ok(())
464}
465
466/// Vendoriza las dependencias de un proyecto Cargo en `<src>/vendor` y escribe
467/// `<src>/.cargo/config.toml` para que el build dentro del sandbox resuelva todo `--offline`.
468///
469/// La red se usa AQUÍ (fuera del sandbox, como el resto del fetch).
470///
471/// **`--locked` condicional:** si la fuente trae un `Cargo.lock` committeado, se vendorea con
472/// `--locked` y el build es reproducible (mismo commit ⇒ mismo lock ⇒ mismas deps; el lock ya está
473/// dentro del commit que hashea la receta). Si la fuente **no** lo trae (p.ej. un monorepo que lo
474/// gitignora, como tawasuyu), se genera el lock al vuelo **sin** `--locked` y se emite un `warn!`:
475/// el build queda internamente consistente pero **no pineado en el tiempo** (las deps pueden
476/// derivar entre corridas). El end-state reproducible es committear el `Cargo.lock` en la fuente
477/// (plan C.2 #5). Ver `docs/runbooks/stage1-vm-boot.md` §8.
478///
479/// Un crate sin deps de crates.io produce un config vacío y ningún `vendor/`: inocuo.
480pub fn vendor_cargo_deps(src: &Path, vendor_dir: &str) -> hammer_core::Result<()> {
481    let locked = src.join("Cargo.lock").is_file();
482    let mut args: Vec<&str> = vec!["vendor"];
483    if locked {
484        args.push("--locked");
485    } else {
486        tracing::warn!(
487            src = %src.display(),
488            "sin Cargo.lock committeado: vendoreo sin --locked; el build NO es reproducible en el \
489             tiempo (deps no pineadas). Committear el lock en la fuente para fijarlo (plan C.2 #5)."
490        );
491    }
492    // Directorio de salida (default `vendor`; override per-receta vía `[source] cargo_vendor_dir`
493    // para no pisar un `vendor/` propio del proyecto). `cargo vendor` emite el `[source.*]` a stdout
494    // apuntando a ESTE dir, así que el `.cargo/config.toml` que persistimos abajo resuelve solo.
495    args.push(vendor_dir);
496    let output = Command::new("cargo")
497        .current_dir(src)
498        .args(&args)
499        .stderr(Stdio::inherit())
500        .output()
501        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn cargo vendor: {e}")))?;
502    if !output.status.success() {
503        return Err(hammer_core::Error::Other(anyhow::anyhow!(
504            "cargo vendor falló (exit {:?})",
505            output.status.code()
506        )));
507    }
508    // `cargo vendor` emite por stdout el fragmento `[source.*]` a instalar; lo persistimos en el
509    // árbol del source para que `cargo build --offline` lo lea (ruta relativa `vendor`, que
510    // resuelve igual dentro del sandbox donde el source se monta en /src).
511    let cargo_dir = src.join(".cargo");
512    std::fs::create_dir_all(&cargo_dir)?;
513    std::fs::write(cargo_dir.join("config.toml"), &output.stdout)?;
514    Ok(())
515}
516
517/// Vendoriza los módulos de un proyecto Go en `<src>/vendor` para que `go build -mod=vendor`
518/// resuelva todo offline dentro del sandbox. La red se usa AQUÍ (fuera del sandbox, como
519/// `vendor_cargo_deps`): `go mod vendor` baja los módulos de `go.mod`/`go.sum` al árbol.
520///
521/// **Reproducible por `go.sum`:** a diferencia de Cargo (lock opcional), Go exige `go.sum`
522/// committeado con el hash de cada módulo; el mismo commit ⇒ el mismo `go.sum` ⇒ el mismo
523/// `vendor/` (ya hasheado dentro del commit que fija la receta). `GOTOOLCHAIN=local` impide que
524/// `go.mod` fuerce la auto-descarga de otra versión del toolchain (el del lab manda y queda fijo);
525/// `GOFLAGS=-mod=mod` permite poblar el `vendor/` aunque el árbol no lo traiga.
526///
527/// Un proyecto sin deps externas produce un `vendor/` vacío (o ninguno): inocuo.
528pub fn vendor_go_deps(src: &Path) -> hammer_core::Result<()> {
529    // Proyecto WORKSPACE (multimódulo): si el árbol trae `go.work`, `go mod vendor` con GOWORK=off
530    // vendorea SÓLO el módulo raíz → el build (que ve todo el workspace) se queja de "inconsistent
531    // vendoring". `go work vendor` (go 1.22+) produce un `vendor/` consistente para TODO el workspace.
532    // Destraba la clase go.work (go-mockery, k3d, git-town, csvtk…). Sin go.work, el camino clásico
533    // `go mod vendor` con GOWORK=off (ignora cualquier go.work espurio y vendorea el raíz).
534    let has_work = src.join("go.work").is_file();
535    let (verb, label): ([&str; 2], &str) = if has_work {
536        (["work", "vendor"], "go work vendor")
537    } else {
538        (["mod", "vendor"], "go mod vendor")
539    };
540    let mut cmd = Command::new("go");
541    cmd.current_dir(src)
542        .args(verb)
543        .env("GOFLAGS", "-mod=mod")
544        .env("GOTOOLCHAIN", "local");
545    if !has_work {
546        cmd.env("GOWORK", "off");
547    }
548    let output = cmd
549        .stdout(Stdio::inherit())
550        .stderr(Stdio::inherit())
551        .output()
552        .map_err(|e| {
553            hammer_core::Error::Other(anyhow::anyhow!(
554                "spawn {label}: {e} (¿está `go` en el PATH del host? lo provee el lab, \
555                 cf. scripts/bootstrap-devfs.sh / vps-setup.sh)"
556            ))
557        })?;
558    if !output.status.success() {
559        return Err(hammer_core::Error::Other(anyhow::anyhow!(
560            "{label} falló (exit {:?})",
561            output.status.code()
562        )));
563    }
564    Ok(())
565}
566
567/// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`.
568pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> {
569    for p in &recipe.source.patches {
570        let patch_path = recipe.base_dir.join(p);
571        let bytes = std::fs::read(&patch_path).map_err(|e| {
572            hammer_core::Error::Recipe(format!(
573                "no pude leer patch {}: {e}",
574                patch_path.display()
575            ))
576        })?;
577        let mut child = Command::new("patch")
578            .args(["-p1", "--batch", "--silent"])
579            .current_dir(tree)
580            .stdin(Stdio::piped())
581            .stdout(Stdio::inherit())
582            .stderr(Stdio::inherit())
583            .spawn()
584            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn patch: {e}")))?;
585        if let Some(mut stdin) = child.stdin.take() {
586            use std::io::Write;
587            stdin
588                .write_all(&bytes)
589                .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("write patch: {e}")))?;
590        }
591        let status = child
592            .wait()
593            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait patch: {e}")))?;
594        if !status.success() {
595            return Err(hammer_core::Error::Other(anyhow::anyhow!(
596                "patch {} falló (exit {:?})",
597                patch_path.display(),
598                status.code()
599            )));
600        }
601    }
602    Ok(())
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn rotar_bases_lista_vacia_o_unica() {
611        assert!(rotar_bases("", "abcd").is_empty());
612        assert!(rotar_bases("  ,  ,", "abcd").is_empty());
613        // Un solo origen: se devuelve tal cual, sin barra final.
614        assert_eq!(rotar_bases("https://a/", "abcd"), vec!["https://a"]);
615    }
616
617    #[test]
618    fn rotar_bases_reparte_pero_es_determinista() {
619        let lista = "https://a, https://b ,https://c/";
620        // Determinista: la misma clave da siempre el mismo orden ⇒ un fallo se reproduce.
621        assert_eq!(rotar_bases(lista, "00ff"), rotar_bases(lista, "00ff"));
622        // Y siempre están LOS TRES: el resto de la lista queda como respaldo, no se descarta.
623        for clave in ["00", "01", "02", "ff00"] {
624            let r = rotar_bases(lista, clave);
625            assert_eq!(r.len(), 3, "clave {clave}");
626            for b in ["https://a", "https://b", "https://c"] {
627                assert!(r.contains(&b.to_string()), "falta {b} con clave {clave}");
628            }
629        }
630        // Reparte: claves distintas eligen primeros distintos, que es lo que ejercita todos los
631        // orígenes en una tanda del corpus en vez de martillar siempre el primero.
632        let primeros: std::collections::HashSet<String> = ["00", "01", "02"]
633            .iter()
634            .map(|c| rotar_bases(lista, c)[0].clone())
635            .collect();
636        assert_eq!(primeros.len(), 3);
637    }
638
639    #[test]
640    fn rotar_bases_no_panica_con_clave_corta() {
641        // `clave[..2]` habría panicado acá; el fold sobre bytes no.
642        assert_eq!(rotar_bases("https://a,https://b", "").len(), 2);
643        assert_eq!(rotar_bases("https://a,https://b", "0").len(), 2);
644    }
645
646    #[test]
647    fn vendor_cargo_deps_runs_and_writes_config() {
648        // Crate sin deps de crates.io: `cargo vendor` sale 0 con config vacío y sin red.
649        // Verifica el camino spawn → captura stdout → escribe `.cargo/config.toml`.
650        let tmp = tempfile::tempdir().unwrap();
651        let src = tmp.path();
652        std::fs::write(
653            src.join("Cargo.toml"),
654            "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
655        )
656        .unwrap();
657        std::fs::create_dir_all(src.join("src")).unwrap();
658        std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
659
660        // `--locked` exige un Cargo.lock presente; lo generamos offline (sin deps).
661        let lock_ok = Command::new("cargo")
662            .args(["generate-lockfile", "--offline"])
663            .current_dir(src)
664            .status()
665            .map(|s| s.success())
666            .unwrap_or(false);
667        if !lock_ok {
668            eprintln!("SKIP: cargo no disponible para generate-lockfile");
669            return;
670        }
671
672        vendor_cargo_deps(src, "vendor").unwrap();
673        assert!(src.join(".cargo/config.toml").is_file(), "debe escribir el config de vendoring");
674    }
675
676    #[test]
677    fn vendor_cargo_deps_without_committed_lock() {
678        // Fuente SIN Cargo.lock (como un monorepo que lo gitignora): vendoreamos sin --locked,
679        // generando el lock al vuelo. Para un crate depless es offline.
680        let tmp = tempfile::tempdir().unwrap();
681        let src = tmp.path();
682        std::fs::write(
683            src.join("Cargo.toml"),
684            "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
685        )
686        .unwrap();
687        std::fs::create_dir_all(src.join("src")).unwrap();
688        std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
689        assert!(!src.join("Cargo.lock").is_file(), "precondición: sin lock");
690
691        if Command::new("cargo").arg("--version").status().map(|s| !s.success()).unwrap_or(true) {
692            eprintln!("SKIP: cargo no disponible");
693            return;
694        }
695        // No debe fallar por falta de --locked; genera el lock y escribe el config.
696        vendor_cargo_deps(src, "vendor").unwrap();
697        assert!(src.join(".cargo/config.toml").is_file());
698        assert!(src.join("Cargo.lock").is_file(), "vendor sin --locked genera el lock");
699    }
700}
701
702fn run_git(args: &[&str], cwd: Option<&Path>) -> hammer_core::Result<()> {
703    let mut cmd = Command::new("git");
704    if let Some(c) = cwd {
705        cmd.arg("-C").arg(c);
706    }
707    cmd.args(args);
708    let status = cmd
709        .stdin(Stdio::null())
710        .stdout(Stdio::inherit())
711        .stderr(Stdio::inherit())
712        .status()
713        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?;
714    if !status.success() {
715        return Err(hammer_core::Error::Other(anyhow::anyhow!(
716            "git {args:?} falló (exit {:?})",
717            status.code()
718        )));
719    }
720    Ok(())
721}