Fase 6 — lenguaje de consulta del sistema (SDD 08 §6)
Forma `kind:value`: `bin`, `file`, `pin`, `service`, `depends`. El evaluador acepta un EvalContext con BaseRef, fs_root y path_env para re-rootear contra un overlay o prefix sin tocar el FHS real. `depends:` implementa un parser ELF64 LE mínimo que recorre PT_DYNAMIC para extraer DT_NEEDED, suficiente para los binarios estáticos+dinámicos del lab. - hammer-core::query: parse(expr) + eval(term, ctx) + eval_str(expr, ctx). - proto: Command::Query gana `expr: Option<String>`. - AgentClient::query_expr: equivalente a query_file pero por el bus. - hammerd: maneja `what="expr"` invocando query::eval_str. - CLI: `hammer query <expr> [--base-ref] [--fs-root]` evalúa en proceso. Tests: 18 unit en `hammer-core::query::tests` (parse, eval con fs_root, ELF gated en `HAMMER_HOST_ELF_TESTS`), 1 e2e en `hammerd::bus_e2e` (query_expr_evaluates_against_host_path).
This commit is contained in:
@@ -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<serde_json::Value> {
|
||||
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),
|
||||
|
||||
@@ -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:<name>`. Opcional.
|
||||
#[arg(long)]
|
||||
base_ref: Option<PathBuf>,
|
||||
/// 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<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,13 +38,18 @@ pub enum Command {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
overlay: Option<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
expr: Option<String>,
|
||||
},
|
||||
/// 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!(
|
||||
|
||||
@@ -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:<name>` — localiza un ejecutable en `$PATH` (o equivalente bajo `fs_root`).
|
||||
Bin(String),
|
||||
/// `file:<absolute_path>` — metadata de un archivo (existe, modo, tamaño, …).
|
||||
File(PathBuf),
|
||||
/// `pin:<name>` — devuelve el valor del pin en `BaseRef.pins`, si existe.
|
||||
Pin(String),
|
||||
/// `service:<name>` — busca el script/launcher del servicio en los lugares
|
||||
/// convencionales (`/etc/init.d/<name>`, `/etc/service/<name>/run`,
|
||||
/// `/run/service/<name>/run`). Devuelve el primero que encuentre.
|
||||
Service(String),
|
||||
/// `depends:<absolute_path>` — 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:<name>`.
|
||||
pub base: Option<BaseRef>,
|
||||
/// Re-rooteo del filesystem. Si está, `bin:` busca bajo `<fs_root>/usr/bin`,
|
||||
/// `<fs_root>/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<PathBuf>,
|
||||
/// `$PATH` a usar para `bin:` cuando no hay `fs_root`. Si `None`, usa el del proceso.
|
||||
pub path_env: Option<String>,
|
||||
}
|
||||
|
||||
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<PathBuf>) -> Self {
|
||||
self.fs_root = Some(root.into());
|
||||
self
|
||||
}
|
||||
pub fn with_path_env(mut self, path: impl Into<String>) -> Self {
|
||||
self.path_env = Some(path.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(expr: &str) -> Result<Term, QueryError> {
|
||||
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<PathBuf> = 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::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
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::<Vec<_>>(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
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::<String>::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<String>),
|
||||
}
|
||||
|
||||
/// 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<ElfInfo, String> {
|
||||
// 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<usize> = None;
|
||||
let mut dyn_size: Option<usize> = 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<usize> = Vec::new();
|
||||
let mut strtab_addr: Option<u64> = None;
|
||||
let mut strtab_size: Option<u64> = 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<usize> = 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<String> = 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:<path> 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:
|
||||
|
||||
+4
-2
@@ -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 <expr>`) 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`:
|
||||
|
||||
Reference in New Issue
Block a user