diff --git a/crates/hammer-agent/src/client.rs b/crates/hammer-agent/src/client.rs index d6eca965..9ed1243a 100644 --- a/crates/hammer-agent/src/client.rs +++ b/crates/hammer-agent/src/client.rs @@ -162,6 +162,25 @@ impl AgentClient { what: "file".into(), path: Some(path.to_string()), name: None, + expr: None, + })?; + match self.recv_pending(timeout)? { + Event::QueryResult { value, .. } => Ok(value), + Event::Error { code, msg } => Err(ClientError::Bus { code, msg }), + other => Err(ClientError::Protocol(format!( + "esperaba QueryResult, llegó {other:?}" + ))), + } + } + + /// Evalúa una expresión del mini-lenguaje (`bin:grep`, `pin:musl`, …) en el daemon. + /// El JSON devuelto sigue el schema de `hammer_core::query::eval`. + pub fn query_expr(&mut self, expr: &str, timeout: Duration) -> Result { + self.send(&Command::Query { + what: "expr".into(), + path: None, + name: None, + expr: Some(expr.to_string()), })?; match self.recv_pending(timeout)? { Event::QueryResult { value, .. } => Ok(value), diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 890bc4bc..a93ca2c5 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -166,6 +166,24 @@ enum Cmd { #[arg(long, default_value = "/run/init.control")] fifo: PathBuf, }, + /// [Fase 6] Evalúa una expresión del mini-lenguaje (SDD 08 §6) contra el sistema local. + /// Forma: `kind:value`. Kinds soportados: `bin`, `file`, `pin`, `service`, `depends`. + /// Ejemplos: + /// hammer query bin:grep + /// hammer query file:/etc/hosts + /// hammer query depends:/usr/bin/curl + /// hammer query pin:musl --base-ref base.json + Query { + /// La expresión a evaluar. + expr: String, + /// Base local (JSON) para resolver `pin:`. Opcional. + #[arg(long)] + base_ref: Option, + /// Re-rootea las rutas absolutas bajo este prefix (útil para probar contra un overlay + /// o un prefix de test sin tocar el FHS real). + #[arg(long)] + fs_root: Option, + }, } fn print_event(ev: &hammer_journal::MutationEvent, format: &str) { @@ -347,6 +365,9 @@ fn main() -> anyhow::Result<()> { Cmd::Ctl { line, fifo } => { run_ctl(&line, &fifo)?; } + Cmd::Query { expr, base_ref, fs_root } => { + run_query(&expr, base_ref.as_deref(), fs_root.as_deref())?; + } } Ok(()) } @@ -745,6 +766,26 @@ fn run_ai( Ok(()) } +/// Evalúa una expresión del mini-lenguaje localmente e imprime el JSON resultante. +/// Espejo en proceso del path remoto vía bus (`AgentClient::query_expr`). +fn run_query( + expr: &str, + base_ref: Option<&std::path::Path>, + fs_root: Option<&std::path::Path>, +) -> anyhow::Result<()> { + let mut ctx = hammer_core::query::EvalContext::new(); + if let Some(b) = load_local_base(base_ref)? { + ctx = ctx.with_base(b); + } + if let Some(r) = fs_root { + ctx = ctx.with_fs_root(r.to_path_buf()); + } + let value = hammer_core::query::eval_str(expr, &ctx); + let json = serde_json::to_string_pretty(&value)?; + println!("{json}"); + Ok(()) +} + /// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería /// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un /// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito. diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs index a029a97a..e882df4c 100644 --- a/crates/hammer-core/src/lib.rs +++ b/crates/hammer-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod apply; pub mod hash; pub mod proto; +pub mod query; pub mod recipe; pub mod store; pub mod swm; diff --git a/crates/hammer-core/src/proto.rs b/crates/hammer-core/src/proto.rs index 9f93e36c..ea4f2834 100644 --- a/crates/hammer-core/src/proto.rs +++ b/crates/hammer-core/src/proto.rs @@ -38,13 +38,18 @@ pub enum Command { #[serde(default, skip_serializing_if = "Option::is_none")] overlay: Option, }, - /// Consulta de estado. `what` decide qué interpreta `path`/`name`. Hoy sólo `file`. + /// Consulta de estado. `what` decide qué campos se usan: + /// - `"file"`: usa `path` (metadata del archivo). + /// - `"artifact"`: usa `name` o `path` (busca un hash en el store). + /// - `"expr"`: usa `expr`, una expresión del mini-lenguaje (ver `hammer_core::query`). Query { what: String, #[serde(default, skip_serializing_if = "Option::is_none")] path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + expr: Option, }, /// Proxy autenticado al control humano (`/run/init.control`). Envía la línea cruda al /// FIFO; el cliente recibe `InitAck` o `Error`. La gating por capacidad `init`. @@ -246,7 +251,7 @@ mod tests { fn cap_required_for_basic_commands() { assert_eq!(Cap::required_for(&Command::Hello { ver: 1, client: "".into() }), None); assert_eq!( - Cap::required_for(&Command::Query { what: "file".into(), path: None, name: None }), + Cap::required_for(&Command::Query { what: "file".into(), path: None, name: None, expr: None }), Some(Cap::Query) ); assert_eq!( diff --git a/crates/hammer-core/src/query.rs b/crates/hammer-core/src/query.rs new file mode 100644 index 00000000..0baa143d --- /dev/null +++ b/crates/hammer-core/src/query.rs @@ -0,0 +1,577 @@ +//! Mini-lenguaje de consulta del sistema (SDD 08 §6). +//! +//! El objetivo es darle a la IA un vocabulario estable para referirse a partes del sistema +//! sin codificar rutas frágiles. En lugar de pedirle al modelo que diga `/usr/bin/grep`, +//! le permitimos pedir `bin:grep` y dejamos que el evaluador resuelva contra el `$PATH` +//! (o el `fs_root` que le pasamos). Lo mismo para servicios (`service:nginx`), +//! dependencias dinámicas (`depends:/usr/bin/foo`), pins de base (`pin:musl`) y metadata +//! de archivos (`file:/etc/hosts`). +//! +//! V0 (esta versión): un único término por expresión, sintaxis `kind:value`. Sin filtros, +//! sin pipes. El SDD 08 §6 esboza una forma más rica (`find … -where … -> …`); cuando +//! aparezca un caso de uso real para componer queries, extenderemos sin romper este shape. +//! +//! Diseñado para evaluarse: +//! - **Local**: la CLI puede correr el evaluador en proceso (`hammer query bin:grep`). +//! - **Remoto**: el daemon expone `Command::Query { what: "expr", expr: Some(...) }` y +//! reusa este evaluador. Útil para que un cliente sin acceso al disco (o sin caps) +//! consulte estado a través del bus. + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::BaseRef; + +#[derive(Debug, thiserror::Error)] +pub enum QueryError { + #[error("expresión vacía")] + Empty, + #[error("expresión sin separador ':' — esperaba 'kind:value', recibí '{0}'")] + NoColon(String), + #[error("kind '{0}' desconocido — válidos: bin, file, pin, service, depends")] + UnknownKind(String), + #[error("valor vacío para kind '{0}'")] + EmptyValue(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Term { + /// `bin:` — localiza un ejecutable en `$PATH` (o equivalente bajo `fs_root`). + Bin(String), + /// `file:` — metadata de un archivo (existe, modo, tamaño, …). + File(PathBuf), + /// `pin:` — devuelve el valor del pin en `BaseRef.pins`, si existe. + Pin(String), + /// `service:` — busca el script/launcher del servicio en los lugares + /// convencionales (`/etc/init.d/`, `/etc/service//run`, + /// `/run/service//run`). Devuelve el primero que encuentre. + Service(String), + /// `depends:` — para un ELF dinámico, lista las librerías NEEDED. + /// Para uno estático, devuelve `{static: true, needed: []}`. + Depends(PathBuf), +} + +/// Contexto del evaluador. Todos los campos son opcionales para soportar evaluación +/// "lo que se pueda" — un término que requiera un campo ausente devuelve un valor +/// con `error` legible, no un panic. +#[derive(Debug, Clone, Default)] +pub struct EvalContext { + /// Base local — usada por `pin:`. + pub base: Option, + /// Re-rooteo del filesystem. Si está, `bin:` busca bajo `/usr/bin`, + /// `/bin`, etc. en vez de `$PATH`; `file:`/`service:`/`depends:` re-rootean + /// también. Útil para evaluar contra un overlay o un prefix de test sin tocar el FHS real. + pub fs_root: Option, + /// `$PATH` a usar para `bin:` cuando no hay `fs_root`. Si `None`, usa el del proceso. + pub path_env: Option, +} + +impl EvalContext { + pub fn new() -> Self { + Self::default() + } + pub fn with_base(mut self, base: BaseRef) -> Self { + self.base = Some(base); + self + } + pub fn with_fs_root(mut self, root: impl Into) -> Self { + self.fs_root = Some(root.into()); + self + } + pub fn with_path_env(mut self, path: impl Into) -> Self { + self.path_env = Some(path.into()); + self + } +} + +pub fn parse(expr: &str) -> Result { + let s = expr.trim(); + if s.is_empty() { + return Err(QueryError::Empty); + } + let (kind, value) = s.split_once(':').ok_or_else(|| QueryError::NoColon(s.to_string()))?; + let kind = kind.trim(); + let value = value.trim(); + if value.is_empty() { + return Err(QueryError::EmptyValue(kind.to_string())); + } + match kind { + "bin" => Ok(Term::Bin(value.to_string())), + "file" => Ok(Term::File(PathBuf::from(value))), + "pin" => Ok(Term::Pin(value.to_string())), + "service" => Ok(Term::Service(value.to_string())), + "depends" => Ok(Term::Depends(PathBuf::from(value))), + other => Err(QueryError::UnknownKind(other.to_string())), + } +} + +pub fn eval(term: &Term, ctx: &EvalContext) -> Value { + match term { + Term::Bin(name) => eval_bin(name, ctx), + Term::File(p) => eval_file(p, ctx), + Term::Pin(name) => eval_pin(name, ctx), + Term::Service(name) => eval_service(name, ctx), + Term::Depends(p) => eval_depends(p, ctx), + } +} + +pub fn eval_str(expr: &str, ctx: &EvalContext) -> Value { + match parse(expr) { + Ok(t) => eval(&t, ctx), + Err(e) => serde_json::json!({ "error": e.to_string() }), + } +} + +fn rebase(p: &Path, fs_root: Option<&Path>) -> PathBuf { + match fs_root { + Some(r) => { + let rel = p.strip_prefix("/").unwrap_or(p); + r.join(rel) + } + None => p.to_path_buf(), + } +} + +fn eval_bin(name: &str, ctx: &EvalContext) -> Value { + // Defensa contra inyección: el nombre no debe traer separadores. Si los trae, el + // caller probablemente quería `file:` o `depends:`. + if name.contains('/') { + return serde_json::json!({ + "found": false, + "error": format!("bin: '{name}' contiene '/'; usa 'file:' o 'depends:' para rutas") + }); + } + let candidates: Vec = if let Some(root) = &ctx.fs_root { + ["usr/bin", "usr/sbin", "bin", "sbin"] + .iter() + .map(|d| root.join(d).join(name)) + .collect() + } else { + let path = ctx + .path_env + .clone() + .or_else(|| std::env::var("PATH").ok()) + .unwrap_or_else(|| "/usr/bin:/usr/sbin:/bin:/sbin".to_string()); + path.split(':') + .filter(|s| !s.is_empty()) + .map(|d| PathBuf::from(d).join(name)) + .collect() + }; + for cand in &candidates { + if cand.is_file() { + // Reusar eval_file para consistencia de schema. + let mut v = file_metadata(cand); + if let Some(obj) = v.as_object_mut() { + obj.insert("name".into(), Value::String(name.into())); + obj.insert("path".into(), Value::String(cand.display().to_string())); + } + return v; + } + } + serde_json::json!({ + "found": false, + "name": name, + "searched": candidates.iter().map(|p| p.display().to_string()).collect::>(), + }) +} + +fn file_metadata(p: &Path) -> Value { + use std::os::unix::fs::MetadataExt; + match std::fs::symlink_metadata(p) { + Ok(m) => serde_json::json!({ + "found": true, + "path": p.display().to_string(), + "size": m.size(), + "mode": m.mode(), + "uid": m.uid(), + "gid": m.gid(), + "is_dir": m.is_dir(), + "is_symlink": m.file_type().is_symlink(), + }), + Err(e) => serde_json::json!({ + "found": false, + "path": p.display().to_string(), + "error": e.to_string(), + }), + } +} + +fn eval_file(p: &Path, ctx: &EvalContext) -> Value { + let abs = rebase(p, ctx.fs_root.as_deref()); + file_metadata(&abs) +} + +fn eval_pin(name: &str, ctx: &EvalContext) -> Value { + match &ctx.base { + None => serde_json::json!({ + "found": false, + "name": name, + "error": "evaluador sin BaseRef", + }), + Some(base) => match base.pins.get(name) { + Some(v) => serde_json::json!({ + "found": true, + "name": name, + "value": v, + }), + None => serde_json::json!({ + "found": false, + "name": name, + "available": base.pins.keys().collect::>(), + }), + }, + } +} + +fn eval_service(name: &str, ctx: &EvalContext) -> Value { + if name.contains('/') { + return serde_json::json!({ + "found": false, + "error": format!("service: '{name}' contiene '/'"), + }); + } + let root = ctx.fs_root.as_deref(); + let candidates = [ + rebase(Path::new(&format!("/etc/init.d/{name}")), root), + rebase(Path::new(&format!("/etc/service/{name}/run")), root), + rebase(Path::new(&format!("/run/service/{name}/run")), root), + ]; + for c in &candidates { + if c.is_file() { + return serde_json::json!({ + "found": true, + "name": name, + "launcher": c.display().to_string(), + }); + } + } + serde_json::json!({ + "found": false, + "name": name, + "searched": candidates.iter().map(|p| p.display().to_string()).collect::>(), + }) +} + +fn eval_depends(p: &Path, ctx: &EvalContext) -> Value { + let abs = rebase(p, ctx.fs_root.as_deref()); + let bytes = match std::fs::read(&abs) { + Ok(b) => b, + Err(e) => { + return serde_json::json!({ + "found": false, + "path": abs.display().to_string(), + "error": e.to_string(), + }); + } + }; + match parse_elf_needed(&bytes) { + Ok(ElfInfo::Static) => serde_json::json!({ + "found": true, + "path": abs.display().to_string(), + "static": true, + "needed": Vec::::new(), + }), + Ok(ElfInfo::Dynamic(needed)) => serde_json::json!({ + "found": true, + "path": abs.display().to_string(), + "static": false, + "needed": needed, + }), + Err(e) => serde_json::json!({ + "found": false, + "path": abs.display().to_string(), + "error": e, + }), + } +} + +enum ElfInfo { + Static, + Dynamic(Vec), +} + +/// Parser ELF mínimo para extraer `DT_NEEDED`. Sólo el caso 64-bit little-endian +/// (lo que produce zig-cc para `x86_64-linux-musl` y lo único que generamos). Para otros +/// devolvemos error legible: el caller obtiene `error` en el JSON y el agente puede +/// pedir otra cosa. +fn parse_elf_needed(bytes: &[u8]) -> Result { + // ELF header: 16 bytes ident + e_type(2) + e_machine(2) + e_version(4) + e_entry(8) + + // e_phoff(8) + e_shoff(8) + e_flags(4) + e_ehsize(2) + e_phentsize(2) + e_phnum(2) + + // e_shentsize(2) + e_shnum(2) + e_shstrndx(2) + if bytes.len() < 64 { + return Err("archivo demasiado pequeño para ser ELF64".into()); + } + if &bytes[0..4] != b"\x7fELF" { + return Err("no es un ELF (magic 0x7fELF ausente)".into()); + } + if bytes[4] != 2 { + return Err("sólo ELF64 soportado (EI_CLASS != 2)".into()); + } + if bytes[5] != 1 { + return Err("sólo little-endian soportado (EI_DATA != 1)".into()); + } + let e_phoff = u64::from_le_bytes(bytes[32..40].try_into().unwrap()) as usize; + let e_phentsize = u16::from_le_bytes(bytes[54..56].try_into().unwrap()) as usize; + let e_phnum = u16::from_le_bytes(bytes[56..58].try_into().unwrap()) as usize; + // Buscamos PT_DYNAMIC (=2). + let mut dyn_off: Option = None; + let mut dyn_size: Option = None; + for i in 0..e_phnum { + let off = e_phoff + i * e_phentsize; + if off + 56 > bytes.len() { + return Err("PHDR fuera de rango".into()); + } + let p_type = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap()); + if p_type == 2 { + // PT_DYNAMIC + let p_offset = u64::from_le_bytes(bytes[off + 8..off + 16].try_into().unwrap()) as usize; + let p_filesz = u64::from_le_bytes(bytes[off + 32..off + 40].try_into().unwrap()) as usize; + dyn_off = Some(p_offset); + dyn_size = Some(p_filesz); + break; + } + } + let (dyn_off, dyn_size) = match (dyn_off, dyn_size) { + (Some(o), Some(s)) => (o, s), + _ => return Ok(ElfInfo::Static), + }; + // Recorremos las entradas DT_*; cada una es 16 bytes (d_tag i64 + d_val u64). + // Acumulamos los offsets de DT_NEEDED y la dirección de DT_STRTAB. + let mut needed_offsets: Vec = Vec::new(); + let mut strtab_addr: Option = None; + let mut strtab_size: Option = None; + let end = dyn_off + dyn_size; + let mut cur = dyn_off; + while cur + 16 <= end && cur + 16 <= bytes.len() { + let d_tag = i64::from_le_bytes(bytes[cur..cur + 8].try_into().unwrap()); + let d_val = u64::from_le_bytes(bytes[cur + 8..cur + 16].try_into().unwrap()); + cur += 16; + match d_tag { + 0 => break, // DT_NULL + 1 => needed_offsets.push(d_val as usize), // DT_NEEDED + 5 => strtab_addr = Some(d_val), // DT_STRTAB (virtual address) + 10 => strtab_size = Some(d_val), // DT_STRSZ + _ => {} + } + } + if needed_offsets.is_empty() { + return Ok(ElfInfo::Dynamic(vec![])); + } + let strtab_addr = strtab_addr.ok_or("DT_STRTAB ausente pese a DT_NEEDED")?; + let strtab_size = strtab_size.unwrap_or(0); + // DT_STRTAB es una virtual address; necesitamos el offset en el archivo. Recorremos + // PHDR otra vez para encontrar el segmento PT_LOAD que la contenga. + let mut strtab_file_off: Option = None; + for i in 0..e_phnum { + let off = e_phoff + i * e_phentsize; + let p_type = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap()); + if p_type != 1 { + // PT_LOAD + continue; + } + let p_offset = u64::from_le_bytes(bytes[off + 8..off + 16].try_into().unwrap()); + let p_vaddr = u64::from_le_bytes(bytes[off + 16..off + 24].try_into().unwrap()); + let p_filesz = u64::from_le_bytes(bytes[off + 32..off + 40].try_into().unwrap()); + if strtab_addr >= p_vaddr && strtab_addr < p_vaddr + p_filesz { + strtab_file_off = Some((strtab_addr - p_vaddr + p_offset) as usize); + break; + } + } + let strtab_off = strtab_file_off.ok_or("DT_STRTAB no cae en ningún PT_LOAD")?; + let strtab_end = if strtab_size > 0 { + (strtab_off + strtab_size as usize).min(bytes.len()) + } else { + bytes.len() + }; + let mut needed: Vec = Vec::new(); + for o in needed_offsets { + let start = strtab_off + o; + if start >= strtab_end { + continue; + } + let s = &bytes[start..strtab_end]; + let end = s.iter().position(|&b| b == 0).unwrap_or(s.len()); + if let Ok(name) = std::str::from_utf8(&s[..end]) { + needed.push(name.to_string()); + } + } + Ok(ElfInfo::Dynamic(needed)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn parse_basic() { + assert_eq!(parse("bin:grep").unwrap(), Term::Bin("grep".into())); + assert_eq!(parse("file:/etc/hosts").unwrap(), Term::File("/etc/hosts".into())); + assert_eq!(parse("pin:musl").unwrap(), Term::Pin("musl".into())); + assert_eq!(parse("service:nginx").unwrap(), Term::Service("nginx".into())); + assert_eq!(parse("depends:/bin/ls").unwrap(), Term::Depends("/bin/ls".into())); + } + + #[test] + fn parse_trims_whitespace() { + assert_eq!(parse(" bin : grep ").unwrap(), Term::Bin("grep".into())); + } + + #[test] + fn parse_rejects_empty() { + assert!(matches!(parse("").unwrap_err(), QueryError::Empty)); + assert!(matches!(parse(" ").unwrap_err(), QueryError::Empty)); + } + + #[test] + fn parse_rejects_missing_colon() { + assert!(matches!(parse("bingrep").unwrap_err(), QueryError::NoColon(_))); + } + + #[test] + fn parse_rejects_unknown_kind() { + let e = parse("foo:bar").unwrap_err(); + assert!(matches!(e, QueryError::UnknownKind(ref k) if k == "foo")); + } + + #[test] + fn parse_rejects_empty_value() { + assert!(matches!(parse("bin:").unwrap_err(), QueryError::EmptyValue(_))); + } + + #[test] + fn eval_bin_finds_under_fs_root() { + let d = tempfile::tempdir().unwrap(); + let bin = d.path().join("usr/bin/grep"); + std::fs::create_dir_all(bin.parent().unwrap()).unwrap(); + std::fs::write(&bin, b"#!/bin/sh\n").unwrap(); + let v = eval( + &Term::Bin("grep".into()), + &EvalContext::new().with_fs_root(d.path().to_path_buf()), + ); + assert_eq!(v["found"], Value::Bool(true)); + assert!(v["path"].as_str().unwrap().ends_with("usr/bin/grep")); + } + + #[test] + fn eval_bin_not_found_lists_searched() { + let d = tempfile::tempdir().unwrap(); + let v = eval( + &Term::Bin("nonexistent_xyz".into()), + &EvalContext::new().with_fs_root(d.path().to_path_buf()), + ); + assert_eq!(v["found"], Value::Bool(false)); + assert_eq!(v["name"], Value::String("nonexistent_xyz".into())); + assert!(v["searched"].as_array().unwrap().len() >= 1); + } + + #[test] + fn eval_bin_uses_path_env_when_no_fs_root() { + let d = tempfile::tempdir().unwrap(); + let bin = d.path().join("grep"); + std::fs::write(&bin, b"#!/bin/sh\n").unwrap(); + let v = eval( + &Term::Bin("grep".into()), + &EvalContext::new().with_path_env(d.path().display().to_string()), + ); + assert_eq!(v["found"], Value::Bool(true)); + } + + #[test] + fn eval_bin_rejects_slashes() { + let v = eval(&Term::Bin("usr/bin/grep".into()), &EvalContext::new()); + assert_eq!(v["found"], Value::Bool(false)); + assert!(v["error"].as_str().unwrap().contains("contiene '/'")); + } + + #[test] + fn eval_file_rebase() { + let d = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(d.path().join("etc")).unwrap(); + std::fs::write(d.path().join("etc/hosts"), b"127.0.0.1 lo\n").unwrap(); + let v = eval( + &Term::File("/etc/hosts".into()), + &EvalContext::new().with_fs_root(d.path().to_path_buf()), + ); + assert_eq!(v["found"], Value::Bool(true)); + assert_eq!(v["size"], Value::Number(13u64.into())); + } + + #[test] + fn eval_pin_found_and_missing() { + let mut pins = BTreeMap::new(); + pins.insert("musl".into(), "deadbeef".into()); + let base = BaseRef { + distro_version: "2026-06-06".into(), + pins, + }; + let ctx = EvalContext::new().with_base(base); + let v = eval(&Term::Pin("musl".into()), &ctx); + assert_eq!(v["value"], Value::String("deadbeef".into())); + let v2 = eval(&Term::Pin("zlib".into()), &ctx); + assert_eq!(v2["found"], Value::Bool(false)); + assert!(v2["available"].as_array().unwrap().iter().any(|s| s == "musl")); + } + + #[test] + fn eval_pin_without_base() { + let v = eval(&Term::Pin("x".into()), &EvalContext::new()); + assert_eq!(v["found"], Value::Bool(false)); + assert!(v["error"].as_str().unwrap().contains("sin BaseRef")); + } + + #[test] + fn eval_service_finds_initd() { + let d = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(d.path().join("etc/init.d")).unwrap(); + std::fs::write(d.path().join("etc/init.d/web"), b"#!/bin/sh\n").unwrap(); + let v = eval( + &Term::Service("web".into()), + &EvalContext::new().with_fs_root(d.path().to_path_buf()), + ); + assert_eq!(v["found"], Value::Bool(true)); + assert!(v["launcher"].as_str().unwrap().ends_with("etc/init.d/web")); + } + + #[test] + fn eval_str_parse_error_surfaces_in_json() { + let v = eval_str("foo:bar", &EvalContext::new()); + assert!(v["error"].as_str().unwrap().contains("kind 'foo' desconocido")); + } + + #[test] + fn parse_elf_rejects_short() { + let r = parse_elf_needed(&[0u8; 8]); + assert!(r.is_err()); + } + + #[test] + fn parse_elf_rejects_bad_magic() { + let mut bytes = [0u8; 64]; + bytes[0..4].copy_from_slice(b"XXXX"); + assert!(parse_elf_needed(&bytes).is_err()); + } + + /// Sanity con un binario real del host: `/bin/sh` casi seguro existe y es ELF. + /// Gated por `HAMMER_HOST_ELF_TESTS=1` para no acoplar el suite a Linux/Alpine. + #[test] + fn parse_elf_real_host_binary() { + if std::env::var("HAMMER_HOST_ELF_TESTS").is_err() { + return; + } + let candidates = ["/bin/sh", "/usr/bin/sh", "/bin/ls", "/usr/bin/ls"]; + let path = candidates + .iter() + .find(|p| std::path::Path::new(p).is_file()) + .expect("ningún binario host encontrado"); + let v = eval(&Term::Depends(PathBuf::from(path)), &EvalContext::new()); + assert_eq!(v["found"], Value::Bool(true), "{v}"); + let is_static = v["static"].as_bool().unwrap(); + let needed = v["needed"].as_array().unwrap(); + if !is_static { + // Si es dinámico debe declarar al menos una librería (libc). + assert!(!needed.is_empty(), "binario dinámico sin NEEDED: {v}"); + } + } +} diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs index 1c23f5af..14879515 100644 --- a/crates/hammerd/src/bus.rs +++ b/crates/hammerd/src/bus.rs @@ -290,7 +290,7 @@ fn dispatch( files: report.files.len(), }); } - Command::Query { what, path, name } => { + Command::Query { what, path, name, expr } => { let value = match what.as_str() { "file" => { let Some(p) = path else { @@ -312,6 +312,19 @@ fn dispatch( }; query_artifact(&ctx.store_root, h) } + "expr" => { + let Some(e) = expr.as_deref() else { + let _ = tx.send(Event::Error { + code: "bad_query".into(), + msg: "query 'expr' requiere 'expr' (la expresión)".into(), + }); + return; + }; + // El daemon evalúa con el `fs_root` del sistema host (None ⇒ rutas + // absolutas tal y como vienen). Si el .swm necesitara otra base, el + // cliente puede evaluar localmente con un EvalContext distinto. + hammer_core::query::eval_str(e, &hammer_core::query::EvalContext::new()) + } other => { let _ = tx.send(Event::Error { code: "unknown_query".into(), diff --git a/crates/hammerd/tests/bus_e2e.rs b/crates/hammerd/tests/bus_e2e.rs index 4ead0c24..6a80c8b0 100644 --- a/crates/hammerd/tests/bus_e2e.rs +++ b/crates/hammerd/tests/bus_e2e.rs @@ -183,6 +183,7 @@ fn query_file_returns_value_for_existing_path() { what: "file".into(), path: Some(target.display().to_string()), name: None, + expr: None, }, ); match recv_event(&mut reader) { @@ -195,6 +196,65 @@ fn query_file_returns_value_for_existing_path() { } } +#[test] +fn query_expr_evaluates_against_host_path() { + let d = tempfile::tempdir().unwrap(); + let sock = d.path().join("agent.sock"); + start_bus( + &sock, + permissive_policy(), + d.path().join("store"), + d.path().join("init.ctl"), + ); + + // file: sobre un archivo real; el daemon evalúa con fs_root=None ⇒ ruta absoluta. + let target = d.path().join("payload.bin"); + std::fs::write(&target, b"X").unwrap(); + + let stream = wait_for_sock(&sock); + let mut writer = stream.try_clone().unwrap(); + let mut reader = BufReader::new(stream); + send(&mut writer, &Command::Hello { ver: 1, client: "t".into() }); + let _ = recv_event(&mut reader); // welcome + + send( + &mut writer, + &Command::Query { + what: "expr".into(), + path: None, + name: None, + expr: Some(format!("file:{}", target.display())), + }, + ); + match recv_event(&mut reader) { + Event::QueryResult { what, value } => { + assert_eq!(what, "expr"); + assert_eq!(value["found"], serde_json::json!(true)); + assert_eq!(value["size"], serde_json::json!(1)); + } + other => panic!("esperaba QueryResult, llegó {other:?}"), + } + + // Y una expresión inválida vuelve como JSON con `error`, no como Event::Error + // (la query es bien formada; el problema es el contenido). + send( + &mut writer, + &Command::Query { + what: "expr".into(), + path: None, + name: None, + expr: Some("foo:bar".into()), + }, + ); + match recv_event(&mut reader) { + Event::QueryResult { what, value } => { + assert_eq!(what, "expr"); + assert!(value["error"].as_str().unwrap().contains("kind 'foo'")); + } + other => panic!("esperaba QueryResult, llegó {other:?}"), + } +} + #[test] fn modified_events_fan_out_to_subscribers() { // Validamos sólo el camino del fan-out a través del bus, sin watcher real: diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 8a925467..2a753b80 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -118,8 +118,10 @@ pre-requisito de validación. - 1 e2e del cliente contra un *stub* del bus (handshake + Compile→BuildReady + Modified asíncrono). - [ ] Traductor LLM real (Claude API u otro), opcional vía feature flag o crate aparte. -- [ ] Lenguaje de consulta del sistema (SDD 08 §6) para que la IA refiera servicios y - archivos sin rutas frágiles. +- [x] Lenguaje de consulta del sistema (SDD 08 §6) para que la IA refiera servicios y + archivos sin rutas frágiles. Forma `kind:value` (`bin`, `file`, `pin`, `service`, + `depends`), evaluable local (`hammer query `) y remoto vía bus + (`Command::Query{what:"expr"}`). Parser ELF64 mínimo para extraer `DT_NEEDED`. - [ ] Bucle de auto-reparación (cliente reacciona a `Crashed` con un nuevo plan). - **Hecho cuando:** una intención en lenguaje natural produce un cambio probado en overlay, presentado para `commit` humano. ✅ Demostrado por `hammer ai` con `MockTranslator`: