TAKANA_X con caída a HAMMER_X (ADR 0016). No es un sed: estas variables son
contrato de usuario —knobs del instalador, el entorno del worker, mirror-env.sh—
y viven en perfiles de shell y units FUERA del repo, así que un renombre duro no
falla ruidosamente: la variable no aparece, se toma el default y el build se
comporta distinto sin que nada lo diga.
Rust: takana_core::env::{var,var_os} toma el nombre canónico y deriva el viejo
cambiando el prefijo — se le pasa TAKANA_* para que un grep del nombre nuevo
encuentre todas las lecturas. 5 tests, con DOS controles negativos: sin ninguna
de las dos no hay valor, y un nombre sin prefijo no inventa una caída.
Migrados los 17 sitios directos y los indirectos que el grep no mostraba
(bases_de_mirror, las constantes de kernel_cmd, ROOT_ENV de qorpa, env_path de
recover). takana-recover lleva la caída inline: es un mini-binario que se copia
a /usr/sbin y no vale arrastrarle una dep entera por dos líneas.
Scripts: 30 lecturas pasan a default, manteniendo el
nombre INTERNO de la variable para no tocar sus 190 usos.
Y donde el script EXPORTA en vez de leer, se ponen LAS DOS (mirror-env.sh y el
fragmento in-VM de bootstrap): ahí el lector puede ser un binario viejo —worker
sin recompilar, el /usr/bin/hammer pinado del baseline— que sólo conoce HAMMER_*.
La caída sirve para lectores nuevos; los viejos necesitan que la vieja siga puesta.
Verificado de punta a punta con el binario, no sólo con unit tests: HAMMER_LAB
sigue surtiendo efecto, TAKANA_LAB hace lo mismo, y con las dos gana TAKANA_LAB.
605 tests en verde y el hash de zlib sigue en b3:dc363f26… , intacto.
Cambio de comportamiento que va aparte y hay que decir: el hostname por defecto
de una instalación nueva pasa de 'hammer' a 'takana' (sólo si no se fija ninguna
de las dos variables).
NO se tocan: las rutas /var/lib/hammer de sistemas instalados, el volid
HAMMER_LIVE del ISO, ni el namespace HARKAQ_*, que es de otro subsistema.
742 lines
27 KiB
Rust
742 lines
27 KiB
Rust
//! 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:<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),
|
|
/// `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
|
|
/// "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))),
|
|
"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<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_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!({
|
|
"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:<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,
|
|
}),
|
|
}
|
|
}
|
|
|
|
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)
|
|
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 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() {
|
|
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<String> = 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<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)]
|
|
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}");
|
|
}
|
|
}
|
|
}
|