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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
8bf1623044
commit
7b362e9d6a
@@ -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
|
||||
|
||||
Generated
+334
-1
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
+102
-29
@@ -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)
|
||||
}
|
||||
|
||||
/// Proyecta un artefacto del store al FHS (real o de overlay). Ver `docs/03-hydration.md`.
|
||||
/// 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). 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)
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+178
-10
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user