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…
⎇ b580c5bfc5b134c64a571136441f585c025deaaf
crates/hammer-build/src/fetch.rs
Historia
Quién tocó qué
crudo
fetch: cargo_vendor_dir per-receta — no pisar el vendor/ propio del proyecto
1//! Fase `fetch`: materializa el árbol fuente de la receta en un directorio trabajable.
2//!
3//! Dos modos, decididos por la receta (ver `hammer_core::SourceKind`):
4//!
5//! * **Git** — mirror local en `<work_root>/repos/<name>.git` + `git archive` del commit
6//! fijado al árbol trabajable. El mirror se reusa entre builds; el árbol es descartable.
7//! * **Tarball** — descarga con `curl`, verificación de sha256, extracción con `tar`. Se
8//! cachean los archivos descargados en `<work_root>/tarballs/`.
9//!
10//! La red sólo se permite aquí, FUERA del sandbox de build. Después del fetch, el árbol del
11//! source se copia a un dir descartable y se aplican los patches; el sandbox sólo ve esa
12//! copia (sin red).
13
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16
17use hammer_core::{Recipe, SourceKind};
18
19/// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta.
20pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result<PathBuf> {
21 match recipe.source.kind()? {
22 SourceKind::Git { repo, commit } => fetch_git(recipe, repo, commit, work_root),
23 SourceKind::Tarball { url, sha256 } => fetch_tarball(recipe, url, sha256, work_root),
24 }
25}
26
27fn fetch_git(
28 recipe: &Recipe,
29 repo: &str,
30 commit: &str,
31 work_root: &Path,
32) -> hammer_core::Result<PathBuf> {
33 let repos_dir = work_root.join("repos");
34 let sources_dir = work_root.join("sources");
35 std::fs::create_dir_all(&repos_dir)?;
36 std::fs::create_dir_all(&sources_dir)?;
37
38 let mirror = repos_dir.join(format!("{}.git", recipe.name));
39 if !mirror.is_dir() {
40 run_git(
41 &[
42 "clone",
43 "--mirror",
44 "--filter=blob:none",
45 repo,
46 mirror.to_str().unwrap(),
47 ],
48 None,
49 )?;
50 }
51
52 // Asegura que tenemos el commit pedido. En un mirror existente puede faltar.
53 if run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() {
54 run_git(&["fetch", "--all", "--tags"], Some(&mirror))?;
55 run_git(&["cat-file", "-e", commit], Some(&mirror)).map_err(|_| {
56 hammer_core::Error::Other(anyhow::anyhow!(
57 "commit {commit} no existe en {repo} tras fetch"
58 ))
59 })?;
60 }
61
62 // Materializa un árbol trabajable mediante `git archive` → tar → extract.
63 // Esto evita el coste de un worktree completo y deja sólo los archivos del commit.
64 let work_tree = sources_dir.join(format!("{}-{}", recipe.name, commit));
65 if work_tree.is_dir() {
66 std::fs::remove_dir_all(&work_tree)?;
67 }
68 std::fs::create_dir_all(&work_tree)?;
69
70 let archive = Command::new("git")
71 .arg("-C")
72 .arg(&mirror)
73 .args(["archive", "--format=tar", commit])
74 .stdout(Stdio::piped())
75 .stderr(Stdio::piped())
76 .spawn()
77 .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git archive: {e}")))?;
78
79 let tar_status = Command::new("tar")
80 .args(["-x", "-C"])
81 .arg(&work_tree)
82 .stdin(archive.stdout.unwrap())
83 .stdout(Stdio::inherit())
84 .stderr(Stdio::inherit())
85 .status()
86 .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x: {e}")))?;
87
88 if !tar_status.success() {
89 return Err(hammer_core::Error::Other(anyhow::anyhow!(
90 "git archive | tar -x falló (tar exit {:?})",
91 tar_status.code()
92 )));
93 }
94 Ok(work_tree)
95}
96
97fn fetch_tarball(
98 recipe: &Recipe,
99 url: &str,
100 sha256: &str,
101 work_root: &Path,
102) -> hammer_core::Result<PathBuf> {
103 let tarballs_dir = work_root.join("tarballs");
104 let sources_dir = work_root.join("sources");
105 std::fs::create_dir_all(&tarballs_dir)?;
106 std::fs::create_dir_all(&sources_dir)?;
107
108 // Nombre por hash: dos URLs distintas con el mismo contenido se cachean una vez, y
109 // cambiar la URL sin cambiar el sha (mirror) no invalida la caché.
110 let cached = tarballs_dir.join(format!("{sha256}.tar"));
111 if !cached.is_file() {
112 download_verify(url, sha256, &cached)?;
113 } else {
114 verify_sha256(&cached, sha256)?;
115 }
116
117 let work_tree = sources_dir.join(format!("{}-{}", recipe.name, &sha256[..16]));
118 if work_tree.is_dir() {
119 std::fs::remove_dir_all(&work_tree)?;
120 }
121 std::fs::create_dir_all(&work_tree)?;
122
123 // `tar` autodetecta el compresor por la cabecera; el sufijo .tar del nombre es solo
124 // identificador interno (no afecta a la detección).
125 let strip = recipe.source.strip_components.to_string();
126 let status = Command::new("tar")
127 .arg("-x")
128 // `--no-same-owner`: extrae como el usuario actual (no preserva el uid del tarball). Si el
129 // fetch corre como root (granja), `tar` por defecto preservaría el owner original → ficheros
130 // de un uid que el sandbox (userns) no puede chmod/escribir → configure de autotools falla
131 // ("Permission denied" creando Makefile, "Operation not permitted" en chmod). Inocuo para el
132 // of_tree (es el árbol de FUENTES, no el output). Destraba libs C como oniguruma.
133 .arg("--no-same-owner")
134 .arg("-f")
135 .arg(&cached)
136 .arg("--strip-components")
137 .arg(&strip)
138 .arg("-C")
139 .arg(&work_tree)
140 .stdout(Stdio::inherit())
141 .stderr(Stdio::inherit())
142 .status()
143 .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x tarball: {e}")))?;
144 if !status.success() {
145 return Err(hammer_core::Error::Other(anyhow::anyhow!(
146 "tar -x falló (exit {:?})",
147 status.code()
148 )));
149 }
150 Ok(work_tree)
151}
152
153/// Descarga `url` a `dst` y verifica el sha256. Si la verificación falla, borra el archivo
154/// para que la próxima corrida lo vuelva a intentar (no envenenamos la caché).
155fn download_verify(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> {
156 let tmp = dst.with_extension("tar.partial");
157 let status = Command::new("curl")
158 .args(["-fL", "--retry", "3", "--connect-timeout", "30", "-o"])
159 .arg(&tmp)
160 .arg(url)
161 .stdout(Stdio::inherit())
162 .stderr(Stdio::inherit())
163 .status()
164 .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?;
165 if !status.success() {
166 let _ = std::fs::remove_file(&tmp);
167 return Err(hammer_core::Error::Other(anyhow::anyhow!(
168 "curl {url} falló (exit {:?})",
169 status.code()
170 )));
171 }
172 if let Err(e) = verify_sha256(&tmp, sha256) {
173 let _ = std::fs::remove_file(&tmp);
174 return Err(e);
175 }
176 std::fs::rename(&tmp, dst)?;
177 Ok(())
178}
179
180fn verify_sha256(path: &Path, expected: &str) -> hammer_core::Result<()> {
181 use sha2::{Digest, Sha256};
182 let mut f = std::fs::File::open(path)?;
183 let mut hasher = Sha256::new();
184 std::io::copy(&mut f, &mut hasher)?;
185 let got = hex::encode(hasher.finalize());
186 if !got.eq_ignore_ascii_case(expected) {
187 return Err(hammer_core::Error::Other(anyhow::anyhow!(
188 "sha256 mismatch en {}: esperado {expected}, obtenido {got}",
189 path.display()
190 )));
191 }
192 Ok(())
193}
194
195/// Vendoriza las dependencias de un proyecto Cargo en `<src>/vendor` y escribe
196/// `<src>/.cargo/config.toml` para que el build dentro del sandbox resuelva todo `--offline`.
197///
198/// La red se usa AQUÍ (fuera del sandbox, como el resto del fetch).
199///
200/// **`--locked` condicional:** si la fuente trae un `Cargo.lock` committeado, se vendorea con
201/// `--locked` y el build es reproducible (mismo commit ⇒ mismo lock ⇒ mismas deps; el lock ya está
202/// dentro del commit que hashea la receta). Si la fuente **no** lo trae (p.ej. un monorepo que lo
203/// gitignora, como tawasuyu), se genera el lock al vuelo **sin** `--locked` y se emite un `warn!`:
204/// el build queda internamente consistente pero **no pineado en el tiempo** (las deps pueden
205/// derivar entre corridas). El end-state reproducible es committear el `Cargo.lock` en la fuente
206/// (plan C.2 #5). Ver `docs/runbooks/stage1-vm-boot.md` §8.
207///
208/// Un crate sin deps de crates.io produce un config vacío y ningún `vendor/`: inocuo.
209pub fn vendor_cargo_deps(src: &Path, vendor_dir: &str) -> hammer_core::Result<()> {
210 let locked = src.join("Cargo.lock").is_file();
211 let mut args: Vec<&str> = vec!["vendor"];
212 if locked {
213 args.push("--locked");
214 } else {
215 tracing::warn!(
216 src = %src.display(),
217 "sin Cargo.lock committeado: vendoreo sin --locked; el build NO es reproducible en el \
218 tiempo (deps no pineadas). Committear el lock en la fuente para fijarlo (plan C.2 #5)."
219 );
220 }
221 // Directorio de salida (default `vendor`; override per-receta vía `[source] cargo_vendor_dir`
222 // para no pisar un `vendor/` propio del proyecto). `cargo vendor` emite el `[source.*]` a stdout
223 // apuntando a ESTE dir, así que el `.cargo/config.toml` que persistimos abajo resuelve solo.
224 args.push(vendor_dir);
225 let output = Command::new("cargo")
226 .current_dir(src)
227 .args(&args)
228 .stderr(Stdio::inherit())
229 .output()
230 .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn cargo vendor: {e}")))?;
231 if !output.status.success() {
232 return Err(hammer_core::Error::Other(anyhow::anyhow!(
233 "cargo vendor falló (exit {:?})",
234 output.status.code()
235 )));
236 }
237 // `cargo vendor` emite por stdout el fragmento `[source.*]` a instalar; lo persistimos en el
238 // árbol del source para que `cargo build --offline` lo lea (ruta relativa `vendor`, que
239 // resuelve igual dentro del sandbox donde el source se monta en /src).
240 let cargo_dir = src.join(".cargo");
241 std::fs::create_dir_all(&cargo_dir)?;
242 std::fs::write(cargo_dir.join("config.toml"), &output.stdout)?;
243 Ok(())
244}
245
246/// Vendoriza los módulos de un proyecto Go en `<src>/vendor` para que `go build -mod=vendor`
247/// resuelva todo offline dentro del sandbox. La red se usa AQUÍ (fuera del sandbox, como
248/// `vendor_cargo_deps`): `go mod vendor` baja los módulos de `go.mod`/`go.sum` al árbol.
249///
250/// **Reproducible por `go.sum`:** a diferencia de Cargo (lock opcional), Go exige `go.sum`
251/// committeado con el hash de cada módulo; el mismo commit ⇒ el mismo `go.sum` ⇒ el mismo
252/// `vendor/` (ya hasheado dentro del commit que fija la receta). `GOTOOLCHAIN=local` impide que
253/// `go.mod` fuerce la auto-descarga de otra versión del toolchain (el del lab manda y queda fijo);
254/// `GOFLAGS=-mod=mod` permite poblar el `vendor/` aunque el árbol no lo traiga.
255///
256/// Un proyecto sin deps externas produce un `vendor/` vacío (o ninguno): inocuo.
257pub fn vendor_go_deps(src: &Path) -> hammer_core::Result<()> {
258 // Proyecto WORKSPACE (multimódulo): si el árbol trae `go.work`, `go mod vendor` con GOWORK=off
259 // vendorea SÓLO el módulo raíz → el build (que ve todo el workspace) se queja de "inconsistent
260 // vendoring". `go work vendor` (go 1.22+) produce un `vendor/` consistente para TODO el workspace.
261 // Destraba la clase go.work (go-mockery, k3d, git-town, csvtk…). Sin go.work, el camino clásico
262 // `go mod vendor` con GOWORK=off (ignora cualquier go.work espurio y vendorea el raíz).
263 let has_work = src.join("go.work").is_file();
264 let (verb, label): ([&str; 2], &str) = if has_work {
265 (["work", "vendor"], "go work vendor")
266 } else {
267 (["mod", "vendor"], "go mod vendor")
268 };
269 let mut cmd = Command::new("go");
270 cmd.current_dir(src)
271 .args(verb)
272 .env("GOFLAGS", "-mod=mod")
273 .env("GOTOOLCHAIN", "local");
274 if !has_work {
275 cmd.env("GOWORK", "off");
276 }
277 let output = cmd
278 .stdout(Stdio::inherit())
279 .stderr(Stdio::inherit())
280 .output()
281 .map_err(|e| {
282 hammer_core::Error::Other(anyhow::anyhow!(
283 "spawn {label}: {e} (¿está `go` en el PATH del host? lo provee el lab, \
284 cf. scripts/bootstrap-devfs.sh / vps-setup.sh)"
285 ))
286 })?;
287 if !output.status.success() {
288 return Err(hammer_core::Error::Other(anyhow::anyhow!(
289 "{label} falló (exit {:?})",
290 output.status.code()
291 )));
292 }
293 Ok(())
294}
295
296/// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`.
297pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> {
298 for p in &recipe.source.patches {
299 let patch_path = recipe.base_dir.join(p);
300 let bytes = std::fs::read(&patch_path).map_err(|e| {
301 hammer_core::Error::Recipe(format!(
302 "no pude leer patch {}: {e}",
303 patch_path.display()
304 ))
305 })?;
306 let mut child = Command::new("patch")
307 .args(["-p1", "--batch", "--silent"])
308 .current_dir(tree)
309 .stdin(Stdio::piped())
310 .stdout(Stdio::inherit())
311 .stderr(Stdio::inherit())
312 .spawn()
313 .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn patch: {e}")))?;
314 if let Some(mut stdin) = child.stdin.take() {
315 use std::io::Write;
316 stdin
317 .write_all(&bytes)
318 .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("write patch: {e}")))?;
319 }
320 let status = child
321 .wait()
322 .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait patch: {e}")))?;
323 if !status.success() {
324 return Err(hammer_core::Error::Other(anyhow::anyhow!(
325 "patch {} falló (exit {:?})",
326 patch_path.display(),
327 status.code()
328 )));
329 }
330 }
331 Ok(())
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn vendor_cargo_deps_runs_and_writes_config() {
340 // Crate sin deps de crates.io: `cargo vendor` sale 0 con config vacío y sin red.
341 // Verifica el camino spawn → captura stdout → escribe `.cargo/config.toml`.
342 let tmp = tempfile::tempdir().unwrap();
343 let src = tmp.path();
344 std::fs::write(
345 src.join("Cargo.toml"),
346 "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
347 )
348 .unwrap();
349 std::fs::create_dir_all(src.join("src")).unwrap();
350 std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
351
352 // `--locked` exige un Cargo.lock presente; lo generamos offline (sin deps).
353 let lock_ok = Command::new("cargo")
354 .args(["generate-lockfile", "--offline"])
355 .current_dir(src)
356 .status()
357 .map(|s| s.success())
358 .unwrap_or(false);
359 if !lock_ok {
360 eprintln!("SKIP: cargo no disponible para generate-lockfile");
361 return;
362 }
363
364 vendor_cargo_deps(src, "vendor").unwrap();
365 assert!(src.join(".cargo/config.toml").is_file(), "debe escribir el config de vendoring");
366 }
367
368 #[test]
369 fn vendor_cargo_deps_without_committed_lock() {
370 // Fuente SIN Cargo.lock (como un monorepo que lo gitignora): vendoreamos sin --locked,
371 // generando el lock al vuelo. Para un crate depless es offline.
372 let tmp = tempfile::tempdir().unwrap();
373 let src = tmp.path();
374 std::fs::write(
375 src.join("Cargo.toml"),
376 "[package]\nname = \"vt\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
377 )
378 .unwrap();
379 std::fs::create_dir_all(src.join("src")).unwrap();
380 std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
381 assert!(!src.join("Cargo.lock").is_file(), "precondición: sin lock");
382
383 if Command::new("cargo").arg("--version").status().map(|s| !s.success()).unwrap_or(true) {
384 eprintln!("SKIP: cargo no disponible");
385 return;
386 }
387 // No debe fallar por falta de --locked; genera el lock y escribe el config.
388 vendor_cargo_deps(src, "vendor").unwrap();
389 assert!(src.join(".cargo/config.toml").is_file());
390 assert!(src.join("Cargo.lock").is_file(), "vendor sin --locked genera el lock");
391 }
392}
393
394fn run_git(args: &[&str], cwd: Option<&Path>) -> hammer_core::Result<()> {
395 let mut cmd = Command::new("git");
396 if let Some(c) = cwd {
397 cmd.arg("-C").arg(c);
398 }
399 cmd.args(args);
400 let status = cmd
401 .stdin(Stdio::null())
402 .stdout(Stdio::inherit())
403 .stderr(Stdio::inherit())
404 .status()
405 .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?;
406 if !status.success() {
407 return Err(hammer_core::Error::Other(anyhow::anyhow!(
408 "git {args:?} falló (exit {:?})",
409 status.code()
410 )));
411 }
412 Ok(())
413}