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…
⎇ 114aeba514f8fe4b09786b4ab6bb61998893d9fe
crates/hammer-build/src/fetch.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
fetch: materializa los submódulos git, que `git archive` no exporta
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}