tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-wasm-core/src/lib.rs Rust · 769 líneas · 28.5 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-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}