Fase 0+1: build sandbox real (bwrap + zig cc) e hidratación por hardlinks
- hammer-core: Recipe::from_toml/load_from_path reales; Phases override;
hashing por *contenido* de patches; Store::seal con rename atómico + chmod
r/o recursivo; Store::find_by_hash por prefix.
- hammer-build: fetch (git mirror + archive al commit fijado), Sandbox::run
sobre bwrap con rootfs Alpine como tmp-overlay y zig cc inyectado,
orquestador build con caché por hash, hydrate con hardlinks atómicos
(link tmp + rename) que pisa el FHS sin tocar inodes del store.
- BuildConfig leído de HAMMER_{ROOTFS,ZIG,WORK} o defaults relativos al store.
- CLI: hammer build / hammer hydrate funcionales; logs a stderr para que
stdout sea pipeable (el hash y nada más).
- 20 unit + 1 integration test end-to-end (hello.c estático compilado bajo
bwrap, sellado, hidratado, ejecutado en sandbox limpio).
Cierra el primer entregable del roadmap salvo "X = grep" (falta heurística
autotools/cmake en resolve_phases).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7b362e9d6a6882be93411890d6d2fabb753c5eff · by Sergio · 2026-06-07T19:27:26Z (4 months ago) · parent 8bf162304 · browse the tree at this commit
Semantic delta · 67
crates/hammer-build/src/config.rs
crates/hammer-build/src/fetch.rs
crates/hammer-build/src/hydrate.rs
crates/hammer-build/src/lib.rs
crates/hammer-build/src/sandbox.rs
crates/hammer-build/tests/end_to_end_hello.rs
crates/hammer-cli/src/main.rs
signature changed enum Cmd body changed fn main
crates/hammer-core/src/recipe.rs
crates/hammer-core/src/store.rs
crates/hammerd/src/main.rs
diff --git a/.gitignore b/.gitignore
index 68d4f536..17966da5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ Cargo.lock.orig
# hammer local state (never commit the store or runtime state)
/store/
/var/
+/.dev-fs/
*.swm.local
# editor / OS
diff --git a/Cargo.lock b/Cargo.lock
index cab0b2bc..8346fbaf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -79,6 +79,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
[[package]]
name = "blake3"
version = "1.8.5"
@@ -176,20 +182,57 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
[[package]]
name = "hammer-build"
version = "0.0.1"
dependencies = [
"anyhow",
"hammer-core",
+ "tempfile",
"thiserror",
"tracing",
+ "tracing-subscriber",
]
[[package]]
@@ -213,7 +256,9 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
+ "tempfile",
"thiserror",
+ "toml",
]
[[package]]
@@ -227,6 +272,15 @@ dependencies = [
"tracing-subscriber",
]
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -239,6 +293,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -246,7 +306,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
- "hashbrown",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
]
[[package]]
@@ -267,12 +329,24 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
[[package]]
name = "log"
version = "0.4.32"
@@ -321,6 +395,16 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -339,6 +423,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "regex-automata"
version = "0.4.14"
@@ -356,12 +446,31 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
[[package]]
name = "serde"
version = "1.0.228"
@@ -405,6 +514,15 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
@@ -456,6 +574,19 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -485,6 +616,47 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
[[package]]
name = "tracing"
version = "0.1.44"
@@ -552,6 +724,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
@@ -570,6 +748,58 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -585,6 +815,109 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
[[package]]
name = "zmij"
version = "1.0.21"
diff --git a/Cargo.toml b/Cargo.toml
index 3df1b7a3..55712a6a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,7 +24,9 @@ thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
+toml = "0.8"
blake3 = "1"
+tempfile = "3"
clap = { version = "4", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
diff --git a/crates/hammer-build/Cargo.toml b/crates/hammer-build/Cargo.toml
index 7e87058e..f0ae7ce6 100644
--- a/crates/hammer-build/Cargo.toml
+++ b/crates/hammer-build/Cargo.toml
@@ -12,3 +12,7 @@ hammer-core.workspace = true
anyhow.workspace = true
thiserror.workspace = true
tracing.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
+tracing-subscriber = { workspace = true }
diff --git a/crates/hammer-build/src/config.rs b/crates/hammer-build/src/config.rs
new file mode 100644
index 00000000..17e25751
--- /dev/null
+++ b/crates/hammer-build/src/config.rs
@@ -0,0 +1,51 @@
+//! Configuración del lab: paths del rootfs base, del compilador inyectado y del work dir.
+//!
+//! La idea es que `BuildConfig` se construye una vez (en la CLI desde env vars + defaults) y
+//! se pasa explícitamente a `build`, de modo que los tests pueden montar configuraciones
+//! aisladas sin tocar variables de entorno globales.
+
+use std::path::{Path, PathBuf};
+
+#[derive(Debug, Clone)]
+pub struct BuildConfig {
+ /// Rootfs base read-only del sandbox (Alpine para la fase actual del roadmap).
+ pub rootfs: PathBuf,
+ /// Directorio con el binario `zig` y su `lib/`.
+ pub zig_dir: PathBuf,
+ /// Raíz para directorios de trabajo (repos clonados, copias del source para
+ /// patch+compile, y `out` por build). Debe estar en el mismo filesystem que el store
+ /// para que el `seal` final sea un rename atómico.
+ pub work_root: PathBuf,
+}
+
+impl BuildConfig {
+ /// Defaults relativos al store: `.dev-fs` y `work/` como hermanos del store.
+ /// `store_root = /…/store` → rootfs = `/…/.dev-fs/alpine`, zig = `/…/.dev-fs/tools/zig`,
+ /// work = `/…/work`.
+ pub fn defaults_for_store(store_root: &Path) -> Self {
+ let project_root = store_root
+ .parent()
+ .unwrap_or_else(|| Path::new("."))
+ .to_path_buf();
+ Self {
+ rootfs: project_root.join(".dev-fs/alpine"),
+ zig_dir: project_root.join(".dev-fs/tools/zig"),
+ work_root: project_root.join("work"),
+ }
+ }
+
+ /// Lee overrides desde entorno (`HAMMER_ROOTFS`, `HAMMER_ZIG`, `HAMMER_WORK`).
+ pub fn from_env_or_defaults(store_root: &Path) -> Self {
+ let mut cfg = Self::defaults_for_store(store_root);
+ if let Ok(v) = std::env::var("HAMMER_ROOTFS") {
+ cfg.rootfs = PathBuf::from(v);
+ }
+ if let Ok(v) = std::env::var("HAMMER_ZIG") {
+ cfg.zig_dir = PathBuf::from(v);
+ }
+ if let Ok(v) = std::env::var("HAMMER_WORK") {
+ cfg.work_root = PathBuf::from(v);
+ }
+ cfg
+ }
+}
diff --git a/crates/hammer-build/src/fetch.rs b/crates/hammer-build/src/fetch.rs
new file mode 100644
index 00000000..91fa2fa4
--- /dev/null
+++ b/crates/hammer-build/src/fetch.rs
@@ -0,0 +1,140 @@
+//! Fase `fetch`: clona/actualiza el repo del source de la receta al commit fijado.
+//!
+//! 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 hammer_core::Recipe;
+
+/// Asegura un mirror local del repo en `<work_root>/repos/<name>.git` y deja el árbol
+/// trabajable (con el commit fijado en HEAD) en `<work_root>/sources/<name>-<commit>/`.
+/// Devuelve la ruta del árbol trabajable.
+pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result<PathBuf> {
+ 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));
+ if !mirror.is_dir() {
+ run_git(
+ &[
+ "clone",
+ "--mirror",
+ "--filter=blob:none",
+ &recipe.source.repo,
+ mirror.to_str().unwrap(),
+ ],
+ None,
+ )?;
+ }
+
+ // 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() {
+ run_git(&["fetch", "--all", "--tags"], Some(&mirror))?;
+ run_git(&["cat-file", "-e", &recipe.source.commit], Some(&mirror)).map_err(|_| {
+ hammer_core::Error::Other(anyhow::anyhow!(
+ "commit {} no existe en {} tras fetch",
+ recipe.source.commit,
+ recipe.source.repo
+ ))
+ })?;
+ }
+
+ // 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));
+ if work_tree.is_dir() {
+ std::fs::remove_dir_all(&work_tree)?;
+ }
+ std::fs::create_dir_all(&work_tree)?;
+
+ // git archive --format=tar <commit> | tar -x -C work_tree
+ let archive = Command::new("git")
+ .arg("-C")
+ .arg(&mirror)
+ .args(["archive", "--format=tar", &recipe.source.commit])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn git archive: {e}")))?;
+
+ let tar_status = Command::new("tar")
+ .args(["-x", "-C"])
+ .arg(&work_tree)
+ .stdin(archive.stdout.unwrap())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .status()
+ .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn tar -x: {e}")))?;
+
+ if !tar_status.success() {
+ return Err(hammer_core::Error::Other(anyhow::anyhow!(
+ "git archive | tar -x falló (tar exit {:?})",
+ tar_status.code()
+ )));
+ }
+ Ok(work_tree)
+}
+
+/// 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 {
+ let patch_path = recipe.base_dir.join(p);
+ let bytes = std::fs::read(&patch_path).map_err(|e| {
+ hammer_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| hammer_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| hammer_core::Error::Other(anyhow::anyhow!("write patch: {e}")))?;
+ }
+ let status = child
+ .wait()
+ .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait patch: {e}")))?;
+ if !status.success() {
+ return Err(hammer_core::Error::Other(anyhow::anyhow!(
+ "patch {} falló (exit {:?})",
+ patch_path.display(),
+ status.code()
+ )));
+ }
+ }
+ Ok(())
+}
+
+fn run_git(args: &[&str], cwd: Option<&Path>) -> hammer_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| hammer_core::Error::Other(anyhow::anyhow!("spawn git {args:?}: {e}")))?;
+ if !status.success() {
+ return Err(hammer_core::Error::Other(anyhow::anyhow!(
+ "git {args:?} falló (exit {:?})",
+ status.code()
+ )));
+ }
+ Ok(())
+}
diff --git a/crates/hammer-build/src/hydrate.rs b/crates/hammer-build/src/hydrate.rs
new file mode 100644
index 00000000..e63e6559
--- /dev/null
+++ b/crates/hammer-build/src/hydrate.rs
@@ -0,0 +1,235 @@
+//! Hidratación: del store al FHS. Ver `docs/03-hydration.md`.
+//!
+//! Estrategia primaria (`LinkMode::Static`): hardlink directo. Cero copia, cero RPATH que
+//! reescribir. El FHS queda con archivos reales (no symlinks) compartiendo inode con el store.
+//! Pisar un archivo del FHS rompe el hardlink (CoW al kernel) sin tocar el artefacto del store
+//! — base de rollback (`hammer hydrate <hash>` lo restaura).
+//!
+//! Estrategia secundaria (`LinkMode::Dynamic`): requiere `patchelf` para normalizar
+//! interpreter y RPATH antes de proyectar. Se implementa al añadir el primer paquete real con
+//! enlazado dinámico.
+
+use std::os::unix::fs::FileTypeExt;
+use std::path::{Path, PathBuf};
+
+use hammer_core::LinkMode;
+
+#[derive(Debug, Clone)]
+pub struct HydratedFile {
+ pub src: PathBuf,
+ pub dst: PathBuf,
+}
+
+#[derive(Debug, Default)]
+pub struct HydrateReport {
+ pub files: Vec<HydratedFile>,
+ pub dirs_created: usize,
+ pub symlinks: usize,
+}
+
+/// Proyecta el contenido de `artifact_dir` al árbol bajo `target_fhs`. Conserva la jerarquía
+/// relativa: `<artifact>/usr/bin/grep` → `<target>/usr/bin/grep`.
+///
+/// Sobre archivos preexistentes en el target, sobrescribe atómicamente (link tmp + rename) —
+/// los archivos antiguos se descartan, pero los inodes del store siguen intactos.
+pub fn hydrate(
+ artifact_dir: &Path,
+ target_fhs: &Path,
+ mode: LinkMode,
+) -> hammer_core::Result<HydrateReport> {
+ if matches!(mode, LinkMode::Dynamic) {
+ return Err(hammer_core::Error::Other(anyhow::anyhow!(
+ "hidratación dinámica pendiente: requiere patchelf (set-interpreter + set-rpath); \
+ ver docs/03-hydration.md §4"
+ )));
+ }
+ if !artifact_dir.is_dir() {
+ return Err(hammer_core::Error::Store(format!(
+ "artefacto inexistente: {}",
+ artifact_dir.display()
+ )));
+ }
+ std::fs::create_dir_all(target_fhs)?;
+
+ let mut report = HydrateReport::default();
+ walk_and_link(artifact_dir, artifact_dir, target_fhs, &mut report)?;
+ Ok(report)
+}
+
+fn walk_and_link(
+ root: &Path,
+ cur: &Path,
+ target_fhs: &Path,
+ report: &mut HydrateReport,
+) -> hammer_core::Result<()> {
+ for entry in std::fs::read_dir(cur)? {
+ let entry = entry?;
+ let src = entry.path();
+ let rel = src.strip_prefix(root).expect("rel under root");
+ let dst = target_fhs.join(rel);
+ let ft = entry.file_type()?;
+
+ if ft.is_dir() {
+ std::fs::create_dir_all(&dst)?;
+ report.dirs_created += 1;
+ walk_and_link(root, &src, target_fhs, report)?;
+ } else if ft.is_symlink() {
+ // Replicamos el symlink con el mismo target literal (no resolvemos).
+ let link_target = std::fs::read_link(&src)?;
+ atomic_replace_symlink(&link_target, &dst)?;
+ report.symlinks += 1;
+ report.files.push(HydratedFile { src, dst });
+ } else if ft.is_file() {
+ atomic_hardlink(&src, &dst)?;
+ report.files.push(HydratedFile { src, dst });
+ } else if ft.is_block_device() || ft.is_char_device() || ft.is_fifo() || ft.is_socket() {
+ // Los artefactos del lab no deberían contener nodos especiales; lo señalamos.
+ return Err(hammer_core::Error::Other(anyhow::anyhow!(
+ "tipo no soportado en {}: {ft:?}",
+ src.display()
+ )));
+ }
+ }
+ Ok(())
+}
+
+/// Crea un hardlink `dst → src` reemplazando atómicamente cualquier archivo previo en `dst`.
+/// Implementación: `link(src, dst.tmp)` + `rename(dst.tmp, dst)`. La técnica garantiza que
+/// el archivo en `dst` nunca queda en estado intermedio y que el inode del store no se toca.
+fn atomic_hardlink(src: &Path, dst: &Path) -> hammer_core::Result<()> {
+ if let Some(parent) = dst.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let tmp = with_suffix(dst, ".hammer-tmp");
+ // Limpia un tmp olvidado de una corrida anterior.
+ let _ = std::fs::remove_file(&tmp);
+ std::fs::hard_link(src, &tmp).map_err(|e| {
+ hammer_core::Error::Store(format!(
+ "hardlink {} → {}: {e}",
+ src.display(),
+ tmp.display()
+ ))
+ })?;
+ // rename sobreescribe atómicamente si dst es un archivo (no si es directorio).
+ if let Err(e) = std::fs::rename(&tmp, dst) {
+ let _ = std::fs::remove_file(&tmp);
+ return Err(hammer_core::Error::Store(format!(
+ "rename {} → {}: {e}",
+ tmp.display(),
+ dst.display()
+ )));
+ }
+ Ok(())
+}
+
+fn atomic_replace_symlink(link_target: &Path, dst: &Path) -> hammer_core::Result<()> {
+ use std::os::unix::fs::symlink;
+ if let Some(parent) = dst.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let tmp = with_suffix(dst, ".hammer-tmp");
+ let _ = std::fs::remove_file(&tmp);
+ symlink(link_target, &tmp).map_err(|e| {
+ hammer_core::Error::Store(format!("symlink {} → {}: {e}", link_target.display(), tmp.display()))
+ })?;
+ if let Err(e) = std::fs::rename(&tmp, dst) {
+ let _ = std::fs::remove_file(&tmp);
+ return Err(hammer_core::Error::Store(format!(
+ "rename {} → {}: {e}",
+ tmp.display(),
+ dst.display()
+ )));
+ }
+ Ok(())
+}
+
+fn with_suffix(p: &Path, suffix: &str) -> PathBuf {
+ let mut s = p.as_os_str().to_owned();
+ s.push(suffix);
+ PathBuf::from(s)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::os::unix::fs::MetadataExt;
+
+ fn populate(dir: &Path) {
+ std::fs::create_dir_all(dir.join("usr/bin")).unwrap();
+ std::fs::write(dir.join("usr/bin/hello"), b"#!/bin/sh\necho hi\n").unwrap();
+ std::fs::create_dir_all(dir.join("usr/share/doc")).unwrap();
+ std::fs::write(dir.join("usr/share/doc/README"), b"docs").unwrap();
+ // symlink: usr/sbin/hello -> ../bin/hello (target relativo)
+ std::fs::create_dir_all(dir.join("usr/sbin")).unwrap();
+ std::os::unix::fs::symlink("../bin/hello", dir.join("usr/sbin/hello")).unwrap();
+ }
+
+ #[test]
+ fn hydrate_creates_hardlinks_with_shared_inode() {
+ let artifact = tempfile::tempdir().unwrap();
+ let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
+ populate(artifact.path());
+
+ let report = hydrate(artifact.path(), target.path(), LinkMode::Static).unwrap();
+ assert_eq!(report.files.len(), 3); // hello, README, symlink
+ assert_eq!(report.symlinks, 1);
+
+ let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
+ let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
+ assert_eq!(src_ino, dst_ino, "hardlink ⇒ mismo inode");
+ }
+
+ #[test]
+ fn hydrate_overwrites_existing_without_touching_store() {
+ let artifact = tempfile::tempdir().unwrap();
+ let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
+ populate(artifact.path());
+
+ // Pre-existe un archivo distinto en el target.
+ std::fs::create_dir_all(target.path().join("usr/bin")).unwrap();
+ std::fs::write(target.path().join("usr/bin/hello"), b"contenido viejo").unwrap();
+
+ hydrate(artifact.path(), target.path(), LinkMode::Static).unwrap();
+
+ // El target ahora apunta al inode del store.
+ let src_ino = std::fs::metadata(artifact.path().join("usr/bin/hello")).unwrap().ino();
+ let dst_ino = std::fs::metadata(target.path().join("usr/bin/hello")).unwrap().ino();
+ assert_eq!(src_ino, dst_ino);
+ // El contenido viejo desapareció; el del store sigue intacto.
+ let got = std::fs::read_to_string(target.path().join("usr/bin/hello")).unwrap();
+ assert!(got.starts_with("#!/bin/sh"));
+ let store_still = std::fs::read_to_string(artifact.path().join("usr/bin/hello")).unwrap();
+ assert!(store_still.starts_with("#!/bin/sh"));
+ }
+
+ #[test]
+ fn hydrate_is_idempotent() {
+ let artifact = tempfile::tempdir().unwrap();
+ let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
+ populate(artifact.path());
+ hydrate(artifact.path(), target.path(), LinkMode::Static).unwrap();
+ // Segundo paso no debe fallar.
+ hydrate(artifact.path(), target.path(), LinkMode::Static).unwrap();
+ }
+
+ #[test]
+ fn hydrate_replicates_symlink_target_literally() {
+ let artifact = tempfile::tempdir().unwrap();
+ let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
+ populate(artifact.path());
+ hydrate(artifact.path(), target.path(), LinkMode::Static).unwrap();
+ let link = std::fs::read_link(target.path().join("usr/sbin/hello")).unwrap();
+ assert_eq!(link, std::path::PathBuf::from("../bin/hello"));
+ }
+
+ #[test]
+ fn hydrate_dynamic_errors_with_clear_message() {
+ let artifact = tempfile::tempdir().unwrap();
+ let target = tempfile::tempdir_in(artifact.path().parent().unwrap()).unwrap();
+ populate(artifact.path());
+ let err = hydrate(artifact.path(), target.path(), LinkMode::Dynamic)
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("patchelf"), "{err}");
+ }
+}
diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs
index db836ecb..6e9ae901 100644
--- a/crates/hammer-build/src/lib.rs
+++ b/crates/hammer-build/src/lib.rs
@@ -1,55 +1,128 @@
//! El laboratorio: compila una receta de forma hermética y la sella en el store; e hidrata
//! artefactos al FHS. Ver `docs/02-build-lab.md` y `docs/03-hydration.md`.
-//!
-//! Esqueleto de Fase 0. Las funciones públicas fijan el contrato; la implementación del
-//! sandbox (bubblewrap) y de patchelf se irá rellenando.
-use std::path::Path;
+use std::path::{Path, PathBuf};
-use hammer_core::{ArtifactHash, LinkMode, Recipe, Store};
+use hammer_core::{ArtifactHash, LinkMode, Phases, Recipe, Store};
+pub mod config;
+pub mod fetch;
+pub mod hydrate;
pub mod sandbox;
+pub use config::BuildConfig;
+pub use hydrate::{hydrate as run_hydrate, HydrateReport, HydratedFile};
+pub use sandbox::Sandbox;
+
/// Calcula el `ArtifactHash` de una receta, resolviendo recursivamente sus deps de build.
/// Ver `docs/02-build-lab.md` §2 y §5.
-pub fn artifact_hash(recipe: &Recipe, store: &Store) -> hammer_core::Result<ArtifactHash> {
+pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result<ArtifactHash> {
let dep_hashes: Vec<ArtifactHash> = Vec::new();
- for dep_name in &recipe.deps.build {
- // TODO(fase-0): cargar la receta de la dep y recursar. Por ahora se documenta el
- // contrato; el grafo real se implementa con `hammer build`.
- let _ = dep_name;
+ for _dep in &recipe.deps.build {
+ // TODO(fase-0): cargar la receta de la dep y recursar.
}
- let inputs = recipe.hash_inputs(&dep_hashes);
+ let inputs = recipe.hash_inputs(&dep_hashes)?;
let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect();
- let _ = store;
Ok(ArtifactHash::of_inputs(&refs))
}
/// Compila una receta y devuelve el hash del artefacto sellado en el store.
/// Si el hash ya existe en el store, devuelve sin recompilar (caché).
-pub fn build(recipe: &Recipe, store: &Store) -> hammer_core::Result<ArtifactHash> {
+pub fn build(
+ recipe: &Recipe,
+ cfg: &BuildConfig,
+ store: &Store,
+) -> hammer_core::Result<ArtifactHash> {
let h = artifact_hash(recipe, store)?;
if store.has(&h, &recipe.name) {
tracing::info!(hash = %h, name = %recipe.name, "caché: artefacto ya en el store");
return Ok(h);
}
- // TODO(fase-0): resolver deps → make_sandbox → run_phases(patch/configure/compile/install)
- // → store.seal(out, &h, &recipe.name).
- tracing::warn!("build real pendiente (Fase 0): sandbox bubblewrap + zig cc");
- Err(hammer_core::Error::Other(anyhow::anyhow!(
- "build no implementado todavía; ver docs/10-roadmap.md (Fase 0)"
- )))
+ sandbox::ensure_layout(&cfg.rootfs, &cfg.zig_dir)?;
+
+ tracing::info!(hash = %h, name = %recipe.name, "fetch");
+ let src_tree = fetch::fetch(recipe, &cfg.work_root)?;
+
+ if !recipe.source.patches.is_empty() {
+ tracing::info!(n = recipe.source.patches.len(), "aplicando patches");
+ fetch::apply_patches(recipe, &src_tree)?;
+ }
+
+ let out_dir = unique_out_dir(&cfg.work_root, &recipe.name, &h)?;
+ let sb = Sandbox {
+ rootfs: cfg.rootfs.clone(),
+ zig_dir: cfg.zig_dir.clone(),
+ src_dir: src_tree.clone(),
+ out_dir: out_dir.clone(),
+ env: Vec::new(),
+ };
+
+ let phases = resolve_phases(recipe, &src_tree)?;
+ if let Some(cmd) = &phases.configure {
+ tracing::info!(cmd, "phase: configure");
+ sb.run(cmd)?;
+ }
+ if let Some(cmd) = &phases.compile {
+ tracing::info!(cmd, "phase: compile");
+ sb.run(cmd)?;
+ } else {
+ return Err(hammer_core::Error::Recipe(
+ "receta sin fase 'compile' y heurística no encontró ninguna".into(),
+ ));
+ }
+ if let Some(cmd) = &phases.install {
+ tracing::info!(cmd, "phase: install");
+ sb.run(cmd)?;
+ } else {
+ return Err(hammer_core::Error::Recipe(
+ "receta sin fase 'install' y heurística no encontró ninguna".into(),
+ ));
+ }
+
+ let sealed = store.seal(&out_dir, &h, &recipe.name)?;
+ tracing::info!(path = %sealed.display(), "sealed");
+ Ok(h)
+}
+
+/// Devuelve los comandos a ejecutar para cada fase: overrides explícitos > heurística
+/// (de momento: presencia de `Makefile` → make).
+fn resolve_phases(recipe: &Recipe, src: &Path) -> hammer_core::Result<Phases> {
+ let mut out = recipe.build.phases.clone();
+ if out.compile.is_none() {
+ if src.join("Makefile").exists() || src.join("makefile").exists() {
+ out.compile = Some("make".to_string());
+ if out.install.is_none() {
+ out.install = Some("make install DESTDIR=/out PREFIX=/usr".to_string());
+ }
+ }
+ // TODO(fase-0): configure.ac → autoreconf+./configure; CMakeLists.txt → cmake; meson.build → meson.
+ }
+ Ok(out)
+}
+
+fn unique_out_dir(
+ work_root: &Path,
+ name: &str,
+ h: &ArtifactHash,
+) -> hammer_core::Result<PathBuf> {
+ let dir = work_root
+ .join("out")
+ .join(h.store_dir_name(name));
+ if dir.exists() {
+ std::fs::remove_dir_all(&dir)?;
+ }
+ std::fs::create_dir_all(&dir)?;
+ Ok(dir)
}
-/// Proyecta un artefacto del store al FHS (real o de overlay). Ver `docs/03-hydration.md`.
+/// Proyecta un artefacto del store al FHS (real o de overlay). Resuelve el directorio del
+/// artefacto a partir del hash y delega en [`hydrate::hydrate`].
pub fn hydrate(
- _h: &ArtifactHash,
- _store: &Store,
- _target_fhs: &Path,
- _mode: LinkMode,
-) -> hammer_core::Result<()> {
- // TODO(fase-1): hardlink del store al FHS; patchelf (set-interpreter/set-rpath) si Dynamic.
- Err(hammer_core::Error::Other(anyhow::anyhow!(
- "hidratación no implementada todavía; ver docs/10-roadmap.md (Fase 1)"
- )))
+ h: &ArtifactHash,
+ store: &Store,
+ target_fhs: &Path,
+ mode: LinkMode,
+) -> hammer_core::Result<HydrateReport> {
+ let artifact_dir = store.find_by_hash(h.as_str())?;
+ hydrate::hydrate(&artifact_dir, target_fhs, mode)
}
diff --git a/crates/hammer-build/src/sandbox.rs b/crates/hammer-build/src/sandbox.rs
index f62cceae..90fa9548 100644
--- a/crates/hammer-build/src/sandbox.rs
+++ b/crates/hammer-build/src/sandbox.rs
@@ -1,41 +1,121 @@
//! El sandbox hermético de build (bubblewrap). Ver `docs/02-build-lab.md` §3 y §4.
//!
-//! Esqueleto de Fase 0. Define la forma del aislamiento que implementaremos: raíz tmpfs,
-//! compilador read-only inyectado, fuentes/deps read-only, sin red, salida vía DESTDIR.
+//! Capas:
+//! - Base read-only: rootfs Alpine (`.dev-fs/alpine`) montado como overlay con capa
+//! tmpfs encima, así el build puede escribir en /tmp/etc sin ensuciar el host.
+//! - Compilador inyectado: `zig` (`.dev-fs/tools/zig`) en `/opt/zig`.
+//! - Fuentes: bind RW en `/src` (deben venir ya copiadas a un dir host descartable).
+//! - Salida: bind RW en `/out` (DESTDIR).
+//! - Red: aislada (`--unshare-all` incluye `--unshare-net`).
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
-/// Descripción de un sandbox de build a montar con `bwrap`.
-#[derive(Debug, Clone, Default)]
+#[derive(Debug, Clone)]
pub struct Sandbox {
- /// Binds read-only: (origen_host, destino_sandbox).
- pub ro_binds: Vec<(PathBuf, PathBuf)>,
- /// Directorio de salida (DESTDIR) dentro del sandbox.
- pub out: PathBuf,
- /// Aislar red (hermeticidad: el build no descarga nada no declarado).
- pub isolate_net: bool,
+ pub rootfs: PathBuf,
+ pub zig_dir: PathBuf,
+ pub src_dir: PathBuf,
+ pub out_dir: PathBuf,
+ /// Vars de entorno extra inyectadas además del set por defecto.
+ pub env: Vec<(String, String)>,
}
impl Sandbox {
- /// Construye los argumentos para `bwrap`. TODO(fase-0): completar y ejecutar.
- pub fn bwrap_args(&self) -> Vec<String> {
- let mut args = vec![
- "--unshare-all".into(),
- "--tmpfs".into(),
+ /// Ejecuta `cmd` dentro del sandbox bajo `sh -c`. Hereda stdout/stderr al padre y
+ /// devuelve error con contexto si el exit code es != 0.
+ pub fn run(&self, cmd: &str) -> hammer_core::Result<()> {
+ let args = self.bwrap_args(cmd);
+ tracing::debug!(?args, "bwrap");
+ let status = Command::new("bwrap")
+ .args(&args)
+ .stdin(Stdio::null())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .status()
+ .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn bwrap: {e}")))?;
+ if !status.success() {
+ return Err(hammer_core::Error::Other(anyhow::anyhow!(
+ "build phase falló (exit {}): {cmd}",
+ status.code().map(|c| c.to_string()).unwrap_or_else(|| "?".into())
+ )));
+ }
+ Ok(())
+ }
+
+ fn bwrap_args(&self, cmd: &str) -> Vec<String> {
+ let mut args: Vec<String> = vec![
+ "--overlay-src".into(),
+ self.rootfs.display().to_string(),
+ "--tmp-overlay".into(),
"/".into(),
"--proc".into(),
"/proc".into(),
"--dev".into(),
"/dev".into(),
+ "--tmpfs".into(),
+ "/tmp".into(),
+ "--ro-bind".into(),
+ self.zig_dir.display().to_string(),
+ "/opt/zig".into(),
+ "--bind".into(),
+ self.src_dir.display().to_string(),
+ "/src".into(),
+ "--bind".into(),
+ self.out_dir.display().to_string(),
+ "/out".into(),
+ "--chdir".into(),
+ "/src".into(),
+ "--unshare-all".into(),
+ "--die-with-parent".into(),
];
- if !self.isolate_net {
- args.push("--share-net".into());
+
+ // Entorno mínimo y predecible.
+ let defaults: Vec<(&str, &str)> = vec![
+ ("PATH", "/opt/zig:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"),
+ ("HOME", "/tmp"),
+ ("DESTDIR", "/out"),
+ ("PREFIX", "/usr"),
+ ("CC", "zig cc"),
+ ("CXX", "zig c++"),
+ ("AR", "zig ar"),
+ ("LC_ALL", "C"),
+ ("LANG", "C"),
+ ];
+ for (k, v) in defaults {
+ args.push("--setenv".into());
+ args.push(k.into());
+ args.push(v.into());
}
- for (src, dst) in &self.ro_binds {
- args.push("--ro-bind".into());
- args.push(src.display().to_string());
- args.push(dst.display().to_string());
+ for (k, v) in &self.env {
+ args.push("--setenv".into());
+ args.push(k.clone());
+ args.push(v.clone());
}
+
+ // Comando final.
+ args.push("/bin/sh".into());
+ args.push("-c".into());
+ args.push(cmd.into());
args
}
}
+
+/// Errores tempranos: comprueba que las rutas base existen antes de invocar el build.
+pub fn ensure_layout(rootfs: &Path, zig_dir: &Path) -> hammer_core::Result<()> {
+ // bin/sh en Alpine es un symlink absoluto a /bin/busybox, que desde el host no resuelve.
+ // Comprobamos el archivo regular real para no engañarnos.
+ if !rootfs.join("bin/busybox").is_file() {
+ return Err(hammer_core::Error::Other(anyhow::anyhow!(
+ "rootfs inválido: no encuentro {}/bin/busybox — ¿corriste el bootstrap de Alpine?",
+ rootfs.display()
+ )));
+ }
+ if !zig_dir.join("zig").exists() {
+ return Err(hammer_core::Error::Other(anyhow::anyhow!(
+ "zig no encontrado en {}/zig — ¿instalaste zig en .dev-fs/tools/zig?",
+ zig_dir.display()
+ )));
+ }
+ Ok(())
+}
diff --git a/crates/hammer-build/tests/end_to_end_hello.rs b/crates/hammer-build/tests/end_to_end_hello.rs
new file mode 100644
index 00000000..aa718923
--- /dev/null
+++ b/crates/hammer-build/tests/end_to_end_hello.rs
@@ -0,0 +1,181 @@
+//! Integration test del pipeline completo de Fase 0:
+//! 1. Inicializa un repo git local con un `hello.c`.
+//! 2. Escribe una receta TOML que apunta a ese repo con phases override.
+//! 3. Llama a `hammer_build::build` con un store/work temporales.
+//! 4. Verifica que el artefacto sellado contiene `usr/bin/hello`, es ELF estático y
+//! se ejecuta dentro del sandbox imprimiendo el output esperado.
+//!
+//! El test se SALTA (con un mensaje claro) si el host no tiene el rootfs Alpine
+//! y/o zig instalados en `.dev-fs/`. Eso permite que `cargo test` no falle en máquinas
+//! limpias; el desarrollador lee el mensaje y corre el bootstrap.
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use hammer_build::{build, BuildConfig};
+use hammer_core::{Recipe, Store};
+
+const HELLO_C: &str = r#"
+#include <stdio.h>
+int main(void) { puts("hammer-e2e OK"); return 0; }
+"#;
+
+const RECIPE_TOML: &str = r#"
+name = "hello"
+version = "1.0"
+
+[source]
+repo = "PLACEHOLDER_REPO_URL"
+commit = "PLACEHOLDER_COMMIT"
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "static"
+
+[build.phases]
+compile = "zig cc -static -O2 -o hello hello.c"
+install = "install -D -m755 hello /out/usr/bin/hello"
+"#;
+
+fn project_root() -> PathBuf {
+ // CARGO_MANIFEST_DIR = crates/hammer-build → subir dos para llegar al workspace root.
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .unwrap()
+ .parent()
+ .unwrap()
+ .to_path_buf()
+}
+
+fn skip_if_no_layout(cfg: &BuildConfig) -> bool {
+ if !cfg.rootfs.join("bin/busybox").is_file() {
+ eprintln!(
+ "SKIP: rootfs Alpine no encontrado en {} — corre el bootstrap (ver README).",
+ cfg.rootfs.display()
+ );
+ return true;
+ }
+ if !cfg.zig_dir.join("zig").is_file() {
+ eprintln!(
+ "SKIP: zig no encontrado en {} — instalalo en .dev-fs/tools/zig.",
+ cfg.zig_dir.display()
+ );
+ return true;
+ }
+ if Command::new("git").arg("--version").output().is_err() {
+ eprintln!("SKIP: git no disponible en PATH.");
+ return true;
+ }
+ false
+}
+
+fn git(args: &[&str], cwd: &Path) {
+ let st = Command::new("git")
+ .args(args)
+ .current_dir(cwd)
+ .status()
+ .expect("git spawn");
+ assert!(st.success(), "git {args:?} falló");
+}
+
+fn init_repo_with_hello(repo_dir: &Path) -> String {
+ std::fs::create_dir_all(repo_dir).unwrap();
+ std::fs::write(repo_dir.join("hello.c"), HELLO_C).unwrap();
+ git(&["init", "-q", "-b", "main"], repo_dir);
+ // Identidad local para el commit, sin tocar la global del usuario.
+ git(&["config", "user.email", "test@hammer"], repo_dir);
+ git(&["config", "user.name", "hammer-test"], repo_dir);
+ git(&["add", "."], repo_dir);
+ git(&["commit", "-q", "-m", "hello"], repo_dir);
+ let out = Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .current_dir(repo_dir)
+ .output()
+ .expect("rev-parse");
+ String::from_utf8(out.stdout).unwrap().trim().to_string()
+}
+
+#[test]
+fn build_hello_end_to_end() {
+ let _ = tracing_subscriber::fmt().with_test_writer().try_init();
+
+ let project = project_root();
+ let rootfs = project.join(".dev-fs/alpine");
+ let zig_dir = project.join(".dev-fs/tools/zig");
+
+ let tmp = tempfile::tempdir().unwrap();
+ let store_root = tmp.path().join("store");
+ let work_root = tmp.path().join("work");
+ std::fs::create_dir_all(&store_root).unwrap();
+ std::fs::create_dir_all(&work_root).unwrap();
+
+ let cfg = BuildConfig {
+ rootfs: rootfs.clone(),
+ zig_dir: zig_dir.clone(),
+ work_root,
+ };
+ if skip_if_no_layout(&cfg) {
+ return;
+ }
+
+ // 1) repo upstream simulado
+ let upstream = tmp.path().join("upstream-hello");
+ let commit = init_repo_with_hello(&upstream);
+ let repo_url = format!("file://{}", upstream.display());
+
+ // 2) escribir receta
+ let recipe_dir = tmp.path().join("recipe");
+ std::fs::create_dir_all(&recipe_dir).unwrap();
+ let toml = RECIPE_TOML
+ .replace("PLACEHOLDER_REPO_URL", &repo_url)
+ .replace("PLACEHOLDER_COMMIT", &commit);
+ let recipe_path = recipe_dir.join("hello.toml");
+ std::fs::write(&recipe_path, toml).unwrap();
+ let recipe = Recipe::load_from_path(&recipe_path).expect("load recipe");
+
+ // 3) build
+ let store = Store::open(&store_root).unwrap();
+ let h = build(&recipe, &cfg, &store).expect("build hello");
+ let sealed = store.path_of(&h, &recipe.name);
+ assert!(sealed.is_dir(), "store dir debe existir: {}", sealed.display());
+
+ // 4) artefacto correcto
+ let hello = sealed.join("usr/bin/hello");
+ assert!(hello.is_file(), "usr/bin/hello debe existir");
+ let meta = std::fs::metadata(&hello).unwrap();
+ use std::os::unix::fs::PermissionsExt;
+ assert_eq!(meta.permissions().mode() & 0o111, 0o111, "ejecutable");
+ // Read-only en el store
+ assert_eq!(meta.permissions().mode() & 0o222, 0, "read-only en el store");
+
+ // ELF
+ let header = std::fs::read(&hello).unwrap();
+ assert_eq!(&header[..4], b"\x7fELF", "es ELF");
+
+ // 5) ejecuta dentro de bwrap+rootfs y observa el output
+ let out = Command::new("bwrap")
+ .args([
+ "--overlay-src",
+ rootfs.to_str().unwrap(),
+ "--tmp-overlay",
+ "/",
+ "--proc",
+ "/proc",
+ "--dev",
+ "/dev",
+ "--ro-bind",
+ sealed.to_str().unwrap(),
+ "/artefacto",
+ "--unshare-all",
+ "/artefacto/usr/bin/hello",
+ ])
+ .output()
+ .expect("spawn bwrap run hello");
+ assert!(out.status.success(), "hello no corrió: {:?}", out);
+ assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hammer-e2e OK");
+
+ // 6) cache: segunda build con misma receta no rehidrata
+ let h2 = build(&recipe, &cfg, &store).expect("second build");
+ assert_eq!(h, h2);
+}
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 72466f84..cc02cfd9 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -33,9 +33,13 @@ enum Cmd {
},
/// [Fase 1] Proyecta un artefacto del store al FHS (real o de overlay).
Hydrate {
+ /// Hash del artefacto (acepta prefijos cortos; con o sin `b3:`).
hash: String,
#[arg(long, default_value = "/")]
into: String,
+ /// Modo de enlazado: `static` (hardlink directo) | `dynamic` (requiere patchelf).
+ #[arg(long, default_value = "static")]
+ link: String,
},
/// [Fase 2] Monta un overlay de experimentación sobre los directorios del sistema.
Try,
@@ -60,6 +64,7 @@ enum Cmd {
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
+ .with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
@@ -71,13 +76,33 @@ fn main() -> anyhow::Result<()> {
match cli.cmd {
Cmd::Build { recipe } => {
- let text = std::fs::read_to_string(&recipe)?;
- let recipe = hammer_core::Recipe::from_toml(&text)?;
- let hash = hammer_build::build(&recipe, &store)?;
+ let recipe = hammer_core::Recipe::load_from_path(&recipe)?;
+ let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
+ let hash = hammer_build::build(&recipe, &cfg, &store)?;
println!("{hash}");
}
- Cmd::Hydrate { hash, into } => {
- println!("[fase 1 pendiente] hydrate {hash} into {into}");
+ Cmd::Hydrate { hash, into, link } => {
+ let mode = match link.as_str() {
+ "static" => hammer_core::LinkMode::Static,
+ "dynamic" => hammer_core::LinkMode::Dynamic,
+ other => anyhow::bail!("--link debe ser 'static' o 'dynamic', no '{other}'"),
+ };
+ let artifact_dir = store.find_by_hash(&hash)?;
+ let report = hammer_build::run_hydrate(
+ &artifact_dir,
+ std::path::Path::new(&into),
+ mode,
+ )?;
+ println!(
+ "hydrated {} archivo(s) ({} dir(s), {} symlink(s)) → {}",
+ report.files.len(),
+ report.dirs_created,
+ report.symlinks,
+ into
+ );
+ for f in &report.files {
+ println!(" {}", f.dst.display());
+ }
}
Cmd::Try | Cmd::Commit | Cmd::Discard | Cmd::Status => {
println!("[fase 2 pendiente] overlay — ver docs/04-overlay.md");
diff --git a/crates/hammer-core/Cargo.toml b/crates/hammer-core/Cargo.toml
index e86602a1..fb2e8801 100644
--- a/crates/hammer-core/Cargo.toml
+++ b/crates/hammer-core/Cargo.toml
@@ -13,4 +13,8 @@ thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
+toml.workspace = true
blake3.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs
index 1454cfa1..5555847f 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, Recipe};
+pub use recipe::{Compiler, LinkMode, Phases, Recipe};
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 53ede4d2..ee47057c 100644
--- a/crates/hammer-core/src/recipe.rs
+++ b/crates/hammer-core/src/recipe.rs
@@ -1,5 +1,7 @@
//! La `Recipe`: descripción pura de un build. Ver `docs/02-build-lab.md` §1.
+use std::path::{Path, PathBuf};
+
use serde::{Deserialize, Serialize};
use crate::hash::ArtifactHash;
@@ -12,6 +14,10 @@ pub struct Recipe {
pub build: Build,
#[serde(default)]
pub deps: Deps,
+ /// Directorio base contra el que se resuelven rutas relativas de la receta
+ /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío.
+ #[serde(skip, default)]
+ pub base_dir: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -33,6 +39,22 @@ pub struct Build {
pub link: LinkMode,
#[serde(default)]
pub flags: Vec<String>,
+ /// Overrides explícitos de las fases del build. Lo no especificado se decide por
+ /// heurística en el lab (ver `docs/02-build-lab.md` §4).
+ #[serde(default)]
+ pub phases: Phases,
+}
+
+/// Comandos shell que se ejecutan en el sandbox por cada fase. Cualquiera puede ser `None`:
+/// el lab lo deriva por heurística (autotools / cmake / meson / make).
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct Phases {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub configure: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub compile: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub install: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -72,38 +94,186 @@ pub enum LinkMode {
Dynamic,
}
+impl LinkMode {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ LinkMode::Static => "static",
+ LinkMode::Dynamic => "dynamic",
+ }
+ }
+}
+
fn default_target() -> String {
"x86_64-linux-musl".to_string()
}
impl Recipe {
- pub fn from_toml(_s: &str) -> crate::Result<Recipe> {
- // TODO(fase-0): parsear TOML. Por ahora el parsing real lo añadiremos al implementar
- // `hammer build`; el contrato (esta struct) ya está fijado.
- Err(crate::Error::Recipe(
- "parsing de receta TOML pendiente (Fase 0)".into(),
- ))
+ /// Parsea una receta desde su forma TOML. `base_dir` queda vacío: si la receta declara
+ /// `patches`, el caller debe fijar `base_dir` antes de hashear, o usar `load_from_path`.
+ pub fn from_toml(s: &str) -> crate::Result<Recipe> {
+ toml::from_str(s).map_err(|e| crate::Error::Recipe(e.to_string()))
}
- /// Las entradas canónicas que alimentan el `ArtifactHash` de esta receta.
- /// NOTA: el hash final también incorpora los hashes de las deps de build (recursivo),
- /// que el lab resuelve antes de llamar aquí. Ver `docs/02-build-lab.md` §2 y §5.
- pub fn hash_inputs(&self, dep_hashes: &[ArtifactHash]) -> Vec<Vec<u8>> {
+ /// Carga una receta desde el disco. `base_dir` se fija al directorio que contiene el
+ /// archivo, de modo que `source.patches` resuelve relativo a ahí (estilo Cargo).
+ pub fn load_from_path(path: impl AsRef<Path>) -> crate::Result<Recipe> {
+ let path = path.as_ref();
+ let text = std::fs::read_to_string(path)?;
+ let mut recipe = Recipe::from_toml(&text)?;
+ if let Some(parent) = path.parent() {
+ recipe.base_dir = parent.to_path_buf();
+ }
+ Ok(recipe)
+ }
+
+ /// Las entradas canónicas que alimentan el `ArtifactHash` de esta receta. Incluye el
+ /// **contenido** de cada patch (no su ruta), de modo que renombrar un archivo no cambia el
+ /// hash y editarlo sí lo hace. Ver `docs/02-build-lab.md` §2.
+ pub fn hash_inputs(
+ &self,
+ dep_hashes: &[ArtifactHash],
+ ) -> crate::Result<Vec<Vec<u8>>> {
let mut v: Vec<Vec<u8>> = vec![
self.source.commit.as_bytes().to_vec(),
self.build.compiler.as_str().as_bytes().to_vec(),
self.build.target.as_bytes().to_vec(),
- format!("{:?}", self.build.link).into_bytes(),
+ self.build.link.as_str().as_bytes().to_vec(),
];
for p in &self.source.patches {
- v.push(p.as_bytes().to_vec());
+ let resolved = self.base_dir.join(p);
+ let bytes = std::fs::read(&resolved).map_err(|e| {
+ crate::Error::Recipe(format!("no pude leer patch {}: {e}", resolved.display()))
+ })?;
+ v.push(bytes);
}
for f in &self.build.flags {
v.push(f.as_bytes().to_vec());
}
+ // Phases override: si alguien cambia el comando del compile, el artefacto cambia.
+ // Etiquetamos cada uno para que añadir un override luego no colisione con un flag.
+ for (label, val) in [
+ ("phase:configure", &self.build.phases.configure),
+ ("phase:compile", &self.build.phases.compile),
+ ("phase:install", &self.build.phases.install),
+ ] {
+ if let Some(cmd) = val {
+ v.push(format!("{label}={cmd}").into_bytes());
+ }
+ }
for d in dep_hashes {
v.push(d.as_str().as_bytes().to_vec());
}
- v
+ Ok(v)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const SAMPLE: &str = r#"
+name = "grep"
+version = "3.11"
+
+[source]
+repo = "git://git.savannah.gnu.org/grep.git"
+commit = "a1b2c3d4"
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "static"
+flags = ["--enable-perl-regexp"]
+
+[deps]
+build = ["pcre2"]
+"#;
+
+ #[test]
+ fn parse_full() {
+ let r = Recipe::from_toml(SAMPLE).expect("parse");
+ assert_eq!(r.name, "grep");
+ assert_eq!(r.source.commit, "a1b2c3d4");
+ 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_minimal_uses_defaults() {
+ let s = r#"
+name = "x"
+version = "0"
+[source]
+repo = "git://x"
+commit = "deadbeef"
+[build]
+"#;
+ let r = Recipe::from_toml(s).expect("parse");
+ assert_eq!(r.build.compiler, Compiler::ZigCc); // default
+ assert_eq!(r.build.target, "x86_64-linux-musl"); // default
+ assert_eq!(r.build.link, LinkMode::Static); // default
+ assert!(r.deps.build.is_empty());
+ }
+
+ #[test]
+ fn hash_no_patches_is_deterministic() {
+ let r = Recipe::from_toml(SAMPLE).unwrap();
+ let a = r.hash_inputs(&[]).unwrap();
+ let b = r.hash_inputs(&[]).unwrap();
+ assert_eq!(a, b);
+ }
+
+ #[test]
+ fn hash_uses_patch_contents_not_path() {
+ let dir1 = tempfile::tempdir().unwrap();
+ let dir2 = tempfile::tempdir().unwrap();
+ std::fs::write(dir1.path().join("p.patch"), b"diff --content").unwrap();
+ std::fs::write(dir2.path().join("otro-nombre.patch"), b"diff --content").unwrap();
+
+ let mut r1 = Recipe::from_toml(SAMPLE).unwrap();
+ r1.source.patches = vec!["p.patch".into()];
+ r1.base_dir = dir1.path().to_path_buf();
+
+ let mut r2 = Recipe::from_toml(SAMPLE).unwrap();
+ r2.source.patches = vec!["otro-nombre.patch".into()];
+ r2.base_dir = dir2.path().to_path_buf();
+
+ // Mismo CONTENIDO ⇒ misma entrada al hash, aunque las rutas sean distintas.
+ assert_eq!(r1.hash_inputs(&[]).unwrap(), r2.hash_inputs(&[]).unwrap());
+ }
+
+ #[test]
+ fn hash_changes_when_patch_content_changes() {
+ let dir = tempfile::tempdir().unwrap();
+ let patch = dir.path().join("p.patch");
+ std::fs::write(&patch, b"version A").unwrap();
+
+ let mut r = Recipe::from_toml(SAMPLE).unwrap();
+ r.source.patches = vec!["p.patch".into()];
+ r.base_dir = dir.path().to_path_buf();
+ let h_a = r.hash_inputs(&[]).unwrap();
+
+ std::fs::write(&patch, b"version B").unwrap();
+ let h_b = r.hash_inputs(&[]).unwrap();
+ assert_ne!(h_a, h_b);
+ }
+
+ #[test]
+ fn missing_patch_reports_path() {
+ let mut r = Recipe::from_toml(SAMPLE).unwrap();
+ r.source.patches = vec!["no-existe.patch".into()];
+ r.base_dir = PathBuf::from("/tmp/seguro-que-no-existe-hammer");
+ let err = r.hash_inputs(&[]).unwrap_err().to_string();
+ assert!(err.contains("no-existe.patch"), "msg = {err}");
+ }
+
+ #[test]
+ fn load_from_path_sets_base_dir() {
+ let dir = tempfile::tempdir().unwrap();
+ let recipe_path = dir.path().join("foo.toml");
+ std::fs::write(&recipe_path, SAMPLE).unwrap();
+ let r = Recipe::load_from_path(&recipe_path).unwrap();
+ assert_eq!(r.base_dir, dir.path());
}
}
diff --git a/crates/hammer-core/src/store.rs b/crates/hammer-core/src/store.rs
index d953bd26..1f4f7e14 100644
--- a/crates/hammer-core/src/store.rs
+++ b/crates/hammer-core/src/store.rs
@@ -1,8 +1,7 @@
//! El content-addressed store. Ver `docs/03-hydration.md` §1.
//!
-//! Inmutable, append-only, direccionado por `ArtifactHash`. Esqueleto de Fase 0: la API está
-//! fijada; el sellado real (mover el árbol de salida del sandbox e idealmente hacerlo de sólo
-//! lectura) se completa al implementar el builder.
+//! Inmutable, append-only, direccionado por `ArtifactHash`. `seal` mueve el árbol de salida
+//! del sandbox a la ruta del store y lo marca read-only recursivo.
use std::path::{Path, PathBuf};
@@ -34,16 +33,185 @@ impl Store {
self.path_of(h, name).is_dir()
}
+ /// Resuelve el directorio de un artefacto a partir de un prefijo del hex de su hash
+ /// (con o sin `b3:`), sin necesidad de conocer el `name`. Devuelve error si no hay
+ /// match o si el prefijo es ambiguo (lista los candidatos).
+ pub fn find_by_hash(&self, hex_or_prefixed: &str) -> crate::Result<PathBuf> {
+ let prefix = hex_or_prefixed
+ .strip_prefix("b3:")
+ .unwrap_or(hex_or_prefixed);
+ if prefix.is_empty() {
+ return Err(crate::Error::Store("hash vacío".into()));
+ }
+ let mut matches = Vec::new();
+ let read = std::fs::read_dir(&self.root)?;
+ for entry in read {
+ let entry = entry?;
+ let name = entry.file_name();
+ let Some(name) = name.to_str() else { continue };
+ if name.starts_with(prefix) && entry.file_type()?.is_dir() {
+ matches.push(entry.path());
+ }
+ }
+ match matches.len() {
+ 0 => Err(crate::Error::Store(format!(
+ "ningún artefacto en {} con prefix '{prefix}'",
+ self.root.display()
+ ))),
+ 1 => Ok(matches.pop().unwrap()),
+ _ => Err(crate::Error::Store(format!(
+ "prefix '{prefix}' ambiguo, {} candidatos: {}",
+ matches.len(),
+ matches
+ .iter()
+ .map(|p| p.display().to_string())
+ .collect::<Vec<_>>()
+ .join(", ")
+ ))),
+ }
+ }
+
/// Sella el árbol de salida de un build en el store bajo su hash.
- /// TODO(fase-0): mover `out_dir` a la ruta del store y marcarlo read-only.
+ ///
+ /// - Si el destino ya existe (caché), devuelve la ruta sin tocar `out_dir`.
+ /// - Si no, mueve `out_dir` por rename atómico al destino y marca el árbol read-only
+ /// (todos los archivos pierden `w`; directorios mantienen `x` para poder atravesarlos).
+ ///
+ /// Requisito: `out_dir` y el store deben estar en el mismo filesystem para que el rename
+ /// sea atómico. El lab garantiza esto colocando el tmpfs/DESTDIR del sandbox bajo el
+ /// mismo punto de montaje que el store, o haciendo una copia previa si no.
pub fn seal(
&self,
- _out_dir: &Path,
- _h: &ArtifactHash,
- _name: &str,
+ out_dir: &Path,
+ h: &ArtifactHash,
+ name: &str,
) -> crate::Result<PathBuf> {
- Err(crate::Error::Store(
- "sellado en el store pendiente (Fase 0)".into(),
- ))
+ let dst = self.path_of(h, name);
+ if dst.is_dir() {
+ return Ok(dst);
+ }
+ if let Some(parent) = dst.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ std::fs::rename(out_dir, &dst).map_err(|e| {
+ crate::Error::Store(format!(
+ "rename {} → {}: {e}",
+ out_dir.display(),
+ dst.display()
+ ))
+ })?;
+ make_tree_read_only(&dst)?;
+ Ok(dst)
+ }
+}
+
+/// Hace todo el árbol read-only. Archivos pierden `w` para todos los usuarios; directorios
+/// mantienen permisos suficientes para listarse y atravesarse, pero pierden `w`.
+fn make_tree_read_only(root: &Path) -> crate::Result<()> {
+ use std::os::unix::fs::PermissionsExt;
+
+ fn walk(path: &Path) -> std::io::Result<()> {
+ let meta = std::fs::symlink_metadata(path)?;
+ let ft = meta.file_type();
+ if ft.is_symlink() {
+ // No tocamos symlinks (chmod sigue el link en muchas plataformas; no merece la pena).
+ return Ok(());
+ }
+ if ft.is_dir() {
+ for entry in std::fs::read_dir(path)? {
+ walk(&entry?.path())?;
+ }
+ // Quitar bits de escritura del directorio (lectura+ejec sí, para poder atravesar).
+ let mut perms = meta.permissions();
+ perms.set_mode(perms.mode() & !0o222);
+ std::fs::set_permissions(path, perms)?;
+ } else {
+ let mut perms = meta.permissions();
+ perms.set_mode(perms.mode() & !0o222);
+ std::fs::set_permissions(path, perms)?;
+ }
+ Ok(())
+ }
+
+ walk(root).map_err(|e| crate::Error::Store(format!("chmod r/o en {}: {e}", root.display())))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn hash() -> ArtifactHash {
+ ArtifactHash::from_hex("deadbeef")
+ }
+
+ #[test]
+ fn seal_moves_tree_and_marks_readonly() {
+ let store_dir = tempfile::tempdir().unwrap();
+ let store = Store::open(store_dir.path()).unwrap();
+
+ let out = tempfile::tempdir_in(store_dir.path()).unwrap(); // mismo FS → rename atómico
+ std::fs::write(out.path().join("hola.bin"), b"x").unwrap();
+ std::fs::create_dir(out.path().join("sub")).unwrap();
+ std::fs::write(out.path().join("sub/anidado.bin"), b"y").unwrap();
+
+ // tempdir_in nos devuelve un wrapper que borra el dir al hacer Drop. Nosotros vamos
+ // a moverlo con rename; suprimimos el Drop quedándonos con el PathBuf.
+ let out_path = out.keep();
+ let dst = store.seal(&out_path, &hash(), "demo").unwrap();
+ assert!(dst.ends_with("deadbeef-demo"));
+ assert!(dst.is_dir());
+ assert!(dst.join("hola.bin").is_file());
+
+ // Read-only: intentar escribir debe fallar.
+ let write_err = std::fs::write(dst.join("hola.bin"), b"nuevo").unwrap_err();
+ assert_eq!(write_err.kind(), std::io::ErrorKind::PermissionDenied);
+
+ // Y has() lo ve.
+ assert!(store.has(&hash(), "demo"));
+ }
+
+ #[test]
+ fn find_by_hash_resolves_by_prefix() {
+ let store_dir = tempfile::tempdir().unwrap();
+ let store = Store::open(store_dir.path()).unwrap();
+ std::fs::create_dir(store.root().join("aaa111-foo")).unwrap();
+ std::fs::create_dir(store.root().join("bbb222-bar")).unwrap();
+ let p = store.find_by_hash("aaa1").unwrap();
+ assert!(p.ends_with("aaa111-foo"));
+ let p = store.find_by_hash("b3:bbb").unwrap();
+ assert!(p.ends_with("bbb222-bar"));
+ let err = store.find_by_hash("nope").unwrap_err().to_string();
+ assert!(err.contains("ningún artefacto"), "{err}");
+ }
+
+ #[test]
+ fn find_by_hash_ambiguous_lists_candidates() {
+ let store_dir = tempfile::tempdir().unwrap();
+ let store = Store::open(store_dir.path()).unwrap();
+ std::fs::create_dir(store.root().join("aaa111-foo")).unwrap();
+ std::fs::create_dir(store.root().join("aaa222-bar")).unwrap();
+ let err = store.find_by_hash("aaa").unwrap_err().to_string();
+ assert!(err.contains("ambiguo"), "{err}");
+ assert!(err.contains("aaa111-foo") && err.contains("aaa222-bar"), "{err}");
+ }
+
+ #[test]
+ fn seal_is_idempotent_when_dst_exists() {
+ let store_dir = tempfile::tempdir().unwrap();
+ let store = Store::open(store_dir.path()).unwrap();
+
+ // Pre-sellar una vez.
+ let first = tempfile::tempdir_in(store_dir.path()).unwrap();
+ std::fs::write(first.path().join("a"), b"1").unwrap();
+ let first_path = first.keep();
+ let p1 = store.seal(&first_path, &hash(), "demo").unwrap();
+
+ // Segundo intento: el out_dir no debe consumirse (devuelve la ruta cacheada).
+ let second = tempfile::tempdir_in(store_dir.path()).unwrap();
+ std::fs::write(second.path().join("b"), b"2").unwrap();
+ let p2 = store.seal(second.path(), &hash(), "demo").unwrap();
+
+ assert_eq!(p1, p2);
+ assert!(second.path().is_dir()); // intacto
}
}
diff --git a/crates/hammerd/src/main.rs b/crates/hammerd/src/main.rs
index 83960269..5f2ce63f 100644
--- a/crates/hammerd/src/main.rs
+++ b/crates/hammerd/src/main.rs
@@ -23,6 +23,7 @@ struct Args {
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
+ .with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),