tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1534Shell 199Rust 109Markdown 993713 Sutichaykuna

⎇ main crates/takana-core/src/query.rs Rust · 741 siq'ikuna · 27.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja b818f5249 · Sergio ·

1//! Mini-lenguaje de consulta del sistema (SDD 08 §6).
2//!
3//! El objetivo es darle a la IA un vocabulario estable para referirse a partes del sistema
4//! sin codificar rutas frágiles. En lugar de pedirle al modelo que diga `/usr/bin/grep`,
5//! le permitimos pedir `bin:grep` y dejamos que el evaluador resuelva contra el `$PATH`
6//! (o el `fs_root` que le pasamos). Lo mismo para servicios (`service:nginx`),
7//! dependencias dinámicas (`depends:/usr/bin/foo`), símbolos exportados por un ELF
8//! (`exports:/lib/libz.so.1`, ADR 0009), pins de base (`pin:musl`) y metadata de archivos
9//! (`file:/etc/hosts`).
10//!
11//! V0 (esta versión): un único término por expresión, sintaxis `kind:value`. Sin filtros,
12//! sin pipes. El SDD 08 §6 esboza una forma más rica (`find … -where … -> …`); cuando
13//! aparezca un caso de uso real para componer queries, extenderemos sin romper este shape.
14//!
15//! Diseñado para evaluarse:
16//! - **Local**: la CLI puede correr el evaluador en proceso (`takana query bin:grep`).
17//! - **Remoto**: el daemon expone `Command::Query { what: "expr", expr: Some(...) }` y
18//!   reusa este evaluador. Útil para que un cliente sin acceso al disco (o sin caps)
19//!   consulte estado a través del bus.
20
21use std::path::{Path, PathBuf};
22
23use serde_json::Value;
24
25use crate::BaseRef;
26
27#[derive(Debug, thiserror::Error)]
28pub enum QueryError {
29    #[error("expresión vacía")]
30    Empty,
31    #[error("expresión sin separador ':' — esperaba 'kind:value', recibí '{0}'")]
32    NoColon(String),
33    #[error("kind '{0}' desconocido — válidos: bin, file, pin, service, depends, exports")]
34    UnknownKind(String),
35    #[error("valor vacío para kind '{0}'")]
36    EmptyValue(String),
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum Term {
41    /// `bin:<name>` — localiza un ejecutable en `$PATH` (o equivalente bajo `fs_root`).
42    Bin(String),
43    /// `file:<absolute_path>` — metadata de un archivo (existe, modo, tamaño, …).
44    File(PathBuf),
45    /// `pin:<name>` — devuelve el valor del pin en `BaseRef.pins`, si existe.
46    Pin(String),
47    /// `service:<name>` — busca el script/launcher del servicio en los lugares
48    /// convencionales (`/etc/init.d/<name>`, `/etc/service/<name>/run`,
49    /// `/run/service/<name>/run`). Devuelve el primero que encuentre.
50    Service(String),
51    /// `depends:<absolute_path>` — para un ELF dinámico, lista las librerías NEEDED.
52    /// Para uno estático, devuelve `{static: true, needed: []}`.
53    Depends(PathBuf),
54    /// `exports:<absolute_path>` — para un ELF con tabla dinámica, lista los símbolos
55    /// que EXPORTA (`.dynsym`, defined + global/weak). Procedencia por-símbolo (ADR 0009,
56    /// puente barato hacia código direccionado por contenido). Metadata descriptiva: no
57    /// entra a ningún hash de identidad. Un estático sin `.dynsym` devuelve `exports: []`.
58    Exports(PathBuf),
59}
60
61/// Contexto del evaluador. Todos los campos son opcionales para soportar evaluación
62/// "lo que se pueda" — un término que requiera un campo ausente devuelve un valor
63/// con `error` legible, no un panic.
64#[derive(Debug, Clone, Default)]
65pub struct EvalContext {
66    /// Base local — usada por `pin:<name>`.
67    pub base: Option<BaseRef>,
68    /// Re-rooteo del filesystem. Si está, `bin:` busca bajo `<fs_root>/usr/bin`,
69    /// `<fs_root>/bin`, etc. en vez de `$PATH`; `file:`/`service:`/`depends:` re-rootean
70    /// también. Útil para evaluar contra un overlay o un prefix de test sin tocar el FHS real.
71    pub fs_root: Option<PathBuf>,
72    /// `$PATH` a usar para `bin:` cuando no hay `fs_root`. Si `None`, usa el del proceso.
73    pub path_env: Option<String>,
74}
75
76impl EvalContext {
77    pub fn new() -> Self {
78        Self::default()
79    }
80    pub fn with_base(mut self, base: BaseRef) -> Self {
81        self.base = Some(base);
82        self
83    }
84    pub fn with_fs_root(mut self, root: impl Into<PathBuf>) -> Self {
85        self.fs_root = Some(root.into());
86        self
87    }
88    pub fn with_path_env(mut self, path: impl Into<String>) -> Self {
89        self.path_env = Some(path.into());
90        self
91    }
92}
93
94pub fn parse(expr: &str) -> Result<Term, QueryError> {
95    let s = expr.trim();
96    if s.is_empty() {
97        return Err(QueryError::Empty);
98    }
99    let (kind, value) = s.split_once(':').ok_or_else(|| QueryError::NoColon(s.to_string()))?;
100    let kind = kind.trim();
101    let value = value.trim();
102    if value.is_empty() {
103        return Err(QueryError::EmptyValue(kind.to_string()));
104    }
105    match kind {
106        "bin" => Ok(Term::Bin(value.to_string())),
107        "file" => Ok(Term::File(PathBuf::from(value))),
108        "pin" => Ok(Term::Pin(value.to_string())),
109        "service" => Ok(Term::Service(value.to_string())),
110        "depends" => Ok(Term::Depends(PathBuf::from(value))),
111        "exports" => Ok(Term::Exports(PathBuf::from(value))),
112        other => Err(QueryError::UnknownKind(other.to_string())),
113    }
114}
115
116pub fn eval(term: &Term, ctx: &EvalContext) -> Value {
117    match term {
118        Term::Bin(name) => eval_bin(name, ctx),
119        Term::File(p) => eval_file(p, ctx),
120        Term::Pin(name) => eval_pin(name, ctx),
121        Term::Service(name) => eval_service(name, ctx),
122        Term::Depends(p) => eval_depends(p, ctx),
123        Term::Exports(p) => eval_exports(p, ctx),
124    }
125}
126
127pub fn eval_str(expr: &str, ctx: &EvalContext) -> Value {
128    match parse(expr) {
129        Ok(t) => eval(&t, ctx),
130        Err(e) => serde_json::json!({ "error": e.to_string() }),
131    }
132}
133
134fn rebase(p: &Path, fs_root: Option<&Path>) -> PathBuf {
135    match fs_root {
136        Some(r) => {
137            let rel = p.strip_prefix("/").unwrap_or(p);
138            r.join(rel)
139        }
140        None => p.to_path_buf(),
141    }
142}
143
144fn eval_bin(name: &str, ctx: &EvalContext) -> Value {
145    // Defensa contra inyección: el nombre no debe traer separadores. Si los trae, el
146    // caller probablemente quería `file:` o `depends:`.
147    if name.contains('/') {
148        return serde_json::json!({
149            "found": false,
150            "error": format!("bin: '{name}' contiene '/'; usa 'file:' o 'depends:' para rutas")
151        });
152    }
153    let candidates: Vec<PathBuf> = if let Some(root) = &ctx.fs_root {
154        ["usr/bin", "usr/sbin", "bin", "sbin"]
155            .iter()
156            .map(|d| root.join(d).join(name))
157            .collect()
158    } else {
159        let path = ctx
160            .path_env
161            .clone()
162            .or_else(|| std::env::var("PATH").ok())
163            .unwrap_or_else(|| "/usr/bin:/usr/sbin:/bin:/sbin".to_string());
164        path.split(':')
165            .filter(|s| !s.is_empty())
166            .map(|d| PathBuf::from(d).join(name))
167            .collect()
168    };
169    for cand in &candidates {
170        if cand.is_file() {
171            // Reusar eval_file para consistencia de schema.
172            let mut v = file_metadata(cand);
173            if let Some(obj) = v.as_object_mut() {
174                obj.insert("name".into(), Value::String(name.into()));
175                obj.insert("path".into(), Value::String(cand.display().to_string()));
176            }
177            return v;
178        }
179    }
180    serde_json::json!({
181        "found": false,
182        "name": name,
183        "searched": candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
184    })
185}
186
187fn file_metadata(p: &Path) -> Value {
188    use std::os::unix::fs::MetadataExt;
189    match std::fs::symlink_metadata(p) {
190        Ok(m) => serde_json::json!({
191            "found": true,
192            "path": p.display().to_string(),
193            "size": m.size(),
194            "mode": m.mode(),
195            "uid": m.uid(),
196            "gid": m.gid(),
197            "is_dir": m.is_dir(),
198            "is_symlink": m.file_type().is_symlink(),
199        }),
200        Err(e) => serde_json::json!({
201            "found": false,
202            "path": p.display().to_string(),
203            "error": e.to_string(),
204        }),
205    }
206}
207
208fn eval_file(p: &Path, ctx: &EvalContext) -> Value {
209    let abs = rebase(p, ctx.fs_root.as_deref());
210    file_metadata(&abs)
211}
212
213fn eval_pin(name: &str, ctx: &EvalContext) -> Value {
214    match &ctx.base {
215        None => serde_json::json!({
216            "found": false,
217            "name": name,
218            "error": "evaluador sin BaseRef",
219        }),
220        Some(base) => match base.pins.get(name) {
221            Some(v) => serde_json::json!({
222                "found": true,
223                "name": name,
224                "value": v,
225            }),
226            None => serde_json::json!({
227                "found": false,
228                "name": name,
229                "available": base.pins.keys().collect::<Vec<_>>(),
230            }),
231        },
232    }
233}
234
235fn eval_service(name: &str, ctx: &EvalContext) -> Value {
236    if name.contains('/') {
237        return serde_json::json!({
238            "found": false,
239            "error": format!("service: '{name}' contiene '/'"),
240        });
241    }
242    let root = ctx.fs_root.as_deref();
243    let candidates = [
244        rebase(Path::new(&format!("/etc/init.d/{name}")), root),
245        rebase(Path::new(&format!("/etc/service/{name}/run")), root),
246        rebase(Path::new(&format!("/run/service/{name}/run")), root),
247    ];
248    for c in &candidates {
249        if c.is_file() {
250            return serde_json::json!({
251                "found": true,
252                "name": name,
253                "launcher": c.display().to_string(),
254            });
255        }
256    }
257    serde_json::json!({
258        "found": false,
259        "name": name,
260        "searched": candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
261    })
262}
263
264fn eval_depends(p: &Path, ctx: &EvalContext) -> Value {
265    let abs = rebase(p, ctx.fs_root.as_deref());
266    let bytes = match std::fs::read(&abs) {
267        Ok(b) => b,
268        Err(e) => {
269            return serde_json::json!({
270                "found": false,
271                "path": abs.display().to_string(),
272                "error": e.to_string(),
273            });
274        }
275    };
276    match parse_elf_info(&bytes) {
277        Ok(ElfInfo::Static) => serde_json::json!({
278            "found": true,
279            "path": abs.display().to_string(),
280            "static": true,
281            "needed": Vec::<String>::new(),
282        }),
283        Ok(ElfInfo::Dynamic { needed, .. }) => serde_json::json!({
284            "found": true,
285            "path": abs.display().to_string(),
286            "static": false,
287            "needed": needed,
288        }),
289        Err(e) => serde_json::json!({
290            "found": false,
291            "path": abs.display().to_string(),
292            "error": e,
293        }),
294    }
295}
296
297/// `exports:<path>` — símbolos que un ELF EXPORTA por su `.dynsym` (ADR 0009). Un ELF sin tabla
298/// dinámica (estático) no tiene `.dynsym` ⇒ `exports: []`. Metadata descriptiva, no de identidad.
299fn eval_exports(p: &Path, ctx: &EvalContext) -> Value {
300    let abs = rebase(p, ctx.fs_root.as_deref());
301    let bytes = match std::fs::read(&abs) {
302        Ok(b) => b,
303        Err(e) => {
304            return serde_json::json!({
305                "found": false,
306                "path": abs.display().to_string(),
307                "error": e.to_string(),
308            });
309        }
310    };
311    match parse_elf_info(&bytes) {
312        Ok(ElfInfo::Static) => serde_json::json!({
313            "found": true,
314            "path": abs.display().to_string(),
315            "static": true,
316            "exports": Vec::<String>::new(),
317        }),
318        Ok(ElfInfo::Dynamic { exports, .. }) => serde_json::json!({
319            "found": true,
320            "path": abs.display().to_string(),
321            "static": false,
322            "exports": exports,
323        }),
324        Err(e) => serde_json::json!({
325            "found": false,
326            "path": abs.display().to_string(),
327            "error": e,
328        }),
329    }
330}
331
332enum ElfInfo {
333    Static,
334    Dynamic { needed: Vec<String>, exports: Vec<String> },
335}
336
337/// Traduce una virtual address a offset de archivo recorriendo los `PT_LOAD`. `None` si la
338/// dirección no cae en ningún segmento cargable.
339fn vaddr_to_file_off(
340    bytes: &[u8],
341    e_phoff: usize,
342    e_phentsize: usize,
343    e_phnum: usize,
344    vaddr: u64,
345) -> Option<usize> {
346    for i in 0..e_phnum {
347        let off = e_phoff + i * e_phentsize;
348        if off + 40 > bytes.len() {
349            return None;
350        }
351        let p_type = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
352        if p_type != 1 {
353            continue; // sólo PT_LOAD
354        }
355        let p_offset = u64::from_le_bytes(bytes[off + 8..off + 16].try_into().unwrap());
356        let p_vaddr = u64::from_le_bytes(bytes[off + 16..off + 24].try_into().unwrap());
357        let p_filesz = u64::from_le_bytes(bytes[off + 32..off + 40].try_into().unwrap());
358        if vaddr >= p_vaddr && vaddr < p_vaddr + p_filesz {
359            return Some((vaddr - p_vaddr + p_offset) as usize);
360        }
361    }
362    None
363}
364
365/// Lee una cadena NUL-terminada de `strtab[off..end]`.
366fn cstr_at(bytes: &[u8], strtab_off: usize, strtab_end: usize, name_off: usize) -> Option<String> {
367    let start = strtab_off.checked_add(name_off)?;
368    if start >= strtab_end {
369        return None;
370    }
371    let s = &bytes[start..strtab_end];
372    let end = s.iter().position(|&b| b == 0).unwrap_or(s.len());
373    std::str::from_utf8(&s[..end]).ok().map(|x| x.to_string())
374}
375
376/// Parser ELF mínimo: extrae `DT_NEEDED` (deps dinámicas) y los símbolos EXPORTADOS de `.dynsym`
377/// (ADR 0009, procedencia por-símbolo). Sólo el caso 64-bit little-endian (lo que produce zig-cc
378/// para `x86_64-linux-musl` y lo único que generamos). Para otros devolvemos error legible: el
379/// caller obtiene `error` en el JSON y el agente puede pedir otra cosa.
380fn parse_elf_info(bytes: &[u8]) -> Result<ElfInfo, String> {
381    // ELF header: 16 bytes ident + e_type(2) + e_machine(2) + e_version(4) + e_entry(8) +
382    // e_phoff(8) + e_shoff(8) + e_flags(4) + e_ehsize(2) + e_phentsize(2) + e_phnum(2) +
383    // e_shentsize(2) + e_shnum(2) + e_shstrndx(2)
384    if bytes.len() < 64 {
385        return Err("archivo demasiado pequeño para ser ELF64".into());
386    }
387    if &bytes[0..4] != b"\x7fELF" {
388        return Err("no es un ELF (magic 0x7fELF ausente)".into());
389    }
390    if bytes[4] != 2 {
391        return Err("sólo ELF64 soportado (EI_CLASS != 2)".into());
392    }
393    if bytes[5] != 1 {
394        return Err("sólo little-endian soportado (EI_DATA != 1)".into());
395    }
396    let e_phoff = u64::from_le_bytes(bytes[32..40].try_into().unwrap()) as usize;
397    let e_phentsize = u16::from_le_bytes(bytes[54..56].try_into().unwrap()) as usize;
398    let e_phnum = u16::from_le_bytes(bytes[56..58].try_into().unwrap()) as usize;
399    // Buscamos PT_DYNAMIC (=2).
400    let mut dyn_off: Option<usize> = None;
401    let mut dyn_size: Option<usize> = None;
402    for i in 0..e_phnum {
403        let off = e_phoff + i * e_phentsize;
404        if off + 56 > bytes.len() {
405            return Err("PHDR fuera de rango".into());
406        }
407        let p_type = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
408        if p_type == 2 {
409            // PT_DYNAMIC
410            let p_offset = u64::from_le_bytes(bytes[off + 8..off + 16].try_into().unwrap()) as usize;
411            let p_filesz = u64::from_le_bytes(bytes[off + 32..off + 40].try_into().unwrap()) as usize;
412            dyn_off = Some(p_offset);
413            dyn_size = Some(p_filesz);
414            break;
415        }
416    }
417    let (dyn_off, dyn_size) = match (dyn_off, dyn_size) {
418        (Some(o), Some(s)) => (o, s),
419        _ => return Ok(ElfInfo::Static),
420    };
421    // Recorremos las entradas DT_*; cada una es 16 bytes (d_tag i64 + d_val u64).
422    // Acumulamos los offsets de DT_NEEDED y la dirección de DT_STRTAB.
423    let mut needed_offsets: Vec<usize> = Vec::new();
424    let mut strtab_addr: Option<u64> = None;
425    let mut strtab_size: Option<u64> = None;
426    let mut symtab_addr: Option<u64> = None;
427    let end = dyn_off + dyn_size;
428    let mut cur = dyn_off;
429    while cur + 16 <= end && cur + 16 <= bytes.len() {
430        let d_tag = i64::from_le_bytes(bytes[cur..cur + 8].try_into().unwrap());
431        let d_val = u64::from_le_bytes(bytes[cur + 8..cur + 16].try_into().unwrap());
432        cur += 16;
433        match d_tag {
434            0 => break,                       // DT_NULL
435            1 => needed_offsets.push(d_val as usize), // DT_NEEDED
436            5 => strtab_addr = Some(d_val),   // DT_STRTAB (virtual address)
437            6 => symtab_addr = Some(d_val),   // DT_SYMTAB (virtual address)
438            10 => strtab_size = Some(d_val),  // DT_STRSZ
439            _ => {}
440        }
441    }
442    // Sin DT_STRTAB no podemos resolver nombres de nada: ni NEEDED ni exports.
443    let strtab_addr = match strtab_addr {
444        Some(a) => a,
445        None => return Ok(ElfInfo::Dynamic { needed: vec![], exports: vec![] }),
446    };
447    let strtab_size = strtab_size.unwrap_or(0);
448    let strtab_off = vaddr_to_file_off(bytes, e_phoff, e_phentsize, e_phnum, strtab_addr)
449        .ok_or("DT_STRTAB no cae en ningún PT_LOAD")?;
450    let strtab_end = if strtab_size > 0 {
451        (strtab_off + strtab_size as usize).min(bytes.len())
452    } else {
453        bytes.len()
454    };
455
456    // NEEDED: cada offset apunta a una cadena en la strtab dinámica.
457    let mut needed: Vec<String> = Vec::new();
458    for o in needed_offsets {
459        if let Some(name) = cstr_at(bytes, strtab_off, strtab_end, o) {
460            needed.push(name);
461        }
462    }
463
464    // EXPORTS (ADR 0009): recorremos `.dynsym`. No hay un DT_ que dé el número de entradas;
465    // usamos el layout convencional (musl/zig) donde la strtab sigue INMEDIATAMENTE a la symtab
466    // ⇒ nsyms = (strtab_addr - symtab_addr)/24. Si el layout no se cumple (strtab antes que
467    // symtab, o symtab ausente), devolvemos exports vacío en vez de adivinar mal.
468    let exports = parse_dynsym_exports(
469        bytes, e_phoff, e_phentsize, e_phnum, symtab_addr, strtab_addr, strtab_off, strtab_end,
470    );
471
472    Ok(ElfInfo::Dynamic { needed, exports })
473}
474
475/// Extrae los símbolos exportados de `.dynsym`: defined (`st_shndx != SHN_UNDEF` y fuera del rango
476/// reservado) + binding GLOBAL/WEAK + nombre no vacío. Orden estable (dedup + sort). Best-effort:
477/// devuelve `[]` si no puede acotar la tabla con seguridad (ver nota de layout en el caller).
478#[allow(clippy::too_many_arguments)]
479fn parse_dynsym_exports(
480    bytes: &[u8],
481    e_phoff: usize,
482    e_phentsize: usize,
483    e_phnum: usize,
484    symtab_addr: Option<u64>,
485    strtab_addr: u64,
486    strtab_off: usize,
487    strtab_end: usize,
488) -> Vec<String> {
489    const SYM_SIZE: u64 = 24; // sizeof(Elf64_Sym)
490    let Some(symtab_addr) = symtab_addr else { return vec![] };
491    // Heurística de layout: la strtab sigue a la symtab. Sin esto no sabemos cuántos símbolos hay.
492    if strtab_addr <= symtab_addr {
493        return vec![];
494    }
495    let nsyms = (strtab_addr - symtab_addr) / SYM_SIZE;
496    let Some(symtab_off) = vaddr_to_file_off(bytes, e_phoff, e_phentsize, e_phnum, symtab_addr)
497    else {
498        return vec![];
499    };
500    let mut exports: Vec<String> = Vec::new();
501    for i in 0..nsyms as usize {
502        let off = symtab_off + i * SYM_SIZE as usize;
503        if off + SYM_SIZE as usize > bytes.len() {
504            break;
505        }
506        let st_name = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap()) as usize;
507        let st_info = bytes[off + 4];
508        let st_shndx = u16::from_le_bytes(bytes[off + 6..off + 8].try_into().unwrap());
509        if st_name == 0 {
510            continue; // sin nombre
511        }
512        // SHN_UNDEF (0) = importado, no exportado; 0xff00.. = reservado (ABS/COMMON/…).
513        if st_shndx == 0 || st_shndx >= 0xff00 {
514            continue;
515        }
516        let bind = st_info >> 4; // STB_GLOBAL=1, STB_WEAK=2, STB_LOCAL=0
517        if bind != 1 && bind != 2 {
518            continue;
519        }
520        if let Some(name) = cstr_at(bytes, strtab_off, strtab_end, st_name) {
521            exports.push(name);
522        }
523    }
524    exports.sort();
525    exports.dedup();
526    exports
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use std::collections::BTreeMap;
533
534    #[test]
535    fn parse_basic() {
536        assert_eq!(parse("bin:grep").unwrap(), Term::Bin("grep".into()));
537        assert_eq!(parse("file:/etc/hosts").unwrap(), Term::File("/etc/hosts".into()));
538        assert_eq!(parse("pin:musl").unwrap(), Term::Pin("musl".into()));
539        assert_eq!(parse("service:nginx").unwrap(), Term::Service("nginx".into()));
540        assert_eq!(parse("depends:/bin/ls").unwrap(), Term::Depends("/bin/ls".into()));
541    }
542
543    #[test]
544    fn parse_trims_whitespace() {
545        assert_eq!(parse("  bin : grep  ").unwrap(), Term::Bin("grep".into()));
546    }
547
548    #[test]
549    fn parse_rejects_empty() {
550        assert!(matches!(parse("").unwrap_err(), QueryError::Empty));
551        assert!(matches!(parse("   ").unwrap_err(), QueryError::Empty));
552    }
553
554    #[test]
555    fn parse_rejects_missing_colon() {
556        assert!(matches!(parse("bingrep").unwrap_err(), QueryError::NoColon(_)));
557    }
558
559    #[test]
560    fn parse_rejects_unknown_kind() {
561        let e = parse("foo:bar").unwrap_err();
562        assert!(matches!(e, QueryError::UnknownKind(ref k) if k == "foo"));
563    }
564
565    #[test]
566    fn parse_rejects_empty_value() {
567        assert!(matches!(parse("bin:").unwrap_err(), QueryError::EmptyValue(_)));
568    }
569
570    #[test]
571    fn eval_bin_finds_under_fs_root() {
572        let d = tempfile::tempdir().unwrap();
573        let bin = d.path().join("usr/bin/grep");
574        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
575        std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
576        let v = eval(
577            &Term::Bin("grep".into()),
578            &EvalContext::new().with_fs_root(d.path().to_path_buf()),
579        );
580        assert_eq!(v["found"], Value::Bool(true));
581        assert!(v["path"].as_str().unwrap().ends_with("usr/bin/grep"));
582    }
583
584    #[test]
585    fn eval_bin_not_found_lists_searched() {
586        let d = tempfile::tempdir().unwrap();
587        let v = eval(
588            &Term::Bin("nonexistent_xyz".into()),
589            &EvalContext::new().with_fs_root(d.path().to_path_buf()),
590        );
591        assert_eq!(v["found"], Value::Bool(false));
592        assert_eq!(v["name"], Value::String("nonexistent_xyz".into()));
593        assert!(v["searched"].as_array().unwrap().len() >= 1);
594    }
595
596    #[test]
597    fn eval_bin_uses_path_env_when_no_fs_root() {
598        let d = tempfile::tempdir().unwrap();
599        let bin = d.path().join("grep");
600        std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
601        let v = eval(
602            &Term::Bin("grep".into()),
603            &EvalContext::new().with_path_env(d.path().display().to_string()),
604        );
605        assert_eq!(v["found"], Value::Bool(true));
606    }
607
608    #[test]
609    fn eval_bin_rejects_slashes() {
610        let v = eval(&Term::Bin("usr/bin/grep".into()), &EvalContext::new());
611        assert_eq!(v["found"], Value::Bool(false));
612        assert!(v["error"].as_str().unwrap().contains("contiene '/'"));
613    }
614
615    #[test]
616    fn eval_file_rebase() {
617        let d = tempfile::tempdir().unwrap();
618        std::fs::create_dir_all(d.path().join("etc")).unwrap();
619        std::fs::write(d.path().join("etc/hosts"), b"127.0.0.1 lo\n").unwrap();
620        let v = eval(
621            &Term::File("/etc/hosts".into()),
622            &EvalContext::new().with_fs_root(d.path().to_path_buf()),
623        );
624        assert_eq!(v["found"], Value::Bool(true));
625        assert_eq!(v["size"], Value::Number(13u64.into()));
626    }
627
628    #[test]
629    fn eval_pin_found_and_missing() {
630        let mut pins = BTreeMap::new();
631        pins.insert("musl".into(), "deadbeef".into());
632        let base = BaseRef {
633            distro_version: "2026-06-06".into(),
634            pins,
635        };
636        let ctx = EvalContext::new().with_base(base);
637        let v = eval(&Term::Pin("musl".into()), &ctx);
638        assert_eq!(v["value"], Value::String("deadbeef".into()));
639        let v2 = eval(&Term::Pin("zlib".into()), &ctx);
640        assert_eq!(v2["found"], Value::Bool(false));
641        assert!(v2["available"].as_array().unwrap().iter().any(|s| s == "musl"));
642    }
643
644    #[test]
645    fn eval_pin_without_base() {
646        let v = eval(&Term::Pin("x".into()), &EvalContext::new());
647        assert_eq!(v["found"], Value::Bool(false));
648        assert!(v["error"].as_str().unwrap().contains("sin BaseRef"));
649    }
650
651    #[test]
652    fn eval_service_finds_initd() {
653        let d = tempfile::tempdir().unwrap();
654        std::fs::create_dir_all(d.path().join("etc/init.d")).unwrap();
655        std::fs::write(d.path().join("etc/init.d/web"), b"#!/bin/sh\n").unwrap();
656        let v = eval(
657            &Term::Service("web".into()),
658            &EvalContext::new().with_fs_root(d.path().to_path_buf()),
659        );
660        assert_eq!(v["found"], Value::Bool(true));
661        assert!(v["launcher"].as_str().unwrap().ends_with("etc/init.d/web"));
662    }
663
664    #[test]
665    fn eval_str_parse_error_surfaces_in_json() {
666        let v = eval_str("foo:bar", &EvalContext::new());
667        assert!(v["error"].as_str().unwrap().contains("kind 'foo' desconocido"));
668    }
669
670    #[test]
671    fn parse_elf_rejects_short() {
672        let r = parse_elf_info(&[0u8; 8]);
673        assert!(r.is_err());
674    }
675
676    #[test]
677    fn parse_elf_rejects_bad_magic() {
678        let mut bytes = [0u8; 64];
679        bytes[0..4].copy_from_slice(b"XXXX");
680        assert!(parse_elf_info(&bytes).is_err());
681    }
682
683    #[test]
684    fn parse_exports_term() {
685        assert_eq!(parse("exports:/lib/libc.so").unwrap(), Term::Exports("/lib/libc.so".into()));
686    }
687
688    #[test]
689    fn exports_on_missing_file_reports_error() {
690        let v = eval(&Term::Exports(PathBuf::from("/no/existe/lib.so")), &EvalContext::new());
691        assert_eq!(v["found"], Value::Bool(false), "{v}");
692        assert!(v["error"].is_string(), "{v}");
693    }
694
695    /// Sanity con un binario real del host: `/bin/sh` casi seguro existe y es ELF.
696    /// Gated por `TAKANA_HOST_ELF_TESTS=1` para no acoplar el suite a Linux/Alpine.
697    #[test]
698    fn parse_elf_real_host_binary() {
699        if crate::env::var("TAKANA_HOST_ELF_TESTS").is_none() {
700            return;
701        }
702        let candidates = ["/bin/sh", "/usr/bin/sh", "/bin/ls", "/usr/bin/ls"];
703        let path = candidates
704            .iter()
705            .find(|p| std::path::Path::new(p).is_file())
706            .expect("ningún binario host encontrado");
707        let v = eval(&Term::Depends(PathBuf::from(path)), &EvalContext::new());
708        assert_eq!(v["found"], Value::Bool(true), "{v}");
709        let is_static = v["static"].as_bool().unwrap();
710        let needed = v["needed"].as_array().unwrap();
711        if !is_static {
712            // Si es dinámico debe declarar al menos una librería (libc).
713            assert!(!needed.is_empty(), "binario dinámico sin NEEDED: {v}");
714        }
715    }
716
717    /// Exports sobre una librería compartida real del host (una `.so` con `.dynsym`). Gated
718    /// igual que arriba: pide `TAKANA_HOST_ELF_TESTS=1`. Verifica que extraemos ≥1 símbolo.
719    #[test]
720    fn parse_exports_real_host_lib() {
721        if crate::env::var("TAKANA_HOST_ELF_TESTS").is_none() {
722            return;
723        }
724        let candidates = [
725            "/lib/libc.so.6",
726            "/lib64/libc.so.6",
727            "/usr/lib/libc.so.6",
728            "/lib/ld-musl-x86_64.so.1",
729            "/usr/lib/libz.so.1",
730        ];
731        let Some(path) = candidates.iter().find(|p| std::path::Path::new(p).is_file()) else {
732            return; // ninguna .so conocida; no fallamos el suite por eso
733        };
734        let v = eval(&Term::Exports(PathBuf::from(path)), &EvalContext::new());
735        assert_eq!(v["found"], Value::Bool(true), "{v}");
736        if v["static"].as_bool() == Some(false) {
737            let exports = v["exports"].as_array().unwrap();
738            assert!(!exports.is_empty(), ".so dinámica sin exports: {path}");
739        }
740    }
741}