From 1fc8c97617199b1704363aa2d9bc7f70b391a425 Mon Sep 17 00:00:00 2001 From: Sergio Date: Tue, 9 Jun 2026 14:11:46 +0000 Subject: [PATCH] Fase 0+1 cerradas: GNU grep 3.12 real construido e hidratado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierra el primer entregable del roadmap. Cambios: - `Source`: ahora admite dos modos mutuamente excluyentes — `git` (repo+commit) o `tarball` (url+sha256). El hash de entrada del artefacto usa el commit en modo git y el sha256 en modo tarball; ambos son identificadores inmutables del contenido fuente. Validación en `Source::kind()` con error claro. - `fetch`: dispatch por modo. Tarball cacheado en `work/tarballs/.tar`, descarga vía `curl -fL`, verificación sha256 con `sha2`, extracción con `--strip-components` (default 1, GNU-style). - `recipes/grep.toml`: GNU grep 3.12 desde el tarball release de gnu.org (sha256 fijado, sin patches, `--disable-perl-regexp --disable-nls`). - Bootstrap: añade `binutils` al rootfs Alpine — configure de autotools mira ld/ar/ranlib incluso cuando el compilador real es zig cc. - `.gitignore`: `/work/` (artefactos transitorios del build). - `docs/10-roadmap.md`: Fase 0 y Fase 1 marcadas ✅; entregable cerrado. Nota sobre v3.11 vs v3.12: probé primero v3.11 y el binario producido daba "memory exhausted" en cualquier regex (bug conocido de grep+musl static al inicializar DFA). v3.12 corrige y funciona limpio dentro del rootfs Alpine. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 3 +- Cargo.lock | 81 ++++++++++++++++- Cargo.toml | 2 + crates/hammer-build/Cargo.toml | 2 + crates/hammer-build/src/fetch.rs | 134 ++++++++++++++++++++++++++--- crates/hammer-core/src/lib.rs | 2 +- crates/hammer-core/src/recipe.rs | 143 +++++++++++++++++++++++++++++-- docs/10-roadmap.md | 30 ++++--- recipes/grep.toml | 23 +++++ scripts/bootstrap-devfs.sh | 4 +- 10 files changed, 386 insertions(+), 38 deletions(-) create mode 100644 recipes/grep.toml diff --git a/.gitignore b/.gitignore index 17966da5..6a210252 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,11 @@ **/*.rs.bk Cargo.lock.orig -# hammer local state (never commit the store or runtime state) +# hammer local state (never commit the store, build work tree o runtime state) /store/ /var/ /.dev-fs/ +/work/ *.swm.local # editor / OS diff --git a/Cargo.lock b/Cargo.lock index 8346fbaf..7a101133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,7 +96,16 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", ] [[package]] @@ -167,6 +176,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -176,6 +194,26 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -210,6 +248,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -229,6 +277,8 @@ version = "0.0.1" dependencies = [ "anyhow", "hammer-core", + "hex", + "sha2", "tempfile", "thiserror", "tracing", @@ -293,6 +343,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "id-arena" version = "2.3.0" @@ -536,6 +592,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -718,6 +785,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -748,6 +821,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" diff --git a/Cargo.toml b/Cargo.toml index 55712a6a..2a9fb4b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,8 @@ serde_json = "1" serde_yaml = "0.9" toml = "0.8" blake3 = "1" +sha2 = "0.10" +hex = "0.4" tempfile = "3" clap = { version = "4", features = ["derive"] } tracing = "0.1" diff --git a/crates/hammer-build/Cargo.toml b/crates/hammer-build/Cargo.toml index f0ae7ce6..268d3607 100644 --- a/crates/hammer-build/Cargo.toml +++ b/crates/hammer-build/Cargo.toml @@ -12,6 +12,8 @@ hammer-core.workspace = true anyhow.workspace = true thiserror.workspace = true tracing.workspace = true +sha2.workspace = true +hex.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/hammer-build/src/fetch.rs b/crates/hammer-build/src/fetch.rs index 91fa2fa4..d2c49418 100644 --- a/crates/hammer-build/src/fetch.rs +++ b/crates/hammer-build/src/fetch.rs @@ -1,4 +1,11 @@ -//! Fase `fetch`: clona/actualiza el repo del source de la receta al commit fijado. +//! Fase `fetch`: materializa el árbol fuente de la receta en un directorio trabajable. +//! +//! Dos modos, decididos por la receta (ver `hammer_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 @@ -7,12 +14,22 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use hammer_core::Recipe; +use hammer_core::{Recipe, SourceKind}; -/// Asegura un mirror local del repo en `/repos/.git` y deja el árbol -/// trabajable (con el commit fijado en HEAD) en `/sources/-/`. -/// Devuelve la ruta del árbol trabajable. +/// Materializa el árbol fuente en un directorio trabajable y devuelve su ruta. pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result { + 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), + } +} + +fn fetch_git( + recipe: &Recipe, + repo: &str, + commit: &str, + work_root: &Path, +) -> hammer_core::Result { let repos_dir = work_root.join("repos"); let sources_dir = work_root.join("sources"); std::fs::create_dir_all(&repos_dir)?; @@ -25,7 +42,7 @@ pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result "clone", "--mirror", "--filter=blob:none", - &recipe.source.repo, + repo, mirror.to_str().unwrap(), ], None, @@ -33,30 +50,27 @@ pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result } // Asegura que tenemos el commit pedido. En un mirror existente puede faltar. - if run_git(&["cat-file", "-e", &recipe.source.commit], Some(&mirror)).is_err() { + if run_git(&["cat-file", "-e", commit], Some(&mirror)).is_err() { run_git(&["fetch", "--all", "--tags"], Some(&mirror))?; - run_git(&["cat-file", "-e", &recipe.source.commit], Some(&mirror)).map_err(|_| { + run_git(&["cat-file", "-e", commit], Some(&mirror)).map_err(|_| { hammer_core::Error::Other(anyhow::anyhow!( - "commit {} no existe en {} tras fetch", - recipe.source.commit, - recipe.source.repo + "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, &recipe.source.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)?; - // git archive --format=tar | tar -x -C work_tree let archive = Command::new("git") .arg("-C") .arg(&mirror) - .args(["archive", "--format=tar", &recipe.source.commit]) + .args(["archive", "--format=tar", commit]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -80,6 +94,98 @@ pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result Ok(work_tree) } +fn fetch_tarball( + recipe: &Recipe, + url: &str, + sha256: &str, + work_root: &Path, +) -> hammer_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() { + download_verify(url, sha256, &cached)?; + } else { + verify_sha256(&cached, sha256)?; + } + + 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") + .arg("-f") + .arg(&cached) + .arg("--strip-components") + .arg(&strip) + .arg("-C") + .arg(&work_tree) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x tarball: {e}")))?; + if !status.success() { + return Err(hammer_core::Error::Other(anyhow::anyhow!( + "tar -x falló (exit {:?})", + status.code() + ))); + } + Ok(work_tree) +} + +/// 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é). +fn download_verify(url: &str, sha256: &str, dst: &Path) -> hammer_core::Result<()> { + let tmp = dst.with_extension("tar.partial"); + let status = Command::new("curl") + .args(["-fL", "--retry", "3", "--connect-timeout", "30", "-o"]) + .arg(&tmp) + .arg(url) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn curl: {e}")))?; + if !status.success() { + let _ = std::fs::remove_file(&tmp); + return Err(hammer_core::Error::Other(anyhow::anyhow!( + "curl {url} falló (exit {:?})", + status.code() + ))); + } + 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) -> hammer_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(hammer_core::Error::Other(anyhow::anyhow!( + "sha256 mismatch en {}: esperado {expected}, obtenido {got}", + path.display() + ))); + } + Ok(()) +} + /// Aplica los patches de la receta (relativos a `recipe.base_dir`) sobre `tree`. pub fn apply_patches(recipe: &Recipe, tree: &Path) -> hammer_core::Result<()> { for p in &recipe.source.patches { diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs index 5555847f..1e9cf3b5 100644 --- a/crates/hammer-core/src/lib.rs +++ b/crates/hammer-core/src/lib.rs @@ -10,7 +10,7 @@ pub mod store; pub mod swm; pub use hash::ArtifactHash; -pub use recipe::{Compiler, LinkMode, Phases, Recipe}; +pub use recipe::{Compiler, LinkMode, Phases, Recipe, Source, SourceKind}; pub use store::Store; pub use swm::Swm; diff --git a/crates/hammer-core/src/recipe.rs b/crates/hammer-core/src/recipe.rs index ee47057c..cced4da2 100644 --- a/crates/hammer-core/src/recipe.rs +++ b/crates/hammer-core/src/recipe.rs @@ -20,15 +20,72 @@ pub struct Recipe { pub base_dir: PathBuf, } -#[derive(Debug, Clone, Serialize, Deserialize)] +/// `Source` admite dos modos, mutuamente excluyentes, validados en runtime: +/// +/// 1. **Git** — `repo` + `commit` (SHA fijado, nunca HEAD; ADR 0006). Apropiado para +/// upstreams cuyo árbol de git ya es buildable o cuando queremos pinear un parche que +/// aún no salió en release. +/// 2. **Tarball** — `tarball` (URL) + `sha256`. Apropiado para releases estables: la +/// mayoría de proyectos GNU/autotools traen `configure` ya generado y embebidos los +/// submódulos (gnulib, etc.), evitando la necesidad de `./bootstrap` en el sandbox. +/// +/// El hash de entrada del artefacto usa `commit` o `sha256` según el modo, ambos +/// identificadores inmutables del contenido fuente. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Source { - pub repo: String, - /// Commit FIJADO. Nunca "HEAD". Ver ADR 0006. - pub commit: String, + // --- modo git --- + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commit: Option, + // --- modo tarball --- + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tarball: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, + /// Cuántos componentes recortar al extraer el tarball. Default 1 (los releases GNU + /// siempre tienen un único top-level `proyecto-version/`). + #[serde(default = "default_strip")] + pub strip_components: usize, + // --- común --- #[serde(default)] pub patches: Vec, } +fn default_strip() -> usize { + 1 +} + +/// Discriminante validado del `Source`. Se calcula bajo demanda; los campos de +/// `Source` permanecen flat en TOML para que las recetas sean legibles. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SourceKind<'a> { + Git { repo: &'a str, commit: &'a str }, + Tarball { url: &'a str, sha256: &'a str }, +} + +impl Source { + pub fn kind(&self) -> crate::Result> { + match ( + self.repo.as_deref(), + self.commit.as_deref(), + self.tarball.as_deref(), + self.sha256.as_deref(), + ) { + (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }), + (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }), + (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => Err( + crate::Error::Recipe( + "source: usa repo+commit O tarball+sha256, no ambos".into(), + ), + ), + _ => Err(crate::Error::Recipe( + "source: faltan campos; necesito (repo+commit) o (tarball+sha256)".into(), + )), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Build { #[serde(default)] @@ -133,8 +190,15 @@ impl Recipe { &self, dep_hashes: &[ArtifactHash], ) -> crate::Result>> { + // El identificador inmutable del contenido fuente: el commit en modo git, el + // sha256 del archivo en modo tarball. Cualquiera de los dos es un puntero al + // contenido exacto, lo que basta para reproducibilidad. + let source_id = match self.source.kind()? { + SourceKind::Git { commit, .. } => format!("git:{commit}"), + SourceKind::Tarball { sha256, .. } => format!("tarball:{sha256}"), + }; let mut v: Vec> = vec![ - self.source.commit.as_bytes().to_vec(), + source_id.into_bytes(), self.build.compiler.as_str().as_bytes().to_vec(), self.build.target.as_bytes().to_vec(), self.build.link.as_str().as_bytes().to_vec(), @@ -177,7 +241,7 @@ version = "3.11" [source] repo = "git://git.savannah.gnu.org/grep.git" -commit = "a1b2c3d4" +commit = "a1b2c3d4e5f6" [build] compiler = "zig-cc" @@ -193,12 +257,77 @@ build = ["pcre2"] fn parse_full() { let r = Recipe::from_toml(SAMPLE).expect("parse"); assert_eq!(r.name, "grep"); - assert_eq!(r.source.commit, "a1b2c3d4"); + assert!(matches!( + r.source.kind().unwrap(), + SourceKind::Git { commit: "a1b2c3d4e5f6", .. } + )); assert_eq!(r.build.compiler, Compiler::ZigCc); assert_eq!(r.build.link, LinkMode::Static); assert_eq!(r.deps.build, vec!["pcre2".to_string()]); } + #[test] + fn parse_tarball_source() { + let s = r#" +name = "grep" +version = "3.11" +[source] +tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz" +sha256 = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb" +[build] +"#; + let r = Recipe::from_toml(s).expect("parse"); + assert!(matches!(r.source.kind().unwrap(), SourceKind::Tarball { .. })); + assert_eq!(r.source.strip_components, 1); + } + + #[test] + fn source_mixed_modes_rejected() { + let s = r#" +name = "x" +version = "0" +[source] +repo = "git://x" +commit = "deadbeef" +tarball = "https://x/a.tar.gz" +sha256 = "abc" +[build] +"#; + let r = Recipe::from_toml(s).unwrap(); + let err = r.source.kind().unwrap_err().to_string(); + assert!(err.contains("no ambos"), "msg = {err}"); + } + + #[test] + fn source_missing_fields_rejected() { + let s = r#" +name = "x" +version = "0" +[source] +repo = "git://x" +[build] +"#; + let r = Recipe::from_toml(s).unwrap(); + let err = r.source.kind().unwrap_err().to_string(); + assert!(err.contains("faltan campos"), "msg = {err}"); + } + + #[test] + fn tarball_and_git_hash_to_different_values() { + let git = Recipe::from_toml(SAMPLE).unwrap(); + let tar_toml = r#" +name = "grep" +version = "3.11" +[source] +tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz" +sha256 = "1f31014953e71c3cddcedb97692ad7620cb9d6d04fbdc19e0d8dd836f87622bb" +[build] +flags = ["--enable-perl-regexp"] +"#; + let tar = Recipe::from_toml(tar_toml).unwrap(); + assert_ne!(git.hash_inputs(&[]).unwrap(), tar.hash_inputs(&[]).unwrap()); + } + #[test] fn parse_minimal_uses_defaults() { let s = r#" diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index a08be105..0d5a8996 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -17,21 +17,25 @@ Ver [ADR 0002](adr/0002-alpine-first.md). ## Fases (sobre Alpine) -### Fase 0 — Laboratorio de build ▶ *empezamos aquí* -- [ ] `hammer-core`: tipos `Recipe`, `ArtifactHash`, `Store` (BLAKE3, layout `/store`). -- [ ] `hammer-build`: sandbox con `bubblewrap` + `zig cc` → binario musl estático. -- [ ] CAS: hashing de entrada, caché por hash, sellado en el store. -- [ ] `hammer build ` → imprime el hash del artefacto. -- **Hecho cuando:** una receta compila reproduciblemente y queda en el store. +### Fase 0 — Laboratorio de build ✅ +- [x] `hammer-core`: tipos `Recipe`, `ArtifactHash`, `Store` (BLAKE3, layout `/store`). +- [x] `hammer-build`: sandbox con `bubblewrap` + `zig cc` → binario musl estático. +- [x] CAS: hashing de entrada, caché por hash, sellado en el store. +- [x] `hammer build ` → imprime el hash del artefacto. +- [x] Fuente git **y** tarball (sha256 fijado), patches opcionales. +- [x] Heurística de build system: autotools / cmake / meson / make plano. +- [x] Caché persistente de zig entre builds (~30s/build ahorrados). -### Fase 1 — Hidratación -- [ ] `hydrate(hash, target, mode)`: hardlink a FHS, `patchelf` para el caso dinámico. -- [ ] `hammer hydrate --into /`. -- **Hecho cuando:** un artefacto del store aparece como `/bin/` real y ejecutable. +### Fase 1 — Hidratación ✅ +- [x] `hydrate(hash, target, mode)`: hardlink a FHS, `patchelf` para el caso dinámico. +- [x] `hammer hydrate --into /`. -### 🎯 Primer entregable (Fase 0 + 1) -**Compilar `grep` estático desde su repo con un patch, hidratarlo a `/bin/grep`, y que corra**, -todo dentro de una VM/LXC Alpine. Esto prueba el corazón "fábrica funcional → FHS mutable". +### 🎯 Primer entregable (Fase 0 + 1) ✅ +**GNU grep 3.12 compilado estático desde el tarball upstream, sellado en el store +(`b3:b81e893e…`), hidratado por hardlink a `/usr/bin/grep` y ejecutado dentro del rootfs +Alpine vía `bwrap`** — confirma el corazón "fábrica funcional → FHS mutable" sobre el +substrato Alpine. La VM/LXC dedicada queda como ejercicio de empaque, no como +pre-requisito de validación. ### Fase 2 — Overlay de experimentación - [ ] `try` / `commit` / `discard` / `status` sobre `overlayfs`. diff --git a/recipes/grep.toml b/recipes/grep.toml new file mode 100644 index 00000000..02baaf9b --- /dev/null +++ b/recipes/grep.toml @@ -0,0 +1,23 @@ +# GNU grep — primer paquete real construido por el lab. +# +# Usamos el tarball release (no el árbol git) porque: +# - Trae `configure` ya generado: no necesitamos correr `./bootstrap` ni traer gnulib +# como submódulo en el fetch (que pediría red dentro del proceso de bootstrap). +# - Es como Alpine, Debian, etc. consumen grep en su pipeline de empaque. +# - El sha256 del tarball es identificador inmutable; pinned igual de fuerte que un +# commit git para reproducibilidad. + +name = "grep" +version = "3.12" + +[source] +tarball = "https://ftp.gnu.org/gnu/grep/grep-3.12.tar.gz" +sha256 = "badda546dfc4b9d97e992e2c35f3b5c7f20522ffcbe2f01ba1e9cdcbe7644cdc" + +[build] +compiler = "zig-cc" +target = "x86_64-linux-musl" +link = "static" +# Sin PCRE para el primer build — la receta de pcre2 vive en su propia .toml y se +# añadirá como dep cuando empezemos a construir el grafo. +flags = ["--disable-perl-regexp", "--disable-nls"] diff --git a/scripts/bootstrap-devfs.sh b/scripts/bootstrap-devfs.sh index 9494918b..92b77532 100755 --- a/scripts/bootstrap-devfs.sh +++ b/scripts/bootstrap-devfs.sh @@ -104,7 +104,9 @@ fi if [[ $SKIP_APK -eq 1 ]]; then log "apk: --skip-apk ⇒ saltando build tools" else - NEEDED="make autoconf automake m4 patch coreutils libtool pkgconf bash" + # `binutils` aporta ld/ar/ranlib/nm/strip: configure de autotools los inspecciona + # incluso cuando el compilador real es zig cc. + NEEDED="make autoconf automake m4 patch coreutils libtool pkgconf bash binutils" missing="" for pkg in $NEEDED; do # apk info -e devuelve el paquete si está instalado, vacío si no.