query: exports:<elf> — procedencia por-símbolo (puente barato de ADR 0009)
Extiende el parser ELF (parse_elf_needed→parse_elf_info): además de DT_NEEDED, recorre .dynsym (DT_SYMTAB) y extrae los símbolos EXPORTADOS (defined + global/weak). Nuevo término `exports:<path>` en el mini-lenguaje de queries; fluye por el bus (query expr) sin cambios. Metadata descriptiva, fuera de hash_inputs (misma disciplina que la evidencia de H1). Refactor: helper vaddr_to_file_off compartido por strtab/symtab + cstr_at. nsyms vía layout convencional (strtab sigue a symtab); best-effort → [] si no se puede acotar con seguridad. Validado end-to-end: 2873 símbolos reales de /lib/libc.so.6 (aguanta glibc y musl). Tests: term-parse + missing-file + host-gated real-.so. Workspace verde (33 suites). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+211
-47
@@ -4,8 +4,9 @@
|
||||
//! 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`).
|
||||
//! 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
|
||||
@@ -29,7 +30,7 @@ pub enum QueryError {
|
||||
Empty,
|
||||
#[error("expresión sin separador ':' — esperaba 'kind:value', recibí '{0}'")]
|
||||
NoColon(String),
|
||||
#[error("kind '{0}' desconocido — válidos: bin, file, pin, service, depends")]
|
||||
#[error("kind '{0}' desconocido — válidos: bin, file, pin, service, depends, exports")]
|
||||
UnknownKind(String),
|
||||
#[error("valor vacío para kind '{0}'")]
|
||||
EmptyValue(String),
|
||||
@@ -50,6 +51,11 @@ pub enum Term {
|
||||
/// `depends:<absolute_path>` — para un ELF dinámico, lista las librerías NEEDED.
|
||||
/// Para uno estático, devuelve `{static: true, needed: []}`.
|
||||
Depends(PathBuf),
|
||||
/// `exports:<absolute_path>` — 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
|
||||
@@ -102,6 +108,7 @@ pub fn parse(expr: &str) -> Result<Term, QueryError> {
|
||||
"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())),
|
||||
}
|
||||
}
|
||||
@@ -113,6 +120,7 @@ pub fn eval(term: &Term, ctx: &EvalContext) -> Value {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,14 +273,14 @@ fn eval_depends(p: &Path, ctx: &EvalContext) -> Value {
|
||||
});
|
||||
}
|
||||
};
|
||||
match parse_elf_needed(&bytes) {
|
||||
match parse_elf_info(&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!({
|
||||
Ok(ElfInfo::Dynamic { needed, .. }) => serde_json::json!({
|
||||
"found": true,
|
||||
"path": abs.display().to_string(),
|
||||
"static": false,
|
||||
@@ -286,16 +294,90 @@ fn eval_depends(p: &Path, ctx: &EvalContext) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
enum ElfInfo {
|
||||
Static,
|
||||
Dynamic(Vec<String>),
|
||||
/// `exports:<path>` — 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::<String>::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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
enum ElfInfo {
|
||||
Static,
|
||||
Dynamic { needed: Vec<String>, exports: Vec<String> },
|
||||
}
|
||||
|
||||
/// 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<usize> {
|
||||
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<String> {
|
||||
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<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)
|
||||
@@ -341,6 +423,7 @@ fn parse_elf_needed(bytes: &[u8]) -> Result<ElfInfo, String> {
|
||||
let mut needed_offsets: Vec<usize> = Vec::new();
|
||||
let mut strtab_addr: Option<u64> = None;
|
||||
let mut strtab_size: Option<u64> = None;
|
||||
let mut symtab_addr: Option<u64> = None;
|
||||
let end = dyn_off + dyn_size;
|
||||
let mut cur = dyn_off;
|
||||
while cur + 16 <= end && cur + 16 <= bytes.len() {
|
||||
@@ -351,52 +434,96 @@ fn parse_elf_needed(bytes: &[u8]) -> Result<ElfInfo, String> {
|
||||
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
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if needed_offsets.is_empty() {
|
||||
return Ok(ElfInfo::Dynamic(vec![]));
|
||||
}
|
||||
let strtab_addr = strtab_addr.ok_or("DT_STRTAB ausente pese a DT_NEEDED")?;
|
||||
// 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);
|
||||
// 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_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<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());
|
||||
if let Some(name) = cstr_at(bytes, strtab_off, strtab_end, o) {
|
||||
needed.push(name);
|
||||
}
|
||||
}
|
||||
Ok(ElfInfo::Dynamic(needed))
|
||||
|
||||
// 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<u64>,
|
||||
strtab_addr: u64,
|
||||
strtab_off: usize,
|
||||
strtab_end: usize,
|
||||
) -> Vec<String> {
|
||||
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<String> = 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)]
|
||||
@@ -542,7 +669,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_elf_rejects_short() {
|
||||
let r = parse_elf_needed(&[0u8; 8]);
|
||||
let r = parse_elf_info(&[0u8; 8]);
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
@@ -550,7 +677,19 @@ mod tests {
|
||||
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());
|
||||
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.
|
||||
@@ -574,4 +713,29 @@ mod tests {
|
||||
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 `HAMMER_HOST_ELF_TESTS=1`. Verifica que extraemos ≥1 símbolo.
|
||||
#[test]
|
||||
fn parse_exports_real_host_lib() {
|
||||
if std::env::var("HAMMER_HOST_ELF_TESTS").is_err() {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user