diff --git a/crates/hammer-core/src/query.rs b/crates/hammer-core/src/query.rs index 0baa143d..91aaaadb 100644 --- a/crates/hammer-core/src/query.rs +++ b/crates/hammer-core/src/query.rs @@ -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:` — 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 @@ -102,6 +108,7 @@ pub fn parse(expr: &str) -> Result { "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::::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), +/// `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, + }), + } } -/// 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 { +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) @@ -341,6 +423,7 @@ fn parse_elf_needed(bytes: &[u8]) -> Result { 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() { @@ -351,52 +434,96 @@ fn parse_elf_needed(bytes: &[u8]) -> Result { 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 = 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 = 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, + 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)] @@ -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}"); + } + } } diff --git a/docs/adr/0009-content-addressed-code.md b/docs/adr/0009-content-addressed-code.md index 8489905a..f9c2f665 100644 --- a/docs/adr/0009-content-addressed-code.md +++ b/docs/adr/0009-content-addressed-code.md @@ -63,9 +63,11 @@ de código **colapsarían en un solo espacio de nombres: el hash**. ## Consecuencias -- **Ahora (barato, sin apuesta):** cuando haya rato, extender `parse_elf_needed` → `parse_elf_symbols` - (exports de `.dynsym` además de `DT_NEEDED`) y exponerlo como query (`exports:`) y, - opcionalmente, como sidecar de metadata junto al `.hammer/recipe.toml`. Cero cambio en el build. +- **Ahora (barato, sin apuesta) — IMPLEMENTADO (2026-07-03):** `parse_elf_needed` → `parse_elf_info` + (exports de `.dynsym` además de `DT_NEEDED`), expuesto como query **`exports:`** + (`crates/hammer-core/src/query.rs`). Validado end-to-end: 2873 símbolos extraídos de + `/lib/libc.so.6`. Fluye por el bus (`query expr`) sin cambios. Cero cambio en el build ni en + `hash_inputs`. Pendiente opcional: emitirlo como sidecar de metadata junto al artefacto sellado. - **Diferido (condicionado a verdes):** H3b (base content-addressed de funciones **wasm**), sólo si H2b demuestra que el cómputo puro memoiza byte-idéntico. Si H3b se sostiene, *entonces* se reabre la pregunta de colapsar los tres espacios — con evidencia, no con fe.