diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index 2715a76c..18a605f9 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -595,7 +595,16 @@ fn recipes_digest(dir: &Path) -> Result { /// Copia recursiva `src`→`dst` preferendo **hardlink** (mismo filesystem, sin coste de bytes) y /// cayendo a copia cuando cruza filesystems. Recrea symlinks tal cual. No hace `chmod`: el builder /// no se sella, así que los hardlinks no deben tocar permisos del origen (p. ej. el `.dev-fs`). -fn link_or_copy_tree(src: &Path, dst: &Path) -> Result<()> { +/// +/// `skip` es un path a no descender: imprescindible cuando `dst` vive **dentro** de `src` (p. ej. +/// `--work-cache work --out work/builder-rootfs`), donde copiar `src` ciegamente se tragaría el +/// propio destino en una recursión infinita. Se compara por path canónico. +fn link_or_copy_tree(src: &Path, dst: &Path, skip: Option<&Path>) -> Result<()> { + if let Some(skip) = skip { + if src.canonicalize().ok().as_deref() == Some(skip) { + return Ok(()); + } + } let meta = std::fs::symlink_metadata(src)?; let ft = meta.file_type(); if ft.is_symlink() { @@ -606,7 +615,7 @@ fn link_or_copy_tree(src: &Path, dst: &Path) -> Result<()> { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; - link_or_copy_tree(&entry.path(), &dst.join(entry.file_name()))?; + link_or_copy_tree(&entry.path(), &dst.join(entry.file_name()), skip)?; } } else if ft.is_file() && std::fs::hard_link(src, dst).is_err() { std::fs::copy(src, dst)?; @@ -641,7 +650,7 @@ fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result spec.toolchain_src.display() ))); } - link_or_copy_tree(&spec.toolchain_src, &staging.join("toolchain"))?; + link_or_copy_tree(&spec.toolchain_src, &staging.join("toolchain"), None)?; // 4) Semilla → /store/-seed-, tal cual está sellada afuera, para que el `hammer` de // adentro resuelva el toolchain por hash sin re-ingerirla. @@ -657,6 +666,7 @@ fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result link_or_copy_tree( &seed_dir, &staging.join("store").join(spec.seed_hash.store_dir_name(&seed_name)), + None, )?; // 5) Binario hammer → /usr/bin/hammer (+x). @@ -675,10 +685,17 @@ fn assemble_builder(spec: &BuilderSpec, store: &Store, staging: &Path) -> Result } } - // 7) Caché de fuentes → /work (opcional, para rebuild offline). + // 7) Caché de fetch → /work (opcional, para rebuild offline). Sólo `repos/` (mirrors git) y + // `tarballs/` (descargas verificadas): de ahí `fetch` rematerializa los árboles fuente. NO se + // copia `sources/` (los árboles ya materializados) — es regenerable y pesado (varios GB). El + // `skip = staging` es defensa extra contra `--work-cache work --out work/builder-rootfs`. if let Some(cache) = &spec.work_cache { - if cache.is_dir() { - link_or_copy_tree(cache, &staging.join("work"))?; + let skip = staging.canonicalize().ok(); + for sub in ["repos", "tarballs"] { + let src = cache.join(sub); + if src.is_dir() { + link_or_copy_tree(&src, &staging.join("work").join(sub), skip.as_deref())?; + } } } @@ -1300,6 +1317,38 @@ mod tests { assert!(env.contains("REF_CONTENT=\n"), "sin referencia ⇒ REF_CONTENT vacío: {env:?}"); } + #[test] + fn builder_rootfs_with_work_cache_containing_out_dir_terminates() { + // Regresión: --work-cache work --out work/builder-rootfs. Copiar la caché no debe tragarse + // el propio destino (recursión infinita que llena el disco). El skip lo corta. + let tmp = tempfile::tempdir().unwrap(); + let (store, mut spec) = builder_fixture(tmp.path()); + + // Caché de fetch con un mirror sintético + un árbol sources/ regenerable (no debe viajar). + let work = tmp.path().join("work"); + std::fs::create_dir_all(work.join("repos/musl.git")).unwrap(); + std::fs::write(work.join("repos/musl.git/HEAD"), b"ref: refs/heads/master\n").unwrap(); + std::fs::create_dir_all(work.join("sources/musl-abc")).unwrap(); + std::fs::write(work.join("sources/musl-abc/configure"), b"#!/bin/sh\n").unwrap(); + spec.work_cache = Some(work.clone()); + + // out_dir vive DENTRO de la caché: el caso que disparaba la recursión. + let out = work.join("builder-rootfs"); + let report = builder_rootfs(&spec, &store, &out).unwrap(); + assert!(report.builder_hash.as_str().starts_with("b3:")); + + // Sólo viajan los mirrors; el destino NO se copió en sí mismo; sources/ NO se embebe. + assert!(out.join("work/repos/musl.git/HEAD").is_file(), "los mirrors git se embeben"); + assert!( + !out.join("work/builder-rootfs").exists(), + "el out_dir no debe haberse copiado en su propio /work (recursión)", + ); + assert!( + !out.join("work/sources").exists(), + "sources/ es regenerable y pesado: no debe viajar en el builder", + ); + } + #[test] fn builder_rootfs_errors_when_stage1_unsealed() { let tmp = tempfile::tempdir().unwrap(); diff --git a/docs/runbooks/stage1-vm-boot.md b/docs/runbooks/stage1-vm-boot.md index 40740ada..38be05cb 100644 --- a/docs/runbooks/stage1-vm-boot.md +++ b/docs/runbooks/stage1-vm-boot.md @@ -292,8 +292,10 @@ hammer --store store bootstrap builder \ --out work/builder-rootfs ``` -`--work-cache work` embebe los mirrors git / tarballs ya fetcheados en `/work` ⇒ rebuild **offline y -determinista** en la VM (sin él, el rebuild fetchea por red; la seed card trae `networking: full`). +`--work-cache work` embebe sólo `work/repos` (mirrors git) y `work/tarballs` (descargas verificadas) +en `/work` ⇒ rebuild **offline y determinista** en la VM (de ahí `fetch` rematerializa los árboles +fuente). **No** copia `work/sources` (los árboles ya materializados): es regenerable y pesa varios GB. +Sin `--work-cache`, el rebuild fetchea por red (la seed card trae `networking: full`). **3. Empaquetar como initramfs y bootear** (igual que §4/§7, `-cpu Broadwell` por AVX):