tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
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}