//! Fase `fetch`: materializa el árbol fuente de la receta en un directorio trabajable. //! //! Dos modos, decididos por la receta (ver `takana_core::SourceKind`): //! //! * **Git** — mirror local en `/repos/.git` + `git archive` del commit //! fijado al árbol trabajable. El mirror se reusa entre builds; el árbol es descartable. //! * **Tarball** — descarga con `curl`, verificación de sha256, extracción con `tar`. Se //! cachean los archivos descargados en `/tarballs/`. //! //! La red sólo se permite aquí, FUERA del sandbox de build. Después del fetch, el árbol del //! source se copia a un dir descartable y se aplican los patches; el sandbox sólo ve esa //! copia (sin red). use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use takana_core::{Recipe, SourceKind}; /// Exclusión mutua del ÁRBOL DE FUENTES de una receta — **la decisión del [ADR 0012]**. /// /// ## Qué carrera cierra /// /// El árbol se nombra de forma determinista y SIN componente por-constructor /// (`-`), así que dos procesos que construyan la MISMA receta resuelven al mismo /// directorio: uno hace `remove_dir_all` mientras el otro corre `tar -x` o compila, y el árbol queda /// roto **para siempre** (el siguiente intento vuelve a chocar). Medido a escala: al invalidar /// `libdrm`, ~93 de 205 recetas KDE murieron con `/src/.zwrap/cc is not a full path to an existing /// compiler tool` — el wrapper de zig que la receta deja EN EL ÁRBOL, barrido por el fetch de otra. /// /// ## Por qué un lock por árbol NO serializa la granja (lo que el ADR temía) /// /// El ADR descartaba a medias esta opción porque «el grafo es un diamante que converge ⇒ puede /// degenerar en serializar casi todo». **Eso supone que un consumidor toca el árbol de su dep, y no /// lo hace.** El orden de `build` es, y esto es lo que decide la cuestión: /// /// 1. `store.has(h)` → si está, vuelve sin tocar NINGÚN árbol (cache hit). /// 2. `materialize_build_deps` → construye las deps (recursivo) **y termina**. /// 3. `fetch` del árbol PROPIO → patches → vendor → compilar → sellar. /// /// De ahí salen las dos propiedades que hacen viable la opción A: /// /// * **Dos recetas distintas nunca comparten su árbol propio** ⇒ sus builds no se serializan entre /// sí. Lo único que se serializa es construir la MISMA receta dos veces a la vez, que es /// precisamente lo que no se debe hacer — y el perdedor del lock, al re-chequear la tienda, /// encuentra el artefacto ya sellado y sale por cache hit en vez de reconstruirlo. /// * **Los locks NO SE ANIDAN**: cuando un build toma el lock de su árbol, sus deps ya están /// construidas y sus locks liberados (paso 2 antes del 3) ⇒ **no puede haber deadlock**, sin /// ordenar nada ni detectar ciclos. /// /// ## El grano es el NOMBRE de la receta, no el árbol /// /// Porque el mirror git (`repos/.git`) es estado compartido también, y su clave es sólo el /// nombre: dos builds de la misma receta a DISTINTO commit tienen árboles distintos y el mismo /// mirror, y un `clone --mirror` sobre un directorio que existe falla. Con el lock por nombre, el /// mirror, el árbol, los patches, el vendoreo y la compilación quedan todos dentro. /// /// ## El fd no se hereda, y eso es deliberado /// /// `CLAUDE.md` documenta cómo un `flock` se queda tomado para siempre: **el lock lo sostiene la /// descripción de fichero, y los hijos la heredan**; un nieto fugado dejó a la granja sin compilar /// hora y media, en silencio. Acá no puede pasar: Rust abre todo con `O_CLOEXEC`, así que ni `tar`, /// ni `git`, ni `bwrap`, ni un nieto suyo heredan este descriptor. Es la ventaja de hacerlo en el /// proceso en vez de con el `flock(1)` de alrededor. /// /// [ADR 0012]: ../../../docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md #[derive(Debug)] pub struct GuardaArbol { _fd: std::fs::File, nombre: String, } impl Drop for GuardaArbol { fn drop(&mut self) { // El `flock` se suelta al cerrar el fd; el log existe para que una espera larga tenga un // final visible en la salida del build. tracing::debug!(receta = %self.nombre, "árbol: lock liberado"); } } /// Toma el lock exclusivo del árbol de `nombre`. Bloquea si otro build lo tiene, **avisando**: una /// espera muda se lee como un cuelgue, que es el modo de fallo que este repo ya pagó con `flock`. pub fn tomar_guarda(work_root: &Path, nombre: &str) -> takana_core::Result { use std::os::unix::io::AsRawFd; let dir = work_root.join("locks"); std::fs::create_dir_all(&dir)?; // El fichero de lock va FUERA de `sources/`: dentro lo barrería el `remove_dir_all` del propio // fetch, que es exactamente el mecanismo que estamos cerrando. let ruta = dir.join(format!("{nombre}.lock")); let f = std::fs::OpenOptions::new() .create(true) .read(true) .write(true) .truncate(false) .open(&ruta)?; let fd = f.as_raw_fd(); // Primero sin bloquear, para poder DECIR que se espera y por qué. let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) }; if rc != 0 { tracing::info!( receta = %nombre, "árbol: esperando el lock — otro build tiene el árbol de fuentes de esta receta (ADR 0012)" ); let rc2 = unsafe { libc::flock(fd, libc::LOCK_EX) }; if rc2 != 0 { return Err(takana_core::Error::Other(anyhow::anyhow!( "no pude tomar el lock del árbol {}: {}", ruta.display(), std::io::Error::last_os_error() ))); } tracing::info!(receta = %nombre, "árbol: lock obtenido tras esperar"); } Ok(GuardaArbol { _fd: f, nombre: nombre.to_string() }) } /// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta **y la guarda**. /// /// La guarda se devuelve —en vez de tomarse y soltarse acá dentro— porque el árbol se sigue mutando /// DESPUÉS del fetch (patches, vendoreo) y se bindea para compilar: un lock que sólo cubriera la /// extracción no cierra la carrera (es el §El dilema del ADR 0012). Quien llama la mantiene viva /// hasta sellar; devolverla en la tupla hace **imposible** olvidarla. pub fn fetch(recipe: &Recipe, work_root: &Path) -> takana_core::Result<(PathBuf, GuardaArbol)> { let guarda = tomar_guarda(work_root, &recipe.name)?; let arbol = match recipe.source.kind()? { SourceKind::Git { repo, commit } => fetch_git(recipe, repo, commit, work_root), SourceKind::Tarball { url, sha256 } => fetch_tarball(recipe, url, sha256, work_root), SourceKind::Dir { dir } => fetch_dir(recipe, dir, work_root), }?; Ok((arbol, guarda)) } /// Materializa el árbol de una receta DERIVADA: copia `/` al workspace. /// /// No hay red, ni caché que invalidar, ni mirror: la fuente ya está en el disco. Se COPIA y no se /// bindea porque las fases mutan el árbol (parches, vendoreo, ficheros generados) y el original vive /// en el repo git — un build que ensucie el working tree del repo sería una forma silenciosa de que /// dos recetas se pisen, que es justo lo que el ADR 0012 documenta para las fuentes compartidas. /// /// El nombre del workspace lleva el hash del CONTENIDO y no el de la ruta: editar el overlay cambia /// de directorio, así que un árbol viejo nunca se reusa por accidente. fn fetch_dir(recipe: &Recipe, dir: &str, work_root: &Path) -> takana_core::Result { let origen = recipe.base_dir.join(dir); if !origen.is_dir() { return Err(takana_core::Error::Other(anyhow::anyhow!( "source.dir apunta a {} y no es un directorio", origen.display() ))); } let h = takana_core::ArtifactHash::of_tree(&origen) .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("hash del árbol {}: {e}", origen.display())))?; let bare = h.as_str().strip_prefix("b3:").unwrap_or(h.as_str()); let sources_dir = work_root.join("sources"); std::fs::create_dir_all(&sources_dir)?; let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &bare[..16])); if work_tree.is_dir() { std::fs::remove_dir_all(&work_tree)?; } std::fs::create_dir_all(&work_tree)?; let st = Command::new("cp") .arg("-a") .arg(format!("{}/.", origen.display())) .arg(&work_tree) .status() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn cp -a del source.dir: {e}")))?; if !st.success() { return Err(takana_core::Error::Other(anyhow::anyhow!( "cp -a de {} falló", origen.display() ))); } Ok(work_tree) } fn fetch_git( recipe: &Recipe, repo: &str, commit: &str, work_root: &Path, ) -> takana_core::Result { let repos_dir = work_root.join("repos"); let sources_dir = work_root.join("sources"); std::fs::create_dir_all(&repos_dir)?; std::fs::create_dir_all(&sources_dir)?; let mirror = repos_dir.join(format!("{}.git", recipe.name)); // Mirror propio de fuentes git (ADR 0013). Se intenta ANTES que el remoto, por la misma razón // que en los tarballs: git verifica cada objeto por su SHA al desempaquetar, así que no hay // diferencia de contenido posible entre las dos rutas — y un mirror que sólo se usa cuando // upstream falla es un mirror que nadie prueba. if !mirror.is_dir() || run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() { let _ = hidratar_desde_bundle(commit, &mirror); } if !mirror.is_dir() { run_git( &[ "clone", "--mirror", "--filter=blob:none", repo, mirror.to_str().unwrap(), ], None, )?; } // Asegura que tenemos el commit pedido. En un mirror existente puede faltar. if run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() { run_git(&["fetch", "--all", "--tags"], Some(&mirror))?; run_git(&["cat-file", "-e", commit], Some(&mirror)).map_err(|_| { takana_core::Error::Other(anyhow::anyhow!( "commit {commit} no existe en {repo} tras fetch" )) })?; } // Materializa un árbol trabajable mediante `git archive` → tar → extract. // Esto evita el coste de un worktree completo y deja sólo los archivos del commit. let work_tree = sources_dir.join(format!("{}-{}", recipe.name, commit)); if work_tree.is_dir() { std::fs::remove_dir_all(&work_tree)?; } std::fs::create_dir_all(&work_tree)?; archivar_en(&mirror, commit, &work_tree)?; // Un `git archive` NO emite nada por una entrada gitlink (modo 160000), así que un árbol con // submódulos sale INCOMPLETO y el fallo aparece recién en `configure`, minutos después y // hablando de un fichero que "no existe". Los materializamos acá, con el mismo mecanismo y el // mismo determinismo: un submódulo ya viene pineado por SHA en el commit del padre (ADR 0006). materializar_submodulos(&mirror, commit, &work_tree, &repos_dir, repo, 0)?; Ok(work_tree) } /// `git archive | tar -x -C `: materializa UN árbol sin pagar un worktree. fn archivar_en(mirror: &Path, commit: &str, destino: &Path) -> takana_core::Result<()> { let archive = Command::new("git") .arg("-C") .arg(mirror) .args(["archive", "--format=tar", commit]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn git archive: {e}")))?; let tar_status = Command::new("tar") .args(["-x", "-C"]) .arg(destino) .stdin(archive.stdout.unwrap()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn tar -x: {e}")))?; if !tar_status.success() { return Err(takana_core::Error::Other(anyhow::anyhow!( "git archive | tar -x falló (tar exit {:?})", tar_status.code() ))); } Ok(()) } /// Cuántos niveles de submódulo anidado aceptamos antes de declarar un ciclo. const PROFUNDIDAD_SUBMODULOS: usize = 8; /// Materializa los **submódulos** del commit dentro del árbol ya extraído, recursivamente. /// /// # Por qué no alcanza un flag en el `clone` /// /// El que pierde los submódulos no es el clon sino `git archive`: una entrada gitlink no emite /// nada al tar, por diseño. `--recurse-submodules` en el `clone` no cambiaría eso. Hay que leer /// `.gitmodules` + los SHA de gitlink del commit, mirrorear cada submódulo y archivarlo en su /// subruta — que es exactamente lo que hace esta función. /// /// # Por qué reescribe la URL /// /// `.gitmodules` suele declarar `git@github.com:X/Y.git`, que es SSH; nuestro fetch es anónimo y /// no tiene claves. La forma `https://github.com/X/Y.git` apunta al MISMO repo y el commit está /// pineado por SHA, así que el contenido no puede diferir: reescribirla no añade confianza que /// verificar. Las URLs relativas (`../foo.git`) se resuelven contra la del padre, como hace git. fn materializar_submodulos( mirror: &Path, commit: &str, destino: &Path, repos_dir: &Path, url_padre: &str, profundidad: usize, ) -> takana_core::Result<()> { let gitlinks = gitlinks_de(mirror, commit)?; if gitlinks.is_empty() { return Ok(()); } if profundidad >= PROFUNDIDAD_SUBMODULOS { return Err(takana_core::Error::Other(anyhow::anyhow!( "submódulos anidados más de {PROFUNDIDAD_SUBMODULOS} niveles en {url_padre} \ ({commit}): parece un ciclo" ))); } let urls = urls_de_gitmodules(mirror, commit)?; for (sha, ruta) in gitlinks { let declarada = urls.get(&ruta).ok_or_else(|| { takana_core::Error::Other(anyhow::anyhow!( "el commit {commit} declara el submódulo `{ruta}` pero .gitmodules no le da url" )) })?; let url = resolver_url_submodulo(declarada, url_padre); let sub_mirror = repos_dir.join(format!("sub-{}.git", sanear_para_ruta(&url))); if !sub_mirror.is_dir() { run_git( &[ "clone", "--mirror", "--filter=blob:none", &url, sub_mirror.to_str().unwrap(), ], None, )?; } if run_git(&["cat-file", "-e", &sha], Some(&sub_mirror)).is_err() { run_git(&["fetch", "--all", "--tags"], Some(&sub_mirror))?; run_git(&["cat-file", "-e", &sha], Some(&sub_mirror)).map_err(|_| { takana_core::Error::Other(anyhow::anyhow!( "el submódulo `{ruta}` pide {sha}, que no existe en {url} tras fetch" )) })?; } let destino_sub = destino.join(&ruta); std::fs::create_dir_all(&destino_sub)?; archivar_en(&sub_mirror, &sha, &destino_sub)?; materializar_submodulos( &sub_mirror, &sha, &destino_sub, repos_dir, &url, profundidad + 1, )?; } Ok(()) } /// Las entradas de tipo `commit` (modo 160000) del commit: `(sha, ruta)`. /// /// `-z` porque una ruta puede llevar espacios y el formato sin `-z` los escapa con comillas. fn gitlinks_de(mirror: &Path, commit: &str) -> takana_core::Result> { let salida = salida_git(&["ls-tree", "-r", "-z", commit], mirror)?; let mut out = Vec::new(); for reg in salida.split('\0').filter(|r| !r.is_empty()) { let Some((meta, ruta)) = reg.split_once('\t') else { continue; }; let campos: Vec<&str> = meta.split_whitespace().collect(); // if campos.len() == 3 && campos[1] == "commit" { out.push((campos[2].to_string(), ruta.to_string())); } } Ok(out) } /// `ruta → url` leído del `.gitmodules` **del commit** (no del working tree, que no existe). /// /// Si el commit no trae `.gitmodules`, devuelve vacío: eso es legítimo (un gitlink sin declarar es /// un error, pero lo reporta quien lo consulta, con el nombre del submódulo a la vista). fn urls_de_gitmodules( mirror: &Path, commit: &str, ) -> takana_core::Result> { let blob = format!("{commit}:.gitmodules"); let salida = match salida_git( &[ "config", "--blob", &blob, "-z", "--get-regexp", "^submodule\\..*\\.(path|url)$", ], mirror, ) { Ok(s) => s, Err(_) => return Ok(Default::default()), }; let mut rutas: std::collections::BTreeMap = Default::default(); let mut urls: std::collections::BTreeMap = Default::default(); for reg in salida.split('\0').filter(|r| !r.is_empty()) { // Con `-z` cada registro es `clave\nvalor`. let Some((clave, valor)) = reg.split_once('\n') else { continue; }; let Some(resto) = clave.strip_prefix("submodule.") else { continue; }; // El NOMBRE del submódulo puede llevar puntos, así que se parte por el último. let Some((nombre, campo)) = resto.rsplit_once('.') else { continue; }; match campo { "path" => { rutas.insert(nombre.to_string(), valor.to_string()); } "url" => { urls.insert(nombre.to_string(), valor.to_string()); } _ => {} } } Ok(rutas .into_iter() .filter_map(|(nombre, ruta)| urls.get(&nombre).map(|u| (ruta, u.clone()))) .collect()) } /// SSH → HTTPS y relativa → absoluta contra la URL del padre. El resto pasa tal cual. fn resolver_url_submodulo(declarada: &str, url_padre: &str) -> String { if let Some(resto) = declarada.strip_prefix("git@") { if let Some((host, ruta)) = resto.split_once(':') { return format!("https://{host}/{ruta}"); } } if let Some(resto) = declarada.strip_prefix("ssh://git@") { return format!("https://{resto}"); } if declarada.starts_with("./") || declarada.starts_with("../") { return resolver_relativa(declarada, url_padre); } declarada.to_string() } /// `../x` sube un segmento de la URL del padre; `./x` se queda en el mismo nivel. fn resolver_relativa(rel: &str, url_padre: &str) -> String { // git resuelve la relativa tratando la URL del padre como un DIRECTORIO: `../l10n.git` sobre // `…/BrowserWorks/waterfox.git` da `…/BrowserWorks/l10n.git`, o sea que `..` se come el nombre // del propio repo. Por eso NO se popea nada antes del bucle. let mut base: Vec<&str> = url_padre.trim_end_matches('/').split('/').collect(); for seg in rel.split('/') { match seg { "." | "" => {} ".." => { base.pop(); } otro => base.push(otro), } } base.join("/") } /// Un nombre de directorio estable y legible para el mirror de una URL. fn sanear_para_ruta(url: &str) -> String { url.chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { c } else { '-' } }) .collect() } /// `git ` capturando stdout. Error si el comando falla. fn salida_git(args: &[&str], cwd: &Path) -> takana_core::Result { let out = Command::new("git") .arg("-C") .arg(cwd) .args(args) .stdin(Stdio::null()) .stderr(Stdio::piped()) .output() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?; if !out.status.success() { return Err(takana_core::Error::Other(anyhow::anyhow!( "git {args:?} falló (exit {:?})", out.status.code() ))); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } /// Los orígenes declarados en `var` (**lista** separada por comas), **rotados** por `clave`. /// /// # Por qué una lista y no una URL (ADR 0014) /// /// El ADR 0013 dejó `TAKANA_MIRROR` con un solo valor y con eso el mecanismo que existe para no /// depender de 79 servidores ajenos pasaba a depender de **uno nuestro**: el mismo fallo, con /// nuestro nombre encima. La identidad del objeto es su `sha256`/`commit`, así que un origen de más /// no añade confianza que verificar — sólo un sitio más donde mirar. Añadirlos es gratis y no /// re-hashea nada (la URL nunca entró en `hash_inputs`). /// /// # Por qué ROTA y no prueba siempre en orden /// /// Es la lección del propio ADR 0013 aplicada un nivel más arriba: *un espejo que sólo se usa /// cuando el otro falla es un espejo que nadie prueba*, y se descubre roto el día que hace falta. /// Con orden fijo, el segundo origen no se toca jamás hasta la emergencia. El reparto es /// **determinista por contenido** — el mismo objeto sale siempre del mismo origen, así que un fallo /// se reproduce y se depura — pero objetos distintos se reparten entre todos, así que una tanda del /// corpus **ejercita la lista entera**. Tras el elegido se recorren los demás como respaldo. fn bases_de_mirror(var: &str, clave: &str) -> Vec { match takana_core::env::var(var) { Some(raw) => rotar_bases(&raw, clave), None => Vec::new(), } } /// La parte pura de [`bases_de_mirror`]: parsea la lista y la rota. Separada del `env::var` para /// poder probarla sin tocar el entorno del proceso (que es global y hace los tests dependientes /// del orden en que corren). fn rotar_bases(raw: &str, clave: &str) -> Vec { let bases: Vec = raw .split(',') .map(|s| s.trim().trim_end_matches('/')) .filter(|s| !s.is_empty()) .map(str::to_string) .collect(); if bases.len() < 2 { return bases; } // Los dos primeros bytes de la clave hex bastan para repartir y no pueden panicar con una clave // corta o vacía (a diferencia de `clave[..2]`). let semilla = clave .as_bytes() .iter() .take(2) .fold(0usize, |a, b| a.wrapping_mul(256).wrapping_add(*b as usize)); let inicio = semilla % bases.len(); bases .iter() .cycle() .skip(inicio) .take(bases.len()) .cloned() .collect() } /// Intenta poblar `destino` (repo bare) con `commit` desde el mirror de fuentes git (ADR 0013). /// Devuelve `true` si el commit quedó disponible. **Nunca es un error**: si no hay mirror, si el /// objeto no está o si algo falla, devuelve `false` y el llamador va al remoto como siempre. /// /// # Por qué un bundle SHALLOW y no un clon completo /// /// El bundle se genera desde un clon `--depth 1` del commit exacto: para `act` son 8,9 MB en vez /// del repositorio entero. Hammer no necesita la historia — lo único que hace con el repo es /// `git archive | tar -x`, o sea materializar UN árbol. /// /// # El detalle que hace que esto funcione: el fichero `shallow` /// /// Un bundle hecho desde un repo shallow **no lleva la frontera de historia**, así que al /// desempaquetarlo git intenta recorrer los padres y aborta: /// /// ```text /// fatal: Failed to traverse parents of commit 4f41128… /// error: … did not send all necessary objects /// ``` /// /// El mensaje suena a bundle incompleto y **no lo es**: los objetos del commit llegan enteros (de /// hecho `git archive` ya funciona pese al error). Lo que falta es decirle a git dónde termina la /// historia. Se arregla escribiendo el propio commit en `/shallow` ANTES del fetch — y no /// hay que transportar nada, porque la frontera de un clon `--depth 1` es exactamente ese commit. /// /// La verificación sigue siendo criptográfica: git comprueba cada objeto contra su SHA al /// desempaquetar, y el commit de la receta es la identidad. Un bundle alterado no pasa. fn hidratar_desde_bundle(commit: &str, destino: &Path) -> bool { // `TAKANA_MIRROR_GIT` es una LISTA (ADR 0014): se prueban todos los orígenes antes de rendirse // y caer a upstream. Un bundle que no está en uno puede estar en otro, y la verificación la // hace git contra el SHA venga de donde venga. bases_de_mirror("TAKANA_MIRROR_GIT", commit) .iter() .any(|base| hidratar_desde_bundle_en(base, commit, destino)) } /// Un solo intento contra `base`. Devuelve `true` si el commit quedó disponible en `destino`. fn hidratar_desde_bundle_en(base: &str, commit: &str, destino: &Path) -> bool { let tmp = destino.with_extension("bundle.partial"); if let Some(padre) = tmp.parent() { let _ = std::fs::create_dir_all(padre); } let _ = std::fs::remove_file(&tmp); if descargar(&format!("{base}/{commit}.bundle"), &tmp).is_err() { let _ = std::fs::remove_file(&tmp); return false; } if !destino.is_dir() && run_git(&["init", "--bare", destino.to_str().unwrap()], None).is_err() { let _ = std::fs::remove_file(&tmp); return false; } // La frontera de historia. Sin esto el fetch aborta aunque los objetos estén completos. // // La frontera se LEE DEL BUNDLE, no se supone. `git bundle list-heads` imprime la lista de refs // sin necesitar los objetos, así que sirve justo antes del fetch. Suponerla igual a `commit` // estaba mal: el campo `commit` de una receta puede pinear un OBJETO TAG anotado (así están // diffutils, findutils-xargs y kustomize), y el fichero `shallow` sólo admite SHAs de commit. // El poblador deja siempre `refs/heads/hammer` en el commit ya pelado. let Some(frontera) = frontera_del_bundle(&tmp) else { let _ = std::fs::remove_file(&tmp); return false; }; let _ = std::fs::write(destino.join("shallow"), format!("{frontera}\n")); // ⚠ RUTA ABSOLUTA, obligatoria. `run_git` invoca `git -C …`, y git resuelve una ruta // relativa de bundle DENTRO de ese `-C` — o sea buscaría `/work/repos/x.bundle.partial`. // El fallo es mudo aquí (se traga y se cae al remoto), así que el síntoma aparece lejos: el build // muere diciendo «commit … no existe en tras fetch», culpando a upstream de un error de // ruta local. let bundle_abs = std::fs::canonicalize(&tmp).unwrap_or_else(|_| tmp.clone()); // `refs/*:refs/*` y no un ref concreto: cuando el pin es un tag anotado el bundle trae DOS refs // (el commit pelado y el objeto tag), y hace falta el objeto tag para que el `cat-file -e` de // abajo — y el `git archive ` de después — encuentren lo que la receta pide. let ok = run_git( &["fetch", bundle_abs.to_str().unwrap(), "refs/*:refs/*"], Some(destino), ) .is_ok() && run_git(&["cat-file", "-e", commit], Some(destino)).is_ok(); let _ = std::fs::remove_file(&tmp); ok } /// SHA del commit que `refs/heads/hammer` señala dentro del bundle, leído sin desempaquetarlo. fn frontera_del_bundle(bundle: &Path) -> Option { let abs = std::fs::canonicalize(bundle).ok()?; let salida = Command::new("git") .args(["bundle", "list-heads"]) .arg(&abs) .output() .ok()?; if !salida.status.success() { return None; } String::from_utf8_lossy(&salida.stdout) .lines() .find_map(|l| { let (sha, refname) = l.split_once(char::is_whitespace)?; (refname.trim() == "refs/heads/hammer").then(|| sha.trim().to_string()) }) } fn fetch_tarball( recipe: &Recipe, url: &str, sha256: &str, work_root: &Path, ) -> takana_core::Result { let tarballs_dir = work_root.join("tarballs"); let sources_dir = work_root.join("sources"); std::fs::create_dir_all(&tarballs_dir)?; std::fs::create_dir_all(&sources_dir)?; // Nombre por hash: dos URLs distintas con el mismo contenido se cachean una vez, y // cambiar la URL sin cambiar el sha (mirror) no invalida la caché. let cached = tarballs_dir.join(format!("{sha256}.tar")); if !cached.is_file() { fetch_con_mirror(url, sha256, &cached)?; } else { verify_sha256(&cached, sha256)?; } // ⚠ CARRERA CONOCIDA, SIN RESOLVER — ver docs/adr/0012-arbol-de-fuentes-compartido-vs-privado.md // El path es determinista y NO lleva nada del constructor ⇒ dos builds de la misma receta (la // misma dep alcanzada por dos caminos) resuelven al mismo directorio: uno borra mientras el otro // extrae y el árbol queda roto con "Directory not empty" (os error 39) hasta borrarlo a mano. // Mitigado SÓLO entre campana-deuda.sh y farm-worker-loop.sh con un lock de script (commit // 1b436a7); el `xargs -P2` de build-farm.sh sigue expuesto. // NO "arreglar" con un lock alrededor de esta extracción: parece correcto y no lo es, porque un // segundo proceso puede borrar un árbol que un bwrap ya está usando para compilar. Este árbol es // caché y workspace mutable a la vez (lib.rs lo parchea y vendorea in situ); el ADR 0012 plantea // las tres salidas y qué medir para elegir. let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &sha256[..16])); if work_tree.is_dir() { std::fs::remove_dir_all(&work_tree)?; } std::fs::create_dir_all(&work_tree)?; // `tar` autodetecta el compresor por la cabecera; el sufijo .tar del nombre es solo // identificador interno (no afecta a la detección). let strip = recipe.source.strip_components.to_string(); let status = Command::new("tar") .arg("-x") // `--no-same-owner`: extrae como el usuario actual (no preserva el uid del tarball). Si el // fetch corre como root (granja), `tar` por defecto preservaría el owner original → ficheros // de un uid que el sandbox (userns) no puede chmod/escribir → configure de autotools falla // ("Permission denied" creando Makefile, "Operation not permitted" en chmod). Inocuo para el // of_tree (es el árbol de FUENTES, no el output). Destraba libs C como oniguruma. .arg("--no-same-owner") .arg("-f") .arg(&cached) .arg("--strip-components") .arg(&strip) .arg("-C") .arg(&work_tree) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn tar -x tarball: {e}")))?; if !status.success() { return Err(takana_core::Error::Other(anyhow::anyhow!( "tar -x falló (exit {:?})", status.code() ))); } Ok(work_tree) } /// Baja el tarball probando el **mirror propio antes que upstream** (ADR 0013). /// /// # Por qué el mirror va primero y no como rescate /// /// El `sha256` se verifica igual venga de donde venga, así que **no hay diferencia de contenido /// posible** entre las dos rutas: preferir upstream «por pureza» no compra nada y paga latencia más /// un tercero en el camino. Además un mirror que sólo se usa cuando upstream falla es un mirror que /// **nadie prueba**, y se descubre roto justo el día en que hace falta. /// /// # Por qué NO avisa cuando cae al mirror /// /// Tentación evidente y **equivocada**: si `takana build` avisara en cada descarga, el aviso sería /// ruido en 561 recetas y nadie lo leería. Peor: haría creer que el bit-rot está vigilado *acá*, /// cuando construir y vigilar upstream son dos trabajos distintos. La vigilancia vive en /// `scripts/fuentes/fuentes-vigia.sh`, la corre el latido y escribe `docs/state/fuentes-vigia.json`. /// Un mirror silencioso **con** vigía separado detecta el bit-rot; uno silencioso **sin** vigía lo /// entierra, que es como el grafo de wlr pasó 17 días anunciando un 121/121 que ya era falso. /// /// `TAKANA_MIRROR` es una **lista** de prefijos de URL separada por comas, de cualquier esquema que /// curl entienda (`file://`, `https://`, `sftp://`…); el objeto se busca por contenido en /// `{base}/{sha256}.tar` en cada uno, en el orden que dicta [`bases_de_mirror`]. /// `TAKANA_MIRROR_KEY`, si está, se pasa como `--key` (Storage Box por sftp). Sin `TAKANA_MIRROR` /// el comportamiento es el de siempre: derecho a upstream. fn fetch_con_mirror(url: &str, sha256: &str, dst: &Path) -> takana_core::Result<()> { for base in bases_de_mirror("TAKANA_MIRROR", sha256) { let espejo = format!("{base}/{sha256}.tar"); // Un fallo acá NO es un error: es el caso normal la primera vez que se ve una fuente, y // también el caso normal cuando un origen de la lista todavía no tiene ese objeto. Se // ignora en silencio y se sigue al siguiente origen, y al final a upstream. if download_verify(&espejo, sha256, dst).is_ok() { return Ok(()); } } download_verify(url, sha256, dst) } /// Descarga `url` a `dst` y verifica el sha256. Si la verificación falla, borra el archivo /// para que la próxima corrida lo vuelva a intentar (no envenenamos la caché). /// Baja `url` a `dst` con curl. Sin verificar nada: quien llama decide cómo se comprueba — /// `download_verify` por sha256 (tarballs), `hidratar_desde_bundle` delegando en git (bundles). fn descargar(url: &str, dst: &Path) -> takana_core::Result<()> { let mut cmd = Command::new("curl"); cmd.args(["-fL", "--retry", "3", "--connect-timeout", "30"]); // Clave para el mirror por sftp (Storage Box). Inocua para http/file. if let Some(key) = takana_core::env::var("TAKANA_MIRROR_KEY") { if !key.is_empty() { cmd.arg("--key").arg(&key); } } let status = cmd .arg("-o") .arg(dst) .arg(url) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?; if !status.success() { return Err(takana_core::Error::Other(anyhow::anyhow!( "curl {url} falló (exit {:?})", status.code() ))); } Ok(()) } fn download_verify(url: &str, sha256: &str, dst: &Path) -> takana_core::Result<()> { let tmp = dst.with_extension("tar.partial"); if let Err(e) = descargar(url, &tmp) { let _ = std::fs::remove_file(&tmp); return Err(e); } if let Err(e) = verify_sha256(&tmp, sha256) { let _ = std::fs::remove_file(&tmp); return Err(e); } std::fs::rename(&tmp, dst)?; Ok(()) } fn verify_sha256(path: &Path, expected: &str) -> takana_core::Result<()> { use sha2::{Digest, Sha256}; let mut f = std::fs::File::open(path)?; let mut hasher = Sha256::new(); std::io::copy(&mut f, &mut hasher)?; let got = hex::encode(hasher.finalize()); if !got.eq_ignore_ascii_case(expected) { return Err(takana_core::Error::Other(anyhow::anyhow!( "sha256 mismatch en {}: esperado {expected}, obtenido {got}", path.display() ))); } Ok(()) } /// Vendoriza las dependencias de un proyecto Cargo en `/vendor` y escribe /// `/.cargo/config.toml` para que el build dentro del sandbox resuelva todo `--offline`. /// /// La red se usa AQUÍ (fuera del sandbox, como el resto del fetch). /// /// **`--locked` condicional:** si la fuente trae un `Cargo.lock` committeado, se vendorea con /// `--locked` y el build es reproducible (mismo commit ⇒ mismo lock ⇒ mismas deps; el lock ya está /// dentro del commit que hashea la receta). Si la fuente **no** lo trae (p.ej. un monorepo que lo /// gitignora, como tawasuyu), se genera el lock al vuelo **sin** `--locked` y se emite un `warn!`: /// el build queda internamente consistente pero **no pineado en el tiempo** (las deps pueden /// derivar entre corridas). El end-state reproducible es committear el `Cargo.lock` en la fuente /// (plan C.2 #5). Ver `docs/runbooks/stage1-vm-boot.md` §8. /// /// Un crate sin deps de crates.io produce un config vacío y ningún `vendor/`: inocuo. pub fn vendor_cargo_deps(src: &Path, vendor_dir: &str) -> takana_core::Result<()> { let locked = src.join("Cargo.lock").is_file(); let mut args: Vec<&str> = vec!["vendor"]; if locked { args.push("--locked"); } else { tracing::warn!( src = %src.display(), "sin Cargo.lock committeado: vendoreo sin --locked; el build NO es reproducible en el \ tiempo (deps no pineadas). Committear el lock en la fuente para fijarlo (plan C.2 #5)." ); } // Directorio de salida (default `vendor`; override per-receta vía `[source] cargo_vendor_dir` // para no pisar un `vendor/` propio del proyecto). `cargo vendor` emite el `[source.*]` a stdout // apuntando a ESTE dir, así que el `.cargo/config.toml` que persistimos abajo resuelve solo. args.push(vendor_dir); let output = Command::new("cargo") .current_dir(src) .args(&args) .stderr(Stdio::inherit()) .output() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn cargo vendor: {e}")))?; if !output.status.success() { return Err(takana_core::Error::Other(anyhow::anyhow!( "cargo vendor falló (exit {:?})", output.status.code() ))); } // `cargo vendor` emite por stdout el fragmento `[source.*]` a instalar; lo persistimos en el // árbol del source para que `cargo build --offline` lo lea (ruta relativa `vendor`, que // resuelve igual dentro del sandbox donde el source se monta en /src). let cargo_dir = src.join(".cargo"); std::fs::create_dir_all(&cargo_dir)?; std::fs::write(cargo_dir.join("config.toml"), &output.stdout)?; Ok(()) } /// Vendoriza los módulos de un proyecto Go en `/vendor` para que `go build -mod=vendor` /// resuelva todo offline dentro del sandbox. La red se usa AQUÍ (fuera del sandbox, como /// `vendor_cargo_deps`): `go mod vendor` baja los módulos de `go.mod`/`go.sum` al árbol. /// /// **Reproducible por `go.sum`:** a diferencia de Cargo (lock opcional), Go exige `go.sum` /// committeado con el hash de cada módulo; el mismo commit ⇒ el mismo `go.sum` ⇒ el mismo /// `vendor/` (ya hasheado dentro del commit que fija la receta). `GOTOOLCHAIN=local` impide que /// `go.mod` fuerce la auto-descarga de otra versión del toolchain (el del lab manda y queda fijo); /// `GOFLAGS=-mod=mod` permite poblar el `vendor/` aunque el árbol no lo traiga. /// /// Un proyecto sin deps externas produce un `vendor/` vacío (o ninguno): inocuo. pub fn vendor_go_deps(src: &Path) -> takana_core::Result<()> { // Proyecto WORKSPACE (multimódulo): si el árbol trae `go.work`, `go mod vendor` con GOWORK=off // vendorea SÓLO el módulo raíz → el build (que ve todo el workspace) se queja de "inconsistent // vendoring". `go work vendor` (go 1.22+) produce un `vendor/` consistente para TODO el workspace. // Destraba la clase go.work (go-mockery, k3d, git-town, csvtk…). Sin go.work, el camino clásico // `go mod vendor` con GOWORK=off (ignora cualquier go.work espurio y vendorea el raíz). let has_work = src.join("go.work").is_file(); let (verb, label): ([&str; 2], &str) = if has_work { (["work", "vendor"], "go work vendor") } else { (["mod", "vendor"], "go mod vendor") }; let mut cmd = Command::new("go"); cmd.current_dir(src) .args(verb) .env("GOFLAGS", "-mod=mod") .env("GOTOOLCHAIN", "local"); if !has_work { cmd.env("GOWORK", "off"); } let output = cmd .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .output() .map_err(|e| { takana_core::Error::Other(anyhow::anyhow!( "spawn {label}: {e} (¿está `go` en el PATH del host? lo provee el lab, \ cf. scripts/bootstrap-devfs.sh / vps-setup.sh)" )) })?; if !output.status.success() { return Err(takana_core::Error::Other(anyhow::anyhow!( "{label} falló (exit {:?})", output.status.code() ))); } Ok(()) } /// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`. pub fn apply_patches(recipe: &Recipe, tree: &Path) -> takana_core::Result<()> { for p in &recipe.source.patches { let patch_path = recipe.base_dir.join(p); let bytes = std::fs::read(&patch_path).map_err(|e| { takana_core::Error::Recipe(format!( "no pude leer patch {}: {e}", patch_path.display() )) })?; let mut child = Command::new("patch") .args(["-p1", "--batch", "--silent"]) .current_dir(tree) .stdin(Stdio::piped()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .spawn() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn patch: {e}")))?; if let Some(mut stdin) = child.stdin.take() { use std::io::Write; stdin .write_all(&bytes) .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("write patch: {e}")))?; } let status = child .wait() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("wait patch: {e}")))?; if !status.success() { return Err(takana_core::Error::Other(anyhow::anyhow!( "patch {} falló (exit {:?})", patch_path.display(), status.code() ))); } } Ok(()) } #[cfg(test)] mod tests { use super::*; /// El invariante del ADR 0012: **dos guardas del mismo nombre no coexisten**. /// /// Se prueba con dos hilos y no con dos procesos porque `flock` se asocia a la DESCRIPCIÓN de /// fichero, no al proceso: dos `open()` distintos —aunque sean del mismo proceso— se excluyen /// igual. El control es el segundo caso: nombres DISTINTOS no se bloquean, que es lo que hace /// que esto no serialice la granja. #[test] fn dos_guardas_del_mismo_arbol_se_excluyen() { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; let tmp = tempfile::tempdir().unwrap(); let raiz = tmp.path().to_path_buf(); let dentro = Arc::new(AtomicBool::new(false)); let piso = Arc::new(AtomicBool::new(false)); let g1 = tomar_guarda(&raiz, "misma").expect("primera guarda"); dentro.store(true, Ordering::SeqCst); let (r2, d2, p2) = (raiz.clone(), dentro.clone(), piso.clone()); let h = std::thread::spawn(move || { let _g2 = tomar_guarda(&r2, "misma").expect("segunda guarda"); // Si entró mientras la primera seguía viva, el lock no sirve. if d2.load(Ordering::SeqCst) { p2.store(true, Ordering::SeqCst); } }); std::thread::sleep(std::time::Duration::from_millis(250)); dentro.store(false, Ordering::SeqCst); drop(g1); h.join().unwrap(); assert!(!piso.load(Ordering::SeqCst), "la segunda guarda entró con la primera viva"); // CONTROL: dos nombres distintos NO se bloquean — si esto fallara, el lock serializaría // builds que no comparten nada y la granja perdería el paralelismo. let _a = tomar_guarda(&raiz, "una").expect("guarda de `una`"); let _b = tomar_guarda(&raiz, "otra").expect("guarda de `otra` (no debe bloquear)"); } /// El fichero de lock NO puede vivir dentro de `sources/`: el propio fetch hace /// `remove_dir_all` del árbol, y un lock ahí adentro se borraría a sí mismo. #[test] fn el_lock_vive_fuera_de_sources() { let tmp = tempfile::tempdir().unwrap(); let _g = tomar_guarda(tmp.path(), "algo").unwrap(); assert!(tmp.path().join("locks/algo.lock").is_file()); assert!(!tmp.path().join("sources/algo.lock").exists()); } #[test] fn resolver_url_reescribe_ssh_a_https() { assert_eq!( resolver_url_submodulo("git@github.com:BrowserWorks/l10n.git", "https://x/y.git"), "https://github.com/BrowserWorks/l10n.git" ); assert_eq!( resolver_url_submodulo("ssh://git@gitlab.com/a/b.git", "https://x/y.git"), "https://gitlab.com/a/b.git" ); // Una https ya buena no se toca. assert_eq!( resolver_url_submodulo("https://github.com/a/b.git", "https://x/y.git"), "https://github.com/a/b.git" ); } #[test] fn resolver_url_resuelve_relativas_contra_el_padre() { // `..` sube desde el DIRECTORIO que contiene al padre, como hace git. assert_eq!( resolver_url_submodulo("../l10n.git", "https://github.com/BrowserWorks/waterfox.git"), "https://github.com/BrowserWorks/l10n.git" ); assert_eq!( resolver_url_submodulo("./sub.git", "https://github.com/o/r.git"), "https://github.com/o/r.git/sub.git" ); } /// El caso que mató a waterfox: un árbol con un gitlink debe llegar COMPLETO. #[test] fn materializa_un_submodulo_real() { let raiz = tempfile::tempdir().unwrap(); let git = |args: &[&str], cwd: &Path| { let st = Command::new("git") .arg("-C") .arg(cwd) .args(args) .env("GIT_AUTHOR_NAME", "t") .env("GIT_AUTHOR_EMAIL", "t@t") .env("GIT_COMMITTER_NAME", "t") .env("GIT_COMMITTER_EMAIL", "t@t") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .unwrap(); assert!(st.success(), "git {args:?} falló"); }; // Un repo `hijo` con un fichero, y un `padre` que lo monta como submódulo. let hijo = raiz.path().join("hijo"); std::fs::create_dir_all(&hijo).unwrap(); git(&["init", "-q", "-b", "main"], &hijo); std::fs::write(hijo.join("moz.build"), "DEL SUBMODULO\n").unwrap(); git(&["add", "moz.build"], &hijo); git(&["commit", "-qm", "hijo"], &hijo); let padre = raiz.path().join("padre"); std::fs::create_dir_all(&padre).unwrap(); git(&["init", "-q", "-b", "main"], &padre); std::fs::write(padre.join("raiz.txt"), "DEL PADRE\n").unwrap(); git(&["add", "raiz.txt"], &padre); git( &[ "-c", "protocol.file.allow=always", "submodule", "add", "-q", hijo.to_str().unwrap(), "browser/locales", ], &padre, ); git(&["commit", "-qm", "padre"], &padre); let mirror = raiz.path().join("padre.git"); let st = Command::new("git") .args(["clone", "--mirror", padre.to_str().unwrap()]) .arg(&mirror) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .unwrap(); assert!(st.success()); let commit = salida_git(&["rev-parse", "main"], &mirror).unwrap(); let commit = commit.trim(); let destino = raiz.path().join("arbol"); std::fs::create_dir_all(&destino).unwrap(); let repos = raiz.path().join("repos"); std::fs::create_dir_all(&repos).unwrap(); archivar_en(&mirror, commit, &destino).unwrap(); // Sin submódulos, `git archive` deja el gitlink como un directorio VACÍO: el fichero que // el build pide no está, y el error llega recién en `configure`. assert!(!destino.join("browser/locales/moz.build").exists()); materializar_submodulos(&mirror, commit, &destino, &repos, padre.to_str().unwrap(), 0) .unwrap(); assert_eq!( std::fs::read_to_string(destino.join("browser/locales/moz.build")).unwrap(), "DEL SUBMODULO\n" ); assert_eq!( std::fs::read_to_string(destino.join("raiz.txt")).unwrap(), "DEL PADRE\n" ); } #[test] fn rotar_bases_lista_vacia_o_unica() { assert!(rotar_bases("", "abcd").is_empty()); assert!(rotar_bases(" , ,", "abcd").is_empty()); // Un solo origen: se devuelve tal cual, sin barra final. assert_eq!(rotar_bases("https://a/", "abcd"), vec!["https://a"]); } #[test] fn rotar_bases_reparte_pero_es_determinista() { let lista = "https://a, https://b ,https://c/"; // Determinista: la misma clave da siempre el mismo orden ⇒ un fallo se reproduce. assert_eq!(rotar_bases(lista, "00ff"), rotar_bases(lista, "00ff")); // Y siempre están LOS TRES: el resto de la lista queda como respaldo, no se descarta. for clave in ["00", "01", "02", "ff00"] { let r = rotar_bases(lista, clave); assert_eq!(r.len(), 3, "clave {clave}"); for b in ["https://a", "https://b", "https://c"] { assert!(r.contains(&b.to_string()), "falta {b} con clave {clave}"); } } // Reparte: claves distintas eligen primeros distintos, que es lo que ejercita todos los // orígenes en una tanda del corpus en vez de martillar siempre el primero. let primeros: std::collections::HashSet = ["00", "01", "02"] .iter() .map(|c| rotar_bases(lista, c)[0].clone()) .collect(); assert_eq!(primeros.len(), 3); } #[test] fn rotar_bases_no_panica_con_clave_corta() { // `clave[..2]` habría panicado acá; el fold sobre bytes no. assert_eq!(rotar_bases("https://a,https://b", "").len(), 2); assert_eq!(rotar_bases("https://a,https://b", "0").len(), 2); } #[test] fn vendor_cargo_deps_runs_and_writes_config() { // Crate sin deps de crates.io: `cargo vendor` sale 0 con config vacío y sin red. // Verifica el camino spawn → captura stdout → escribe `.cargo/config.toml`. let tmp = tempfile::tempdir().unwrap(); let src = tmp.path(); std::fs::write( src.join("Cargo.toml"), "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n", ) .unwrap(); std::fs::create_dir_all(src.join("src")).unwrap(); std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap(); // `--locked` exige un Cargo.lock presente; lo generamos offline (sin deps). let lock_ok = Command::new("cargo") .args(["generate-lockfile", "--offline"]) .current_dir(src) .status() .map(|s| s.success()) .unwrap_or(false); if !lock_ok { eprintln!("SKIP: cargo no disponible para generate-lockfile"); return; } vendor_cargo_deps(src, "vendor").unwrap(); assert!(src.join(".cargo/config.toml").is_file(), "debe escribir el config de vendoring"); } #[test] fn vendor_cargo_deps_without_committed_lock() { // Fuente SIN Cargo.lock (como un monorepo que lo gitignora): vendoreamos sin --locked, // generando el lock al vuelo. Para un crate depless es offline. let tmp = tempfile::tempdir().unwrap(); let src = tmp.path(); std::fs::write( src.join("Cargo.toml"), "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n", ) .unwrap(); std::fs::create_dir_all(src.join("src")).unwrap(); std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap(); assert!(!src.join("Cargo.lock").is_file(), "precondición: sin lock"); if Command::new("cargo").arg("--version").status().map(|s| !s.success()).unwrap_or(true) { eprintln!("SKIP: cargo no disponible"); return; } // No debe fallar por falta de --locked; genera el lock y escribe el config. vendor_cargo_deps(src, "vendor").unwrap(); assert!(src.join(".cargo/config.toml").is_file()); assert!(src.join("Cargo.lock").is_file(), "vendor sin --locked genera el lock"); } } fn run_git(args: &[&str], cwd: Option<&Path>) -> takana_core::Result<()> { let mut cmd = Command::new("git"); if let Some(c) = cwd { cmd.arg("-C").arg(c); } cmd.args(args); let status = cmd .stdin(Stdio::null()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .map_err(|e| takana_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?; if !status.success() { return Err(takana_core::Error::Other(anyhow::anyhow!( "git {args:?} falló (exit {:?})", status.code() ))); } Ok(()) }