Scaffold inicial: workspace Rust + SDDs completos

Arranque del proyecto hammer (distro AI-nativa: laboratorio funcional en el
sótano, terminal mutable clásica arriba, integración de IA programadora).

- Workspace Rust (compila, tests verdes): hammer-core, hammer-build,
  hammer-cli (bin `hammer`), hammerd.
- SDDs 00-10 + 6 ADRs en docs/ con toda la arquitectura.
- Esqueletos navegables mapeados a las fases del roadmap; Fase 0/1 listas
  para implementar el sandbox real.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-06-06 19:06:04 +00:00
co-authored by Claude Opus 4.8
commit 8bf1623044
37 changed files with 2746 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "hammer-build"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "El laboratorio de hammer: sandbox de build (bubblewrap + zig cc) e hidratación."
[dependencies]
hammer-core.workspace = true
anyhow.workspace = true
thiserror.workspace = true
tracing.workspace = true
+55
View File
@@ -0,0 +1,55 @@
//! 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 hammer_core::{ArtifactHash, LinkMode, Recipe, Store};
pub mod 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> {
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;
}
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> {
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)"
)))
}
/// Proyecta un artefacto del store al FHS (real o de overlay). Ver `docs/03-hydration.md`.
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)"
)))
}
+41
View File
@@ -0,0 +1,41 @@
//! 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.
use std::path::PathBuf;
/// Descripción de un sandbox de build a montar con `bwrap`.
#[derive(Debug, Clone, Default)]
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,
}
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(),
"/".into(),
"--proc".into(),
"/proc".into(),
"--dev".into(),
"/dev".into(),
];
if !self.isolate_net {
args.push("--share-net".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());
}
args
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "hammer-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "El binario `hammer`: orquesta build, hydrate, try/commit, apply/export y ctl."
[[bin]]
name = "hammer"
path = "src/main.rs"
[dependencies]
hammer-core.workspace = true
hammer-build.workspace = true
anyhow.workspace = true
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
+99
View File
@@ -0,0 +1,99 @@
//! `hammer` — el punto de entrada de todo flujo manual. Ver `docs/`.
//!
//! Los subcomandos están mapeados a las fases del roadmap (`docs/10-roadmap.md`). Los que aún
//! no están implementados devuelven un error claro indicando su fase, para que el esqueleto
//! sea navegable desde el día uno.
use clap::{Parser, Subcommand};
const DEFAULT_STORE: &str = "/store";
#[derive(Parser)]
#[command(
name = "hammer",
version,
about = "Laboratorio funcional en el sótano, terminal mutable arriba.",
long_about = "hammer: distro AI-nativa. Ver docs/ para el diseño completo."
)]
struct Cli {
/// Ruta del content-addressed store.
#[arg(long, default_value = DEFAULT_STORE, global = true)]
store: String,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// [Fase 0] Compila una receta y la sella en el store.
Build {
/// Ruta a la receta TOML.
recipe: String,
},
/// [Fase 1] Proyecta un artefacto del store al FHS (real o de overlay).
Hydrate {
hash: String,
#[arg(long, default_value = "/")]
into: String,
},
/// [Fase 2] Monta un overlay de experimentación sobre los directorios del sistema.
Try,
/// [Fase 2] Fusiona el overlay activo al FHS real (registra en el diario).
Commit,
/// [Fase 2] Descarta el overlay activo; vuelve al estado base.
Discard,
/// [Fase 2] Muestra el estado de overlays activos.
Status,
/// [Fase 3] Ver/seguir el diario de mutaciones.
Journal,
/// [Fase 4] Aplica un manifiesto .swm (reproduce y lo deja en un overlay).
Apply { file: String },
/// [Fase 4] Exporta el delta del sistema como manifiesto .swm.
Export {
#[arg(long)]
base: Option<String>,
},
/// [Fase 5] Envía un comando al init (proxy a /run/init.control).
Ctl { line: String },
}
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let cli = Cli::parse();
let store = hammer_core::Store::open(&cli.store)?;
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)?;
println!("{hash}");
}
Cmd::Hydrate { hash, into } => {
println!("[fase 1 pendiente] hydrate {hash} into {into}");
}
Cmd::Try | Cmd::Commit | Cmd::Discard | Cmd::Status => {
println!("[fase 2 pendiente] overlay — ver docs/04-overlay.md");
}
Cmd::Journal => {
println!("[fase 3 pendiente] diario — ver docs/05-journal.md");
}
Cmd::Apply { file } => {
println!("[fase 4 pendiente] apply {file} — ver docs/06-swm-format.md");
}
Cmd::Export { base } => {
println!("[fase 4 pendiente] export base={base:?} — ver docs/05-journal.md");
}
Cmd::Ctl { line } => {
println!("[fase 5 pendiente] ctl {line:?} — ver docs/07-agent-bus.md");
}
}
Ok(())
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "hammer-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "Tipos compartidos de hammer: Recipe, Swm, hashing CAS y el store."
[dependencies]
anyhow.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
blake3.workspace = true
+66
View File
@@ -0,0 +1,66 @@
//! Direccionamiento por contenido (CAS). Ver `docs/02-build-lab.md` §2.
//!
//! El `ArtifactHash` identifica un artefacto por TODO lo que influye en su salida:
//! commit fuente + parches + flags/compilador/target + hashes de dependencias.
use serde::{Deserialize, Serialize};
/// Hash BLAKE3 de un artefacto, con prefijo legible `b3:`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ArtifactHash(String);
impl ArtifactHash {
/// Construye desde bytes ya hasheados (representación hex).
pub fn from_hex(hex: impl Into<String>) -> Self {
ArtifactHash(format!("b3:{}", hex.into()))
}
/// Hashea un conjunto ordenado de entradas. El llamador es responsable de pasar las
/// entradas en orden canónico y estable (ver `docs/02-build-lab.md` §2).
pub fn of_inputs(inputs: &[&[u8]]) -> Self {
let mut hasher = blake3::Hasher::new();
for chunk in inputs {
// length-prefijado para evitar colisiones por concatenación ambigua.
hasher.update(&(chunk.len() as u64).to_le_bytes());
hasher.update(chunk);
}
ArtifactHash(format!("b3:{}", hasher.finalize().to_hex()))
}
/// Forma corta para directorios del store: `<hash-sin-prefijo>-<name>`.
pub fn store_dir_name(&self, name: &str) -> String {
let bare = self.0.strip_prefix("b3:").unwrap_or(&self.0);
format!("{bare}-{name}")
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ArtifactHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic_and_order_sensitive() {
let a = ArtifactHash::of_inputs(&[b"grep", b"abc123", b"--static"]);
let b = ArtifactHash::of_inputs(&[b"grep", b"abc123", b"--static"]);
assert_eq!(a, b, "misma entrada ⇒ mismo hash");
let c = ArtifactHash::of_inputs(&[b"abc123", b"grep", b"--static"]);
assert_ne!(a, c, "orden distinto ⇒ hash distinto");
}
#[test]
fn store_dir_name_strips_prefix() {
let h = ArtifactHash::from_hex("deadbeef");
assert_eq!(h.store_dir_name("grep"), "deadbeef-grep");
}
}
+32
View File
@@ -0,0 +1,32 @@
//! Tipos núcleo de hammer, compartidos por el lab, la CLI y el daemon.
//!
//! Ver `docs/01-architecture.md` y siguientes. Esto es el esqueleto de Fase 0: los tipos y
//! contratos están definidos; la lógica pesada (sandbox, fanotify, bus) vive en los otros
//! crates y se irá rellenando por fase.
pub mod hash;
pub mod recipe;
pub mod store;
pub mod swm;
pub use hash::ArtifactHash;
pub use recipe::{Compiler, LinkMode, Recipe};
pub use store::Store;
pub use swm::Swm;
/// Error común del ecosistema hammer.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("serialización: {0}")]
Serde(String),
#[error("receta inválida: {0}")]
Recipe(String),
#[error("store: {0}")]
Store(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
+109
View File
@@ -0,0 +1,109 @@
//! La `Recipe`: descripción pura de un build. Ver `docs/02-build-lab.md` §1.
use serde::{Deserialize, Serialize};
use crate::hash::ArtifactHash;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recipe {
pub name: String,
pub version: String,
pub source: Source,
pub build: Build,
#[serde(default)]
pub deps: Deps,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Source {
pub repo: String,
/// Commit FIJADO. Nunca "HEAD". Ver ADR 0006.
pub commit: String,
#[serde(default)]
pub patches: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Build {
#[serde(default)]
pub compiler: Compiler,
#[serde(default = "default_target")]
pub target: String,
#[serde(default)]
pub link: LinkMode,
#[serde(default)]
pub flags: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Deps {
#[serde(default)]
pub build: Vec<String>,
#[serde(default)]
pub runtime: Vec<String>,
}
/// Compilador del lab, POR RECETA (no global). `zig-cc` por defecto; escotilla a clang/gcc
/// para paquetes con gcc-ismos. Ver ADR 0003.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Compiler {
#[default]
ZigCc,
Clang,
Gcc,
}
impl Compiler {
pub fn as_str(&self) -> &'static str {
match self {
Compiler::ZigCc => "zig-cc",
Compiler::Clang => "clang",
Compiler::Gcc => "gcc",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LinkMode {
#[default]
Static,
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(),
))
}
/// 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>> {
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(),
];
for p in &self.source.patches {
v.push(p.as_bytes().to_vec());
}
for f in &self.build.flags {
v.push(f.as_bytes().to_vec());
}
for d in dep_hashes {
v.push(d.as_str().as_bytes().to_vec());
}
v
}
}
+49
View File
@@ -0,0 +1,49 @@
//! 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.
use std::path::{Path, PathBuf};
use crate::hash::ArtifactHash;
pub struct Store {
root: PathBuf,
}
impl Store {
/// Abre (o prepara) un store en `root` (p. ej. `/store`).
pub fn open(root: impl Into<PathBuf>) -> crate::Result<Self> {
let root = root.into();
std::fs::create_dir_all(&root)?;
Ok(Store { root })
}
pub fn root(&self) -> &Path {
&self.root
}
/// Ruta del artefacto en el store para un hash + nombre legible.
pub fn path_of(&self, h: &ArtifactHash, name: &str) -> PathBuf {
self.root.join(h.store_dir_name(name))
}
/// ¿Ya existe el artefacto? (caché del lab; ver `docs/02-build-lab.md` §5).
pub fn has(&self, h: &ArtifactHash, name: &str) -> bool {
self.path_of(h, name).is_dir()
}
/// 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.
pub fn seal(
&self,
_out_dir: &Path,
_h: &ArtifactHash,
_name: &str,
) -> crate::Result<PathBuf> {
Err(crate::Error::Store(
"sellado en el store pendiente (Fase 0)".into(),
))
}
}
+121
View File
@@ -0,0 +1,121 @@
//! El manifiesto `.swm` (Software Mutación). Ver `docs/06-swm-format.md`.
//!
//! Es la unidad de intercambio: receta de transformación sobre fuente pública + ediciones de
//! config. NUNCA transporta binarios cocidos (salvo `FileDrop` con hash declarado).
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Swm {
pub swm_version: u32,
pub base: Base,
pub mutations: Vec<Mutation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<Signature>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Base {
pub distro_version: String,
#[serde(default)]
pub pins: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Mutation {
SourcePatch {
repo: String,
commit: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
patch_url: Option<String>,
build: SwmBuild,
target_bin: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_hash: Option<String>,
},
ConfigEdit {
file: String,
inline_diff: String,
},
InitRule {
action: String,
service: String,
command: String,
},
FileDrop {
path: String,
/// hash BLAKE3 del contenido, declarado para verificación.
content_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_url: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwmBuild {
#[serde(default = "default_compiler")]
pub compiler: String,
#[serde(default = "default_target")]
pub target: String,
#[serde(default = "default_link")]
pub link: String,
#[serde(default)]
pub flags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Signature {
pub by: String,
pub alg: String,
pub sig: String,
}
fn default_compiler() -> String {
"zig-cc".into()
}
fn default_target() -> String {
"x86_64-linux-musl".into()
}
fn default_link() -> String {
"static".into()
}
impl Swm {
pub fn from_yaml(s: &str) -> crate::Result<Swm> {
serde_yaml::from_str(s).map_err(|e| crate::Error::Serde(e.to_string()))
}
pub fn to_yaml(&self) -> crate::Result<String> {
serde_yaml::to_string(self).map_err(|e| crate::Error::Serde(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_yaml() {
let yaml = r#"
swm_version: 1
base:
distro_version: "2026-06-06"
pins:
grep: "a1b2c3d"
mutations:
- type: config_edit
file: "/etc/network.conf"
inline_diff: |
- DHCP=yes
+ IP=192.168.1.100
"#;
let swm = Swm::from_yaml(yaml).expect("parse");
assert_eq!(swm.swm_version, 1);
assert_eq!(swm.mutations.len(), 1);
let back = swm.to_yaml().expect("serialize");
assert!(back.contains("config_edit"));
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "hammerd"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "Daemon de hammer: bus de agente (/run/agent.sock) y diario de mutaciones (fanotify)."
[[bin]]
name = "hammerd"
path = "src/main.rs"
[dependencies]
hammer-core.workspace = true
anyhow.workspace = true
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
+44
View File
@@ -0,0 +1,44 @@
//! `hammerd` — daemon de hammer. Dos responsabilidades:
//! 1. Bus de agente: /run/agent.sock (JSON-líneas, SO_PEERCRED). Ver `docs/07-agent-bus.md`.
//! 2. Diario de mutaciones: fanotify sobre /bin,/sbin,/lib,/etc. Ver `docs/05-journal.md`.
//!
//! Esqueleto de arranque. Las dos subsistemas (Fases 3 y 5) se implementarán por separado;
//! aquí queda el binario navegable y el cableado básico de logging/argumentos.
use clap::Parser;
#[derive(Parser)]
#[command(name = "hammerd", version, about = "Daemon de hammer: bus de agente + diario.")]
struct Args {
/// Socket del bus de agente.
#[arg(long, default_value = "/run/agent.sock")]
agent_sock: String,
/// FIFO de control humano del init.
#[arg(long, default_value = "/run/init.control")]
init_control: String,
/// Directorio del diario de mutaciones.
#[arg(long, default_value = "/var/lib/hammer/journal")]
journal: String,
}
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let args = Args::parse();
tracing::info!(
agent_sock = %args.agent_sock,
init_control = %args.init_control,
journal = %args.journal,
"hammerd: arranque (esqueleto)"
);
// TODO(fase-3): iniciar el watcher fanotify → diario.
// TODO(fase-5): servir el bus de agente en agent_sock (JSON-líneas, SO_PEERCRED).
tracing::warn!("subsistemas pendientes: diario (Fase 3) y bus de agente (Fase 5)");
Ok(())
}