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…
⎇ 0a4b3abb461b5e9b6a68cbb0508d2eb7899acae3
crates/takana-build/src/fetch.rs
Historia
Quién tocó qué
crudo
1//! Fase `fetch`: materializa el árbol fuente de la receta en un directorio trabajable.
2//!
3//! Dos modos, decididos por la receta (ver `takana_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 takana_core::{Recipe, SourceKind};
18
19/// Exclusión mutua del ÁRBOL DE FUENTES de una receta — **la decisión del [ADR 0012]**.
20///
21/// ## Qué carrera cierra
22///
23/// El árbol se nombra de forma determinista y SIN componente por-constructor
24/// (`<nombre>-<sha|commit>`), así que dos procesos que construyan la MISMA receta resuelven al mismo
25/// directorio: uno hace `remove_dir_all` mientras el otro corre `tar -x` o compila, y el árbol queda
26/// roto **para siempre** (el siguiente intento vuelve a chocar). Medido a escala: al invalidar
27/// `libdrm`, ~93 de 205 recetas KDE murieron con `/src/.zwrap/cc is not a full path to an existing
28/// compiler tool` — el wrapper de zig que la receta deja EN EL ÁRBOL, barrido por el fetch de otra.
29///
30/// ## Por qué un lock por árbol NO serializa la granja (lo que el ADR temía)
31///
32/// El ADR descartaba a medias esta opción porque «el grafo es un diamante que converge ⇒ puede
33/// degenerar en serializar casi todo». **Eso supone que un consumidor toca el árbol de su dep, y no
34/// lo hace.** El orden de `build` es, y esto es lo que decide la cuestión:
35///
36/// 1. `store.has(h)` → si está, vuelve sin tocar NINGÚN árbol (cache hit).
37/// 2. `materialize_build_deps` → construye las deps (recursivo) **y termina**.
38/// 3. `fetch` del árbol PROPIO → patches → vendor → compilar → sellar.
39///
40/// De ahí salen las dos propiedades que hacen viable la opción A:
41///
42/// * **Dos recetas distintas nunca comparten su árbol propio** ⇒ sus builds no se serializan entre
43/// sí. Lo único que se serializa es construir la MISMA receta dos veces a la vez, que es
44/// precisamente lo que no se debe hacer — y el perdedor del lock, al re-chequear la tienda,
45/// encuentra el artefacto ya sellado y sale por cache hit en vez de reconstruirlo.
46/// * **Los locks NO SE ANIDAN**: cuando un build toma el lock de su árbol, sus deps ya están
47/// construidas y sus locks liberados (paso 2 antes del 3) ⇒ **no puede haber deadlock**, sin
48/// ordenar nada ni detectar ciclos.
49///
50/// ## El grano es el NOMBRE de la receta, no el árbol
51///
52/// Porque el mirror git (`repos/<nombre>.git`) es estado compartido también, y su clave es sólo el
53/// nombre: dos builds de la misma receta a DISTINTO commit tienen árboles distintos y el mismo
54/// mirror, y un `clone --mirror` sobre un directorio que existe falla. Con el lock por nombre, el
55/// mirror, el árbol, los patches, el vendoreo y la compilación quedan todos dentro.
56///
57/// ## El fd no se hereda, y eso es deliberado
58///
59/// `CLAUDE.md` documenta cómo un `flock` se queda tomado para siempre: **el lock lo sostiene la
60/// descripción de fichero, y los hijos la heredan**; un nieto fugado dejó a la granja sin compilar
61/// hora y media, en silencio. Acá no puede pasar: Rust abre todo con `O_CLOEXEC`, así que ni `tar`,
62/// ni `git`, ni `bwrap`, ni un nieto suyo heredan este descriptor. Es la ventaja de hacerlo en el
63/// proceso en vez de con el `flock(1)` de alrededor.
64///
65/// [ADR 0012]: ../../../docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md
66#[derive(Debug)]
67pub struct GuardaArbol {
68 _fd: std::fs::File,
69 nombre: String,
70}
71
72impl Drop for GuardaArbol {
73 fn drop(&mut self) {
74 // El `flock` se suelta al cerrar el fd; el log existe para que una espera larga tenga un
75 // final visible en la salida del build.
76 tracing::debug!(receta = %self.nombre, "árbol: lock liberado");
77 }
78}
79
80/// Toma el lock exclusivo del árbol de `nombre`. Bloquea si otro build lo tiene, **avisando**: una
81/// espera muda se lee como un cuelgue, que es el modo de fallo que este repo ya pagó con `flock`.
82pub fn tomar_guarda(work_root: &Path, nombre: &str) -> takana_core::Result<GuardaArbol> {
83 use std::os::unix::io::AsRawFd;
84 let dir = work_root.join("locks");
85 std::fs::create_dir_all(&dir)?;
86 // El fichero de lock va FUERA de `sources/`: dentro lo barrería el `remove_dir_all` del propio
87 // fetch, que es exactamente el mecanismo que estamos cerrando.
88 let ruta = dir.join(format!("{nombre}.lock"));
89 let f = std::fs::OpenOptions::new()
90 .create(true)
91 .read(true)
92 .write(true)
93 .truncate(false)
94 .open(&ruta)?;
95 let fd = f.as_raw_fd();
96 // Primero sin bloquear, para poder DECIR que se espera y por qué.
97 let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
98 if rc != 0 {
99 tracing::info!(
100 receta = %nombre,
101 "árbol: esperando el lock — otro build tiene el árbol de fuentes de esta receta (ADR 0012)"
102 );
103 let rc2 = unsafe { libc::flock(fd, libc::LOCK_EX) };
104 if rc2 != 0 {
105 return Err(takana_core::Error::Other(anyhow::anyhow!(
106 "no pude tomar el lock del árbol {}: {}",
107 ruta.display(),
108 std::io::Error::last_os_error()
109 )));
110 }
111 tracing::info!(receta = %nombre, "árbol: lock obtenido tras esperar");
112 }
113 Ok(GuardaArbol { _fd: f, nombre: nombre.to_string() })
114}
115
116/// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta **y la guarda**.
117///
118/// La guarda se devuelve —en vez de tomarse y soltarse acá dentro— porque el árbol se sigue mutando
119/// DESPUÉS del fetch (patches, vendoreo) y se bindea para compilar: un lock que sólo cubriera la
120/// extracción no cierra la carrera (es el §El dilema del ADR 0012). Quien llama la mantiene viva
121/// hasta sellar; devolverla en la tupla hace **imposible** olvidarla.
122pub fn fetch(recipe: &Recipe, work_root: &Path) -> takana_core::Result<(PathBuf, GuardaArbol)> {
123 let guarda = tomar_guarda(work_root, &recipe.name)?;
124 let arbol = match recipe.source.kind()? {
125 SourceKind::Git { repo, commit } => fetch_git(recipe, repo, commit, work_root),
126 SourceKind::Tarball { url, sha256 } => fetch_tarball(recipe, url, sha256, work_root),
127 SourceKind::Dir { dir } => fetch_dir(recipe, dir, work_root),
128 }?;
129 Ok((arbol, guarda))
130}
131
132/// Materializa el árbol de una receta DERIVADA: copia `<dir-de-la-receta>/<dir>` al workspace.
133///
134/// No hay red, ni caché que invalidar, ni mirror: la fuente ya está en el disco. Se COPIA y no se
135/// bindea porque las fases mutan el árbol (parches, vendoreo, ficheros generados) y el original vive
136/// en el repo git — un build que ensucie el working tree del repo sería una forma silenciosa de que
137/// dos recetas se pisen, que es justo lo que el ADR 0012 documenta para las fuentes compartidas.
138///
139/// El nombre del workspace lleva el hash del CONTENIDO y no el de la ruta: editar el overlay cambia
140/// de directorio, así que un árbol viejo nunca se reusa por accidente.
141fn fetch_dir(recipe: &Recipe, dir: &str, work_root: &Path) -> takana_core::Result<PathBuf> {
142 let origen = recipe.base_dir.join(dir);
143 if !origen.is_dir() {
144 return Err(takana_core::Error::Other(anyhow::anyhow!(
145 "source.dir apunta a {} y no es un directorio",
146 origen.display()
147 )));
148 }
149 let h = takana_core::ArtifactHash::of_tree(&origen)
150 .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("hash del árbol {}: {e}", origen.display())))?;
151 let bare = h.as_str().strip_prefix("b3:").unwrap_or(h.as_str());
152 let sources_dir = work_root.join("sources");
153 std::fs::create_dir_all(&sources_dir)?;
154 let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &bare[..16]));
155 if work_tree.is_dir() {
156 std::fs::remove_dir_all(&work_tree)?;
157 }
158 std::fs::create_dir_all(&work_tree)?;
159 let st = Command::new("cp")
160 .arg("-a")
161 .arg(format!("{}/.", origen.display()))
162 .arg(&work_tree)
163 .status()
164 .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn cp -a del source.dir: {e}")))?;
165 if !st.success() {
166 return Err(takana_core::Error::Other(anyhow::anyhow!(
167 "cp -a de {} falló",
168 origen.display()
169 )));
170 }
171 Ok(work_tree)
172}
173
174fn fetch_git(
175 recipe: &Recipe,
176 repo: &str,
177 commit: &str,
178 work_root: &Path,
179) -> takana_core::Result<PathBuf> {
180 let repos_dir = work_root.join("repos");
181 let sources_dir = work_root.join("sources");
182 std::fs::create_dir_all(&repos_dir)?;
183 std::fs::create_dir_all(&sources_dir)?;
184
185 let mirror = repos_dir.join(format!("{}.git", recipe.name));
186
187 // Mirror propio de fuentes git (ADR 0013). Se intenta ANTES que el remoto, por la misma razón
188 // que en los tarballs: git verifica cada objeto por su SHA al desempaquetar, así que no hay
189 // diferencia de contenido posible entre las dos rutas — y un mirror que sólo se usa cuando
190 // upstream falla es un mirror que nadie prueba.
191 if !mirror.is_dir() || run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() {
192 let _ = hidratar_desde_bundle(commit, &mirror);
193 }
194
195 if !mirror.is_dir() {
196 run_git(
197 &[
198 "clone",
199 "--mirror",
200 "--filter=blob:none",
201 repo,
202 mirror.to_str().unwrap(),
203 ],
204 None,
205 )?;
206 }
207
208 // Asegura que tenemos el commit pedido. En un mirror existente puede faltar.
209 if run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() {
210 run_git(&["fetch", "--all", "--tags"], Some(&mirror))?;
211 run_git(&["cat-file", "-e", commit], Some(&mirror)).map_err(|_| {
212 takana_core::Error::Other(anyhow::anyhow!(
213 "commit {commit} no existe en {repo} tras fetch"
214 ))
215 })?;
216 }
217
218 // Materializa un árbol trabajable mediante `git archive` → tar → extract.
219 // Esto evita el coste de un worktree completo y deja sólo los archivos del commit.
220 let work_tree = sources_dir.join(format!("{}-{}", recipe.name, commit));
221 if work_tree.is_dir() {
222 std::fs::remove_dir_all(&work_tree)?;
223 }
224 std::fs::create_dir_all(&work_tree)?;
225
226 archivar_en(&mirror, commit, &work_tree)?;
227
228 // Un `git archive` NO emite nada por una entrada gitlink (modo 160000), así que un árbol con
229 // submódulos sale INCOMPLETO y el fallo aparece recién en `configure`, minutos después y
230 // hablando de un fichero que "no existe". Los materializamos acá, con el mismo mecanismo y el
231 // mismo determinismo: un submódulo ya viene pineado por SHA en el commit del padre (ADR 0006).
232 materializar_submodulos(&mirror, commit, &work_tree, &repos_dir, repo, 0)?;
233
234 Ok(work_tree)
235}
236
237/// `git archive <commit> | tar -x -C <destino>`: materializa UN árbol sin pagar un worktree.
238fn archivar_en(mirror: &Path, commit: &str, destino: &Path) -> takana_core::Result<()> {
239 let archive = Command::new("git")
240 .arg("-C")
241 .arg(mirror)
242 .args(["archive", "--format=tar", commit])
243 .stdout(Stdio::piped())
244 .stderr(Stdio::piped())
245 .spawn()
246 .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn git archive: {e}")))?;
247
248 let tar_status = Command::new("tar")
249 .args(["-x", "-C"])
250 .arg(destino)
251 .stdin(archive.stdout.unwrap())
252 .stdout(Stdio::inherit())
253 .stderr(Stdio::inherit())
254 .status()
255 .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn tar -x: {e}")))?;
256
257 if !tar_status.success() {
258 return Err(takana_core::Error::Other(anyhow::anyhow!(
259 "git archive | tar -x falló (tar exit {:?})",
260 tar_status.code()
261 )));
262 }
263 Ok(())
264}
265
266/// Cuántos niveles de submódulo anidado aceptamos antes de declarar un ciclo.
267const PROFUNDIDAD_SUBMODULOS: usize = 8;
268
269/// Materializa los **submódulos** del commit dentro del árbol ya extraído, recursivamente.
270///
271/// # Por qué no alcanza un flag en el `clone`
272///
273/// El que pierde los submódulos no es el clon sino `git archive`: una entrada gitlink no emite
274/// nada al tar, por diseño. `--recurse-submodules` en el `clone` no cambiaría eso. Hay que leer
275/// `.gitmodules` + los SHA de gitlink del commit, mirrorear cada submódulo y archivarlo en su
276/// subruta — que es exactamente lo que hace esta función.
277///
278/// # Por qué reescribe la URL
279///
280/// `.gitmodules` suele declarar `git@github.com:X/Y.git`, que es SSH; nuestro fetch es anónimo y
281/// no tiene claves. La forma `https://github.com/X/Y.git` apunta al MISMO repo y el commit está
282/// pineado por SHA, así que el contenido no puede diferir: reescribirla no añade confianza que
283/// verificar. Las URLs relativas (`../foo.git`) se resuelven contra la del padre, como hace git.
284fn materializar_submodulos(
285 mirror: &Path,
286 commit: &str,
287 destino: &Path,
288 repos_dir: &Path,
289 url_padre: &str,
290 profundidad: usize,
291) -> takana_core::Result<()> {
292 let gitlinks = gitlinks_de(mirror, commit)?;
293 if gitlinks.is_empty() {
294 return Ok(());
295 }
296 if profundidad >= PROFUNDIDAD_SUBMODULOS {
297 return Err(takana_core::Error::Other(anyhow::anyhow!(
298 "submódulos anidados más de {PROFUNDIDAD_SUBMODULOS} niveles en {url_padre} \
299 ({commit}): parece un ciclo"
300 )));
301 }
302
303 let urls = urls_de_gitmodules(mirror, commit)?;
304
305 for (sha, ruta) in gitlinks {
306 let declarada = urls.get(&ruta).ok_or_else(|| {
307 takana_core::Error::Other(anyhow::anyhow!(
308 "el commit {commit} declara el submódulo `{ruta}` pero .gitmodules no le da url"
309 ))
310 })?;
311 let url = resolver_url_submodulo(declarada, url_padre);
312
313 let sub_mirror = repos_dir.join(format!("sub-{}.git", sanear_para_ruta(&url)));
314 if !sub_mirror.is_dir() {
315 run_git(
316 &[
317 "clone",
318 "--mirror",
319 "--filter=blob:none",
320 &url,
321 sub_mirror.to_str().unwrap(),
322 ],
323 None,
324 )?;
325 }
326 if run_git(&["cat-file", "-e", &sha], Some(&sub_mirror)).is_err() {
327 run_git(&["fetch", "--all", "--tags"], Some(&sub_mirror))?;
328 run_git(&["cat-file", "-e", &sha], Some(&sub_mirror)).map_err(|_| {
329 takana_core::Error::Other(anyhow::anyhow!(
330 "el submódulo `{ruta}` pide {sha}, que no existe en {url} tras fetch"
331 ))
332 })?;
333 }
334
335 let destino_sub = destino.join(&ruta);
336 std::fs::create_dir_all(&destino_sub)?;
337 archivar_en(&sub_mirror, &sha, &destino_sub)?;
338 materializar_submodulos(
339 &sub_mirror,
340 &sha,
341 &destino_sub,
342 repos_dir,
343 &url,
344 profundidad + 1,
345 )?;
346 }
347 Ok(())
348}
349
350/// Las entradas de tipo `commit` (modo 160000) del commit: `(sha, ruta)`.
351///
352/// `-z` porque una ruta puede llevar espacios y el formato sin `-z` los escapa con comillas.
353fn gitlinks_de(mirror: &Path, commit: &str) -> takana_core::Result<Vec<(String, String)>> {
354 let salida = salida_git(&["ls-tree", "-r", "-z", commit], mirror)?;
355 let mut out = Vec::new();
356 for reg in salida.split('\0').filter(|r| !r.is_empty()) {
357 let Some((meta, ruta)) = reg.split_once('\t') else {
358 continue;
359 };
360 let campos: Vec<&str> = meta.split_whitespace().collect();
361 // <modo> <tipo> <sha>
362 if campos.len() == 3 && campos[1] == "commit" {
363 out.push((campos[2].to_string(), ruta.to_string()));
364 }
365 }
366 Ok(out)
367}
368
369/// `ruta → url` leído del `.gitmodules` **del commit** (no del working tree, que no existe).
370///
371/// Si el commit no trae `.gitmodules`, devuelve vacío: eso es legítimo (un gitlink sin declarar es
372/// un error, pero lo reporta quien lo consulta, con el nombre del submódulo a la vista).
373fn urls_de_gitmodules(
374 mirror: &Path,
375 commit: &str,
376) -> takana_core::Result<std::collections::BTreeMap<String, String>> {
377 let blob = format!("{commit}:.gitmodules");
378 let salida = match salida_git(
379 &[
380 "config",
381 "--blob",
382 &blob,
383 "-z",
384 "--get-regexp",
385 "^submodule\\..*\\.(path|url)$",
386 ],
387 mirror,
388 ) {
389 Ok(s) => s,
390 Err(_) => return Ok(Default::default()),
391 };
392
393 let mut rutas: std::collections::BTreeMap<String, String> = Default::default();
394 let mut urls: std::collections::BTreeMap<String, String> = Default::default();
395 for reg in salida.split('\0').filter(|r| !r.is_empty()) {
396 // Con `-z` cada registro es `clave\nvalor`.
397 let Some((clave, valor)) = reg.split_once('\n') else {
398 continue;
399 };
400 let Some(resto) = clave.strip_prefix("submodule.") else {
401 continue;
402 };
403 // El NOMBRE del submódulo puede llevar puntos, así que se parte por el último.
404 let Some((nombre, campo)) = resto.rsplit_once('.') else {
405 continue;
406 };
407 match campo {
408 "path" => {
409 rutas.insert(nombre.to_string(), valor.to_string());
410 }
411 "url" => {
412 urls.insert(nombre.to_string(), valor.to_string());
413 }
414 _ => {}
415 }
416 }
417
418 Ok(rutas
419 .into_iter()
420 .filter_map(|(nombre, ruta)| urls.get(&nombre).map(|u| (ruta, u.clone())))
421 .collect())
422}
423
424/// SSH → HTTPS y relativa → absoluta contra la URL del padre. El resto pasa tal cual.
425fn resolver_url_submodulo(declarada: &str, url_padre: &str) -> String {
426 if let Some(resto) = declarada.strip_prefix("git@") {
427 if let Some((host, ruta)) = resto.split_once(':') {
428 return format!("https://{host}/{ruta}");
429 }
430 }
431 if let Some(resto) = declarada.strip_prefix("ssh://git@") {
432 return format!("https://{resto}");
433 }
434 if declarada.starts_with("./") || declarada.starts_with("../") {
435 return resolver_relativa(declarada, url_padre);
436 }
437 declarada.to_string()
438}
439
440/// `../x` sube un segmento de la URL del padre; `./x` se queda en el mismo nivel.
441fn resolver_relativa(rel: &str, url_padre: &str) -> String {
442 // git resuelve la relativa tratando la URL del padre como un DIRECTORIO: `../l10n.git` sobre
443 // `…/BrowserWorks/waterfox.git` da `…/BrowserWorks/l10n.git`, o sea que `..` se come el nombre
444 // del propio repo. Por eso NO se popea nada antes del bucle.
445 let mut base: Vec<&str> = url_padre.trim_end_matches('/').split('/').collect();
446 for seg in rel.split('/') {
447 match seg {
448 "." | "" => {}
449 ".." => {
450 base.pop();
451 }
452 otro => base.push(otro),
453 }
454 }
455 base.join("/")
456}
457
458/// Un nombre de directorio estable y legible para el mirror de una URL.
459fn sanear_para_ruta(url: &str) -> String {
460 url.chars()
461 .map(|c| {
462 if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
463 c
464 } else {
465 '-'
466 }
467 })
468 .collect()
469}
470
471/// `git <args>` capturando stdout. Error si el comando falla.
472fn salida_git(args: &[&str], cwd: &Path) -> takana_core::Result<String> {
473 let out = Command::new("git")
474 .arg("-C")
475 .arg(cwd)
476 .args(args)
477 .stdin(Stdio::null())
478 .stderr(Stdio::piped())
479 .output()
480 .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?;
481 if !out.status.success() {
482 return Err(takana_core::Error::Other(anyhow::anyhow!(
483 "git {args:?} falló (exit {:?})",
484 out.status.code()
485 )));
486 }
487 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
488}
489
490/// Los orígenes declarados en `var` (**lista** separada por comas), **rotados** por `clave`.
491///
492/// # Por qué una lista y no una URL (ADR 0014)
493///
494/// El ADR 0013 dejó `TAKANA_MIRROR` con un solo valor y con eso el mecanismo que existe para no
495/// depender de 79 servidores ajenos pasaba a depender de **uno nuestro**: el mismo fallo, con
496/// nuestro nombre encima. La identidad del objeto es su `sha256`/`commit`, así que un origen de más
497/// no añade confianza que verificar — sólo un sitio más donde mirar. Añadirlos es gratis y no
498/// re-hashea nada (la URL nunca entró en `hash_inputs`).
499///
500/// # Por qué ROTA y no prueba siempre en orden
501///
502/// Es la lección del propio ADR 0013 aplicada un nivel más arriba: *un espejo que sólo se usa
503/// cuando el otro falla es un espejo que nadie prueba*, y se descubre roto el día que hace falta.
504/// Con orden fijo, el segundo origen no se toca jamás hasta la emergencia. El reparto es
505/// **determinista por contenido** — el mismo objeto sale siempre del mismo origen, así que un fallo
506/// se reproduce y se depura — pero objetos distintos se reparten entre todos, así que una tanda del
507/// corpus **ejercita la lista entera**. Tras el elegido se recorren los demás como respaldo.
508fn bases_de_mirror(var: &str, clave: &str) -> Vec<String> {
509 match takana_core::env::var(var) {
510 Some(raw) => rotar_bases(&raw, clave),
511 None => Vec::new(),
512 }
513}
514
515/// La parte pura de [`bases_de_mirror`]: parsea la lista y la rota. Separada del `env::var` para
516/// poder probarla sin tocar el entorno del proceso (que es global y hace los tests dependientes
517/// del orden en que corren).
518fn rotar_bases(raw: &str, clave: &str) -> Vec<String> {
519 let bases: Vec<String> = raw
520 .split(',')
521 .map(|s| s.trim().trim_end_matches('/'))
522 .filter(|s| !s.is_empty())
523 .map(str::to_string)
524 .collect();
525 if bases.len() < 2 {
526 return bases;
527 }
528 // Los dos primeros bytes de la clave hex bastan para repartir y no pueden panicar con una clave
529 // corta o vacía (a diferencia de `clave[..2]`).
530 let semilla = clave
531 .as_bytes()
532 .iter()
533 .take(2)
534 .fold(0usize, |a, b| a.wrapping_mul(256).wrapping_add(*b as usize));
535 let inicio = semilla % bases.len();
536 bases
537 .iter()
538 .cycle()
539 .skip(inicio)
540 .take(bases.len())
541 .cloned()
542 .collect()
543}
544
545/// Intenta poblar `destino` (repo bare) con `commit` desde el mirror de fuentes git (ADR 0013).
546/// Devuelve `true` si el commit quedó disponible. **Nunca es un error**: si no hay mirror, si el
547/// objeto no está o si algo falla, devuelve `false` y el llamador va al remoto como siempre.
548///
549/// # Por qué un bundle SHALLOW y no un clon completo
550///
551/// El bundle se genera desde un clon `--depth 1` del commit exacto: para `act` son 8,9 MB en vez
552/// del repositorio entero. Hammer no necesita la historia — lo único que hace con el repo es
553/// `git archive <commit> | tar -x`, o sea materializar UN árbol.
554///
555/// # El detalle que hace que esto funcione: el fichero `shallow`
556///
557/// Un bundle hecho desde un repo shallow **no lleva la frontera de historia**, así que al
558/// desempaquetarlo git intenta recorrer los padres y aborta:
559///
560/// ```text
561/// fatal: Failed to traverse parents of commit 4f41128…
562/// error: … did not send all necessary objects
563/// ```
564///
565/// El mensaje suena a bundle incompleto y **no lo es**: los objetos del commit llegan enteros (de
566/// hecho `git archive` ya funciona pese al error). Lo que falta es decirle a git dónde termina la
567/// historia. Se arregla escribiendo el propio commit en `<destino>/shallow` ANTES del fetch — y no
568/// hay que transportar nada, porque la frontera de un clon `--depth 1` es exactamente ese commit.
569///
570/// La verificación sigue siendo criptográfica: git comprueba cada objeto contra su SHA al
571/// desempaquetar, y el commit de la receta es la identidad. Un bundle alterado no pasa.
572fn hidratar_desde_bundle(commit: &str, destino: &Path) -> bool {
573 // `TAKANA_MIRROR_GIT` es una LISTA (ADR 0014): se prueban todos los orígenes antes de rendirse
574 // y caer a upstream. Un bundle que no está en uno puede estar en otro, y la verificación la
575 // hace git contra el SHA venga de donde venga.
576 bases_de_mirror("TAKANA_MIRROR_GIT", commit)
577 .iter()
578 .any(|base| hidratar_desde_bundle_en(base, commit, destino))
579}
580
581/// Un solo intento contra `base`. Devuelve `true` si el commit quedó disponible en `destino`.
582fn hidratar_desde_bundle_en(base: &str, commit: &str, destino: &Path) -> bool {
583 let tmp = destino.with_extension("bundle.partial");
584 if let Some(padre) = tmp.parent() {
585 let _ = std::fs::create_dir_all(padre);
586 }
587 let _ = std::fs::remove_file(&tmp);
588 if descargar(&format!("{base}/{commit}.bundle"), &tmp).is_err() {
589 let _ = std::fs::remove_file(&tmp);
590 return false;
591 }
592 if !destino.is_dir() && run_git(&["init", "--bare", destino.to_str().unwrap()], None).is_err() {
593 let _ = std::fs::remove_file(&tmp);
594 return false;
595 }
596 // La frontera de historia. Sin esto el fetch aborta aunque los objetos estén completos.
597 //
598 // La frontera se LEE DEL BUNDLE, no se supone. `git bundle list-heads` imprime la lista de refs
599 // sin necesitar los objetos, así que sirve justo antes del fetch. Suponerla igual a `commit`
600 // estaba mal: el campo `commit` de una receta puede pinear un OBJETO TAG anotado (así están
601 // diffutils, findutils-xargs y kustomize), y el fichero `shallow` sólo admite SHAs de commit.
602 // El poblador deja siempre `refs/heads/hammer` en el commit ya pelado.
603 let Some(frontera) = frontera_del_bundle(&tmp) else {
604 let _ = std::fs::remove_file(&tmp);
605 return false;
606 };
607 let _ = std::fs::write(destino.join("shallow"), format!("{frontera}\n"));
608 // ⚠ RUTA ABSOLUTA, obligatoria. `run_git` invoca `git -C <destino> …`, y git resuelve una ruta
609 // relativa de bundle DENTRO de ese `-C` — o sea buscaría `<destino>/work/repos/x.bundle.partial`.
610 // El fallo es mudo aquí (se traga y se cae al remoto), así que el síntoma aparece lejos: el build
611 // muere diciendo «commit … no existe en <repo> tras fetch», culpando a upstream de un error de
612 // ruta local.
613 let bundle_abs = std::fs::canonicalize(&tmp).unwrap_or_else(|_| tmp.clone());
614 // `refs/*:refs/*` y no un ref concreto: cuando el pin es un tag anotado el bundle trae DOS refs
615 // (el commit pelado y el objeto tag), y hace falta el objeto tag para que el `cat-file -e` de
616 // abajo — y el `git archive <commit>` de después — encuentren lo que la receta pide.
617 let ok = run_git(
618 &["fetch", bundle_abs.to_str().unwrap(), "refs/*:refs/*"],
619 Some(destino),
620 )
621 .is_ok()
622 && run_git(&["cat-file", "-e", commit], Some(destino)).is_ok();
623 let _ = std::fs::remove_file(&tmp);
624 ok
625}
626
627/// SHA del commit que `refs/heads/hammer` señala dentro del bundle, leído sin desempaquetarlo.
628fn frontera_del_bundle(bundle: &Path) -> Option<String> {
629 let abs = std::fs::canonicalize(bundle).ok()?;
630 let salida = Command::new("git")
631 .args(["bundle", "list-heads"])
632 .arg(&abs)
633 .output()
634 .ok()?;
635 if !salida.status.success() {
636 return None;
637 }
638 String::from_utf8_lossy(&salida.stdout)
639 .lines()
640 .find_map(|l| {
641 let (sha, refname) = l.split_once(char::is_whitespace)?;
642 (refname.trim() == "refs/heads/hammer").then(|| sha.trim().to_string())
643 })
644}
645
646fn fetch_tarball(
647 recipe: &Recipe,
648 url: &str,
649 sha256: &str,
650 work_root: &Path,
651) -> takana_core::Result<PathBuf> {
652 let tarballs_dir = work_root.join("tarballs");
653 let sources_dir = work_root.join("sources");
654 std::fs::create_dir_all(&tarballs_dir)?;
655 std::fs::create_dir_all(&sources_dir)?;
656
657 // Nombre por hash: dos URLs distintas con el mismo contenido se cachean una vez, y
658 // cambiar la URL sin cambiar el sha (mirror) no invalida la caché.
659 let cached = tarballs_dir.join(format!("{sha256}.tar"));
660 if !cached.is_file() {
661 fetch_con_mirror(url, sha256, &cached)?;
662 } else {
663 verify_sha256(&cached, sha256)?;
664 }
665
666 // ⚠ CARRERA CONOCIDA, SIN RESOLVER — ver docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md
667 // El path es determinista y NO lleva nada del constructor ⇒ dos builds de la misma receta (la
668 // misma dep alcanzada por dos caminos) resuelven al mismo directorio: uno borra mientras el otro
669 // extrae y el árbol queda roto con "Directory not empty" (os error 39) hasta borrarlo a mano.
670 // Mitigado SÓLO entre campana-deuda.sh y farm-worker-loop.sh con un lock de script (commit
671 // 1b436a7); el `xargs -P2` de build-farm.sh sigue expuesto.
672 // NO "arreglar" con un lock alrededor de esta extracción: parece correcto y no lo es, porque un
673 // segundo proceso puede borrar un árbol que un bwrap ya está usando para compilar. Este árbol es
674 // caché y workspace mutable a la vez (lib.rs lo parchea y vendorea in situ); el ADR 0012 plantea
675 // las tres salidas y qué medir para elegir.
676 let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &sha256[..16]));
677 if work_tree.is_dir() {
678 std::fs::remove_dir_all(&work_tree)?;
679 }
680 std::fs::create_dir_all(&work_tree)?;
681
682 // `tar` autodetecta el compresor por la cabecera; el sufijo .tar del nombre es solo
683 // identificador interno (no afecta a la detección).
684 let strip = recipe.source.strip_components.to_string();
685 let status = Command::new("tar")
686 .arg("-x")
687 // `--no-same-owner`: extrae como el usuario actual (no preserva el uid del tarball). Si el
688 // fetch corre como root (granja), `tar` por defecto preservaría el owner original → ficheros
689 // de un uid que el sandbox (userns) no puede chmod/escribir → configure de autotools falla
690 // ("Permission denied" creando Makefile, "Operation not permitted" en chmod). Inocuo para el
691 // of_tree (es el árbol de FUENTES, no el output). Destraba libs C como oniguruma.
692 .arg("--no-same-owner")
693 .arg("-f")
694 .arg(&cached)
695 .arg("--strip-components")
696 .arg(&strip)
697 .arg("-C")
698 .arg(&work_tree)
699 .stdout(Stdio::inherit())
700 .stderr(Stdio::inherit())
701 .status()
702 .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn tar -x tarball: {e}")))?;
703 if !status.success() {
704 return Err(takana_core::Error::Other(anyhow::anyhow!(
705 "tar -x falló (exit {:?})",
706 status.code()
707 )));
708 }
709 Ok(work_tree)
710}
711
712/// Baja el tarball probando el **mirror propio antes que upstream** (ADR 0013).
713///
714/// # Por qué el mirror va primero y no como rescate
715///
716/// El `sha256` se verifica igual venga de donde venga, así que **no hay diferencia de contenido
717/// posible** entre las dos rutas: preferir upstream «por pureza» no compra nada y paga latencia más
718/// un tercero en el camino. Además un mirror que sólo se usa cuando upstream falla es un mirror que
719/// **nadie prueba**, y se descubre roto justo el día en que hace falta.
720///
721/// # Por qué NO avisa cuando cae al mirror
722///
723/// Tentación evidente y **equivocada**: si `takana build` avisara en cada descarga, el aviso sería
724/// ruido en 561 recetas y nadie lo leería. Peor: haría creer que el bit-rot está vigilado *acá*,
725/// cuando construir y vigilar upstream son dos trabajos distintos. La vigilancia vive en
726/// `scripts/fuentes/fuentes-vigia.sh`, la corre el latido y escribe `docs/state/fuentes-vigia.json`.
727/// Un mirror silencioso **con** vigía separado detecta el bit-rot; uno silencioso **sin** vigía lo
728/// entierra, que es como el grafo de wlr pasó 17 días anunciando un 121/121 que ya era falso.
729///
730/// `TAKANA_MIRROR` es una **lista** de prefijos de URL separada por comas, de cualquier esquema que
731/// curl entienda (`file://`, `https://`, `sftp://`…); el objeto se busca por contenido en
732/// `{base}/{sha256}.tar` en cada uno, en el orden que dicta [`bases_de_mirror`].
733/// `TAKANA_MIRROR_KEY`, si está, se pasa como `--key` (Storage Box por sftp). Sin `TAKANA_MIRROR`
734/// el comportamiento es el de siempre: derecho a upstream.
735fn fetch_con_mirror(url: &str, sha256: &str, dst: &Path) -> takana_core::Result<()> {
736 for base in bases_de_mirror("TAKANA_MIRROR", sha256) {
737 let espejo = format!("{base}/{sha256}.tar");
738 // Un fallo acá NO es un error: es el caso normal la primera vez que se ve una fuente, y
739 // también el caso normal cuando un origen de la lista todavía no tiene ese objeto. Se
740 // ignora en silencio y se sigue al siguiente origen, y al final a upstream.
741 if download_verify(&espejo, sha256, dst).is_ok() {
742 return Ok(());
743 }
744 }
745 download_verify(url, sha256, dst)
746}
747
748/// Descarga `url` a `dst` y verifica el sha256. Si la verificación falla, borra el archivo
749/// para que la próxima corrida lo vuelva a intentar (no envenenamos la caché).
750/// Baja `url` a `dst` con curl. Sin verificar nada: quien llama decide cómo se comprueba —
751/// `download_verify` por sha256 (tarballs), `hidratar_desde_bundle` delegando en git (bundles).
752fn descargar(url: &str, dst: &Path) -> takana_core::Result<()> {
753 let mut cmd = Command::new("curl");
754 cmd.args(["-fL", "--retry", "3", "--connect-timeout", "30"]);
755 // Clave para el mirror por sftp (Storage Box). Inocua para http/file.
756 if let Some(key) = takana_core::env::var("TAKANA_MIRROR_KEY") {
757 if !key.is_empty() {
758 cmd.arg("--key").arg(&key);
759 }
760 }
761 let status = cmd
762 .arg("-o")
763 .arg(dst)
764 .arg(url)
765 .stdout(Stdio::inherit())
766 .stderr(Stdio::inherit())
767 .status()
768 .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?;
769 if !status.success() {
770 return Err(takana_core::Error::Other(anyhow::anyhow!(
771 "curl {url} falló (exit {:?})",
772 status.code()
773 )));
774 }
775 Ok(())
776}
777
778fn download_verify(url: &str, sha256: &str, dst: &Path) -> takana_core::Result<()> {
779 let tmp = dst.with_extension("tar.partial");
780 if let Err(e) = descargar(url, &tmp) {
781 let _ = std::fs::remove_file(&tmp);
782 return Err(e);
783 }
784 if let Err(e) = verify_sha256(&tmp, sha256) {
785 let _ = std::fs::remove_file(&tmp);
786 return Err(e);
787 }
788 std::fs::rename(&tmp, dst)?;
789 Ok(())
790}
791
792fn verify_sha256(path: &Path, expected: &str) -> takana_core::Result<()> {
793 use sha2::{Digest, Sha256};
794 let mut f = std::fs::File::open(path)?;
795 let mut hasher = Sha256::new();
796 std::io::copy(&mut f, &mut hasher)?;
797 let got = hex::encode(hasher.finalize());
798 if !got.eq_ignore_ascii_case(expected) {
799 return Err(takana_core::Error::Other(anyhow::anyhow!(
800 "sha256 mismatch en {}: esperado {expected}, obtenido {got}",
801 path.display()
802 )));
803 }
804 Ok(())
805}
806
807/// Vendoriza las dependencias de un proyecto Cargo en `<src>/vendor` y escribe
808/// `<src>/.cargo/config.toml` para que el build dentro del sandbox resuelva todo `--offline`.
809///
810/// La red se usa AQUÍ (fuera del sandbox, como el resto del fetch).
811///
812/// **`--locked` condicional:** si la fuente trae un `Cargo.lock` committeado, se vendorea con
813/// `--locked` y el build es reproducible (mismo commit ⇒ mismo lock ⇒ mismas deps; el lock ya está
814/// dentro del commit que hashea la receta). Si la fuente **no** lo trae (p.ej. un monorepo que lo
815/// gitignora, como tawasuyu), se genera el lock al vuelo **sin** `--locked` y se emite un `warn!`:
816/// el build queda internamente consistente pero **no pineado en el tiempo** (las deps pueden
817/// derivar entre corridas). El end-state reproducible es committear el `Cargo.lock` en la fuente
818/// (plan C.2 #5). Ver `docs/runbooks/stage1-vm-boot.md` §8.
819///
820/// Un crate sin deps de crates.io produce un config vacío y ningún `vendor/`: inocuo.
821pub fn vendor_cargo_deps(src: &Path, vendor_dir: &str) -> takana_core::Result<()> {
822 let locked = src.join("Cargo.lock").is_file();
823 let mut args: Vec<&str> = vec!["vendor"];
824 if locked {
825 args.push("--locked");
826 } else {
827 tracing::warn!(
828 src = %src.display(),
829 "sin Cargo.lock committeado: vendoreo sin --locked; el build NO es reproducible en el \
830 tiempo (deps no pineadas). Committear el lock en la fuente para fijarlo (plan C.2 #5)."
831 );
832 }
833 // Directorio de salida (default `vendor`; override per-receta vía `[source] cargo_vendor_dir`
834 // para no pisar un `vendor/` propio del proyecto). `cargo vendor` emite el `[source.*]` a stdout
835 // apuntando a ESTE dir, así que el `.cargo/config.toml` que persistimos abajo resuelve solo.
836 args.push(vendor_dir);
837 let output = Command::new("cargo")
838 .current_dir(src)
839 .args(&args)
840 .stderr(Stdio::inherit())
841 .output()
842 .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn cargo vendor: {e}")))?;
843 if !output.status.success() {
844 return Err(takana_core::Error::Other(anyhow::anyhow!(
845 "cargo vendor falló (exit {:?})",
846 output.status.code()
847 )));
848 }
849 // `cargo vendor` emite por stdout el fragmento `[source.*]` a instalar; lo persistimos en el
850 // árbol del source para que `cargo build --offline` lo lea (ruta relativa `vendor`, que
851 // resuelve igual dentro del sandbox donde el source se monta en /src).
852 let cargo_dir = src.join(".cargo");
853 std::fs::create_dir_all(&cargo_dir)?;
854 std::fs::write(cargo_dir.join("config.toml"), &output.stdout)?;
855 Ok(())
856}
857
858/// Vendoriza los módulos de un proyecto Go en `<src>/vendor` para que `go build -mod=vendor`
859/// resuelva todo offline dentro del sandbox. La red se usa AQUÍ (fuera del sandbox, como
860/// `vendor_cargo_deps`): `go mod vendor` baja los módulos de `go.mod`/`go.sum` al árbol.
861///
862/// **Reproducible por `go.sum`:** a diferencia de Cargo (lock opcional), Go exige `go.sum`
863/// committeado con el hash de cada módulo; el mismo commit ⇒ el mismo `go.sum` ⇒ el mismo
864/// `vendor/` (ya hasheado dentro del commit que fija la receta). `GOTOOLCHAIN=local` impide que
865/// `go.mod` fuerce la auto-descarga de otra versión del toolchain (el del lab manda y queda fijo);
866/// `GOFLAGS=-mod=mod` permite poblar el `vendor/` aunque el árbol no lo traiga.
867///
868/// Un proyecto sin deps externas produce un `vendor/` vacío (o ninguno): inocuo.
869pub fn vendor_go_deps(src: &Path) -> takana_core::Result<()> {
870 // Proyecto WORKSPACE (multimódulo): si el árbol trae `go.work`, `go mod vendor` con GOWORK=off
871 // vendorea SÓLO el módulo raíz → el build (que ve todo el workspace) se queja de "inconsistent
872 // vendoring". `go work vendor` (go 1.22+) produce un `vendor/` consistente para TODO el workspace.
873 // Destraba la clase go.work (go-mockery, k3d, git-town, csvtk…). Sin go.work, el camino clásico
874 // `go mod vendor` con GOWORK=off (ignora cualquier go.work espurio y vendorea el raíz).
875 let has_work = src.join("go.work").is_file();
876 let (verb, label): ([&str; 2], &str) = if has_work {
877 (["work", "vendor"], "go work vendor")
878 } else {
879 (["mod", "vendor"], "go mod vendor")
880 };
881 let mut cmd = Command::new("go");
882 cmd.current_dir(src)
883 .args(verb)
884 .env("GOFLAGS", "-mod=mod")
885 .env("GOTOOLCHAIN", "local");
886 if !has_work {
887 cmd.env("GOWORK", "off");
888 }
889 let output = cmd
890 .stdout(Stdio::inherit())
891 .stderr(Stdio::inherit())
892 .output()
893 .map_err(|e| {
894 takana_core::Error::Other(anyhow::anyhow!(
895 "spawn {label}: {e} (¿está `go` en el PATH del host? lo provee el lab, \
896 cf. scripts/bootstrap-devfs.sh / vps-setup.sh)"
897 ))
898 })?;
899 if !output.status.success() {
900 return Err(takana_core::Error::Other(anyhow::anyhow!(
901 "{label} falló (exit {:?})",
902 output.status.code()
903 )));
904 }
905 Ok(())
906}
907
908/// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`.
909pub fn apply_patches(recipe: &Recipe, tree: &Path) -> takana_core::Result<()> {
910 for p in &recipe.source.patches {
911 let patch_path = recipe.base_dir.join(p);
912 let bytes = std::fs::read(&patch_path).map_err(|e| {
913 takana_core::Error::Recipe(format!(
914 "no pude leer patch {}: {e}",
915 patch_path.display()
916 ))
917 })?;
918 let mut child = Command::new("patch")
919 .args(["-p1", "--batch", "--silent"])
920 .current_dir(tree)
921 .stdin(Stdio::piped())
922 .stdout(Stdio::inherit())
923 .stderr(Stdio::inherit())
924 .spawn()
925 .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn patch: {e}")))?;
926 if let Some(mut stdin) = child.stdin.take() {
927 use std::io::Write;
928 stdin
929 .write_all(&bytes)
930 .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("write patch: {e}")))?;
931 }
932 let status = child
933 .wait()
934 .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("wait patch: {e}")))?;
935 if !status.success() {
936 return Err(takana_core::Error::Other(anyhow::anyhow!(
937 "patch {} falló (exit {:?})",
938 patch_path.display(),
939 status.code()
940 )));
941 }
942 }
943 Ok(())
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949
950 /// El invariante del ADR 0012: **dos guardas del mismo nombre no coexisten**.
951 ///
952 /// Se prueba con dos hilos y no con dos procesos porque `flock` se asocia a la DESCRIPCIÓN de
953 /// fichero, no al proceso: dos `open()` distintos —aunque sean del mismo proceso— se excluyen
954 /// igual. El control es el segundo caso: nombres DISTINTOS no se bloquean, que es lo que hace
955 /// que esto no serialice la granja.
956 #[test]
957 fn dos_guardas_del_mismo_arbol_se_excluyen() {
958 use std::sync::atomic::{AtomicBool, Ordering};
959 use std::sync::Arc;
960 let tmp = tempfile::tempdir().unwrap();
961 let raiz = tmp.path().to_path_buf();
962
963 let dentro = Arc::new(AtomicBool::new(false));
964 let piso = Arc::new(AtomicBool::new(false));
965
966 let g1 = tomar_guarda(&raiz, "misma").expect("primera guarda");
967 dentro.store(true, Ordering::SeqCst);
968
969 let (r2, d2, p2) = (raiz.clone(), dentro.clone(), piso.clone());
970 let h = std::thread::spawn(move || {
971 let _g2 = tomar_guarda(&r2, "misma").expect("segunda guarda");
972 // Si entró mientras la primera seguía viva, el lock no sirve.
973 if d2.load(Ordering::SeqCst) {
974 p2.store(true, Ordering::SeqCst);
975 }
976 });
977
978 std::thread::sleep(std::time::Duration::from_millis(250));
979 dentro.store(false, Ordering::SeqCst);
980 drop(g1);
981 h.join().unwrap();
982 assert!(!piso.load(Ordering::SeqCst), "la segunda guarda entró con la primera viva");
983
984 // CONTROL: dos nombres distintos NO se bloquean — si esto fallara, el lock serializaría
985 // builds que no comparten nada y la granja perdería el paralelismo.
986 let _a = tomar_guarda(&raiz, "una").expect("guarda de `una`");
987 let _b = tomar_guarda(&raiz, "otra").expect("guarda de `otra` (no debe bloquear)");
988 }
989
990 /// El fichero de lock NO puede vivir dentro de `sources/`: el propio fetch hace
991 /// `remove_dir_all` del árbol, y un lock ahí adentro se borraría a sí mismo.
992 #[test]
993 fn el_lock_vive_fuera_de_sources() {
994 let tmp = tempfile::tempdir().unwrap();
995 let _g = tomar_guarda(tmp.path(), "algo").unwrap();
996 assert!(tmp.path().join("locks/algo.lock").is_file());
997 assert!(!tmp.path().join("sources/algo.lock").exists());
998 }
999
1000 #[test]
1001 fn resolver_url_reescribe_ssh_a_https() {
1002 assert_eq!(
1003 resolver_url_submodulo("git@github.com:BrowserWorks/l10n.git", "https://x/y.git"),
1004 "https://github.com/BrowserWorks/l10n.git"
1005 );
1006 assert_eq!(
1007 resolver_url_submodulo("ssh://git@gitlab.com/a/b.git", "https://x/y.git"),
1008 "https://gitlab.com/a/b.git"
1009 );
1010 // Una https ya buena no se toca.
1011 assert_eq!(
1012 resolver_url_submodulo("https://github.com/a/b.git", "https://x/y.git"),
1013 "https://github.com/a/b.git"
1014 );
1015 }
1016
1017 #[test]
1018 fn resolver_url_resuelve_relativas_contra_el_padre() {
1019 // `..` sube desde el DIRECTORIO que contiene al padre, como hace git.
1020 assert_eq!(
1021 resolver_url_submodulo("../l10n.git", "https://github.com/BrowserWorks/waterfox.git"),
1022 "https://github.com/BrowserWorks/l10n.git"
1023 );
1024 assert_eq!(
1025 resolver_url_submodulo("./sub.git", "https://github.com/o/r.git"),
1026 "https://github.com/o/r.git/sub.git"
1027 );
1028 }
1029
1030 /// El caso que mató a waterfox: un árbol con un gitlink debe llegar COMPLETO.
1031 #[test]
1032 fn materializa_un_submodulo_real() {
1033 let raiz = tempfile::tempdir().unwrap();
1034 let git = |args: &[&str], cwd: &Path| {
1035 let st = Command::new("git")
1036 .arg("-C")
1037 .arg(cwd)
1038 .args(args)
1039 .env("GIT_AUTHOR_NAME", "t")
1040 .env("GIT_AUTHOR_EMAIL", "t@t")
1041 .env("GIT_COMMITTER_NAME", "t")
1042 .env("GIT_COMMITTER_EMAIL", "t@t")
1043 .stdout(Stdio::null())
1044 .stderr(Stdio::null())
1045 .status()
1046 .unwrap();
1047 assert!(st.success(), "git {args:?} falló");
1048 };
1049
1050 // Un repo `hijo` con un fichero, y un `padre` que lo monta como submódulo.
1051 let hijo = raiz.path().join("hijo");
1052 std::fs::create_dir_all(&hijo).unwrap();
1053 git(&["init", "-q", "-b", "main"], &hijo);
1054 std::fs::write(hijo.join("moz.build"), "DEL SUBMODULO\n").unwrap();
1055 git(&["add", "moz.build"], &hijo);
1056 git(&["commit", "-qm", "hijo"], &hijo);
1057
1058 let padre = raiz.path().join("padre");
1059 std::fs::create_dir_all(&padre).unwrap();
1060 git(&["init", "-q", "-b", "main"], &padre);
1061 std::fs::write(padre.join("raiz.txt"), "DEL PADRE\n").unwrap();
1062 git(&["add", "raiz.txt"], &padre);
1063 git(
1064 &[
1065 "-c",
1066 "protocol.file.allow=always",
1067 "submodule",
1068 "add",
1069 "-q",
1070 hijo.to_str().unwrap(),
1071 "browser/locales",
1072 ],
1073 &padre,
1074 );
1075 git(&["commit", "-qm", "padre"], &padre);
1076
1077 let mirror = raiz.path().join("padre.git");
1078 let st = Command::new("git")
1079 .args(["clone", "--mirror", padre.to_str().unwrap()])
1080 .arg(&mirror)
1081 .stdout(Stdio::null())
1082 .stderr(Stdio::null())
1083 .status()
1084 .unwrap();
1085 assert!(st.success());
1086 let commit = salida_git(&["rev-parse", "main"], &mirror).unwrap();
1087 let commit = commit.trim();
1088
1089 let destino = raiz.path().join("arbol");
1090 std::fs::create_dir_all(&destino).unwrap();
1091 let repos = raiz.path().join("repos");
1092 std::fs::create_dir_all(&repos).unwrap();
1093
1094 archivar_en(&mirror, commit, &destino).unwrap();
1095 // Sin submódulos, `git archive` deja el gitlink como un directorio VACÍO: el fichero que
1096 // el build pide no está, y el error llega recién en `configure`.
1097 assert!(!destino.join("browser/locales/moz.build").exists());
1098
1099 materializar_submodulos(&mirror, commit, &destino, &repos, padre.to_str().unwrap(), 0)
1100 .unwrap();
1101 assert_eq!(
1102 std::fs::read_to_string(destino.join("browser/locales/moz.build")).unwrap(),
1103 "DEL SUBMODULO\n"
1104 );
1105 assert_eq!(
1106 std::fs::read_to_string(destino.join("raiz.txt")).unwrap(),
1107 "DEL PADRE\n"
1108 );
1109 }
1110
1111 #[test]
1112 fn rotar_bases_lista_vacia_o_unica() {
1113 assert!(rotar_bases("", "abcd").is_empty());
1114 assert!(rotar_bases(" , ,", "abcd").is_empty());
1115 // Un solo origen: se devuelve tal cual, sin barra final.
1116 assert_eq!(rotar_bases("https://a/", "abcd"), vec!["https://a"]);
1117 }
1118
1119 #[test]
1120 fn rotar_bases_reparte_pero_es_determinista() {
1121 let lista = "https://a, https://b ,https://c/";
1122 // Determinista: la misma clave da siempre el mismo orden ⇒ un fallo se reproduce.
1123 assert_eq!(rotar_bases(lista, "00ff"), rotar_bases(lista, "00ff"));
1124 // Y siempre están LOS TRES: el resto de la lista queda como respaldo, no se descarta.
1125 for clave in ["00", "01", "02", "ff00"] {
1126 let r = rotar_bases(lista, clave);
1127 assert_eq!(r.len(), 3, "clave {clave}");
1128 for b in ["https://a", "https://b", "https://c"] {
1129 assert!(r.contains(&b.to_string()), "falta {b} con clave {clave}");
1130 }
1131 }
1132 // Reparte: claves distintas eligen primeros distintos, que es lo que ejercita todos los
1133 // orígenes en una tanda del corpus en vez de martillar siempre el primero.
1134 let primeros: std::collections::HashSet<String> = ["00", "01", "02"]
1135 .iter()
1136 .map(|c| rotar_bases(lista, c)[0].clone())
1137 .collect();
1138 assert_eq!(primeros.len(), 3);
1139 }
1140
1141 #[test]
1142 fn rotar_bases_no_panica_con_clave_corta() {
1143 // `clave[..2]` habría panicado acá; el fold sobre bytes no.
1144 assert_eq!(rotar_bases("https://a,https://b", "").len(), 2);
1145 assert_eq!(rotar_bases("https://a,https://b", "0").len(), 2);
1146 }
1147
1148 #[test]
1149 fn vendor_cargo_deps_runs_and_writes_config() {
1150 // Crate sin deps de crates.io: `cargo vendor` sale 0 con config vacío y sin red.
1151 // Verifica el camino spawn → captura stdout → escribe `.cargo/config.toml`.
1152 let tmp = tempfile::tempdir().unwrap();
1153 let src = tmp.path();
1154 std::fs::write(
1155 src.join("Cargo.toml"),
1156 "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
1157 )
1158 .unwrap();
1159 std::fs::create_dir_all(src.join("src")).unwrap();
1160 std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
1161
1162 // `--locked` exige un Cargo.lock presente; lo generamos offline (sin deps).
1163 let lock_ok = Command::new("cargo")
1164 .args(["generate-lockfile", "--offline"])
1165 .current_dir(src)
1166 .status()
1167 .map(|s| s.success())
1168 .unwrap_or(false);
1169 if !lock_ok {
1170 eprintln!("SKIP: cargo no disponible para generate-lockfile");
1171 return;
1172 }
1173
1174 vendor_cargo_deps(src, "vendor").unwrap();
1175 assert!(src.join(".cargo/config.toml").is_file(), "debe escribir el config de vendoring");
1176 }
1177
1178 #[test]
1179 fn vendor_cargo_deps_without_committed_lock() {
1180 // Fuente SIN Cargo.lock (como un monorepo que lo gitignora): vendoreamos sin --locked,
1181 // generando el lock al vuelo. Para un crate depless es offline.
1182 let tmp = tempfile::tempdir().unwrap();
1183 let src = tmp.path();
1184 std::fs::write(
1185 src.join("Cargo.toml"),
1186 "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
1187 )
1188 .unwrap();
1189 std::fs::create_dir_all(src.join("src")).unwrap();
1190 std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
1191 assert!(!src.join("Cargo.lock").is_file(), "precondición: sin lock");
1192
1193 if Command::new("cargo").arg("--version").status().map(|s| !s.success()).unwrap_or(true) {
1194 eprintln!("SKIP: cargo no disponible");
1195 return;
1196 }
1197 // No debe fallar por falta de --locked; genera el lock y escribe el config.
1198 vendor_cargo_deps(src, "vendor").unwrap();
1199 assert!(src.join(".cargo/config.toml").is_file());
1200 assert!(src.join("Cargo.lock").is_file(), "vendor sin --locked genera el lock");
1201 }
1202}
1203
1204fn run_git(args: &[&str], cwd: Option<&Path>) -> takana_core::Result<()> {
1205 let mut cmd = Command::new("git");
1206 if let Some(c) = cwd {
1207 cmd.arg("-C").arg(c);
1208 }
1209 cmd.args(args);
1210 let status = cmd
1211 .stdin(Stdio::null())
1212 .stdout(Stdio::inherit())
1213 .stderr(Stdio::inherit())
1214 .status()
1215 .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?;
1216 if !status.success() {
1217 return Err(takana_core::Error::Other(anyhow::anyhow!(
1218 "git {args:?} falló (exit {:?})",
1219 status.code()
1220 )));
1221 }
1222 Ok(())
1223}