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 1430Shell 145Markdown 87Rust 852973 Definiciones

⎇ dbbc10e8546e1cd36b479338722e2f20382dd6ff crates/hammer-core/src/query.rs Rust · 577 líneas · 20.8 KB Historia Quién tocó qué crudo

Fase 6 — lenguaje de consulta del sistema (SDD 08 §6) dbbc10e85 · 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`), pins de base (`pin:musl`) y metadata
8//! de archivos (`file:/etc/hosts`).
9//!
10//! V0 (esta versión): un único término por expresión, sintaxis `kind:value`. Sin filtros,
11//! sin pipes. El SDD 08 §6 esboza una forma más rica (`find … -where … -> …`); cuando
12//! aparezca un caso de uso real para componer queries, extenderemos sin romper este shape.
13//!
14//! Diseñado para evaluarse:
15//! - **Local**: la CLI puede correr el evaluador en proceso (`hammer query bin:grep`).
16//! - **Remoto**: el daemon expone `Command::Query { what: "expr", expr: Some(...) }` y
17//!   reusa este evaluador. Útil para que un cliente sin acceso al disco (o sin caps)
18//!   consulte estado a través del bus.
19
20use std::path::{Path, PathBuf};
21
22use serde_json::Value;
23
24use crate::BaseRef;
25
26#[derive(Debug, thiserror::Error)]
27pub enum QueryError {
28    #[error("expresión vacía")]
29    Empty,
30    #[error("expresión sin separador ':' — esperaba 'kind:value', recibí '{0}'")]
31    NoColon(String),
32    #[error("kind '{0}' desconocido — válidos: bin, file, pin, service, depends")]
33    UnknownKind(String),
34    #[error("valor vacío para kind '{0}'")]
35    EmptyValue(String),
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Term {
40    /// `bin:<name>` — localiza un ejecutable en `$PATH` (o equivalente bajo `fs_root`).
41    Bin(String),
42    /// `file:<absolute_path>` — metadata de un archivo (existe, modo, tamaño, …).
43    File(PathBuf),
44    /// `pin:<name>` — devuelve el valor del pin en `BaseRef.pins`, si existe.
45    Pin(String),
46    /// `service:<name>` — busca el script/launcher del servicio en los lugares
47    /// convencionales (`/etc/init.d/<name>`, `/etc/service/<name>/run`,
48    /// `/run/service/<name>/run`). Devuelve el primero que encuentre.
49    Service(String),
50    /// `depends:<absolute_path>` — para un ELF dinámico, lista las librerías NEEDED.
51    /// Para uno estático, devuelve `{static: true, needed: []}`.
52    Depends(PathBuf),
53}
54
55/// Contexto del evaluador. Todos los campos son opcionales para soportar evaluación
56/// "lo que se pueda" — un término que requiera un campo ausente devuelve un valor
57/// con `error` legible, no un panic.
58#[derive(Debug, Clone, Default)]
59pub struct EvalContext {
60    /// Base local — usada por `pin:<name>`.
61    pub base: Option<BaseRef>,
62    /// Re-rooteo del filesystem. Si está, `bin:` busca bajo `<fs_root>/usr/bin`,
63    /// `<fs_root>/bin`, etc. en vez de `$PATH`; `file:`/`service:`/`depends:` re-rootean
64    /// también. Útil para evaluar contra un overlay o un prefix de test sin tocar el FHS real.
65    pub fs_root: Option<PathBuf>,
66    /// `$PATH` a usar para `bin:` cuando no hay `fs_root`. Si `None`, usa el del proceso.
67    pub path_env: Option<String>,
68}
69
70impl EvalContext {
71    pub fn new() -> Self {
72        Self::default()
73    }
74    pub fn with_base(mut self, base: BaseRef) -> Self {
75        self.base = Some(base);
76        self
77    }
78    pub fn with_fs_root(mut self, root: impl Into<PathBuf>) -> Self {
79        self.fs_root = Some(root.into());
80        self
81    }
82    pub fn with_path_env(mut self, path: impl Into<String>) -> Self {
83        self.path_env = Some(path.into());
84        self
85    }
86}
87
88pub fn parse(expr: &str) -> Result<Term, QueryError> {
89    let s = expr.trim();
90    if s.is_empty() {
91        return Err(QueryError::Empty);
92    }
93    let (kind, value) = s.split_once(':').ok_or_else(|| QueryError::NoColon(s.to_string()))?;
94    let kind = kind.trim();
95    let value = value.trim();
96    if value.is_empty() {
97        return Err(QueryError::EmptyValue(kind.to_string()));
98    }
99    match kind {
100        "bin" => Ok(Term::Bin(value.to_string())),
101        "file" => Ok(Term::File(PathBuf::from(value))),
102        "pin" => Ok(Term::Pin(value.to_string())),
103        "service" => Ok(Term::Service(value.to_string())),
104        "depends" => Ok(Term::Depends(PathBuf::from(value))),
105        other => Err(QueryError::UnknownKind(other.to_string())),
106    }
107}
108
109pub fn eval(term: &Term, ctx: &EvalContext) -> Value {
110    match term {
111        Term::Bin(name) => eval_bin(name, ctx),
112        Term::File(p) => eval_file(p, ctx),
113        Term::Pin(name) => eval_pin(name, ctx),
114        Term::Service(name) => eval_service(name, ctx),
115        Term::Depends(p) => eval_depends(p, ctx),
116    }
117}
118
119pub fn eval_str(expr: &str, ctx: &EvalContext) -> Value {
120    match parse(expr) {
121        Ok(t) => eval(&t, ctx),
122        Err(e) => serde_json::json!({ "error": e.to_string() }),
123    }
124}
125
126fn rebase(p: &Path, fs_root: Option<&Path>) -> PathBuf {
127    match fs_root {
128        Some(r) => {
129            let rel = p.strip_prefix("/").unwrap_or(p);
130            r.join(rel)
131        }
132        None => p.to_path_buf(),
133    }
134}
135
136fn eval_bin(name: &str, ctx: &EvalContext) -> Value {
137    // Defensa contra inyección: el nombre no debe traer separadores. Si los trae, el
138    // caller probablemente quería `file:` o `depends:`.
139    if name.contains('/') {
140        return serde_json::json!({
141            "found": false,
142            "error": format!("bin: '{name}' contiene '/'; usa 'file:' o 'depends:' para rutas")
143        });
144    }
145    let candidates: Vec<PathBuf> = if let Some(root) = &ctx.fs_root {
146        ["usr/bin", "usr/sbin", "bin", "sbin"]
147            .iter()
148            .map(|d| root.join(d).join(name))
149            .collect()
150    } else {
151        let path = ctx
152            .path_env
153            .clone()
154            .or_else(|| std::env::var("PATH").ok())
155            .unwrap_or_else(|| "/usr/bin:/usr/sbin:/bin:/sbin".to_string());
156        path.split(':')
157            .filter(|s| !s.is_empty())
158            .map(|d| PathBuf::from(d).join(name))
159            .collect()
160    };
161    for cand in &candidates {
162        if cand.is_file() {
163            // Reusar eval_file para consistencia de schema.
164            let mut v = file_metadata(cand);
165            if let Some(obj) = v.as_object_mut() {
166                obj.insert("name".into(), Value::String(name.into()));
167                obj.insert("path".into(), Value::String(cand.display().to_string()));
168            }
169            return v;
170        }
171    }
172    serde_json::json!({
173        "found": false,
174        "name": name,
175        "searched": candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
176    })
177}
178
179fn file_metadata(p: &Path) -> Value {
180    use std::os::unix::fs::MetadataExt;
181    match std::fs::symlink_metadata(p) {
182        Ok(m) => serde_json::json!({
183            "found": true,
184            "path": p.display().to_string(),
185            "size": m.size(),
186            "mode": m.mode(),
187            "uid": m.uid(),
188            "gid": m.gid(),
189            "is_dir": m.is_dir(),
190            "is_symlink": m.file_type().is_symlink(),
191        }),
192        Err(e) => serde_json::json!({
193            "found": false,
194            "path": p.display().to_string(),
195            "error": e.to_string(),
196        }),
197    }
198}
199
200fn eval_file(p: &Path, ctx: &EvalContext) -> Value {
201    let abs = rebase(p, ctx.fs_root.as_deref());
202    file_metadata(&abs)
203}
204
205fn eval_pin(name: &str, ctx: &EvalContext) -> Value {
206    match &ctx.base {
207        None => serde_json::json!({
208            "found": false,
209            "name": name,
210            "error": "evaluador sin BaseRef",
211        }),
212        Some(base) => match base.pins.get(name) {
213            Some(v) => serde_json::json!({
214                "found": true,
215                "name": name,
216                "value": v,
217            }),
218            None => serde_json::json!({
219                "found": false,
220                "name": name,
221                "available": base.pins.keys().collect::<Vec<_>>(),
222            }),
223        },
224    }
225}
226
227fn eval_service(name: &str, ctx: &EvalContext) -> Value {
228    if name.contains('/') {
229        return serde_json::json!({
230            "found": false,
231            "error": format!("service: '{name}' contiene '/'"),
232        });
233    }
234    let root = ctx.fs_root.as_deref();
235    let candidates = [
236        rebase(Path::new(&format!("/etc/init.d/{name}")), root),
237        rebase(Path::new(&format!("/etc/service/{name}/run")), root),
238        rebase(Path::new(&format!("/run/service/{name}/run")), root),
239    ];
240    for c in &candidates {
241        if c.is_file() {
242            return serde_json::json!({
243                "found": true,
244                "name": name,
245                "launcher": c.display().to_string(),
246            });
247        }
248    }
249    serde_json::json!({
250        "found": false,
251        "name": name,
252        "searched": candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
253    })
254}
255
256fn eval_depends(p: &Path, ctx: &EvalContext) -> Value {
257    let abs = rebase(p, ctx.fs_root.as_deref());
258    let bytes = match std::fs::read(&abs) {
259        Ok(b) => b,
260        Err(e) => {
261            return serde_json::json!({
262                "found": false,
263                "path": abs.display().to_string(),
264                "error": e.to_string(),
265            });
266        }
267    };
268    match parse_elf_needed(&bytes) {
269        Ok(ElfInfo::Static) => serde_json::json!({
270            "found": true,
271            "path": abs.display().to_string(),
272            "static": true,
273            "needed": Vec::<String>::new(),
274        }),
275        Ok(ElfInfo::Dynamic(needed)) => serde_json::json!({
276            "found": true,
277            "path": abs.display().to_string(),
278            "static": false,
279            "needed": needed,
280        }),
281        Err(e) => serde_json::json!({
282            "found": false,
283            "path": abs.display().to_string(),
284            "error": e,
285        }),
286    }
287}
288
289enum ElfInfo {
290    Static,
291    Dynamic(Vec<String>),
292}
293
294/// Parser ELF mínimo para extraer `DT_NEEDED`. Sólo el caso 64-bit little-endian
295/// (lo que produce zig-cc para `x86_64-linux-musl` y lo único que generamos). Para otros
296/// devolvemos error legible: el caller obtiene `error` en el JSON y el agente puede
297/// pedir otra cosa.
298fn parse_elf_needed(bytes: &[u8]) -> Result<ElfInfo, String> {
299    // ELF header: 16 bytes ident + e_type(2) + e_machine(2) + e_version(4) + e_entry(8) +
300    // e_phoff(8) + e_shoff(8) + e_flags(4) + e_ehsize(2) + e_phentsize(2) + e_phnum(2) +
301    // e_shentsize(2) + e_shnum(2) + e_shstrndx(2)
302    if bytes.len() < 64 {
303        return Err("archivo demasiado pequeño para ser ELF64".into());
304    }
305    if &bytes[0..4] != b"\x7fELF" {
306        return Err("no es un ELF (magic 0x7fELF ausente)".into());
307    }
308    if bytes[4] != 2 {
309        return Err("sólo ELF64 soportado (EI_CLASS != 2)".into());
310    }
311    if bytes[5] != 1 {
312        return Err("sólo little-endian soportado (EI_DATA != 1)".into());
313    }
314    let e_phoff = u64::from_le_bytes(bytes[32..40].try_into().unwrap()) as usize;
315    let e_phentsize = u16::from_le_bytes(bytes[54..56].try_into().unwrap()) as usize;
316    let e_phnum = u16::from_le_bytes(bytes[56..58].try_into().unwrap()) as usize;
317    // Buscamos PT_DYNAMIC (=2).
318    let mut dyn_off: Option<usize> = None;
319    let mut dyn_size: Option<usize> = None;
320    for i in 0..e_phnum {
321        let off = e_phoff + i * e_phentsize;
322        if off + 56 > bytes.len() {
323            return Err("PHDR fuera de rango".into());
324        }
325        let p_type = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
326        if p_type == 2 {
327            // PT_DYNAMIC
328            let p_offset = u64::from_le_bytes(bytes[off + 8..off + 16].try_into().unwrap()) as usize;
329            let p_filesz = u64::from_le_bytes(bytes[off + 32..off + 40].try_into().unwrap()) as usize;
330            dyn_off = Some(p_offset);
331            dyn_size = Some(p_filesz);
332            break;
333        }
334    }
335    let (dyn_off, dyn_size) = match (dyn_off, dyn_size) {
336        (Some(o), Some(s)) => (o, s),
337        _ => return Ok(ElfInfo::Static),
338    };
339    // Recorremos las entradas DT_*; cada una es 16 bytes (d_tag i64 + d_val u64).
340    // Acumulamos los offsets de DT_NEEDED y la dirección de DT_STRTAB.
341    let mut needed_offsets: Vec<usize> = Vec::new();
342    let mut strtab_addr: Option<u64> = None;
343    let mut strtab_size: Option<u64> = None;
344    let end = dyn_off + dyn_size;
345    let mut cur = dyn_off;
346    while cur + 16 <= end && cur + 16 <= bytes.len() {
347        let d_tag = i64::from_le_bytes(bytes[cur..cur + 8].try_into().unwrap());
348        let d_val = u64::from_le_bytes(bytes[cur + 8..cur + 16].try_into().unwrap());
349        cur += 16;
350        match d_tag {
351            0 => break,                       // DT_NULL
352            1 => needed_offsets.push(d_val as usize), // DT_NEEDED
353            5 => strtab_addr = Some(d_val),   // DT_STRTAB (virtual address)
354            10 => strtab_size = Some(d_val),  // DT_STRSZ
355            _ => {}
356        }
357    }
358    if needed_offsets.is_empty() {
359        return Ok(ElfInfo::Dynamic(vec![]));
360    }
361    let strtab_addr = strtab_addr.ok_or("DT_STRTAB ausente pese a DT_NEEDED")?;
362    let strtab_size = strtab_size.unwrap_or(0);
363    // DT_STRTAB es una virtual address; necesitamos el offset en el archivo. Recorremos
364    // PHDR otra vez para encontrar el segmento PT_LOAD que la contenga.
365    let mut strtab_file_off: Option<usize> = None;
366    for i in 0..e_phnum {
367        let off = e_phoff + i * e_phentsize;
368        let p_type = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
369        if p_type != 1 {
370            // PT_LOAD
371            continue;
372        }
373        let p_offset = u64::from_le_bytes(bytes[off + 8..off + 16].try_into().unwrap());
374        let p_vaddr = u64::from_le_bytes(bytes[off + 16..off + 24].try_into().unwrap());
375        let p_filesz = u64::from_le_bytes(bytes[off + 32..off + 40].try_into().unwrap());
376        if strtab_addr >= p_vaddr && strtab_addr < p_vaddr + p_filesz {
377            strtab_file_off = Some((strtab_addr - p_vaddr + p_offset) as usize);
378            break;
379        }
380    }
381    let strtab_off = strtab_file_off.ok_or("DT_STRTAB no cae en ningún PT_LOAD")?;
382    let strtab_end = if strtab_size > 0 {
383        (strtab_off + strtab_size as usize).min(bytes.len())
384    } else {
385        bytes.len()
386    };
387    let mut needed: Vec<String> = Vec::new();
388    for o in needed_offsets {
389        let start = strtab_off + o;
390        if start >= strtab_end {
391            continue;
392        }
393        let s = &bytes[start..strtab_end];
394        let end = s.iter().position(|&b| b == 0).unwrap_or(s.len());
395        if let Ok(name) = std::str::from_utf8(&s[..end]) {
396            needed.push(name.to_string());
397        }
398    }
399    Ok(ElfInfo::Dynamic(needed))
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use std::collections::BTreeMap;
406
407    #[test]
408    fn parse_basic() {
409        assert_eq!(parse("bin:grep").unwrap(), Term::Bin("grep".into()));
410        assert_eq!(parse("file:/etc/hosts").unwrap(), Term::File("/etc/hosts".into()));
411        assert_eq!(parse("pin:musl").unwrap(), Term::Pin("musl".into()));
412        assert_eq!(parse("service:nginx").unwrap(), Term::Service("nginx".into()));
413        assert_eq!(parse("depends:/bin/ls").unwrap(), Term::Depends("/bin/ls".into()));
414    }
415
416    #[test]
417    fn parse_trims_whitespace() {
418        assert_eq!(parse("  bin : grep  ").unwrap(), Term::Bin("grep".into()));
419    }
420
421    #[test]
422    fn parse_rejects_empty() {
423        assert!(matches!(parse("").unwrap_err(), QueryError::Empty));
424        assert!(matches!(parse("   ").unwrap_err(), QueryError::Empty));
425    }
426
427    #[test]
428    fn parse_rejects_missing_colon() {
429        assert!(matches!(parse("bingrep").unwrap_err(), QueryError::NoColon(_)));
430    }
431
432    #[test]
433    fn parse_rejects_unknown_kind() {
434        let e = parse("foo:bar").unwrap_err();
435        assert!(matches!(e, QueryError::UnknownKind(ref k) if k == "foo"));
436    }
437
438    #[test]
439    fn parse_rejects_empty_value() {
440        assert!(matches!(parse("bin:").unwrap_err(), QueryError::EmptyValue(_)));
441    }
442
443    #[test]
444    fn eval_bin_finds_under_fs_root() {
445        let d = tempfile::tempdir().unwrap();
446        let bin = d.path().join("usr/bin/grep");
447        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
448        std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
449        let v = eval(
450            &Term::Bin("grep".into()),
451            &EvalContext::new().with_fs_root(d.path().to_path_buf()),
452        );
453        assert_eq!(v["found"], Value::Bool(true));
454        assert!(v["path"].as_str().unwrap().ends_with("usr/bin/grep"));
455    }
456
457    #[test]
458    fn eval_bin_not_found_lists_searched() {
459        let d = tempfile::tempdir().unwrap();
460        let v = eval(
461            &Term::Bin("nonexistent_xyz".into()),
462            &EvalContext::new().with_fs_root(d.path().to_path_buf()),
463        );
464        assert_eq!(v["found"], Value::Bool(false));
465        assert_eq!(v["name"], Value::String("nonexistent_xyz".into()));
466        assert!(v["searched"].as_array().unwrap().len() >= 1);
467    }
468
469    #[test]
470    fn eval_bin_uses_path_env_when_no_fs_root() {
471        let d = tempfile::tempdir().unwrap();
472        let bin = d.path().join("grep");
473        std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
474        let v = eval(
475            &Term::Bin("grep".into()),
476            &EvalContext::new().with_path_env(d.path().display().to_string()),
477        );
478        assert_eq!(v["found"], Value::Bool(true));
479    }
480
481    #[test]
482    fn eval_bin_rejects_slashes() {
483        let v = eval(&Term::Bin("usr/bin/grep".into()), &EvalContext::new());
484        assert_eq!(v["found"], Value::Bool(false));
485        assert!(v["error"].as_str().unwrap().contains("contiene '/'"));
486    }
487
488    #[test]
489    fn eval_file_rebase() {
490        let d = tempfile::tempdir().unwrap();
491        std::fs::create_dir_all(d.path().join("etc")).unwrap();
492        std::fs::write(d.path().join("etc/hosts"), b"127.0.0.1 lo\n").unwrap();
493        let v = eval(
494            &Term::File("/etc/hosts".into()),
495            &EvalContext::new().with_fs_root(d.path().to_path_buf()),
496        );
497        assert_eq!(v["found"], Value::Bool(true));
498        assert_eq!(v["size"], Value::Number(13u64.into()));
499    }
500
501    #[test]
502    fn eval_pin_found_and_missing() {
503        let mut pins = BTreeMap::new();
504        pins.insert("musl".into(), "deadbeef".into());
505        let base = BaseRef {
506            distro_version: "2026-06-06".into(),
507            pins,
508        };
509        let ctx = EvalContext::new().with_base(base);
510        let v = eval(&Term::Pin("musl".into()), &ctx);
511        assert_eq!(v["value"], Value::String("deadbeef".into()));
512        let v2 = eval(&Term::Pin("zlib".into()), &ctx);
513        assert_eq!(v2["found"], Value::Bool(false));
514        assert!(v2["available"].as_array().unwrap().iter().any(|s| s == "musl"));
515    }
516
517    #[test]
518    fn eval_pin_without_base() {
519        let v = eval(&Term::Pin("x".into()), &EvalContext::new());
520        assert_eq!(v["found"], Value::Bool(false));
521        assert!(v["error"].as_str().unwrap().contains("sin BaseRef"));
522    }
523
524    #[test]
525    fn eval_service_finds_initd() {
526        let d = tempfile::tempdir().unwrap();
527        std::fs::create_dir_all(d.path().join("etc/init.d")).unwrap();
528        std::fs::write(d.path().join("etc/init.d/web"), b"#!/bin/sh\n").unwrap();
529        let v = eval(
530            &Term::Service("web".into()),
531            &EvalContext::new().with_fs_root(d.path().to_path_buf()),
532        );
533        assert_eq!(v["found"], Value::Bool(true));
534        assert!(v["launcher"].as_str().unwrap().ends_with("etc/init.d/web"));
535    }
536
537    #[test]
538    fn eval_str_parse_error_surfaces_in_json() {
539        let v = eval_str("foo:bar", &EvalContext::new());
540        assert!(v["error"].as_str().unwrap().contains("kind 'foo' desconocido"));
541    }
542
543    #[test]
544    fn parse_elf_rejects_short() {
545        let r = parse_elf_needed(&[0u8; 8]);
546        assert!(r.is_err());
547    }
548
549    #[test]
550    fn parse_elf_rejects_bad_magic() {
551        let mut bytes = [0u8; 64];
552        bytes[0..4].copy_from_slice(b"XXXX");
553        assert!(parse_elf_needed(&bytes).is_err());
554    }
555
556    /// Sanity con un binario real del host: `/bin/sh` casi seguro existe y es ELF.
557    /// Gated por `HAMMER_HOST_ELF_TESTS=1` para no acoplar el suite a Linux/Alpine.
558    #[test]
559    fn parse_elf_real_host_binary() {
560        if std::env::var("HAMMER_HOST_ELF_TESTS").is_err() {
561            return;
562        }
563        let candidates = ["/bin/sh", "/usr/bin/sh", "/bin/ls", "/usr/bin/ls"];
564        let path = candidates
565            .iter()
566            .find(|p| std::path::Path::new(p).is_file())
567            .expect("ningún binario host encontrado");
568        let v = eval(&Term::Depends(PathBuf::from(path)), &EvalContext::new());
569        assert_eq!(v["found"], Value::Bool(true), "{v}");
570        let is_static = v["static"].as_bool().unwrap();
571        let needed = v["needed"].as_array().unwrap();
572        if !is_static {
573            // Si es dinámico debe declarar al menos una librería (libc).
574            assert!(!needed.is_empty(), "binario dinámico sin NEEDED: {v}");
575        }
576    }
577}