gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-wasm-registry/src/lib.rs Rust · 541 líneas · 19.6 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! llimphi-wasm-registry — puente data-driven a registros públicos de apps WASM.
2//!
3//! El catálogo propio (`llimphi-wasm-core::Catalog`) resuelve el "qué apps hay"
4//! sobre la malla soberana. Este crate abre la otra puerta: **conectarse a
5//! registros ya existentes en internet** (wasmer/WAPM, un índice estático, lo
6//! que sea REST+JSON) de forma agnóstica y configurable, para tener catálogos
7//! reales con qué probar sin publicar nada primero.
8//!
9//! La apuesta es la misma que `shared/foreign-platform` (de donde se copia el
10//! patrón): un **descriptor RON** describe el endpoint de listado y el mapeo
11//! campo-de-dominio → path-JSON; sumar un registro compatible es escribir un
12//! `.ron`, no Rust.
13//!
14//! ## Honestidad de alcance
15//!
16//! Esto trae **metadatos + una URL de descarga** por app, y `ingest` baja el
17//! módulo y lo vuelve un [`CatalogEntry`] resoluble (lo hashea al CAS — de ahí
18//! sale el bytecode-hash, igual que `--install`). Lo que **no** garantiza es
19//! que el módulo *corra* como app Tier 3: la mayoría del WASM público sigue
20//! otro ABI (WASI, component-model, wasm-bindgen/DOM), no el `wasm_view →
21//! WireNode` del runner. Para *probar el pipeline de descubrimiento/descarga*
22//! sirve cualquiera; para *ejecutar como UI Llimphi* sólo corren los módulos
23//! compilados contra `llimphi-wasm-app-sdk`. La verificación de integridad y de
24//! concesión del core sigue valiendo: un módulo bajado de la red se hashea y,
25//! si pretende capacidades, su concesión debe estar firmada por una clave de tu
26//! anillo.
27
28mod json;
29
30use llimphi_wasm_core::{bytecode_hash, hash_to_hex, CatalogEntry, DiskStore, Hash};
31use serde::Deserialize;
32use serde_json::Value;
33
34/// Errores del puente a un registro remoto.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum RegistryError {
37    /// El descriptor RON no parsea.
38    Descriptor(String),
39    /// Falla de red en el GET.
40    Network(String),
41    /// La respuesta no es JSON válido.
42    Parse(String),
43    /// El JSON no tiene la forma que el descriptor espera.
44    Mapping(String),
45    /// No se pudo guardar el módulo bajado en el CAS.
46    Store(String),
47}
48
49impl std::fmt::Display for RegistryError {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            RegistryError::Descriptor(e) => write!(f, "descriptor inválido: {e}"),
53            RegistryError::Network(e) => write!(f, "red: {e}"),
54            RegistryError::Parse(e) => write!(f, "respuesta no-JSON: {e}"),
55            RegistryError::Mapping(e) => write!(f, "mapeo: {e}"),
56            RegistryError::Store(e) => write!(f, "guardar en CAS: {e}"),
57        }
58    }
59}
60
61impl std::error::Error for RegistryError {}
62
63pub type RegistryResult<T> = Result<T, RegistryError>;
64
65// ===========================================================================
66//  Descriptor — el "script de texto" deserializable desde RON
67// ===========================================================================
68
69/// La descripción de un registro REST de apps WASM. Se deserializa de un `.ron`.
70#[derive(Debug, Clone, Deserialize)]
71pub struct RegistryDescriptor {
72    /// Nombre legible ("wasmer", "índice local").
73    pub name: String,
74    /// Familia de API (informativo).
75    #[serde(default)]
76    pub kind: String,
77    /// Instancias base sugeridas (la primera se usa si no se da otra).
78    #[serde(default)]
79    pub default_instances: Vec<String>,
80    /// El endpoint que lista apps.
81    pub list: ListEndpoint,
82}
83
84/// Un endpoint que devuelve una **lista** de apps.
85#[derive(Debug, Clone, Deserialize)]
86pub struct ListEndpoint {
87    /// Path relativo a la instancia. Admite placeholder `{query}` (URL-encode).
88    pub path: String,
89    /// Query string clave→valor; los valores admiten `{query}`.
90    #[serde(default)]
91    pub query: Vec<(String, String)>,
92    /// Dónde vive el array en la respuesta. `""` ⇒ la raíz ES el array.
93    #[serde(default)]
94    pub list_path: String,
95    /// Cómo mapear cada item del array a una [`RemoteApp`].
96    pub fields: AppFields,
97}
98
99/// Mapeo campo-de-dominio → path-JSON. `id`, `name` y `wasm_url` son
100/// obligatorios (sin la URL no hay nada que ingerir); el resto opcional.
101#[derive(Debug, Clone, Deserialize)]
102pub struct AppFields {
103    pub id: String,
104    pub name: String,
105    /// Path a la URL de descarga del `.wasm`.
106    pub wasm_url: String,
107    #[serde(default)]
108    pub description: Option<String>,
109    #[serde(default)]
110    pub category: Option<String>,
111    #[serde(default)]
112    pub version: Option<String>,
113}
114
115impl RegistryDescriptor {
116    /// Parsea un descriptor desde texto RON.
117    pub fn from_ron(src: &str) -> RegistryResult<Self> {
118        ron::from_str(src).map_err(|e| RegistryError::Descriptor(e.to_string()))
119    }
120}
121
122/// Una app **descubierta** en un registro remoto: metadatos + URL de descarga.
123/// Todavía no está en el CAS (no tiene bytecode-hash) — eso lo hace [`ingest`].
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct RemoteApp {
126    pub id: String,
127    pub name: String,
128    pub description: String,
129    pub category: Option<String>,
130    pub version: Option<String>,
131    pub wasm_url: String,
132}
133
134impl RemoteApp {
135    /// ¿La consulta (sin distinguir mayúsculas) aparece en id/nombre/desc?
136    pub fn matches(&self, query: &str) -> bool {
137        if query.is_empty() {
138            return true;
139        }
140        let q = query.to_lowercase();
141        self.id.to_lowercase().contains(&q)
142            || self.name.to_lowercase().contains(&q)
143            || self.description.to_lowercase().contains(&q)
144    }
145}
146
147// ===========================================================================
148//  Transporte HTTP — abstracto para testear sin red
149// ===========================================================================
150
151/// Abstracción del fetch HTTP. La real es [`UreqFetch`]; los tests inyectan una
152/// que devuelve fixtures, así el mapeo se valida en CI sin red (igual criterio
153/// que `foreign-platform` y el resto de los núcleos del repo).
154pub trait HttpFetch {
155    /// GET de una URL completa → texto del cuerpo.
156    fn get_text(&self, url: &str) -> RegistryResult<String>;
157    /// GET de una URL completa → bytes crudos (para bajar el `.wasm`).
158    fn get_bytes(&self, url: &str) -> RegistryResult<Vec<u8>>;
159}
160
161/// Fetch real sobre `ureq` (HTTP síncrono, sin tokio).
162#[derive(Debug, Clone, Default)]
163pub struct UreqFetch;
164
165impl HttpFetch for UreqFetch {
166    fn get_text(&self, url: &str) -> RegistryResult<String> {
167        ureq::get(url)
168            .call()
169            .map_err(|e| RegistryError::Network(e.to_string()))?
170            .into_string()
171            .map_err(|e| RegistryError::Network(e.to_string()))
172    }
173
174    fn get_bytes(&self, url: &str) -> RegistryResult<Vec<u8>> {
175        let resp = ureq::get(url)
176            .call()
177            .map_err(|e| RegistryError::Network(e.to_string()))?;
178        let mut buf = Vec::new();
179        std::io::Read::read_to_end(&mut resp.into_reader(), &mut buf)
180            .map_err(|e| RegistryError::Network(e.to_string()))?;
181        Ok(buf)
182    }
183}
184
185/// Fetch desde el **filesystem local**: un "registro" que es un directorio con
186/// un JSON de listado y los `.wasm`. No es red — sirve para un catálogo de
187/// prueba reproducible y offline, y para tests. `root` es la base; una URL
188/// absoluta se lee tal cual, una relativa se une a `root`.
189#[derive(Debug, Clone)]
190pub struct LocalFetch {
191    pub root: std::path::PathBuf,
192}
193
194impl LocalFetch {
195    pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
196        Self { root: root.into() }
197    }
198
199    fn resolve(&self, url: &str) -> std::path::PathBuf {
200        let url = url.strip_prefix("file://").unwrap_or(url);
201        let p = std::path::Path::new(url);
202        if p.is_absolute() {
203            p.to_path_buf()
204        } else {
205            self.root.join(url)
206        }
207    }
208}
209
210impl HttpFetch for LocalFetch {
211    fn get_text(&self, url: &str) -> RegistryResult<String> {
212        std::fs::read_to_string(self.resolve(url)).map_err(|e| RegistryError::Network(e.to_string()))
213    }
214    fn get_bytes(&self, url: &str) -> RegistryResult<Vec<u8>> {
215        std::fs::read(self.resolve(url)).map_err(|e| RegistryError::Network(e.to_string()))
216    }
217}
218
219// ===========================================================================
220//  RegistryProvider — interpreta el descriptor sobre una instancia concreta
221// ===========================================================================
222
223/// Un registro concreto: un descriptor + una instancia base + un fetcher.
224/// Genérico sobre el fetcher para inyectar fixtures en tests.
225#[derive(Debug, Clone)]
226pub struct RegistryProvider<F: HttpFetch = UreqFetch> {
227    descriptor: RegistryDescriptor,
228    base: String,
229    fetch: F,
230}
231
232impl RegistryProvider<UreqFetch> {
233    /// Construye sobre la instancia `base` (red real). `base` vacío ⇒ primera
234    /// `default_instances` del descriptor.
235    pub fn new(descriptor: RegistryDescriptor, base: impl Into<String>) -> Self {
236        Self::with_fetch(descriptor, base, UreqFetch)
237    }
238}
239
240impl<F: HttpFetch> RegistryProvider<F> {
241    /// Construye con un fetcher explícito (tests).
242    pub fn with_fetch(descriptor: RegistryDescriptor, base: impl Into<String>, fetch: F) -> Self {
243        let mut base = base.into();
244        if base.is_empty() {
245            base = descriptor
246                .default_instances
247                .first()
248                .cloned()
249                .unwrap_or_default();
250        }
251        let base = base.trim_end_matches('/').to_string();
252        Self { descriptor, base, fetch }
253    }
254
255    fn fill(tpl: &str, query: &str) -> String {
256        tpl.replace("{query}", &url_encode(query))
257    }
258
259    fn list_url(&self, query: &str) -> String {
260        let ep = &self.descriptor.list;
261        let mut url = format!("{}{}", self.base, Self::fill(&ep.path, query));
262        if !ep.query.is_empty() {
263            let qs: Vec<String> = ep
264                .query
265                .iter()
266                .map(|(k, v)| format!("{}={}", url_encode(k), Self::fill(v, query)))
267                .collect();
268            url.push('?');
269            url.push_str(&qs.join("&"));
270        }
271        url
272    }
273
274    /// Lista las apps del registro (filtrando por `query` server-side vía el
275    /// placeholder, y devolviendo lo que el endpoint dé). Mapea cada item según
276    /// el descriptor; un item al que le falten campos obligatorios se descarta
277    /// (no tumba la página entera).
278    pub fn list(&self, query: &str) -> RegistryResult<Vec<RemoteApp>> {
279        let url = self.list_url(query);
280        let body = self.fetch.get_text(&url)?;
281        let value: Value =
282            serde_json::from_str(&body).map_err(|e| RegistryError::Parse(e.to_string()))?;
283        let ep = &self.descriptor.list;
284        let arr = json::get_array(&value, &ep.list_path).ok_or_else(|| {
285            RegistryError::Mapping(format!("no hay array en «{}»", ep.list_path))
286        })?;
287        Ok(arr.iter().filter_map(|item| map_app(item, &ep.fields)).collect())
288    }
289
290    /// Descarga el módulo de una [`RemoteApp`], lo guarda en `store` (lo hashea
291    /// — content-addressing) y devuelve un [`CatalogEntry`] resoluble. App de
292    /// sólo-UI: `declarados = 0`, sin concesión (una capacidad exigiría una
293    /// concesión firmada aparte). Es el puente registro-remoto → catálogo local.
294    pub fn ingest(&self, app: &RemoteApp, store: &DiskStore) -> RegistryResult<CatalogEntry> {
295        let bytes = self.fetch.get_bytes(&app.wasm_url)?;
296        let bytecode = store.put(&bytes).map_err(|e| RegistryError::Store(e.to_string()))?;
297        Ok(CatalogEntry {
298            id: app.id.clone(),
299            name: app.name.clone(),
300            description: app.description.clone(),
301            category: app.category.clone(),
302            bytecode,
303            declarados: 0,
304            concesion: None,
305        })
306    }
307}
308
309/// El bytecode-hash que tendría una app si se ingiriera (sin guardar). Útil
310/// para mostrar/cotejar antes de descargar al CAS definitivo.
311pub fn hash_de(bytes: &[u8]) -> Hash {
312    bytecode_hash(bytes)
313}
314
315/// Conveniencia: el hex del hash de unos bytes ya descargados.
316pub fn hash_hex_de(bytes: &[u8]) -> String {
317    hash_to_hex(&bytecode_hash(bytes))
318}
319
320fn map_app(item: &Value, f: &AppFields) -> Option<RemoteApp> {
321    let id = json::get_string(item, &f.id)?;
322    let name = json::get_string(item, &f.name)?;
323    let wasm_url = json::get_string(item, &f.wasm_url)?;
324    Some(RemoteApp {
325        id,
326        name,
327        description: f
328            .description
329            .as_deref()
330            .and_then(|p| json::get_string(item, p))
331            .unwrap_or_default(),
332        category: f.category.as_deref().and_then(|p| json::get_string(item, p)),
333        version: f.version.as_deref().and_then(|p| json::get_string(item, p)),
334        wasm_url,
335    })
336}
337
338/// URL-encode mínimo (gemelo del de `foreign-platform`): deja a-zA-Z0-9-_.~ y
339/// percent-codea el resto. Suficiente para queries de búsqueda.
340fn url_encode(s: &str) -> String {
341    let mut out = String::with_capacity(s.len());
342    for b in s.bytes() {
343        match b {
344            b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
345                out.push(b as char)
346            }
347            b' ' => out.push_str("%20"),
348            _ => out.push_str(&format!("%{b:02X}")),
349        }
350    }
351    out
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    const DESCRIPTOR_RON: &str = r#"#![enable(implicit_some)]
359    (
360        name: "ejemplo",
361        kind: "rest-generico",
362        default_instances: ["https://apps.example"],
363        list: (
364            path: "/v1/apps",
365            query: [("q", "{query}"), ("limit", "20")],
366            list_path: "results",
367            fields: (
368                id: "slug",
369                name: "title",
370                wasm_url: "download.wasm",
371                description: "summary",
372                category: "tags.0",
373            ),
374        ),
375    )"#;
376
377    /// Una respuesta JSON con la forma que el descriptor espera.
378    fn cuerpo_lista() -> String {
379        r#"{
380            "results": [
381                {
382                    "slug": "counter",
383                    "title": "Contador",
384                    "summary": "suma y resta",
385                    "tags": ["demo", "ui"],
386                    "download": { "wasm": "https://apps.example/blobs/counter.wasm" }
387                },
388                {
389                    "slug": "roto",
390                    "title": "Sin URL"
391                }
392            ]
393        }"#
394        .to_string()
395    }
396
397    /// Fetcher de fixtures: texto canónico para el listado; bytes arbitrarios
398    /// (el counter real lo inyecta el test de ingest) para la descarga.
399    struct MockFetch {
400        body: String,
401        wasm: Vec<u8>,
402    }
403    impl HttpFetch for MockFetch {
404        fn get_text(&self, _url: &str) -> RegistryResult<String> {
405            Ok(self.body.clone())
406        }
407        fn get_bytes(&self, _url: &str) -> RegistryResult<Vec<u8>> {
408            Ok(self.wasm.clone())
409        }
410    }
411
412    fn provider(wasm: Vec<u8>) -> RegistryProvider<MockFetch> {
413        let desc = RegistryDescriptor::from_ron(DESCRIPTOR_RON).expect("descriptor parsea");
414        RegistryProvider::with_fetch(
415            desc,
416            "",
417            MockFetch {
418                body: cuerpo_lista(),
419                wasm,
420            },
421        )
422    }
423
424    #[test]
425    fn descriptor_ron_parsea() {
426        let d = RegistryDescriptor::from_ron(DESCRIPTOR_RON).unwrap();
427        assert_eq!(d.name, "ejemplo");
428        assert_eq!(d.list.path, "/v1/apps");
429        assert_eq!(d.list.fields.id, "slug");
430    }
431
432    #[test]
433    fn list_mapea_e_ignora_items_rotos() {
434        let p = provider(vec![]);
435        let apps = p.list("cont").unwrap();
436        // El item sin URL se descarta; queda el counter.
437        assert_eq!(apps.len(), 1);
438        let a = &apps[0];
439        assert_eq!(a.id, "counter");
440        assert_eq!(a.name, "Contador");
441        assert_eq!(a.description, "suma y resta");
442        assert_eq!(a.category.as_deref(), Some("demo"));
443        assert_eq!(a.wasm_url, "https://apps.example/blobs/counter.wasm");
444    }
445
446    #[test]
447    fn url_se_arma_con_query_y_encode() {
448        let p = provider(vec![]);
449        let url = p.list_url("hola mundo");
450        assert_eq!(url, "https://apps.example/v1/apps?q=hola%20mundo&limit=20");
451    }
452
453    #[test]
454    fn matches_busca_en_metadatos() {
455        let p = provider(vec![]);
456        let a = &p.list("").unwrap()[0];
457        assert!(a.matches("CONT"));
458        assert!(a.matches("suma"));
459        assert!(!a.matches("zzz"));
460        assert!(a.matches("")); // vacío = todo
461    }
462
463    /// El counter Tier 3 real — el "módulo bajado del registro".
464    const COUNTER_WASM: &[u8] =
465        include_bytes!("../../llimphi-wasm-runner/assets/counter.wasm");
466
467    #[test]
468    fn ingest_baja_hashea_y_da_catalog_entry() {
469        use llimphi_wasm_core::{bytecode_hash, Catalog};
470
471        let p = provider(COUNTER_WASM.to_vec());
472        let app = p.list("").unwrap().into_iter().next().unwrap();
473
474        let dir = std::env::temp_dir()
475            .join(format!("llimphi-wasm-registry-{}", std::process::id()));
476        let _ = std::fs::remove_dir_all(&dir);
477        let store = DiskStore::open(&dir).unwrap();
478
479        let entry = p.ingest(&app, &store).expect("ingerir");
480        // El bytecode-hash es el del módulo bajado, content-addressed.
481        assert_eq!(entry.bytecode, bytecode_hash(COUNTER_WASM));
482        assert_eq!(entry.id, "counter");
483        assert!(entry.concesion.is_none(), "sólo-UI por defecto");
484        // Y el blob quedó en el CAS: armamos un catálogo y se resuelve por id.
485        assert_eq!(store.get(&entry.bytecode).as_deref(), Some(COUNTER_WASM));
486        let cat = Catalog::new(vec![entry]);
487        let v = llimphi_wasm_core::resolve_from_catalog(
488            &store,
489            &llimphi_wasm_core::TrustRing::empty(),
490            &cat,
491            "counter",
492        )
493        .expect("resuelve lo ingerido");
494        assert_eq!(v.permisos, 0);
495        assert_eq!(v.wasm, COUNTER_WASM);
496
497        std::fs::remove_dir_all(&dir).ok();
498    }
499
500    #[test]
501    fn registro_local_offline_lista_e_ingiere() {
502        // Un "registro" que es un directorio con apps.json + el .wasm. Sin red:
503        // el catálogo de prueba reproducible que pidió el usuario.
504        let dir = std::env::temp_dir()
505            .join(format!("llimphi-wasm-registry-local-{}", std::process::id()));
506        let _ = std::fs::remove_dir_all(&dir);
507        std::fs::create_dir_all(&dir).unwrap();
508        std::fs::write(dir.join("counter.wasm"), COUNTER_WASM).unwrap();
509        std::fs::write(
510            dir.join("apps.json"),
511            r#"{"results":[{"slug":"counter","title":"Contador","summary":"demo local","tags":["demo"],"download":{"wasm":"counter.wasm"}}]}"#,
512        )
513        .unwrap();
514
515        // Descriptor local: path directo al JSON, sin query (no es una URL real).
516        let local_ron = r#"#![enable(implicit_some)]
517        (
518            name: "local",
519            list: (
520                path: "/apps.json",
521                list_path: "results",
522                fields: (id: "slug", name: "title", wasm_url: "download.wasm",
523                         description: "summary", category: "tags.0"),
524            ),
525        )"#;
526        let desc = RegistryDescriptor::from_ron(local_ron).unwrap();
527        let provider = RegistryProvider::with_fetch(desc, dir.to_str().unwrap(), LocalFetch::new(&dir));
528
529        let apps = provider.list("").unwrap();
530        assert_eq!(apps.len(), 1);
531        assert_eq!(apps[0].wasm_url, "counter.wasm");
532
533        let cas = dir.join("cas");
534        let store = DiskStore::open(&cas).unwrap();
535        let entry = provider.ingest(&apps[0], &store).unwrap();
536        assert_eq!(entry.bytecode, llimphi_wasm_core::bytecode_hash(COUNTER_WASM));
537        assert_eq!(store.get(&entry.bytecode).as_deref(), Some(COUNTER_WASM));
538
539        std::fs::remove_dir_all(&dir).ok();
540    }
541}