tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! Certificación headless de la distribución por hash: CAS, integridad,
2//! concesión Ed25519 contra anillo, permisos efectivos, y correr la app
3//! resuelta de punta a punta. Sin red ni GPU.
4
5use agora_core::Keypair;
6use app_bus::Launch;
7use format::{ConcesionCapacidad, Hash, Permisos, PERMISO_GRAFO_ESCRITURA, PERMISO_RED};
8use llimphi_wasm_dist::{
9 bytecode_hash, grant_hash, hash_to_hex, resolve, resolve_from_catalog, resolve_launch,
10 resolve_manifest, verify_grant, verify_integrity, AppManifest, AppRef, BlobSource, Catalog,
11 CatalogEntry, DiskStore, DistError, EventPayload, MapSource, TrustRing, VerifiedAppExt,
12};
13
14/// El mismo wasm que corre el runner Tier 3 — lo distribuimos por hash.
15const COUNTER_WASM: &[u8] = include_bytes!("../../llimphi-wasm-runner/assets/counter.wasm");
16
17const SEED: [u8; 32] = [7; 32];
18
19fn tmpdir(nombre: &str) -> std::path::PathBuf {
20 let dir = std::env::temp_dir().join(format!("llimphi-wasm-dist-{nombre}"));
21 let _ = std::fs::remove_dir_all(&dir);
22 dir
23}
24
25/// Firma una concesión como lo haría `agora-cli wawa grant` (offline).
26fn firmar(kp: &Keypair, bytecode: Hash, permisos: Permisos) -> ConcesionCapacidad {
27 let mensaje = format::mensaje_capacidad(&bytecode, permisos);
28 ConcesionCapacidad {
29 bytecode,
30 permisos,
31 autor: kp.public_key(),
32 firma: kp.sign(&mensaje),
33 }
34}
35
36/// Source en memoria que devuelve bytes arbitrarios — para forzar tampering.
37struct FixedSource(Vec<u8>);
38impl BlobSource for FixedSource {
39 fn fetch(&self, _hash: &Hash) -> Option<Vec<u8>> {
40 Some(self.0.clone())
41 }
42}
43
44#[test]
45fn disk_store_round_trip() {
46 let store = DiskStore::open(tmpdir("rt")).unwrap();
47 let h = store.put(COUNTER_WASM).unwrap();
48 assert_eq!(h, bytecode_hash(COUNTER_WASM), "put devuelve el hash canónico");
49 assert_eq!(store.get(&h).as_deref(), Some(COUNTER_WASM), "get recupera los bytes");
50 assert_eq!(store.fetch(&[0u8; 32]), None, "hash desconocido ⇒ None");
51}
52
53#[test]
54fn integridad_detecta_tampering() {
55 let h = bytecode_hash(COUNTER_WASM);
56 assert!(verify_integrity(COUNTER_WASM, &h));
57
58 let mut roto = COUNTER_WASM.to_vec();
59 roto[100] ^= 0xff;
60 assert!(!verify_integrity(&roto, &h), "un byte cambiado ⇒ falla");
61
62 // Un source que entrega bytes corruptos bajo el hash legítimo es rechazado.
63 let source = FixedSource(roto);
64 let err = resolve(&source, &TrustRing::empty(), &AppRef::pure(h)).unwrap_err();
65 assert_eq!(err, DistError::IntegridadFallo);
66}
67
68#[test]
69fn concesion_valida_da_permisos() {
70 let store = DiskStore::open(tmpdir("grant-ok")).unwrap();
71 let h = store.put(COUNTER_WASM).unwrap();
72 let kp = Keypair::from_seed(SEED);
73 let grant = firmar(&kp, h, PERMISO_RED);
74 let trust = TrustRing::new(vec![kp.public_key()]);
75
76 // La concesión sola verifica y rinde sus permisos.
77 assert_eq!(verify_grant(&grant, &trust), Ok(PERMISO_RED));
78
79 // Resuelta con declarados = RED ⇒ efectivos = RED.
80 let app = AppRef {
81 bytecode: h,
82 declarados: PERMISO_RED,
83 concesion: Some(grant),
84 };
85 let verified = resolve(&store, &trust, &app).unwrap();
86 assert_eq!(verified.permisos, PERMISO_RED);
87 assert_eq!(verified.wasm, COUNTER_WASM);
88}
89
90#[test]
91fn concesion_autor_no_confiable() {
92 let kp = Keypair::from_seed(SEED);
93 let grant = firmar(&kp, bytecode_hash(COUNTER_WASM), PERMISO_RED);
94 // Anillo vacío: el autor no está → rechazo antes de gastar cripto.
95 assert_eq!(verify_grant(&grant, &TrustRing::empty()), Err(DistError::AutorNoConfiable));
96}
97
98#[test]
99fn concesion_firma_invalida() {
100 let kp = Keypair::from_seed(SEED);
101 let mut grant = firmar(&kp, bytecode_hash(COUNTER_WASM), PERMISO_RED);
102 grant.firma[0] ^= 0xff; // corrompe la firma
103 let trust = TrustRing::new(vec![kp.public_key()]);
104 assert_eq!(verify_grant(&grant, &trust), Err(DistError::FirmaInvalida));
105}
106
107#[test]
108fn concesion_para_otro_bytecode() {
109 let store = DiskStore::open(tmpdir("otro-bc")).unwrap();
110 let h = store.put(COUNTER_WASM).unwrap();
111 let kp = Keypair::from_seed(SEED);
112 // Concesión firmada para OTRO bytecode (hash distinto), válida en sí misma.
113 let otro = bytecode_hash(b"otra app distinta");
114 let grant = firmar(&kp, otro, PERMISO_RED);
115 let trust = TrustRing::new(vec![kp.public_key()]);
116
117 let app = AppRef {
118 bytecode: h,
119 declarados: PERMISO_RED,
120 concesion: Some(grant),
121 };
122 assert_eq!(
123 resolve(&store, &trust, &app).unwrap_err(),
124 DistError::ConcesionParaOtroBytecode
125 );
126}
127
128#[test]
129fn permisos_efectivos_son_interseccion() {
130 let store = DiskStore::open(tmpdir("interseccion")).unwrap();
131 let h = store.put(COUNTER_WASM).unwrap();
132 let kp = Keypair::from_seed(SEED);
133 // La concesión otorga RED|GRAFO, pero el manifiesto sólo declara RED.
134 let grant = firmar(&kp, h, PERMISO_RED | PERMISO_GRAFO_ESCRITURA);
135 let trust = TrustRing::new(vec![kp.public_key()]);
136
137 let app = AppRef {
138 bytecode: h,
139 declarados: PERMISO_RED,
140 concesion: Some(grant),
141 };
142 let verified = resolve(&store, &trust, &app).unwrap();
143 // efectivos = declarados & concedidos = RED. No escala a GRAFO.
144 assert_eq!(verified.permisos, PERMISO_RED);
145}
146
147#[test]
148fn resolve_launch_y_corre_la_app() {
149 // El camino "abrir app distribuida": el Launch lleva sólo el hash hex.
150 let store = DiskStore::open(tmpdir("e2e")).unwrap();
151 let h = store.put(COUNTER_WASM).unwrap();
152 let launch = Launch::Wasm {
153 bytecode_hex: hash_to_hex(&h),
154 grant_hex: None,
155 };
156
157 let verified = resolve_launch(&store, &TrustRing::empty(), &launch).unwrap();
158 assert_eq!(verified.bytecode, h);
159 assert_eq!(verified.permisos, 0, "UI pura sin concesión ⇒ sin permisos");
160
161 // Y la app resuelta CORRE: cargamos el guest y lo manejamos.
162 let mut guest = verified.load().unwrap();
163 let n0 = guest.view().children[0].text.as_ref().unwrap().content.clone();
164 assert_eq!(n0, "0");
165 guest.dispatch(0, EventPayload::Click).unwrap(); // botón +1 ⇒ Increment
166 let n1 = guest.view().children[0].text.as_ref().unwrap().content.clone();
167 assert_eq!(n1, "1", "la app distribuida por hash incrementa de verdad");
168}
169
170#[test]
171fn resolve_launch_con_concesion_da_permisos() {
172 // El Launch lleva bytecode_hex Y grant_hex: la app de la UI corre con
173 // permisos reales (descubrimiento de concesión desde el lanzamiento).
174 let store = DiskStore::open(tmpdir("launch-grant")).unwrap();
175 let bc = store.put(COUNTER_WASM).unwrap();
176 let kp = Keypair::from_seed(SEED);
177 let grant = firmar(&kp, bc, PERMISO_RED);
178 let gh = store.put_grant(&grant).unwrap();
179
180 let launch = Launch::Wasm {
181 bytecode_hex: hash_to_hex(&bc),
182 grant_hex: Some(hash_to_hex(&gh)),
183 };
184 let trust = TrustRing::new(vec![kp.public_key()]);
185 let verified = resolve_launch(&store, &trust, &launch).unwrap();
186 // declarados = MAX ⇒ efectivos = concedidos = RED.
187 assert_eq!(verified.permisos, PERMISO_RED);
188 assert!(verified.load().is_ok());
189}
190
191#[test]
192fn manifiesto_con_concesion_da_permisos_reales() {
193 // El descubrimiento de concesiones: el manifiesto referencia bytecode Y
194 // concesión por hash; ambos viven en el store; resolve_manifest los trae,
195 // verifica la cadena y rinde permisos reales.
196 let store = DiskStore::open(tmpdir("manifest-grant")).unwrap();
197 let bc = store.put(COUNTER_WASM).unwrap();
198 let kp = Keypair::from_seed(SEED);
199 let grant = firmar(&kp, bc, PERMISO_RED);
200 let gh = store.put_grant(&grant).unwrap();
201 assert_eq!(gh, grant_hash(&grant), "put_grant direcciona por grant_hash");
202
203 let manifest = AppManifest {
204 bytecode: bc,
205 declarados: PERMISO_RED,
206 concesion: Some(gh),
207 };
208 let trust = TrustRing::new(vec![kp.public_key()]);
209
210 let verified = resolve_manifest(&store, &trust, &manifest).unwrap();
211 assert_eq!(verified.permisos, PERMISO_RED, "permisos reales, no 0");
212 assert_eq!(verified.wasm, COUNTER_WASM);
213 // Y carga en el runner con ese permiso (que gatea host_net_request).
214 assert!(verified.load().is_ok());
215}
216
217#[test]
218fn manifiesto_concesion_faltante() {
219 // El manifiesto declara una concesión que el source NO tiene.
220 let mut src = MapSource::new();
221 let bc = src.put(COUNTER_WASM);
222 let manifest = AppManifest {
223 bytecode: bc,
224 declarados: PERMISO_RED,
225 concesion: Some([0xab; 32]), // hash que nadie sirve
226 };
227 assert_eq!(
228 resolve_manifest(&src, &TrustRing::empty(), &manifest).unwrap_err(),
229 DistError::ConcesionNoEncontrada
230 );
231}
232
233#[test]
234fn manifiesto_concesion_para_otro_bytecode() {
235 // Concesión válida y servida, pero firmada para OTRO bytecode.
236 let mut src = MapSource::new();
237 let bc = src.put(COUNTER_WASM);
238 let kp = Keypair::from_seed(SEED);
239 let grant = firmar(&kp, bytecode_hash(b"otra app"), PERMISO_RED);
240 let gh = src.put_grant(&grant);
241 let manifest = AppManifest {
242 bytecode: bc,
243 declarados: PERMISO_RED,
244 concesion: Some(gh),
245 };
246 let trust = TrustRing::new(vec![kp.public_key()]);
247 assert_eq!(
248 resolve_manifest(&src, &trust, &manifest).unwrap_err(),
249 DistError::ConcesionParaOtroBytecode
250 );
251}
252
253#[test]
254fn manifiesto_pura_corre_sin_permisos() {
255 let mut src = MapSource::new();
256 let bc = src.put(COUNTER_WASM);
257 let verified =
258 resolve_manifest(&src, &TrustRing::empty(), &AppManifest::pure(bc)).unwrap();
259 assert_eq!(verified.permisos, 0);
260 assert!(verified.load().is_ok());
261}
262
263#[test]
264fn manifiesto_round_trip() {
265 let m = AppManifest {
266 bytecode: [3; 32],
267 declarados: PERMISO_RED,
268 concesion: Some([7; 32]),
269 };
270 assert_eq!(AppManifest::deserializar(&m.serializar()).unwrap(), m);
271}
272
273#[test]
274fn catalogo_busca_y_corre_la_app_elegida() {
275 // El "qué apps hay": un catálogo nombra apps; buscas por texto, eliges una
276 // y se resuelve por su hash + corre. El catálogo mismo viaja como blob (su
277 // hash es su dirección de contenido).
278 let mut src = MapSource::new();
279 let bc = src.put(COUNTER_WASM); // bytecode al CAS
280 let kp = Keypair::from_seed(SEED);
281 let grant = firmar(&kp, bc, PERMISO_RED);
282 let gh = src.put_grant(&grant); // concesión al CAS
283 let trust = TrustRing::new(vec![kp.public_key()]);
284
285 let catalog = Catalog::new(vec![
286 CatalogEntry {
287 id: "counter".into(),
288 name: "Contador".into(),
289 description: "suma y resta en red".into(),
290 category: Some("demo".into()),
291 bytecode: bc,
292 declarados: PERMISO_RED,
293 concesion: Some(gh),
294 },
295 CatalogEntry {
296 id: "otra".into(),
297 name: "Otra".into(),
298 description: "app que no está en el CAS".into(),
299 category: Some("demo".into()),
300 bytecode: [9; 32],
301 declarados: 0,
302 concesion: None,
303 },
304 ]);
305
306 // El catálogo es content-addressed: serializa y rehashea estable.
307 assert_eq!(
308 Catalog::deserializar(&catalog.serializar()).unwrap().hash(),
309 catalog.hash()
310 );
311
312 // Búsqueda por texto encuentra el contador.
313 let hits = catalog.search("suma");
314 assert_eq!(hits.len(), 1);
315 assert_eq!(hits[0].id, "counter");
316
317 // Resolver por id: trae bytecode + concesión, verifica, permisos reales.
318 let verified = resolve_from_catalog(&src, &trust, &catalog, "counter").unwrap();
319 assert_eq!(verified.permisos, PERMISO_RED, "la concesión del catálogo da RED");
320
321 // Y CORRE.
322 let mut guest = verified.load().unwrap();
323 guest.dispatch(0, EventPayload::Click).unwrap();
324 assert_eq!(
325 guest.view().children[0].text.as_ref().unwrap().content,
326 "1",
327 "la app elegida del catálogo incrementa de verdad"
328 );
329
330 // Una entrada cuyo bytecode no está en el CAS no resuelve (pero el catálogo
331 // sí la lista — descubrir ≠ tener).
332 assert!(resolve_from_catalog(&src, &trust, &catalog, "otra").is_err());
333 // Un id ausente del catálogo.
334 assert_eq!(
335 resolve_from_catalog(&src, &trust, &catalog, "nada").unwrap_err(),
336 DistError::NoEncontrado
337 );
338}