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 Sutichaykuna

⎇ 114aeba514f8fe4b09786b4ab6bb61998893d9fe crates/hammer-build/src/fetch.rs Rust · 1069 siq'ikuna · 44.6 KB Ñawpa willay Pi imata llamkarqan ch'uya

fetch: materializa los submódulos git, que `git archive` no exporta 114aeba51 · 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    archivar_en(&mirror, commit, &work_tree)?;
123
124    // Un `git archive` NO emite nada por una entrada gitlink (modo 160000), así que un árbol con
125    // submódulos sale INCOMPLETO y el fallo aparece recién en `configure`, minutos después y
126    // hablando de un fichero que "no existe". Los materializamos acá, con el mismo mecanismo y el
127    // mismo determinismo: un submódulo ya viene pineado por SHA en el commit del padre (ADR 0006).
128    materializar_submodulos(&mirror, commit, &work_tree, &repos_dir, repo, 0)?;
129
130    Ok(work_tree)
131}
132
133/// `git archive <commit> | tar -x -C <destino>`: materializa UN árbol sin pagar un worktree.
134fn archivar_en(mirror: &Path, commit: &str, destino: &Path) -> hammer_core::Result<()> {
135    let archive = Command::new("git")
136        .arg("-C")
137        .arg(mirror)
138        .args(["archive", "--format=tar", commit])
139        .stdout(Stdio::piped())
140        .stderr(Stdio::piped())
141        .spawn()
142        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git archive: {e}")))?;
143
144    let tar_status = Command::new("tar")
145        .args(["-x", "-C"])
146        .arg(destino)
147        .stdin(archive.stdout.unwrap())
148        .stdout(Stdio::inherit())
149        .stderr(Stdio::inherit())
150        .status()
151        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x: {e}")))?;
152
153    if !tar_status.success() {
154        return Err(hammer_core::Error::Other(anyhow::anyhow!(
155            "git archive | tar -x falló (tar exit {:?})",
156            tar_status.code()
157        )));
158    }
159    Ok(())
160}
161
162/// Cuántos niveles de submódulo anidado aceptamos antes de declarar un ciclo.
163const PROFUNDIDAD_SUBMODULOS: usize = 8;
164
165/// Materializa los **submódulos** del commit dentro del árbol ya extraído, recursivamente.
166///
167/// # Por qué no alcanza un flag en el `clone`
168///
169/// El que pierde los submódulos no es el clon sino `git archive`: una entrada gitlink no emite
170/// nada al tar, por diseño. `--recurse-submodules` en el `clone` no cambiaría eso. Hay que leer
171/// `.gitmodules` + los SHA de gitlink del commit, mirrorear cada submódulo y archivarlo en su
172/// subruta — que es exactamente lo que hace esta función.
173///
174/// # Por qué reescribe la URL
175///
176/// `.gitmodules` suele declarar `git@github.com:X/Y.git`, que es SSH; nuestro fetch es anónimo y
177/// no tiene claves. La forma `https://github.com/X/Y.git` apunta al MISMO repo y el commit está
178/// pineado por SHA, así que el contenido no puede diferir: reescribirla no añade confianza que
179/// verificar. Las URLs relativas (`../foo.git`) se resuelven contra la del padre, como hace git.
180fn materializar_submodulos(
181    mirror: &Path,
182    commit: &str,
183    destino: &Path,
184    repos_dir: &Path,
185    url_padre: &str,
186    profundidad: usize,
187) -> hammer_core::Result<()> {
188    let gitlinks = gitlinks_de(mirror, commit)?;
189    if gitlinks.is_empty() {
190        return Ok(());
191    }
192    if profundidad >= PROFUNDIDAD_SUBMODULOS {
193        return Err(hammer_core::Error::Other(anyhow::anyhow!(
194            "submódulos anidados más de {PROFUNDIDAD_SUBMODULOS} niveles en {url_padre} \
195             ({commit}): parece un ciclo"
196        )));
197    }
198
199    let urls = urls_de_gitmodules(mirror, commit)?;
200
201    for (sha, ruta) in gitlinks {
202        let declarada = urls.get(&ruta).ok_or_else(|| {
203            hammer_core::Error::Other(anyhow::anyhow!(
204                "el commit {commit} declara el submódulo `{ruta}` pero .gitmodules no le da url"
205            ))
206        })?;
207        let url = resolver_url_submodulo(declarada, url_padre);
208
209        let sub_mirror = repos_dir.join(format!("sub-{}.git", sanear_para_ruta(&url)));
210        if !sub_mirror.is_dir() {
211            run_git(
212                &[
213                    "clone",
214                    "--mirror",
215                    "--filter=blob:none",
216                    &url,
217                    sub_mirror.to_str().unwrap(),
218                ],
219                None,
220            )?;
221        }
222        if run_git(&["cat-file", "-e", &sha], Some(&sub_mirror)).is_err() {
223            run_git(&["fetch", "--all", "--tags"], Some(&sub_mirror))?;
224            run_git(&["cat-file", "-e", &sha], Some(&sub_mirror)).map_err(|_| {
225                hammer_core::Error::Other(anyhow::anyhow!(
226                    "el submódulo `{ruta}` pide {sha}, que no existe en {url} tras fetch"
227                ))
228            })?;
229        }
230
231        let destino_sub = destino.join(&ruta);
232        std::fs::create_dir_all(&destino_sub)?;
233        archivar_en(&sub_mirror, &sha, &destino_sub)?;
234        materializar_submodulos(
235            &sub_mirror,
236            &sha,
237            &destino_sub,
238            repos_dir,
239            &url,
240            profundidad + 1,
241        )?;
242    }
243    Ok(())
244}
245
246/// Las entradas de tipo `commit` (modo 160000) del commit: `(sha, ruta)`.
247///
248/// `-z` porque una ruta puede llevar espacios y el formato sin `-z` los escapa con comillas.
249fn gitlinks_de(mirror: &Path, commit: &str) -> hammer_core::Result<Vec<(String, String)>> {
250    let salida = salida_git(&["ls-tree", "-r", "-z", commit], mirror)?;
251    let mut out = Vec::new();
252    for reg in salida.split('\0').filter(|r| !r.is_empty()) {
253        let Some((meta, ruta)) = reg.split_once('\t') else {
254            continue;
255        };
256        let campos: Vec<&str> = meta.split_whitespace().collect();
257        // <modo> <tipo> <sha>
258        if campos.len() == 3 && campos[1] == "commit" {
259            out.push((campos[2].to_string(), ruta.to_string()));
260        }
261    }
262    Ok(out)
263}
264
265/// `ruta → url` leído del `.gitmodules` **del commit** (no del working tree, que no existe).
266///
267/// Si el commit no trae `.gitmodules`, devuelve vacío: eso es legítimo (un gitlink sin declarar es
268/// un error, pero lo reporta quien lo consulta, con el nombre del submódulo a la vista).
269fn urls_de_gitmodules(
270    mirror: &Path,
271    commit: &str,
272) -> hammer_core::Result<std::collections::BTreeMap<String, String>> {
273    let blob = format!("{commit}:.gitmodules");
274    let salida = match salida_git(
275        &[
276            "config",
277            "--blob",
278            &blob,
279            "-z",
280            "--get-regexp",
281            "^submodule\\..*\\.(path|url)$",
282        ],
283        mirror,
284    ) {
285        Ok(s) => s,
286        Err(_) => return Ok(Default::default()),
287    };
288
289    let mut rutas: std::collections::BTreeMap<String, String> = Default::default();
290    let mut urls: std::collections::BTreeMap<String, String> = Default::default();
291    for reg in salida.split('\0').filter(|r| !r.is_empty()) {
292        // Con `-z` cada registro es `clave\nvalor`.
293        let Some((clave, valor)) = reg.split_once('\n') else {
294            continue;
295        };
296        let Some(resto) = clave.strip_prefix("submodule.") else {
297            continue;
298        };
299        // El NOMBRE del submódulo puede llevar puntos, así que se parte por el último.
300        let Some((nombre, campo)) = resto.rsplit_once('.') else {
301            continue;
302        };
303        match campo {
304            "path" => {
305                rutas.insert(nombre.to_string(), valor.to_string());
306            }
307            "url" => {
308                urls.insert(nombre.to_string(), valor.to_string());
309            }
310            _ => {}
311        }
312    }
313
314    Ok(rutas
315        .into_iter()
316        .filter_map(|(nombre, ruta)| urls.get(&nombre).map(|u| (ruta, u.clone())))
317        .collect())
318}
319
320/// SSH → HTTPS y relativa → absoluta contra la URL del padre. El resto pasa tal cual.
321fn resolver_url_submodulo(declarada: &str, url_padre: &str) -> String {
322    if let Some(resto) = declarada.strip_prefix("git@") {
323        if let Some((host, ruta)) = resto.split_once(':') {
324            return format!("https://{host}/{ruta}");
325        }
326    }
327    if let Some(resto) = declarada.strip_prefix("ssh://git@") {
328        return format!("https://{resto}");
329    }
330    if declarada.starts_with("./") || declarada.starts_with("../") {
331        return resolver_relativa(declarada, url_padre);
332    }
333    declarada.to_string()
334}
335
336/// `../x` sube un segmento de la URL del padre; `./x` se queda en el mismo nivel.
337fn resolver_relativa(rel: &str, url_padre: &str) -> String {
338    // git resuelve la relativa tratando la URL del padre como un DIRECTORIO: `../l10n.git` sobre
339    // `…/BrowserWorks/waterfox.git` da `…/BrowserWorks/l10n.git`, o sea que `..` se come el nombre
340    // del propio repo. Por eso NO se popea nada antes del bucle.
341    let mut base: Vec<&str> = url_padre.trim_end_matches('/').split('/').collect();
342    for seg in rel.split('/') {
343        match seg {
344            "." | "" => {}
345            ".." => {
346                base.pop();
347            }
348            otro => base.push(otro),
349        }
350    }
351    base.join("/")
352}
353
354/// Un nombre de directorio estable y legible para el mirror de una URL.
355fn sanear_para_ruta(url: &str) -> String {
356    url.chars()
357        .map(|c| {
358            if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
359                c
360            } else {
361                '-'
362            }
363        })
364        .collect()
365}
366
367/// `git <args>` capturando stdout. Error si el comando falla.
368fn salida_git(args: &[&str], cwd: &Path) -> hammer_core::Result<String> {
369    let out = Command::new("git")
370        .arg("-C")
371        .arg(cwd)
372        .args(args)
373        .stdin(Stdio::null())
374        .stderr(Stdio::piped())
375        .output()
376        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?;
377    if !out.status.success() {
378        return Err(hammer_core::Error::Other(anyhow::anyhow!(
379            "git {args:?} falló (exit {:?})",
380            out.status.code()
381        )));
382    }
383    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
384}
385
386/// Los orígenes declarados en `var` (**lista** separada por comas), **rotados** por `clave`.
387///
388/// # Por qué una lista y no una URL (ADR 0014)
389///
390/// El ADR 0013 dejó `HAMMER_MIRROR` con un solo valor y con eso el mecanismo que existe para no
391/// depender de 79 servidores ajenos pasaba a depender de **uno nuestro**: el mismo fallo, con
392/// nuestro nombre encima. La identidad del objeto es su `sha256`/`commit`, así que un origen de más
393/// no añade confianza que verificar — sólo un sitio más donde mirar. Añadirlos es gratis y no
394/// re-hashea nada (la URL nunca entró en `hash_inputs`).
395///
396/// # Por qué ROTA y no prueba siempre en orden
397///
398/// Es la lección del propio ADR 0013 aplicada un nivel más arriba: *un espejo que sólo se usa
399/// cuando el otro falla es un espejo que nadie prueba*, y se descubre roto el día que hace falta.
400/// Con orden fijo, el segundo origen no se toca jamás hasta la emergencia. El reparto es
401/// **determinista por contenido** — el mismo objeto sale siempre del mismo origen, así que un fallo
402/// se reproduce y se depura — pero objetos distintos se reparten entre todos, así que una tanda del
403/// corpus **ejercita la lista entera**. Tras el elegido se recorren los demás como respaldo.
404fn bases_de_mirror(var: &str, clave: &str) -> Vec<String> {
405    match std::env::var(var) {
406        Ok(raw) => rotar_bases(&raw, clave),
407        Err(_) => Vec::new(),
408    }
409}
410
411/// La parte pura de [`bases_de_mirror`]: parsea la lista y la rota. Separada del `env::var` para
412/// poder probarla sin tocar el entorno del proceso (que es global y hace los tests dependientes
413/// del orden en que corren).
414fn rotar_bases(raw: &str, clave: &str) -> Vec<String> {
415    let bases: Vec<String> = raw
416        .split(',')
417        .map(|s| s.trim().trim_end_matches('/'))
418        .filter(|s| !s.is_empty())
419        .map(str::to_string)
420        .collect();
421    if bases.len() < 2 {
422        return bases;
423    }
424    // Los dos primeros bytes de la clave hex bastan para repartir y no pueden panicar con una clave
425    // corta o vacía (a diferencia de `clave[..2]`).
426    let semilla = clave
427        .as_bytes()
428        .iter()
429        .take(2)
430        .fold(0usize, |a, b| a.wrapping_mul(256).wrapping_add(*b as usize));
431    let inicio = semilla % bases.len();
432    bases
433        .iter()
434        .cycle()
435        .skip(inicio)
436        .take(bases.len())
437        .cloned()
438        .collect()
439}
440
441/// Intenta poblar `destino` (repo bare) con `commit` desde el mirror de fuentes git (ADR 0013).
442/// Devuelve `true` si el commit quedó disponible. **Nunca es un error**: si no hay mirror, si el
443/// objeto no está o si algo falla, devuelve `false` y el llamador va al remoto como siempre.
444///
445/// # Por qué un bundle SHALLOW y no un clon completo
446///
447/// El bundle se genera desde un clon `--depth 1` del commit exacto: para `act` son 8,9 MB en vez
448/// del repositorio entero. Hammer no necesita la historia — lo único que hace con el repo es
449/// `git archive <commit> | tar -x`, o sea materializar UN árbol.
450///
451/// # El detalle que hace que esto funcione: el fichero `shallow`
452///
453/// Un bundle hecho desde un repo shallow **no lleva la frontera de historia**, así que al
454/// desempaquetarlo git intenta recorrer los padres y aborta:
455///
456/// ```text
457/// fatal: Failed to traverse parents of commit 4f41128…
458/// error: … did not send all necessary objects
459/// ```
460///
461/// El mensaje suena a bundle incompleto y **no lo es**: los objetos del commit llegan enteros (de
462/// hecho `git archive` ya funciona pese al error). Lo que falta es decirle a git dónde termina la
463/// historia. Se arregla escribiendo el propio commit en `<destino>/shallow` ANTES del fetch — y no
464/// hay que transportar nada, porque la frontera de un clon `--depth 1` es exactamente ese commit.
465///
466/// La verificación sigue siendo criptográfica: git comprueba cada objeto contra su SHA al
467/// desempaquetar, y el commit de la receta es la identidad. Un bundle alterado no pasa.
468fn hidratar_desde_bundle(commit: &str, destino: &Path) -> bool {
469    // `HAMMER_MIRROR_GIT` es una LISTA (ADR 0014): se prueban todos los orígenes antes de rendirse
470    // y caer a upstream. Un bundle que no está en uno puede estar en otro, y la verificación la
471    // hace git contra el SHA venga de donde venga.
472    bases_de_mirror("HAMMER_MIRROR_GIT", commit)
473        .iter()
474        .any(|base| hidratar_desde_bundle_en(base, commit, destino))
475}
476
477/// Un solo intento contra `base`. Devuelve `true` si el commit quedó disponible en `destino`.
478fn hidratar_desde_bundle_en(base: &str, commit: &str, destino: &Path) -> bool {
479    let tmp = destino.with_extension("bundle.partial");
480    if let Some(padre) = tmp.parent() {
481        let _ = std::fs::create_dir_all(padre);
482    }
483    let _ = std::fs::remove_file(&tmp);
484    if descargar(&format!("{base}/{commit}.bundle"), &tmp).is_err() {
485        let _ = std::fs::remove_file(&tmp);
486        return false;
487    }
488    if !destino.is_dir() && run_git(&["init", "--bare", destino.to_str().unwrap()], None).is_err() {
489        let _ = std::fs::remove_file(&tmp);
490        return false;
491    }
492    // La frontera de historia. Sin esto el fetch aborta aunque los objetos estén completos.
493    //
494    // La frontera se LEE DEL BUNDLE, no se supone. `git bundle list-heads` imprime la lista de refs
495    // sin necesitar los objetos, así que sirve justo antes del fetch. Suponerla igual a `commit`
496    // estaba mal: el campo `commit` de una receta puede pinear un OBJETO TAG anotado (así están
497    // diffutils, findutils-xargs y kustomize), y el fichero `shallow` sólo admite SHAs de commit.
498    // El poblador deja siempre `refs/heads/hammer` en el commit ya pelado.
499    let Some(frontera) = frontera_del_bundle(&tmp) else {
500        let _ = std::fs::remove_file(&tmp);
501        return false;
502    };
503    let _ = std::fs::write(destino.join("shallow"), format!("{frontera}\n"));
504    // ⚠ RUTA ABSOLUTA, obligatoria. `run_git` invoca `git -C <destino> …`, y git resuelve una ruta
505    // relativa de bundle DENTRO de ese `-C` — o sea buscaría `<destino>/work/repos/x.bundle.partial`.
506    // El fallo es mudo aquí (se traga y se cae al remoto), así que el síntoma aparece lejos: el build
507    // muere diciendo «commit … no existe en <repo> tras fetch», culpando a upstream de un error de
508    // ruta local.
509    let bundle_abs = std::fs::canonicalize(&tmp).unwrap_or_else(|_| tmp.clone());
510    // `refs/*:refs/*` y no un ref concreto: cuando el pin es un tag anotado el bundle trae DOS refs
511    // (el commit pelado y el objeto tag), y hace falta el objeto tag para que el `cat-file -e` de
512    // abajo — y el `git archive <commit>` de después — encuentren lo que la receta pide.
513    let ok = run_git(
514        &["fetch", bundle_abs.to_str().unwrap(), "refs/*:refs/*"],
515        Some(destino),
516    )
517    .is_ok()
518        && run_git(&["cat-file", "-e", commit], Some(destino)).is_ok();
519    let _ = std::fs::remove_file(&tmp);
520    ok
521}
522
523/// SHA del commit que `refs/heads/hammer` señala dentro del bundle, leído sin desempaquetarlo.
524fn frontera_del_bundle(bundle: &Path) -> Option<String> {
525    let abs = std::fs::canonicalize(bundle).ok()?;
526    let salida = Command::new("git")
527        .args(["bundle", "list-heads"])
528        .arg(&abs)
529        .output()
530        .ok()?;
531    if !salida.status.success() {
532        return None;
533    }
534    String::from_utf8_lossy(&salida.stdout)
535        .lines()
536        .find_map(|l| {
537            let (sha, refname) = l.split_once(char::is_whitespace)?;
538            (refname.trim() == "refs/heads/hammer").then(|| sha.trim().to_string())
539        })
540}
541
542fn fetch_tarball(
543    recipe: &Recipe,
544    url: &str,
545    sha256: &str,
546    work_root: &Path,
547) -> hammer_core::Result<PathBuf> {
548    let tarballs_dir = work_root.join("tarballs");
549    let sources_dir = work_root.join("sources");
550    std::fs::create_dir_all(&tarballs_dir)?;
551    std::fs::create_dir_all(&sources_dir)?;
552
553    // Nombre por hash: dos URLs distintas con el mismo contenido se cachean una vez, y
554    // cambiar la URL sin cambiar el sha (mirror) no invalida la caché.
555    let cached = tarballs_dir.join(format!("{sha256}.tar"));
556    if !cached.is_file() {
557        fetch_con_mirror(url, sha256, &cached)?;
558    } else {
559        verify_sha256(&cached, sha256)?;
560    }
561
562    // ⚠ CARRERA CONOCIDA, SIN RESOLVER — ver docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md
563    // El path es determinista y NO lleva nada del constructor ⇒ dos builds de la misma receta (la
564    // misma dep alcanzada por dos caminos) resuelven al mismo directorio: uno borra mientras el otro
565    // extrae y el árbol queda roto con "Directory not empty" (os error 39) hasta borrarlo a mano.
566    // Mitigado SÓLO entre campana-deuda.sh y farm-worker-loop.sh con un lock de script (commit
567    // 1b436a7); el `xargs -P2` de build-farm.sh sigue expuesto.
568    // NO "arreglar" con un lock alrededor de esta extracción: parece correcto y no lo es, porque un
569    // segundo proceso puede borrar un árbol que un bwrap ya está usando para compilar. Este árbol es
570    // caché y workspace mutable a la vez (lib.rs lo parchea y vendorea in situ); el ADR 0012 plantea
571    // las tres salidas y qué medir para elegir.
572    let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &sha256[..16]));
573    if work_tree.is_dir() {
574        std::fs::remove_dir_all(&work_tree)?;
575    }
576    std::fs::create_dir_all(&work_tree)?;
577
578    // `tar` autodetecta el compresor por la cabecera; el sufijo .tar del nombre es solo
579    // identificador interno (no afecta a la detección).
580    let strip = recipe.source.strip_components.to_string();
581    let status = Command::new("tar")
582        .arg("-x")
583        // `--no-same-owner`: extrae como el usuario actual (no preserva el uid del tarball). Si el
584        // fetch corre como root (granja), `tar` por defecto preservaría el owner original → ficheros
585        // de un uid que el sandbox (userns) no puede chmod/escribir → configure de autotools falla
586        // ("Permission denied" creando Makefile, "Operation not permitted" en chmod). Inocuo para el
587        // of_tree (es el árbol de FUENTES, no el output). Destraba libs C como oniguruma.
588        .arg("--no-same-owner")
589        .arg("-f")
590        .arg(&cached)
591        .arg("--strip-components")
592        .arg(&strip)
593        .arg("-C")
594        .arg(&work_tree)
595        .stdout(Stdio::inherit())
596        .stderr(Stdio::inherit())
597        .status()
598        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x tarball: {e}")))?;
599    if !status.success() {
600        return Err(hammer_core::Error::Other(anyhow::anyhow!(
601            "tar -x falló (exit {:?})",
602            status.code()
603        )));
604    }
605    Ok(work_tree)
606}
607
608/// Baja el tarball probando el **mirror propio antes que upstream** (ADR 0013).
609///
610/// # Por qué el mirror va primero y no como rescate
611///
612/// El `sha256` se verifica igual venga de donde venga, así que **no hay diferencia de contenido
613/// posible** entre las dos rutas: preferir upstream «por pureza» no compra nada y paga latencia más
614/// un tercero en el camino. Además un mirror que sólo se usa cuando upstream falla es un mirror que
615/// **nadie prueba**, y se descubre roto justo el día en que hace falta.
616///
617/// # Por qué NO avisa cuando cae al mirror
618///
619/// Tentación evidente y **equivocada**: si `hammer build` avisara en cada descarga, el aviso sería
620/// ruido en 561 recetas y nadie lo leería. Peor: haría creer que el bit-rot está vigilado *acá*,
621/// cuando construir y vigilar upstream son dos trabajos distintos. La vigilancia vive en
622/// `scripts/fuentes/fuentes-vigia.sh`, la corre el latido y escribe `docs/state/fuentes-vigia.json`.
623/// Un mirror silencioso **con** vigía separado detecta el bit-rot; uno silencioso **sin** vigía lo
624/// entierra, que es como el grafo de wlr pasó 17 días anunciando un 121/121 que ya era falso.
625///
626/// `HAMMER_MIRROR` es una **lista** de prefijos de URL separada por comas, de cualquier esquema que
627/// curl entienda (`file://`, `https://`, `sftp://`…); el objeto se busca por contenido en
628/// `{base}/{sha256}.tar` en cada uno, en el orden que dicta [`bases_de_mirror`].
629/// `HAMMER_MIRROR_KEY`, si está, se pasa como `--key` (Storage Box por sftp). Sin `HAMMER_MIRROR`
630/// el comportamiento es el de siempre: derecho a upstream.
631fn fetch_con_mirror(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> {
632    for base in bases_de_mirror("HAMMER_MIRROR", sha256) {
633        let espejo = format!("{base}/{sha256}.tar");
634        // Un fallo acá NO es un error: es el caso normal la primera vez que se ve una fuente, y
635        // también el caso normal cuando un origen de la lista todavía no tiene ese objeto. Se
636        // ignora en silencio y se sigue al siguiente origen, y al final a upstream.
637        if download_verify(&espejo, sha256, dst).is_ok() {
638            return Ok(());
639        }
640    }
641    download_verify(url, sha256, dst)
642}
643
644/// Descarga `url` a `dst` y verifica el sha256. Si la verificación falla, borra el archivo
645/// para que la próxima corrida lo vuelva a intentar (no envenenamos la caché).
646/// Baja `url` a `dst` con curl. Sin verificar nada: quien llama decide cómo se comprueba —
647/// `download_verify` por sha256 (tarballs), `hidratar_desde_bundle` delegando en git (bundles).
648fn descargar(url: &str, dst: &Path) -> hammer_core::Result<()> {
649    let mut cmd = Command::new("curl");
650    cmd.args(["-fL", "--retry", "3", "--connect-timeout", "30"]);
651    // Clave para el mirror por sftp (Storage Box). Inocua para http/file.
652    if let Ok(key) = std::env::var("HAMMER_MIRROR_KEY") {
653        if !key.is_empty() {
654            cmd.arg("--key").arg(&key);
655        }
656    }
657    let status = cmd
658        .arg("-o")
659        .arg(dst)
660        .arg(url)
661        .stdout(Stdio::inherit())
662        .stderr(Stdio::inherit())
663        .status()
664        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?;
665    if !status.success() {
666        return Err(hammer_core::Error::Other(anyhow::anyhow!(
667            "curl {url} falló (exit {:?})",
668            status.code()
669        )));
670    }
671    Ok(())
672}
673
674fn download_verify(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> {
675    let tmp = dst.with_extension("tar.partial");
676    if let Err(e) = descargar(url, &tmp) {
677        let _ = std::fs::remove_file(&tmp);
678        return Err(e);
679    }
680    if let Err(e) = verify_sha256(&tmp, sha256) {
681        let _ = std::fs::remove_file(&tmp);
682        return Err(e);
683    }
684    std::fs::rename(&tmp, dst)?;
685    Ok(())
686}
687
688fn verify_sha256(path: &Path, expected: &str) -> hammer_core::Result<()> {
689    use sha2::{Digest, Sha256};
690    let mut f = std::fs::File::open(path)?;
691    let mut hasher = Sha256::new();
692    std::io::copy(&mut f, &mut hasher)?;
693    let got = hex::encode(hasher.finalize());
694    if !got.eq_ignore_ascii_case(expected) {
695        return Err(hammer_core::Error::Other(anyhow::anyhow!(
696            "sha256 mismatch en {}: esperado {expected}, obtenido {got}",
697            path.display()
698        )));
699    }
700    Ok(())
701}
702
703/// Vendoriza las dependencias de un proyecto Cargo en `<src>/vendor` y escribe
704/// `<src>/.cargo/config.toml` para que el build dentro del sandbox resuelva todo `--offline`.
705///
706/// La red se usa AQUÍ (fuera del sandbox, como el resto del fetch).
707///
708/// **`--locked` condicional:** si la fuente trae un `Cargo.lock` committeado, se vendorea con
709/// `--locked` y el build es reproducible (mismo commit ⇒ mismo lock ⇒ mismas deps; el lock ya está
710/// dentro del commit que hashea la receta). Si la fuente **no** lo trae (p.ej. un monorepo que lo
711/// gitignora, como tawasuyu), se genera el lock al vuelo **sin** `--locked` y se emite un `warn!`:
712/// el build queda internamente consistente pero **no pineado en el tiempo** (las deps pueden
713/// derivar entre corridas). El end-state reproducible es committear el `Cargo.lock` en la fuente
714/// (plan C.2 #5). Ver `docs/runbooks/stage1-vm-boot.md` §8.
715///
716/// Un crate sin deps de crates.io produce un config vacío y ningún `vendor/`: inocuo.
717pub fn vendor_cargo_deps(src: &Path, vendor_dir: &str) -> hammer_core::Result<()> {
718    let locked = src.join("Cargo.lock").is_file();
719    let mut args: Vec<&str> = vec!["vendor"];
720    if locked {
721        args.push("--locked");
722    } else {
723        tracing::warn!(
724            src = %src.display(),
725            "sin Cargo.lock committeado: vendoreo sin --locked; el build NO es reproducible en el \
726             tiempo (deps no pineadas). Committear el lock en la fuente para fijarlo (plan C.2 #5)."
727        );
728    }
729    // Directorio de salida (default `vendor`; override per-receta vía `[source] cargo_vendor_dir`
730    // para no pisar un `vendor/` propio del proyecto). `cargo vendor` emite el `[source.*]` a stdout
731    // apuntando a ESTE dir, así que el `.cargo/config.toml` que persistimos abajo resuelve solo.
732    args.push(vendor_dir);
733    let output = Command::new("cargo")
734        .current_dir(src)
735        .args(&args)
736        .stderr(Stdio::inherit())
737        .output()
738        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn cargo vendor: {e}")))?;
739    if !output.status.success() {
740        return Err(hammer_core::Error::Other(anyhow::anyhow!(
741            "cargo vendor falló (exit {:?})",
742            output.status.code()
743        )));
744    }
745    // `cargo vendor` emite por stdout el fragmento `[source.*]` a instalar; lo persistimos en el
746    // árbol del source para que `cargo build --offline` lo lea (ruta relativa `vendor`, que
747    // resuelve igual dentro del sandbox donde el source se monta en /src).
748    let cargo_dir = src.join(".cargo");
749    std::fs::create_dir_all(&cargo_dir)?;
750    std::fs::write(cargo_dir.join("config.toml"), &output.stdout)?;
751    Ok(())
752}
753
754/// Vendoriza los módulos de un proyecto Go en `<src>/vendor` para que `go build -mod=vendor`
755/// resuelva todo offline dentro del sandbox. La red se usa AQUÍ (fuera del sandbox, como
756/// `vendor_cargo_deps`): `go mod vendor` baja los módulos de `go.mod`/`go.sum` al árbol.
757///
758/// **Reproducible por `go.sum`:** a diferencia de Cargo (lock opcional), Go exige `go.sum`
759/// committeado con el hash de cada módulo; el mismo commit ⇒ el mismo `go.sum` ⇒ el mismo
760/// `vendor/` (ya hasheado dentro del commit que fija la receta). `GOTOOLCHAIN=local` impide que
761/// `go.mod` fuerce la auto-descarga de otra versión del toolchain (el del lab manda y queda fijo);
762/// `GOFLAGS=-mod=mod` permite poblar el `vendor/` aunque el árbol no lo traiga.
763///
764/// Un proyecto sin deps externas produce un `vendor/` vacío (o ninguno): inocuo.
765pub fn vendor_go_deps(src: &Path) -> hammer_core::Result<()> {
766    // Proyecto WORKSPACE (multimódulo): si el árbol trae `go.work`, `go mod vendor` con GOWORK=off
767    // vendorea SÓLO el módulo raíz → el build (que ve todo el workspace) se queja de "inconsistent
768    // vendoring". `go work vendor` (go 1.22+) produce un `vendor/` consistente para TODO el workspace.
769    // Destraba la clase go.work (go-mockery, k3d, git-town, csvtk…). Sin go.work, el camino clásico
770    // `go mod vendor` con GOWORK=off (ignora cualquier go.work espurio y vendorea el raíz).
771    let has_work = src.join("go.work").is_file();
772    let (verb, label): ([&str; 2], &str) = if has_work {
773        (["work", "vendor"], "go work vendor")
774    } else {
775        (["mod", "vendor"], "go mod vendor")
776    };
777    let mut cmd = Command::new("go");
778    cmd.current_dir(src)
779        .args(verb)
780        .env("GOFLAGS", "-mod=mod")
781        .env("GOTOOLCHAIN", "local");
782    if !has_work {
783        cmd.env("GOWORK", "off");
784    }
785    let output = cmd
786        .stdout(Stdio::inherit())
787        .stderr(Stdio::inherit())
788        .output()
789        .map_err(|e| {
790            hammer_core::Error::Other(anyhow::anyhow!(
791                "spawn {label}: {e} (¿está `go` en el PATH del host? lo provee el lab, \
792                 cf. scripts/bootstrap-devfs.sh / vps-setup.sh)"
793            ))
794        })?;
795    if !output.status.success() {
796        return Err(hammer_core::Error::Other(anyhow::anyhow!(
797            "{label} falló (exit {:?})",
798            output.status.code()
799        )));
800    }
801    Ok(())
802}
803
804/// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`.
805pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> {
806    for p in &recipe.source.patches {
807        let patch_path = recipe.base_dir.join(p);
808        let bytes = std::fs::read(&patch_path).map_err(|e| {
809            hammer_core::Error::Recipe(format!(
810                "no pude leer patch {}: {e}",
811                patch_path.display()
812            ))
813        })?;
814        let mut child = Command::new("patch")
815            .args(["-p1", "--batch", "--silent"])
816            .current_dir(tree)
817            .stdin(Stdio::piped())
818            .stdout(Stdio::inherit())
819            .stderr(Stdio::inherit())
820            .spawn()
821            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn patch: {e}")))?;
822        if let Some(mut stdin) = child.stdin.take() {
823            use std::io::Write;
824            stdin
825                .write_all(&bytes)
826                .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("write patch: {e}")))?;
827        }
828        let status = child
829            .wait()
830            .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait patch: {e}")))?;
831        if !status.success() {
832            return Err(hammer_core::Error::Other(anyhow::anyhow!(
833                "patch {} falló (exit {:?})",
834                patch_path.display(),
835                status.code()
836            )));
837        }
838    }
839    Ok(())
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845
846    #[test]
847    fn resolver_url_reescribe_ssh_a_https() {
848        assert_eq!(
849            resolver_url_submodulo("git@github.com:BrowserWorks/l10n.git", "https://x/y.git"),
850            "https://github.com/BrowserWorks/l10n.git"
851        );
852        assert_eq!(
853            resolver_url_submodulo("ssh://git@gitlab.com/a/b.git", "https://x/y.git"),
854            "https://gitlab.com/a/b.git"
855        );
856        // Una https ya buena no se toca.
857        assert_eq!(
858            resolver_url_submodulo("https://github.com/a/b.git", "https://x/y.git"),
859            "https://github.com/a/b.git"
860        );
861    }
862
863    #[test]
864    fn resolver_url_resuelve_relativas_contra_el_padre() {
865        // `..` sube desde el DIRECTORIO que contiene al padre, como hace git.
866        assert_eq!(
867            resolver_url_submodulo("../l10n.git", "https://github.com/BrowserWorks/waterfox.git"),
868            "https://github.com/BrowserWorks/l10n.git"
869        );
870        assert_eq!(
871            resolver_url_submodulo("./sub.git", "https://github.com/o/r.git"),
872            "https://github.com/o/r.git/sub.git"
873        );
874    }
875
876    /// El caso que mató a waterfox: un árbol con un gitlink debe llegar COMPLETO.
877    #[test]
878    fn materializa_un_submodulo_real() {
879        let raiz = tempfile::tempdir().unwrap();
880        let git = |args: &[&str], cwd: &Path| {
881            let st = Command::new("git")
882                .arg("-C")
883                .arg(cwd)
884                .args(args)
885                .env("GIT_AUTHOR_NAME", "t")
886                .env("GIT_AUTHOR_EMAIL", "t@t")
887                .env("GIT_COMMITTER_NAME", "t")
888                .env("GIT_COMMITTER_EMAIL", "t@t")
889                .stdout(Stdio::null())
890                .stderr(Stdio::null())
891                .status()
892                .unwrap();
893            assert!(st.success(), "git {args:?} falló");
894        };
895
896        // Un repo `hijo` con un fichero, y un `padre` que lo monta como submódulo.
897        let hijo = raiz.path().join("hijo");
898        std::fs::create_dir_all(&hijo).unwrap();
899        git(&["init", "-q", "-b", "main"], &hijo);
900        std::fs::write(hijo.join("moz.build"), "DEL SUBMODULO\n").unwrap();
901        git(&["add", "moz.build"], &hijo);
902        git(&["commit", "-qm", "hijo"], &hijo);
903
904        let padre = raiz.path().join("padre");
905        std::fs::create_dir_all(&padre).unwrap();
906        git(&["init", "-q", "-b", "main"], &padre);
907        std::fs::write(padre.join("raiz.txt"), "DEL PADRE\n").unwrap();
908        git(&["add", "raiz.txt"], &padre);
909        git(
910            &[
911                "-c",
912                "protocol.file.allow=always",
913                "submodule",
914                "add",
915                "-q",
916                hijo.to_str().unwrap(),
917                "browser/locales",
918            ],
919            &padre,
920        );
921        git(&["commit", "-qm", "padre"], &padre);
922
923        let mirror = raiz.path().join("padre.git");
924        let st = Command::new("git")
925            .args(["clone", "--mirror", padre.to_str().unwrap()])
926            .arg(&mirror)
927            .stdout(Stdio::null())
928            .stderr(Stdio::null())
929            .status()
930            .unwrap();
931        assert!(st.success());
932        let commit = salida_git(&["rev-parse", "main"], &mirror).unwrap();
933        let commit = commit.trim();
934
935        let destino = raiz.path().join("arbol");
936        std::fs::create_dir_all(&destino).unwrap();
937        let repos = raiz.path().join("repos");
938        std::fs::create_dir_all(&repos).unwrap();
939
940        archivar_en(&mirror, commit, &destino).unwrap();
941        // Sin submódulos, `git archive` deja el gitlink como un directorio VACÍO: el fichero que
942        // el build pide no está, y el error llega recién en `configure`.
943        assert!(!destino.join("browser/locales/moz.build").exists());
944
945        materializar_submodulos(&mirror, commit, &destino, &repos, padre.to_str().unwrap(), 0)
946            .unwrap();
947        assert_eq!(
948            std::fs::read_to_string(destino.join("browser/locales/moz.build")).unwrap(),
949            "DEL SUBMODULO\n"
950        );
951        assert_eq!(
952            std::fs::read_to_string(destino.join("raiz.txt")).unwrap(),
953            "DEL PADRE\n"
954        );
955    }
956
957    #[test]
958    fn rotar_bases_lista_vacia_o_unica() {
959        assert!(rotar_bases("", "abcd").is_empty());
960        assert!(rotar_bases("  ,  ,", "abcd").is_empty());
961        // Un solo origen: se devuelve tal cual, sin barra final.
962        assert_eq!(rotar_bases("https://a/", "abcd"), vec!["https://a"]);
963    }
964
965    #[test]
966    fn rotar_bases_reparte_pero_es_determinista() {
967        let lista = "https://a, https://b ,https://c/";
968        // Determinista: la misma clave da siempre el mismo orden ⇒ un fallo se reproduce.
969        assert_eq!(rotar_bases(lista, "00ff"), rotar_bases(lista, "00ff"));
970        // Y siempre están LOS TRES: el resto de la lista queda como respaldo, no se descarta.
971        for clave in ["00", "01", "02", "ff00"] {
972            let r = rotar_bases(lista, clave);
973            assert_eq!(r.len(), 3, "clave {clave}");
974            for b in ["https://a", "https://b", "https://c"] {
975                assert!(r.contains(&b.to_string()), "falta {b} con clave {clave}");
976            }
977        }
978        // Reparte: claves distintas eligen primeros distintos, que es lo que ejercita todos los
979        // orígenes en una tanda del corpus en vez de martillar siempre el primero.
980        let primeros: std::collections::HashSet<String> = ["00", "01", "02"]
981            .iter()
982            .map(|c| rotar_bases(lista, c)[0].clone())
983            .collect();
984        assert_eq!(primeros.len(), 3);
985    }
986
987    #[test]
988    fn rotar_bases_no_panica_con_clave_corta() {
989        // `clave[..2]` habría panicado acá; el fold sobre bytes no.
990        assert_eq!(rotar_bases("https://a,https://b", "").len(), 2);
991        assert_eq!(rotar_bases("https://a,https://b", "0").len(), 2);
992    }
993
994    #[test]
995    fn vendor_cargo_deps_runs_and_writes_config() {
996        // Crate sin deps de crates.io: `cargo vendor` sale 0 con config vacío y sin red.
997        // Verifica el camino spawn → captura stdout → escribe `.cargo/config.toml`.
998        let tmp = tempfile::tempdir().unwrap();
999        let src = tmp.path();
1000        std::fs::write(
1001            src.join("Cargo.toml"),
1002            "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
1003        )
1004        .unwrap();
1005        std::fs::create_dir_all(src.join("src")).unwrap();
1006        std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
1007
1008        // `--locked` exige un Cargo.lock presente; lo generamos offline (sin deps).
1009        let lock_ok = Command::new("cargo")
1010            .args(["generate-lockfile", "--offline"])
1011            .current_dir(src)
1012            .status()
1013            .map(|s| s.success())
1014            .unwrap_or(false);
1015        if !lock_ok {
1016            eprintln!("SKIP: cargo no disponible para generate-lockfile");
1017            return;
1018        }
1019
1020        vendor_cargo_deps(src, "vendor").unwrap();
1021        assert!(src.join(".cargo/config.toml").is_file(), "debe escribir el config de vendoring");
1022    }
1023
1024    #[test]
1025    fn vendor_cargo_deps_without_committed_lock() {
1026        // Fuente SIN Cargo.lock (como un monorepo que lo gitignora): vendoreamos sin --locked,
1027        // generando el lock al vuelo. Para un crate depless es offline.
1028        let tmp = tempfile::tempdir().unwrap();
1029        let src = tmp.path();
1030        std::fs::write(
1031            src.join("Cargo.toml"),
1032            "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
1033        )
1034        .unwrap();
1035        std::fs::create_dir_all(src.join("src")).unwrap();
1036        std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
1037        assert!(!src.join("Cargo.lock").is_file(), "precondición: sin lock");
1038
1039        if Command::new("cargo").arg("--version").status().map(|s| !s.success()).unwrap_or(true) {
1040            eprintln!("SKIP: cargo no disponible");
1041            return;
1042        }
1043        // No debe fallar por falta de --locked; genera el lock y escribe el config.
1044        vendor_cargo_deps(src, "vendor").unwrap();
1045        assert!(src.join(".cargo/config.toml").is_file());
1046        assert!(src.join("Cargo.lock").is_file(), "vendor sin --locked genera el lock");
1047    }
1048}
1049
1050fn run_git(args: &[&str], cwd: Option<&Path>) -> hammer_core::Result<()> {
1051    let mut cmd = Command::new("git");
1052    if let Some(c) = cwd {
1053        cmd.arg("-C").arg(c);
1054    }
1055    cmd.args(args);
1056    let status = cmd
1057        .stdin(Stdio::null())
1058        .stdout(Stdio::inherit())
1059        .stderr(Stdio::inherit())
1060        .status()
1061        .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?;
1062    if !status.success() {
1063        return Err(hammer_core::Error::Other(anyhow::anyhow!(
1064            "git {args:?} falló (exit {:?})",
1065            status.code()
1066        )));
1067    }
1068    Ok(())
1069}