//! 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`), símbolos exportados por un ELF //! (`exports:/lib/libz.so.1`, ADR 0009), 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, exports")] 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), /// `exports:` — para un ELF con tabla dinámica, lista los símbolos /// que EXPORTA (`.dynsym`, defined + global/weak). Procedencia por-símbolo (ADR 0009, /// puente barato hacia código direccionado por contenido). Metadata descriptiva: no /// entra a ningún hash de identidad. Un estático sin `.dynsym` devuelve `exports: []`. Exports(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))), "exports" => Ok(Term::Exports(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), Term::Exports(p) => eval_exports(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_info(&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, }), } } /// `exports:` — símbolos que un ELF EXPORTA por su `.dynsym` (ADR 0009). Un ELF sin tabla /// dinámica (estático) no tiene `.dynsym` ⇒ `exports: []`. Metadata descriptiva, no de identidad. fn eval_exports(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_info(&bytes) { Ok(ElfInfo::Static) => serde_json::json!({ "found": true, "path": abs.display().to_string(), "static": true, "exports": Vec::::new(), }), Ok(ElfInfo::Dynamic { exports, .. }) => serde_json::json!({ "found": true, "path": abs.display().to_string(), "static": false, "exports": exports, }), Err(e) => serde_json::json!({ "found": false, "path": abs.display().to_string(), "error": e, }), } } enum ElfInfo { Static, Dynamic { needed: Vec, exports: Vec }, } /// Traduce una virtual address a offset de archivo recorriendo los `PT_LOAD`. `None` si la /// dirección no cae en ningún segmento cargable. fn vaddr_to_file_off( bytes: &[u8], e_phoff: usize, e_phentsize: usize, e_phnum: usize, vaddr: u64, ) -> Option { for i in 0..e_phnum { let off = e_phoff + i * e_phentsize; if off + 40 > bytes.len() { return None; } let p_type = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap()); if p_type != 1 { continue; // sólo PT_LOAD } 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 vaddr >= p_vaddr && vaddr < p_vaddr + p_filesz { return Some((vaddr - p_vaddr + p_offset) as usize); } } None } /// Lee una cadena NUL-terminada de `strtab[off..end]`. fn cstr_at(bytes: &[u8], strtab_off: usize, strtab_end: usize, name_off: usize) -> Option { let start = strtab_off.checked_add(name_off)?; if start >= strtab_end { return None; } let s = &bytes[start..strtab_end]; let end = s.iter().position(|&b| b == 0).unwrap_or(s.len()); std::str::from_utf8(&s[..end]).ok().map(|x| x.to_string()) } /// Parser ELF mínimo: extrae `DT_NEEDED` (deps dinámicas) y los símbolos EXPORTADOS de `.dynsym` /// (ADR 0009, procedencia por-símbolo). 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_info(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 mut symtab_addr: 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) 6 => symtab_addr = Some(d_val), // DT_SYMTAB (virtual address) 10 => strtab_size = Some(d_val), // DT_STRSZ _ => {} } } // Sin DT_STRTAB no podemos resolver nombres de nada: ni NEEDED ni exports. let strtab_addr = match strtab_addr { Some(a) => a, None => return Ok(ElfInfo::Dynamic { needed: vec![], exports: vec![] }), }; let strtab_size = strtab_size.unwrap_or(0); let strtab_off = vaddr_to_file_off(bytes, e_phoff, e_phentsize, e_phnum, strtab_addr) .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() }; // NEEDED: cada offset apunta a una cadena en la strtab dinámica. let mut needed: Vec = Vec::new(); for o in needed_offsets { if let Some(name) = cstr_at(bytes, strtab_off, strtab_end, o) { needed.push(name); } } // EXPORTS (ADR 0009): recorremos `.dynsym`. No hay un DT_ que dé el número de entradas; // usamos el layout convencional (musl/zig) donde la strtab sigue INMEDIATAMENTE a la symtab // ⇒ nsyms = (strtab_addr - symtab_addr)/24. Si el layout no se cumple (strtab antes que // symtab, o symtab ausente), devolvemos exports vacío en vez de adivinar mal. let exports = parse_dynsym_exports( bytes, e_phoff, e_phentsize, e_phnum, symtab_addr, strtab_addr, strtab_off, strtab_end, ); Ok(ElfInfo::Dynamic { needed, exports }) } /// Extrae los símbolos exportados de `.dynsym`: defined (`st_shndx != SHN_UNDEF` y fuera del rango /// reservado) + binding GLOBAL/WEAK + nombre no vacío. Orden estable (dedup + sort). Best-effort: /// devuelve `[]` si no puede acotar la tabla con seguridad (ver nota de layout en el caller). #[allow(clippy::too_many_arguments)] fn parse_dynsym_exports( bytes: &[u8], e_phoff: usize, e_phentsize: usize, e_phnum: usize, symtab_addr: Option, strtab_addr: u64, strtab_off: usize, strtab_end: usize, ) -> Vec { const SYM_SIZE: u64 = 24; // sizeof(Elf64_Sym) let Some(symtab_addr) = symtab_addr else { return vec![] }; // Heurística de layout: la strtab sigue a la symtab. Sin esto no sabemos cuántos símbolos hay. if strtab_addr <= symtab_addr { return vec![]; } let nsyms = (strtab_addr - symtab_addr) / SYM_SIZE; let Some(symtab_off) = vaddr_to_file_off(bytes, e_phoff, e_phentsize, e_phnum, symtab_addr) else { return vec![]; }; let mut exports: Vec = Vec::new(); for i in 0..nsyms as usize { let off = symtab_off + i * SYM_SIZE as usize; if off + SYM_SIZE as usize > bytes.len() { break; } let st_name = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap()) as usize; let st_info = bytes[off + 4]; let st_shndx = u16::from_le_bytes(bytes[off + 6..off + 8].try_into().unwrap()); if st_name == 0 { continue; // sin nombre } // SHN_UNDEF (0) = importado, no exportado; 0xff00.. = reservado (ABS/COMMON/…). if st_shndx == 0 || st_shndx >= 0xff00 { continue; } let bind = st_info >> 4; // STB_GLOBAL=1, STB_WEAK=2, STB_LOCAL=0 if bind != 1 && bind != 2 { continue; } if let Some(name) = cstr_at(bytes, strtab_off, strtab_end, st_name) { exports.push(name); } } exports.sort(); exports.dedup(); exports } #[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_info(&[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_info(&bytes).is_err()); } #[test] fn parse_exports_term() { assert_eq!(parse("exports:/lib/libc.so").unwrap(), Term::Exports("/lib/libc.so".into())); } #[test] fn exports_on_missing_file_reports_error() { let v = eval(&Term::Exports(PathBuf::from("/no/existe/lib.so")), &EvalContext::new()); assert_eq!(v["found"], Value::Bool(false), "{v}"); assert!(v["error"].is_string(), "{v}"); } /// Sanity con un binario real del host: `/bin/sh` casi seguro existe y es ELF. /// Gated por `TAKANA_HOST_ELF_TESTS=1` para no acoplar el suite a Linux/Alpine. #[test] fn parse_elf_real_host_binary() { if crate::env::var("TAKANA_HOST_ELF_TESTS").is_none() { 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}"); } } /// Exports sobre una librería compartida real del host (una `.so` con `.dynsym`). Gated /// igual que arriba: pide `TAKANA_HOST_ELF_TESTS=1`. Verifica que extraemos ≥1 símbolo. #[test] fn parse_exports_real_host_lib() { if crate::env::var("TAKANA_HOST_ELF_TESTS").is_none() { return; } let candidates = [ "/lib/libc.so.6", "/lib64/libc.so.6", "/usr/lib/libc.so.6", "/lib/ld-musl-x86_64.so.1", "/usr/lib/libz.so.1", ]; let Some(path) = candidates.iter().find(|p| std::path::Path::new(p).is_file()) else { return; // ninguna .so conocida; no fallamos el suite por eso }; let v = eval(&Term::Exports(PathBuf::from(path)), &EvalContext::new()); assert_eq!(v["found"], Value::Bool(true), "{v}"); if v["static"].as_bool() == Some(false) { let exports = v["exports"].as_array().unwrap(); assert!(!exports.is_empty(), ".so dinámica sin exports: {path}"); } } }