gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 815Rust 56Shell 37Markdown 291229 Definiciones

⎇ 0a4b3abb461b5e9b6a68cbb0508d2eb7899acae3 crates/takana-build/src/fetch.rs Rust · 1223 líneas · 52.3 KB Historia Quién tocó qué crudo

paso 6: ADR 0012 decidido e implementado — la carrera del árbol de fuentes se cierra con un lock por receta 0a4b3abb4 · Sergio ·

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}