tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1
llimphi-wasm-core/src/lib.rs
History
Who touched what
raw
1//! llimphi-wasm-core — distribución por hash de apps WASM Tier 3 (sin GPU).
2//!
3//! El núcleo puro de la distribución: resolución por hash, verificación de
4//! integridad y de concesión Ed25519, permisos efectivos. **No depende del
5//! runner ni de Llimphi** — así un servicio headless (relay/servidor de blobs)
6//! lo usa sin arrastrar el stack gráfico. El puente para *correr* la
7//! [`VerifiedApp`] vive en `llimphi-wasm-dist` (que suma el runner).
8//!
9//! La cadena, espejo host de lo que el kernel de wawa hace con sus apps:
10//!
11//! 1. **Resolver** el bytecode por su hash BLAKE3 desde un [`BlobSource`]
12//! (hoy [`DiskStore`], un CAS local; o un backend P2P como `llimphi-wasm-net`).
13//! 2. **Verificar integridad**: el wasm recuperado debe rehashear al hash
14//! pedido — content-addressing, detección de tampering.
15//! 3. **Verificar la concesión** Ed25519 ([`format::ConcesionCapacidad`]):
16//! el autor debe habitar un [`TrustRing`] cargable y la firma cubrir
17//! `mensaje_capacidad(bytecode, permisos)`. Una concesión para el bytecode
18//! X jamás vale para Y.
19//! 4. **Permisos efectivos** = `declarados & concedidos` — un manifiesto no
20//! puede escalar un binario más allá de su concesión.
21//!
22//! El hash del bytecode es el del **objeto que lo envuelve**
23//! (`Objeto{datos:wasm,hijos:[]}`), idéntico a la ceremonia `agora-cli wawa
24//! concesion` y al kernel: una concesión firmada allá vale aquí sin cambios.
25
26use std::fs;
27use std::path::{Path, PathBuf};
28
29use std::collections::HashMap;
30
31use agora_core::verify_signature;
32use format::{ConcesionCapacidad, Objeto, Permisos};
33use serde::{Deserialize, Serialize};
34
35pub use format::{Hash, Permisos as PermisosBitfield};
36
37/// Errores de la cadena resolver→verificar.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum DistError {
40 /// El `BlobSource` no tiene bytes para ese hash.
41 NoEncontrado,
42 /// El wasm recuperado no rehashea al hash pedido.
43 IntegridadFallo,
44 /// El autor de la concesión no está en el anillo de confianza.
45 AutorNoConfiable,
46 /// La firma Ed25519 de la concesión no valida.
47 FirmaInvalida,
48 /// La concesión es para otro bytecode (su `bytecode` no coincide).
49 ConcesionParaOtroBytecode,
50 /// El hex del hash es inválido.
51 HexInvalido,
52 /// El hash no mide 32 bytes.
53 HashLongitud,
54 /// El `Launch` no es una variante `Wasm`.
55 LaunchNoWasm,
56 /// El manifiesto referencia una concesión que el source no tiene.
57 ConcesionNoEncontrada,
58 /// El blob de la concesión no deserializa a una ConcesionCapacidad.
59 ConcesionCorrupta,
60 /// No se pudo cargar el guest en el runner.
61 Carga(String),
62}
63
64impl std::fmt::Display for DistError {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 DistError::NoEncontrado => write!(f, "bytecode no encontrado en el source"),
68 DistError::IntegridadFallo => write!(f, "el wasm no coincide con su hash (tampering)"),
69 DistError::AutorNoConfiable => write!(f, "autor de la concesión fuera del anillo"),
70 DistError::FirmaInvalida => write!(f, "firma Ed25519 de la concesión inválida"),
71 DistError::ConcesionParaOtroBytecode => write!(f, "la concesión es para otro bytecode"),
72 DistError::HexInvalido => write!(f, "hash hex inválido"),
73 DistError::HashLongitud => write!(f, "el hash no mide 32 bytes"),
74 DistError::LaunchNoWasm => write!(f, "el Launch no es Wasm"),
75 DistError::ConcesionNoEncontrada => write!(f, "concesión no encontrada en el source"),
76 DistError::ConcesionCorrupta => write!(f, "el blob de la concesión es inválido"),
77 DistError::Carga(e) => write!(f, "cargar guest: {e}"),
78 }
79 }
80}
81
82impl std::error::Error for DistError {}
83
84// =====================================================================
85// Hash canónico del bytecode
86// =====================================================================
87
88/// Hash BLAKE3 del **objeto** que envuelve `inner` (`Objeto{datos:inner,hijos:[]}`)
89/// — la identidad direccionada por contenido que usan el kernel y agora-cli. NO
90/// es `blake3(inner)` crudo. El bytecode y la concesión se direccionan así.
91pub fn object_hash(inner: &[u8]) -> Hash {
92 let obj = Objeto {
93 datos: inner.to_vec(),
94 hijos: Vec::new(),
95 };
96 let payload = obj.serializar().expect("serializar objeto");
97 format::hash(&payload)
98}
99
100/// Hash canónico de un bytecode wasm (= [`object_hash`] del wasm).
101pub fn bytecode_hash(wasm: &[u8]) -> Hash {
102 object_hash(wasm)
103}
104
105/// Hash del objeto-concesión: su dirección en el CAS, idéntica a la que emite
106/// `agora-cli wawa concesion` y a la que `EntradaApp.concesion` referencia.
107pub fn grant_hash(grant: &ConcesionCapacidad) -> Hash {
108 object_hash(&grant.serializar().expect("serializar concesión"))
109}
110
111/// Hash → hex de 64 caracteres.
112pub fn hash_to_hex(h: &Hash) -> String {
113 hex::encode(h)
114}
115
116/// hex → Hash (32 bytes). Tolera espacios alrededor.
117pub fn hash_from_hex(s: &str) -> Result<Hash, DistError> {
118 let v = hex::decode(s.trim()).map_err(|_| DistError::HexInvalido)?;
119 v.try_into().map_err(|_| DistError::HashLongitud)
120}
121
122// =====================================================================
123// Fuente de bytecode — la costura de transporte
124// =====================================================================
125
126/// De dónde salen los bytes de un bytecode dado su hash. `DiskStore` es la
127/// impl local; un backend P2P (BrahmanNet: DHT find_providers + stream del
128/// blob) sería otra impl, y `LayeredSource` los compone (caché local → red).
129pub trait BlobSource {
130 /// Recupera el wasm cuyo objeto-bytecode hashea a `hash`, si lo tiene.
131 fn fetch(&self, hash: &Hash) -> Option<Vec<u8>>;
132}
133
134/// CAS local en disco: un archivo por bytecode, nombrado por el hex de su hash.
135pub struct DiskStore {
136 dir: PathBuf,
137}
138
139impl DiskStore {
140 /// Abre (creando si hace falta) el directorio del store.
141 pub fn open(dir: impl Into<PathBuf>) -> std::io::Result<Self> {
142 let dir = dir.into();
143 fs::create_dir_all(&dir)?;
144 Ok(Self { dir })
145 }
146
147 /// Inscribe un wasm y devuelve su hash canónico (la "dirección").
148 pub fn put(&self, wasm: &[u8]) -> std::io::Result<Hash> {
149 let h = bytecode_hash(wasm);
150 fs::write(self.path(&h), wasm)?;
151 Ok(h)
152 }
153
154 /// Inscribe una concesión como blob y devuelve su [`grant_hash`]. Así el
155 /// grant viaja por el mismo CAS/P2P que el bytecode — un blob más.
156 pub fn put_grant(&self, grant: &ConcesionCapacidad) -> std::io::Result<Hash> {
157 let bytes = grant
158 .serializar()
159 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
160 let h = object_hash(&bytes);
161 fs::write(self.path(&h), &bytes)?;
162 Ok(h)
163 }
164
165 /// Recupera los bytes crudos por hash (sin verificar — eso lo hace `resolve`).
166 pub fn get(&self, hash: &Hash) -> Option<Vec<u8>> {
167 fs::read(self.path(hash)).ok()
168 }
169
170 fn path(&self, h: &Hash) -> PathBuf {
171 self.dir.join(hash_to_hex(h))
172 }
173}
174
175impl BlobSource for DiskStore {
176 fn fetch(&self, hash: &Hash) -> Option<Vec<u8>> {
177 self.get(hash)
178 }
179}
180
181/// Compone fuentes en orden: la primera que tenga el blob gana. Patrón
182/// caché-local → red: `LayeredSource::new(vec![Box::new(disk), Box::new(p2p)])`.
183pub struct LayeredSource {
184 layers: Vec<Box<dyn BlobSource>>,
185}
186
187impl LayeredSource {
188 pub fn new(layers: Vec<Box<dyn BlobSource>>) -> Self {
189 Self { layers }
190 }
191}
192
193impl BlobSource for LayeredSource {
194 fn fetch(&self, hash: &Hash) -> Option<Vec<u8>> {
195 self.layers.iter().find_map(|l| l.fetch(hash))
196 }
197}
198
199/// CAS en memoria — útil para tests y para juntar blobs ya traídos de la red
200/// (bytecode + concesión) antes de pasarlos por [`resolve_manifest`].
201#[derive(Debug, Default, Clone)]
202pub struct MapSource {
203 blobs: HashMap<Hash, Vec<u8>>,
204}
205
206impl MapSource {
207 pub fn new() -> Self {
208 Self::default()
209 }
210
211 /// Inserta un blob bajo un hash arbitrario (p.ej. bytes ya traídos por P2P).
212 pub fn insert(&mut self, hash: Hash, bytes: Vec<u8>) {
213 self.blobs.insert(hash, bytes);
214 }
215
216 /// Inserta un wasm bajo su [`bytecode_hash`] y lo devuelve.
217 pub fn put(&mut self, wasm: &[u8]) -> Hash {
218 let h = bytecode_hash(wasm);
219 self.blobs.insert(h, wasm.to_vec());
220 h
221 }
222
223 /// Inserta una concesión bajo su [`grant_hash`] y lo devuelve.
224 pub fn put_grant(&mut self, grant: &ConcesionCapacidad) -> Hash {
225 let bytes = grant.serializar().expect("serializar concesión");
226 let h = object_hash(&bytes);
227 self.blobs.insert(h, bytes);
228 h
229 }
230}
231
232impl BlobSource for MapSource {
233 fn fetch(&self, hash: &Hash) -> Option<Vec<u8>> {
234 self.blobs.get(hash).cloned()
235 }
236}
237
238// =====================================================================
239// Anillo de confianza host-side
240// =====================================================================
241
242/// Conjunto de claves públicas Ed25519 que pueden conceder capacidades. Es el
243/// equivalente cargable del `AGORA_AUTH_RING` del kernel (que vive en `.rodata`
244/// y sólo cambia con reflash); aquí se carga de archivo.
245#[derive(Debug, Clone, Default)]
246pub struct TrustRing {
247 keys: Vec<[u8; 32]>,
248}
249
250impl TrustRing {
251 pub fn new(keys: Vec<[u8; 32]>) -> Self {
252 Self { keys }
253 }
254
255 pub fn empty() -> Self {
256 Self { keys: Vec::new() }
257 }
258
259 pub fn contains(&self, k: &[u8; 32]) -> bool {
260 self.keys.iter().any(|x| x == k)
261 }
262
263 pub fn len(&self) -> usize {
264 self.keys.len()
265 }
266
267 pub fn is_empty(&self) -> bool {
268 self.keys.is_empty()
269 }
270
271 /// Parsea un anillo: una pubkey en hex (64 chars) por línea; `#` comenta;
272 /// líneas en blanco se ignoran. Las entradas mal formadas se descartan.
273 pub fn from_hex_lines(s: &str) -> Self {
274 let keys = s
275 .lines()
276 .map(|l| l.split('#').next().unwrap_or("").trim())
277 .filter(|l| !l.is_empty())
278 .filter_map(|l| hash_from_hex(l).ok())
279 .collect();
280 Self { keys }
281 }
282
283 /// Carga el anillo desde un archivo (formato de [`Self::from_hex_lines`]).
284 pub fn load(path: impl AsRef<Path>) -> std::io::Result<Self> {
285 Ok(Self::from_hex_lines(&fs::read_to_string(path)?))
286 }
287}
288
289// =====================================================================
290// Verificación
291// =====================================================================
292
293/// `true` si `wasm` rehashea exactamente a `expected`.
294pub fn verify_integrity(wasm: &[u8], expected: &Hash) -> bool {
295 &bytecode_hash(wasm) == expected
296}
297
298/// Verifica una concesión: autor en el anillo + firma Ed25519 válida sobre
299/// `mensaje_capacidad(bytecode, permisos)`. Devuelve los permisos concedidos.
300///
301/// Inlinea el equivalente host de `claves::verificar_concesion_capacidad` del
302/// kernel (allá usa `ed25519-compact` zero-alloc; aquí `ed25519-dalek` vía
303/// `agora_core::verify_signature`).
304pub fn verify_grant(grant: &ConcesionCapacidad, trust: &TrustRing) -> Result<Permisos, DistError> {
305 if !trust.contains(&grant.autor) {
306 return Err(DistError::AutorNoConfiable);
307 }
308 let mensaje = format::mensaje_capacidad(&grant.bytecode, grant.permisos);
309 verify_signature(&grant.autor, &mensaje, &grant.firma).map_err(|_| DistError::FirmaInvalida)?;
310 Ok(grant.permisos)
311}
312
313// =====================================================================
314// Referencia, resolución y carga
315// =====================================================================
316
317/// Referencia a una app distribuida: qué bytecode, qué permisos declara su
318/// manifiesto, y (opcional) la concesión que los respalda.
319#[derive(Debug, Clone)]
320pub struct AppRef {
321 pub bytecode: Hash,
322 /// Permisos que el manifiesto declara querer. Sin concesión, no valen nada.
323 pub declarados: Permisos,
324 /// Concesión firmada que autoriza permisos para `bytecode`. `None` ⇒ app
325 /// sin capacidades (fail-closed): permisos efectivos = 0.
326 pub concesion: Option<ConcesionCapacidad>,
327}
328
329impl AppRef {
330 /// App de sólo-UI, sin permisos ni concesión.
331 pub fn pure(bytecode: Hash) -> Self {
332 Self {
333 bytecode,
334 declarados: 0,
335 concesion: None,
336 }
337 }
338}
339
340/// App resuelta y verificada, lista para correr. Datos puros: el puente para
341/// cargarla en el runner Tier 3 (`VerifiedApp::load`) lo agrega
342/// `llimphi-wasm-dist`, que es quien depende del runner/GPU.
343#[derive(Debug, Clone)]
344pub struct VerifiedApp {
345 pub wasm: Vec<u8>,
346 pub permisos: Permisos,
347 pub bytecode: Hash,
348}
349
350/// Manifiesto distribuible de una app: referencia **por hash** a su bytecode y,
351/// opcional, a la concesión que autoriza sus permisos. Es un objeto
352/// content-addressed más — un peer lo publica, otro lo resuelve trayendo ambos
353/// blobs por la malla. Espejo host de `format::EntradaApp{bytecode, permisos,
354/// concesion}`, sin los campos de runtime del kernel (techo de memoria, fuel).
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct AppManifest {
357 pub bytecode: Hash,
358 /// Permisos que el manifiesto declara. Sin concesión que los respalde, 0.
359 pub declarados: Permisos,
360 /// Hash del objeto-concesión en el CAS. `None` ⇒ app de sólo-UI.
361 pub concesion: Option<Hash>,
362}
363
364impl AppManifest {
365 /// App de sólo-UI: bytecode, sin permisos ni concesión.
366 pub fn pure(bytecode: Hash) -> Self {
367 Self {
368 bytecode,
369 declarados: 0,
370 concesion: None,
371 }
372 }
373
374 pub fn serializar(&self) -> Vec<u8> {
375 postcard::to_allocvec(self).expect("serializar AppManifest")
376 }
377
378 pub fn deserializar(bytes: &[u8]) -> Result<Self, DistError> {
379 postcard::from_bytes(bytes).map_err(|_| DistError::ConcesionCorrupta)
380 }
381}
382
383// =====================================================================
384// Atajo de DESARROLLO local (mudanza a Linux, 2026-07-17) — FAIL-CLOSED
385// =====================================================================
386
387/// `true` sólo si `LLIMPHI_WASM_DEV_TRUST` está en `1`/`true` en el entorno.
388///
389/// Con el atajo activo, `resolve`/`resolve_manifest` dejan de exigir una
390/// concesión firmada Ed25519 contra el `TrustRing`: la app corre con el techo
391/// que su concesión DECLARA (sin verificar la firma) o, sin concesión, con sus
392/// permisos declarados. Quita la fricción de firmar una concesión nueva en cada
393/// rebuild al desarrollar apps Tier 3 / plugins en Linux.
394///
395/// Fail-closed por diseño:
396/// - apagado salvo env EXPLÍCITA — NO se enciende solo por ser build debug;
397/// - la integridad del bytecode se sigue verificando SIEMPRE (sale del hash,
398/// es gratis); el atajo sólo saltea la firma de la concesión;
399/// - cada concesión sin verificar emite un aviso ruidoso a stderr, para que
400/// jamás pase inadvertido en un despliegue real.
401fn dev_trust_active() -> bool {
402 matches!(
403 std::env::var("LLIMPHI_WASM_DEV_TRUST").ok().as_deref(),
404 Some("1") | Some("true")
405 )
406}
407
408/// Aviso ruidoso: se concedieron permisos SIN verificar la firma de la concesión.
409fn advertir_dev_trust(permisos: Permisos) {
410 eprintln!(
411 "⚠️ LLIMPHI_WASM_DEV_TRUST activo: app WASM corriendo con permisos \
412 {permisos:#010x} SIN verificar concesión firmada. Solo desarrollo \
413 local — NUNCA en producción."
414 );
415}
416
417/// Resuelve un [`AppManifest`] end-to-end: trae el bytecode **y** la concesión
418/// por hash del `source` (CAS local o P2P), verifica integridad de ambos, valida
419/// la concesión contra el anillo y devuelve la app con sus permisos efectivos.
420///
421/// Esto es el "descubrimiento de concesiones": el grant no viaja inline, se
422/// fetchea por su hash igual que el bytecode. Una app con capacidades llega así
423/// por la red y corre con permisos reales.
424pub fn resolve_manifest(
425 source: &impl BlobSource,
426 trust: &TrustRing,
427 manifest: &AppManifest,
428) -> Result<VerifiedApp, DistError> {
429 let wasm = source.fetch(&manifest.bytecode).ok_or(DistError::NoEncontrado)?;
430 if !verify_integrity(&wasm, &manifest.bytecode) {
431 return Err(DistError::IntegridadFallo);
432 }
433 let permisos = if dev_trust_active() {
434 // Atajo dev: honrar el techo de la concesión referenciada (si está y
435 // parsea, sin verificar su firma) o los declarados. No exige que la
436 // concesión sea fetcheable ni válida. Integridad del bytecode ya
437 // verificada arriba.
438 let claim = manifest.concesion.as_ref().and_then(|gh| {
439 let blob = source.fetch(gh)?;
440 ConcesionCapacidad::deserializar(&blob).ok().map(|g| g.permisos)
441 });
442 let p = claim.unwrap_or(manifest.declarados);
443 advertir_dev_trust(p);
444 p
445 } else {
446 match &manifest.concesion {
447 Some(grant_hash) => {
448 let blob = source
449 .fetch(grant_hash)
450 .ok_or(DistError::ConcesionNoEncontrada)?;
451 // Integridad del objeto-concesión: su contenido debe direccionarse al
452 // hash pedido (la red no es de fiar).
453 if &object_hash(&blob) != grant_hash {
454 return Err(DistError::IntegridadFallo);
455 }
456 let grant = ConcesionCapacidad::deserializar(&blob)
457 .map_err(|_| DistError::ConcesionCorrupta)?;
458 if grant.bytecode != manifest.bytecode {
459 return Err(DistError::ConcesionParaOtroBytecode);
460 }
461 let concedidos = verify_grant(&grant, trust)?;
462 format::permisos_efectivos(manifest.declarados, concedidos)
463 }
464 None => 0,
465 }
466 };
467 Ok(VerifiedApp {
468 wasm,
469 permisos,
470 bytecode: manifest.bytecode,
471 })
472}
473
474/// Resuelve y verifica una `AppRef` contra un source y un anillo de confianza.
475pub fn resolve(
476 source: &impl BlobSource,
477 trust: &TrustRing,
478 app: &AppRef,
479) -> Result<VerifiedApp, DistError> {
480 let wasm = source.fetch(&app.bytecode).ok_or(DistError::NoEncontrado)?;
481 if !verify_integrity(&wasm, &app.bytecode) {
482 return Err(DistError::IntegridadFallo);
483 }
484 let permisos = if dev_trust_active() {
485 // Atajo dev: techo de la concesión (sin verificar firma) o los declarados.
486 let p = app
487 .concesion
488 .as_ref()
489 .map(|c| c.permisos)
490 .unwrap_or(app.declarados);
491 advertir_dev_trust(p);
492 p
493 } else {
494 match &app.concesion {
495 Some(c) => {
496 if c.bytecode != app.bytecode {
497 return Err(DistError::ConcesionParaOtroBytecode);
498 }
499 let concedidos = verify_grant(c, trust)?;
500 format::permisos_efectivos(app.declarados, concedidos)
501 }
502 // Sin concesión: cero capacidades. Una app de UI pura corre igual.
503 None => 0,
504 }
505 };
506 Ok(VerifiedApp {
507 wasm,
508 permisos,
509 bytecode: app.bytecode,
510 })
511}
512
513// =====================================================================
514// Cableado de app_bus::Launch::Wasm
515// =====================================================================
516
517/// Extrae el hash del bytecode de un `Launch::Wasm`.
518pub fn hash_from_launch(launch: &app_bus::Launch) -> Result<Hash, DistError> {
519 match launch {
520 app_bus::Launch::Wasm { bytecode_hex, .. } => hash_from_hex(bytecode_hex),
521 _ => Err(DistError::LaunchNoWasm),
522 }
523}
524
525/// Traduce un `Launch::Wasm` a un [`AppManifest`]. Si el launch trae `grant_hex`,
526/// el manifiesto referencia esa concesión y declara `Permisos::MAX` (honrar el
527/// grant completo: efectivos = MAX & concedidos = concedidos). Sin grant, app de
528/// sólo-UI. Es la traducción "UI → distribución": app-bus transporta los hex,
529/// `dist` los vuelve un manifiesto resoluble.
530pub fn manifest_from_launch(launch: &app_bus::Launch) -> Result<AppManifest, DistError> {
531 match launch {
532 app_bus::Launch::Wasm {
533 bytecode_hex,
534 grant_hex,
535 } => {
536 let bytecode = hash_from_hex(bytecode_hex)?;
537 let concesion = match grant_hex {
538 Some(h) => Some(hash_from_hex(h)?),
539 None => None,
540 };
541 // Sin concesión, una app declara 0 (sólo-UI) — salvo en modo
542 // dev-trust, donde declara MAX para que el atajo la corra local con
543 // capacidades plenas sin firmar nada.
544 let declarados = if concesion.is_some() || dev_trust_active() {
545 Permisos::MAX
546 } else {
547 0
548 };
549 Ok(AppManifest {
550 bytecode,
551 declarados,
552 concesion,
553 })
554 }
555 _ => Err(DistError::LaunchNoWasm),
556 }
557}
558
559/// Resuelve un `Launch::Wasm` a una `VerifiedApp`, end-to-end: trae el bytecode
560/// y —si el launch declara `grant_hex`— la concesión por hash del `source`, los
561/// verifica y corre la app con sus permisos efectivos. Cierra el lanzamiento de
562/// apps WASM con capacidades desde la UI.
563pub fn resolve_launch(
564 source: &impl BlobSource,
565 trust: &TrustRing,
566 launch: &app_bus::Launch,
567) -> Result<VerifiedApp, DistError> {
568 let manifest = manifest_from_launch(launch)?;
569 resolve_manifest(source, trust, &manifest)
570}
571
572// =====================================================================
573// Catálogo — el índice buscable de apps ("qué apps existen")
574// =====================================================================
575
576/// Una entrada del catálogo: metadatos buscables + referencias **por hash** al
577/// bytecode y, opcional, a su concesión. Es la unidad que falta sobre el
578/// transporte por hash: el `BlobSource` sabe traer un blob *si conoces su hash*,
579/// pero no *qué apps hay*; esto lo nombra y lo describe.
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581pub struct CatalogEntry {
582 /// Identificador estable (para `get`/lanzar por id).
583 pub id: String,
584 /// Nombre legible.
585 pub name: String,
586 /// Descripción corta (entra en la búsqueda).
587 pub description: String,
588 /// Categoría opcional (para filtrar/agrupar).
589 pub category: Option<String>,
590 /// Hash del bytecode en el CAS.
591 pub bytecode: Hash,
592 /// Permisos que la app declara querer (sin concesión que los respalde, 0).
593 pub declarados: Permisos,
594 /// Hash del objeto-concesión, si la app pide capacidades.
595 pub concesion: Option<Hash>,
596}
597
598impl CatalogEntry {
599 /// El [`AppManifest`] resoluble de esta entrada — el puente catálogo→correr.
600 pub fn manifest(&self) -> AppManifest {
601 AppManifest {
602 bytecode: self.bytecode,
603 declarados: self.declarados,
604 concesion: self.concesion,
605 }
606 }
607
608 /// ¿La consulta (sin distinguir mayúsculas) aparece en id/nombre/
609 /// descripción/categoría? Una consulta vacía siempre coincide.
610 pub fn matches(&self, query: &str) -> bool {
611 if query.is_empty() {
612 return true;
613 }
614 let q = query.to_lowercase();
615 self.id.to_lowercase().contains(&q)
616 || self.name.to_lowercase().contains(&q)
617 || self.description.to_lowercase().contains(&q)
618 || self
619 .category
620 .as_deref()
621 .map(|c| c.to_lowercase().contains(&q))
622 .unwrap_or(false)
623 }
624}
625
626/// Catálogo de apps: el índice buscable. Es **él mismo un objeto
627/// content-addressed** (postcard) — por eso viaja por la misma malla que los
628/// bytecodes (`llimphi-wasm-net::fetch_blob` por su [`Catalog::hash`]) sin
629/// protocolo nuevo: quien lo publica comparte su hash, quien lo recibe lo busca.
630///
631/// **No necesita ser de confianza para ser seguro**: cada app que nombra se
632/// resuelve por su propio hash y se re-verifica de forma independiente
633/// (integridad + concesión Ed25519 contra el [`TrustRing`]). Un catálogo
634/// adversario, a lo sumo, ofrece apps cuyas concesiones igual deben estar
635/// firmadas por una clave de tu anillo — no puede colar código alterado ni
636/// permisos sin respaldo.
637#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
638pub struct Catalog {
639 pub entries: Vec<CatalogEntry>,
640}
641
642impl Catalog {
643 pub fn new(entries: Vec<CatalogEntry>) -> Self {
644 Self { entries }
645 }
646
647 pub fn serializar(&self) -> Vec<u8> {
648 postcard::to_allocvec(self).expect("serializar Catalog")
649 }
650
651 pub fn deserializar(bytes: &[u8]) -> Result<Self, DistError> {
652 postcard::from_bytes(bytes).map_err(|_| DistError::ConcesionCorrupta)
653 }
654
655 /// La dirección de contenido del catálogo (el mismo esquema que bytecode y
656 /// concesión: `hash(Objeto{datos,hijos:[]})`), para publicarlo/traerlo.
657 pub fn hash(&self) -> Hash {
658 object_hash(&self.serializar())
659 }
660
661 /// Entradas que coinciden con `query` (substring en id/nombre/desc/cat).
662 /// Una consulta vacía devuelve todo el catálogo.
663 pub fn search(&self, query: &str) -> Vec<&CatalogEntry> {
664 self.entries.iter().filter(|e| e.matches(query)).collect()
665 }
666
667 /// La entrada de id exacto, si existe.
668 pub fn get(&self, id: &str) -> Option<&CatalogEntry> {
669 self.entries.iter().find(|e| e.id == id)
670 }
671
672 /// Agrega o reemplaza (por id) una entrada. Mantiene el id único, así
673 /// re-publicar una app la actualiza en vez de duplicarla.
674 pub fn upsert(&mut self, entry: CatalogEntry) {
675 match self.entries.iter_mut().find(|e| e.id == entry.id) {
676 Some(slot) => *slot = entry,
677 None => self.entries.push(entry),
678 }
679 }
680}
681
682/// Resuelve y verifica una app del catálogo por su `id`, end-to-end: ubica la
683/// entrada, trae bytecode (+ concesión) por hash del `source`, los verifica y
684/// devuelve la app con sus permisos efectivos. El puente catálogo→correr.
685pub fn resolve_from_catalog(
686 source: &impl BlobSource,
687 trust: &TrustRing,
688 catalog: &Catalog,
689 id: &str,
690) -> Result<VerifiedApp, DistError> {
691 let entry = catalog.get(id).ok_or(DistError::NoEncontrado)?;
692 resolve_manifest(source, trust, &entry.manifest())
693}
694
695#[cfg(test)]
696mod catalog_tests {
697 use super::*;
698
699 fn entry(id: &str, name: &str, desc: &str, cat: Option<&str>) -> CatalogEntry {
700 CatalogEntry {
701 id: id.into(),
702 name: name.into(),
703 description: desc.into(),
704 category: cat.map(Into::into),
705 bytecode: bytecode_hash(id.as_bytes()),
706 declarados: 0,
707 concesion: None,
708 }
709 }
710
711 fn cat() -> Catalog {
712 Catalog::new(vec![
713 entry("counter", "Contador", "suma y resta un número", Some("demo")),
714 entry("form", "Formulario", "campos, slider y radio", Some("demo")),
715 entry("paint", "Pintura", "lienzo de dibujo", Some("arte")),
716 ])
717 }
718
719 #[test]
720 fn search_substring_case_insensitive() {
721 let c = cat();
722 // por nombre/desc, sin distinguir mayúsculas
723 let ids: Vec<&str> = c.search("RADIO").iter().map(|e| e.id.as_str()).collect();
724 assert_eq!(ids, ["form"]);
725 // por categoría: dos demos
726 assert_eq!(c.search("demo").len(), 2);
727 // consulta vacía = todo
728 assert_eq!(c.search("").len(), 3);
729 // sin coincidencias
730 assert!(c.search("zzz").is_empty());
731 }
732
733 #[test]
734 fn get_y_manifest() {
735 let c = cat();
736 let e = c.get("paint").expect("existe");
737 assert_eq!(e.name, "Pintura");
738 let m = e.manifest();
739 assert_eq!(m.bytecode, bytecode_hash(b"paint"));
740 assert!(m.concesion.is_none());
741 assert!(c.get("ausente").is_none());
742 }
743
744 #[test]
745 fn upsert_actualiza_sin_duplicar() {
746 let mut c = cat();
747 assert_eq!(c.entries.len(), 3);
748 c.upsert(entry("form", "Formulario v2", "ahora con multiline", Some("demo")));
749 assert_eq!(c.entries.len(), 3, "mismo id ⇒ reemplaza, no agrega");
750 assert_eq!(c.get("form").unwrap().name, "Formulario v2");
751 c.upsert(entry("nuevo", "Nuevo", "otra app", None));
752 assert_eq!(c.entries.len(), 4);
753 }
754
755 #[test]
756 fn serde_roundtrip_y_hash_estable() {
757 let c = cat();
758 let bytes = c.serializar();
759 let back = Catalog::deserializar(&bytes).expect("re-deserializa");
760 assert_eq!(c, back);
761 // El hash es la dirección de contenido: estable y reproducible.
762 assert_eq!(c.hash(), back.hash());
763 assert_eq!(c.hash(), object_hash(&c.serializar()));
764 // Cambiar una entrada cambia el hash.
765 let mut c2 = c.clone();
766 c2.upsert(entry("counter", "Contador", "cambió la desc", Some("demo")));
767 assert_ne!(c.hash(), c2.hash());
768 }
769}