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 `/repos/.git` y deja el árbol +/// trabajable (con el commit fijado en HEAD) en `/sources/-/`. +/// Devuelve la ruta del árbol trabajable. +pub fn fetch(recipe: &Recipe, work_root: &Path) -> hammer_core::Result { + let repos_dir = work_root.join("repos"); + let sources_dir = work_root.join("sources"); + std::fs::create_dir_all(&repos_dir)?; + 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 | 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 ` 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, + pub dirs_created: usize, + pub symlinks: usize, +} + +/// Proyecta el contenido de `artifact_dir` al árbol bajo `target_fhs`. Conserva la jerarquía +/// relativa: `/usr/bin/grep` → `/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 { + 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 { +pub fn artifact_hash(recipe: &Recipe, _store: &Store) -> hammer_core::Result { let dep_hashes: Vec = 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 { +pub fn build( + recipe: &Recipe, + cfg: &BuildConfig, + store: &Store, +) -> hammer_core::Result { 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 { + 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 { + 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 { + 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 { - 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 { + let mut args: Vec = 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 +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, + /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub install: Option, } #[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 { - // 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 { + 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> { + /// 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) -> crate::Result { + 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>> { let mut v: Vec> = 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 { + 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::>() + .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 { - 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()),