From 85795c42cbd49259b2fef73a15989c73ac319141 Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 4 Jun 2026 12:17:58 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20minga=20standalone=20=E2=80=94=20compar?= =?UTF-8?q?tici=C3=B3n=20P2P=20soberana=20con=20montaje=20FUSE=20content-a?= =?UTF-8?q?ddressed=20(front-door,=20git-dep=20al=20monorepo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Front-door limpio: solo crates del dominio; Llimphi y lo fundacional por git-dep del monorepo gioser.git. cargo check pasa (7 crates, 0 errores). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + 03_ukupacha/minga/ARQUITECTURA.md | 130 + 03_ukupacha/minga/LEEME.md | 37 + 03_ukupacha/minga/README.md | 39 + 03_ukupacha/minga/README.qu.md | 26 + 03_ukupacha/minga/REPORTE.md | 221 + 03_ukupacha/minga/card-discovery/Cargo.toml | 21 + .../minga/card-discovery/src/discovery.rs | 64 + 03_ukupacha/minga/card-discovery/src/index.rs | 116 + 03_ukupacha/minga/card-discovery/src/lib.rs | 19 + .../minga/card-discovery/src/registry.rs | 46 + 03_ukupacha/minga/minga-cli/Cargo.toml | 35 + 03_ukupacha/minga/minga-cli/LEEME.md | 17 + 03_ukupacha/minga/minga-cli/README.md | 17 + 03_ukupacha/minga/minga-cli/src/bundle.rs | 455 + 03_ukupacha/minga/minga-cli/src/commands.rs | 1354 +++ 03_ukupacha/minga/minga-cli/src/error.rs | 109 + 03_ukupacha/minga/minga-cli/src/lib.rs | 26 + 03_ukupacha/minga/minga-cli/src/main.rs | 713 ++ 03_ukupacha/minga/minga-cli/src/serve.rs | 291 + .../minga/minga-cli/tests/bundle_roundtrip.rs | 330 + .../minga/minga-cli/tests/cli_smoke.rs | 356 + .../minga/minga-cli/tests/serve_http.rs | 256 + 03_ukupacha/minga/minga-cli/tests/watcher.rs | 129 + 03_ukupacha/minga/minga-core/Cargo.toml | 27 + 03_ukupacha/minga/minga-core/LEEME.md | 9 + 03_ukupacha/minga/minga-core/README.md | 9 + .../minga/minga-core/src/alpha/common.rs | 105 + .../minga/minga-core/src/alpha/ecmascript.rs | 365 + 03_ukupacha/minga/minga-core/src/alpha/go.rs | 283 + 03_ukupacha/minga/minga-core/src/alpha/mod.rs | 69 + .../minga/minga-core/src/alpha/python.rs | 387 + .../minga/minga-core/src/alpha/rust.rs | 562 ++ 03_ukupacha/minga/minga-core/src/ast.rs | 52 + .../minga/minga-core/src/attestation.rs | 127 + 03_ukupacha/minga/minga-core/src/cas.rs | 95 + 03_ukupacha/minga/minga-core/src/identity.rs | 223 + 03_ukupacha/minga/minga-core/src/lib.rs | 27 + 03_ukupacha/minga/minga-core/src/mst.rs | 457 + 03_ukupacha/minga/minga-core/src/parse.rs | 523 ++ .../minga/minga-core/src/retraction.rs | 176 + 03_ukupacha/minga/minga-core/src/root_decl.rs | 45 + 03_ukupacha/minga/minga-core/src/store.rs | 144 + .../minga-core/tests/alpha_invariants.rs | 367 + .../minga/minga-core/tests/alpha_polyglot.rs | 307 + .../tests/attestation_invariants.rs | 136 + .../minga/minga-core/tests/cas_invariants.rs | 64 + .../minga-core/tests/identity_invariants.rs | 172 + .../minga/minga-core/tests/mst_invariants.rs | 347 + .../minga-core/tests/serde_roundtrips.rs | 134 + .../minga-core/tests/store_invariants.rs | 129 + 03_ukupacha/minga/minga-dht/Cargo.toml | 16 + 03_ukupacha/minga/minga-dht/LEEME.md | 9 + 03_ukupacha/minga/minga-dht/README.md | 9 + 03_ukupacha/minga/minga-dht/src/key.rs | 8 + 03_ukupacha/minga/minga-dht/src/lib.rs | 72 + .../minga/minga-explorer-llimphi/Cargo.toml | 26 + .../minga/minga-explorer-llimphi/LEEME.md | 16 + .../minga/minga-explorer-llimphi/README.md | 16 + .../minga/minga-explorer-llimphi/src/main.rs | 653 ++ 03_ukupacha/minga/minga-p2p/Cargo.toml | 26 + 03_ukupacha/minga/minga-p2p/LEEME.md | 10 + 03_ukupacha/minga/minga-p2p/README.md | 10 + .../minga/minga-p2p/src/async_driver.rs | 100 + 03_ukupacha/minga/minga-p2p/src/harness.rs | 95 + 03_ukupacha/minga/minga-p2p/src/lib.rs | 26 + 03_ukupacha/minga/minga-p2p/src/message.rs | 116 + 03_ukupacha/minga/minga-p2p/src/network.rs | 22 + 03_ukupacha/minga/minga-p2p/src/peer.rs | 458 + 03_ukupacha/minga/minga-p2p/src/session.rs | 687 ++ .../tests/async_driver_invariants.rs | 161 + .../minga/minga-p2p/tests/kad_discovery.rs | 189 + .../minga/minga-p2p/tests/kad_providers.rs | 139 + .../minga-p2p/tests/libp2p_integration.rs | 161 + .../minga/minga-p2p/tests/passive_listener.rs | 128 + .../minga/minga-p2p/tests/persistent_peer.rs | 239 + .../minga/minga-p2p/tests/sync_invariants.rs | 1072 +++ .../minga/minga-p2p/tests/wire_roundtrips.rs | 128 + 03_ukupacha/minga/minga-store/Cargo.toml | 18 + 03_ukupacha/minga/minga-store/LEEME.md | 9 + 03_ukupacha/minga/minga-store/README.md | 9 + .../minga-store/src/alpha_paths_store.rs | 166 + .../minga-store/src/attestation_store.rs | 83 + 03_ukupacha/minga/minga-store/src/error.rs | 19 + .../minga/minga-store/src/keypair_file.rs | 41 + 03_ukupacha/minga/minga-store/src/lib.rs | 46 + .../minga/minga-store/src/mst_store.rs | 81 + .../minga/minga-store/src/node_store.rs | 171 + .../minga-store/src/path_history_store.rs | 136 + 03_ukupacha/minga/minga-store/src/repo.rs | 86 + .../minga/minga-store/src/retraction_store.rs | 79 + .../minga/minga-store/src/roots_store.rs | 105 + .../minga/minga-store/src/timestamp_store.rs | 64 + .../minga-store/tests/alpha_paths_rebuild.rs | 45 + .../tests/attestation_store_persistence.rs | 113 + .../tests/keypair_file_persistence.rs | 67 + .../tests/mst_store_persistence.rs | 119 + .../tests/node_store_persistence.rs | 145 + .../minga-store/tests/repo_integration.rs | 104 + 03_ukupacha/minga/minga-vfs/Cargo.toml | 16 + 03_ukupacha/minga/minga-vfs/LEEME.md | 9 + 03_ukupacha/minga/minga-vfs/README.md | 9 + 03_ukupacha/minga/minga-vfs/src/fs.rs | 346 + 03_ukupacha/minga/minga-vfs/src/lib.rs | 81 + 03_ukupacha/minga/minga-vfs/src/render.rs | 427 + 03_ukupacha/minga/minga-vfs/src/source.rs | 164 + .../minga/minga-vfs/tests/projection.rs | 77 + Cargo.lock | 7873 +++++++++++++++++ Cargo.toml | 443 + LICENSE | 21 + README.md | 11 + 111 files changed, 25926 insertions(+) create mode 100644 .gitignore create mode 100644 03_ukupacha/minga/ARQUITECTURA.md create mode 100644 03_ukupacha/minga/LEEME.md create mode 100644 03_ukupacha/minga/README.md create mode 100644 03_ukupacha/minga/README.qu.md create mode 100644 03_ukupacha/minga/REPORTE.md create mode 100644 03_ukupacha/minga/card-discovery/Cargo.toml create mode 100644 03_ukupacha/minga/card-discovery/src/discovery.rs create mode 100644 03_ukupacha/minga/card-discovery/src/index.rs create mode 100644 03_ukupacha/minga/card-discovery/src/lib.rs create mode 100644 03_ukupacha/minga/card-discovery/src/registry.rs create mode 100644 03_ukupacha/minga/minga-cli/Cargo.toml create mode 100644 03_ukupacha/minga/minga-cli/LEEME.md create mode 100644 03_ukupacha/minga/minga-cli/README.md create mode 100644 03_ukupacha/minga/minga-cli/src/bundle.rs create mode 100644 03_ukupacha/minga/minga-cli/src/commands.rs create mode 100644 03_ukupacha/minga/minga-cli/src/error.rs create mode 100644 03_ukupacha/minga/minga-cli/src/lib.rs create mode 100644 03_ukupacha/minga/minga-cli/src/main.rs create mode 100644 03_ukupacha/minga/minga-cli/src/serve.rs create mode 100644 03_ukupacha/minga/minga-cli/tests/bundle_roundtrip.rs create mode 100644 03_ukupacha/minga/minga-cli/tests/cli_smoke.rs create mode 100644 03_ukupacha/minga/minga-cli/tests/serve_http.rs create mode 100644 03_ukupacha/minga/minga-cli/tests/watcher.rs create mode 100644 03_ukupacha/minga/minga-core/Cargo.toml create mode 100644 03_ukupacha/minga/minga-core/LEEME.md create mode 100644 03_ukupacha/minga/minga-core/README.md create mode 100644 03_ukupacha/minga/minga-core/src/alpha/common.rs create mode 100644 03_ukupacha/minga/minga-core/src/alpha/ecmascript.rs create mode 100644 03_ukupacha/minga/minga-core/src/alpha/go.rs create mode 100644 03_ukupacha/minga/minga-core/src/alpha/mod.rs create mode 100644 03_ukupacha/minga/minga-core/src/alpha/python.rs create mode 100644 03_ukupacha/minga/minga-core/src/alpha/rust.rs create mode 100644 03_ukupacha/minga/minga-core/src/ast.rs create mode 100644 03_ukupacha/minga/minga-core/src/attestation.rs create mode 100644 03_ukupacha/minga/minga-core/src/cas.rs create mode 100644 03_ukupacha/minga/minga-core/src/identity.rs create mode 100644 03_ukupacha/minga/minga-core/src/lib.rs create mode 100644 03_ukupacha/minga/minga-core/src/mst.rs create mode 100644 03_ukupacha/minga/minga-core/src/parse.rs create mode 100644 03_ukupacha/minga/minga-core/src/retraction.rs create mode 100644 03_ukupacha/minga/minga-core/src/root_decl.rs create mode 100644 03_ukupacha/minga/minga-core/src/store.rs create mode 100644 03_ukupacha/minga/minga-core/tests/alpha_invariants.rs create mode 100644 03_ukupacha/minga/minga-core/tests/alpha_polyglot.rs create mode 100644 03_ukupacha/minga/minga-core/tests/attestation_invariants.rs create mode 100644 03_ukupacha/minga/minga-core/tests/cas_invariants.rs create mode 100644 03_ukupacha/minga/minga-core/tests/identity_invariants.rs create mode 100644 03_ukupacha/minga/minga-core/tests/mst_invariants.rs create mode 100644 03_ukupacha/minga/minga-core/tests/serde_roundtrips.rs create mode 100644 03_ukupacha/minga/minga-core/tests/store_invariants.rs create mode 100644 03_ukupacha/minga/minga-dht/Cargo.toml create mode 100644 03_ukupacha/minga/minga-dht/LEEME.md create mode 100644 03_ukupacha/minga/minga-dht/README.md create mode 100644 03_ukupacha/minga/minga-dht/src/key.rs create mode 100644 03_ukupacha/minga/minga-dht/src/lib.rs create mode 100644 03_ukupacha/minga/minga-explorer-llimphi/Cargo.toml create mode 100644 03_ukupacha/minga/minga-explorer-llimphi/LEEME.md create mode 100644 03_ukupacha/minga/minga-explorer-llimphi/README.md create mode 100644 03_ukupacha/minga/minga-explorer-llimphi/src/main.rs create mode 100644 03_ukupacha/minga/minga-p2p/Cargo.toml create mode 100644 03_ukupacha/minga/minga-p2p/LEEME.md create mode 100644 03_ukupacha/minga/minga-p2p/README.md create mode 100644 03_ukupacha/minga/minga-p2p/src/async_driver.rs create mode 100644 03_ukupacha/minga/minga-p2p/src/harness.rs create mode 100644 03_ukupacha/minga/minga-p2p/src/lib.rs create mode 100644 03_ukupacha/minga/minga-p2p/src/message.rs create mode 100644 03_ukupacha/minga/minga-p2p/src/network.rs create mode 100644 03_ukupacha/minga/minga-p2p/src/peer.rs create mode 100644 03_ukupacha/minga/minga-p2p/src/session.rs create mode 100644 03_ukupacha/minga/minga-p2p/tests/async_driver_invariants.rs create mode 100644 03_ukupacha/minga/minga-p2p/tests/kad_discovery.rs create mode 100644 03_ukupacha/minga/minga-p2p/tests/kad_providers.rs create mode 100644 03_ukupacha/minga/minga-p2p/tests/libp2p_integration.rs create mode 100644 03_ukupacha/minga/minga-p2p/tests/passive_listener.rs create mode 100644 03_ukupacha/minga/minga-p2p/tests/persistent_peer.rs create mode 100644 03_ukupacha/minga/minga-p2p/tests/sync_invariants.rs create mode 100644 03_ukupacha/minga/minga-p2p/tests/wire_roundtrips.rs create mode 100644 03_ukupacha/minga/minga-store/Cargo.toml create mode 100644 03_ukupacha/minga/minga-store/LEEME.md create mode 100644 03_ukupacha/minga/minga-store/README.md create mode 100644 03_ukupacha/minga/minga-store/src/alpha_paths_store.rs create mode 100644 03_ukupacha/minga/minga-store/src/attestation_store.rs create mode 100644 03_ukupacha/minga/minga-store/src/error.rs create mode 100644 03_ukupacha/minga/minga-store/src/keypair_file.rs create mode 100644 03_ukupacha/minga/minga-store/src/lib.rs create mode 100644 03_ukupacha/minga/minga-store/src/mst_store.rs create mode 100644 03_ukupacha/minga/minga-store/src/node_store.rs create mode 100644 03_ukupacha/minga/minga-store/src/path_history_store.rs create mode 100644 03_ukupacha/minga/minga-store/src/repo.rs create mode 100644 03_ukupacha/minga/minga-store/src/retraction_store.rs create mode 100644 03_ukupacha/minga/minga-store/src/roots_store.rs create mode 100644 03_ukupacha/minga/minga-store/src/timestamp_store.rs create mode 100644 03_ukupacha/minga/minga-store/tests/alpha_paths_rebuild.rs create mode 100644 03_ukupacha/minga/minga-store/tests/attestation_store_persistence.rs create mode 100644 03_ukupacha/minga/minga-store/tests/keypair_file_persistence.rs create mode 100644 03_ukupacha/minga/minga-store/tests/mst_store_persistence.rs create mode 100644 03_ukupacha/minga/minga-store/tests/node_store_persistence.rs create mode 100644 03_ukupacha/minga/minga-store/tests/repo_integration.rs create mode 100644 03_ukupacha/minga/minga-vfs/Cargo.toml create mode 100644 03_ukupacha/minga/minga-vfs/LEEME.md create mode 100644 03_ukupacha/minga/minga-vfs/README.md create mode 100644 03_ukupacha/minga/minga-vfs/src/fs.rs create mode 100644 03_ukupacha/minga/minga-vfs/src/lib.rs create mode 100644 03_ukupacha/minga/minga-vfs/src/render.rs create mode 100644 03_ukupacha/minga/minga-vfs/src/source.rs create mode 100644 03_ukupacha/minga/minga-vfs/tests/projection.rs create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7141ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +*.pdb diff --git a/03_ukupacha/minga/ARQUITECTURA.md b/03_ukupacha/minga/ARQUITECTURA.md new file mode 100644 index 0000000..5780a50 --- /dev/null +++ b/03_ukupacha/minga/ARQUITECTURA.md @@ -0,0 +1,130 @@ +# ARQUITECTURA.md — minga + +> Descripción técnico-arquitectónica densa, optimizada para consumo por IA. +> Snapshot: 2026-05-30. Fuente autoritativa cuando difiera con la prosa de los READMEs. + +```yaml +DOMINIO: minga +CUADRANTE: 03_ukupacha (RAÍZ) +NOMBRE: quechua "trabajo comunitario voluntario" +TESIS: VFS P2P content-addressed que versiona CÓDIGO COMO AST (no texto); la verdad es el BLAKE3 del contenido, no el path +PARADIGMA: VCS semántico distribuido + DHT + FUSE; sin servidor, sin autoridad; latencia comunitaria/doméstica, no CDN global +TAMAÑO: ~10 KLoC, 8 crates +ESTADO: octavo sprint cerrado (2026-05-29) — backlog completo salvo 1 item trigger-driven +``` + +## Idea-fuerza + +``` +IDENTIDAD = ESTRUCTURA, NO PATH: + Cada archivo se parsea a SemanticNode (AST normalizado) y se direcciona por BLAKE3 de su estructura lógica. + α-hashing per-lenguaje: dos archivos con misma estructura bajo renombrado de variables ligadas ⇒ mismo α-hash. + => versionar SIGNIFICADO, no líneas. Renombrar una variable local no cambia la raíz α. + +VERDAD CRIPTOGRÁFICA: + Un peer NUNCA puede colar contenido falso bajo un hash legítimo: hash_components() es función pura, + el receptor re-verifica hash_stored(stored)==hash antes de insertar. Sin confianza en el peer. +``` + +## Crates + +``` +minga-core (3640) PURO sin IO: SemanticNode(AST) · ContentHash(BLAKE3) · Mst(Merkle Search Tree) · + Attestation/Retraction(Ed25519) · Did · α-hashing per-dialect (Rust/Py/JS/TS/Go/…) +minga-store (1077) sled 8-tree write-through: nodes(CAS) · attestations · mst · roots(α→struct+dialect) · + path_history · alpha_paths · retractions · timestamps. "Same shape" que wawa-fs. +minga-dht (223) discovery typed sobre Kademlia compartida: DhtKey = [kind_tag(1)]++[blake3(32)]; + RecordKind{Code,Card,Persona,Service} evita colisión de namespaces en la MISMA DHT. +minga-p2p (1504) MingaPeer (API alto nivel) + SyncSession (máquina de estados) + protocolo /minga/sync/1.0.0. +minga-vfs (1018) VFS distribuido vía FUSE: path→DHT→lazy chunk fetch; cooperative fetch de peer cercano. +minga-cli (2948) init/ingest/log/show/blame/history/roots/sign/verify/retire/prune/diff/watch/sync/mount/bundle/serve. +minga-explorer-llimphi (439) dashboard: peers · content local · tráfico; watcher reactivo a wawa-config. +card-discovery (245) widget de descubrimiento de Cards Brahman; consumido por nahual-shell y agora-app. ← NEXO BRAHMAN +``` + +## Transporte: `BrahmanNet` (re-export de `shared/card/card-net`) + +``` +STACK libp2p: TCP + Noise + Yamux + Kademlia(MemoryStore, modo Server) + identify + stream::Behaviour. +UN SOLO NODO, MÚLTIPLES PROTOCOLOS sobre el mismo PeerId: + /brahman/handshake/1.0.0 (identidad remota — card-handshake) + /minga/sync/1.0.0 (sync de código — minga) + /agora/gossip/1.0.0 (web-of-trust — agora) +CONVERGENCIA: MingaPeer::open_with_node(Arc) y AgoraNet::sharing(net) ADOPTAN el mismo nodo. + La convergencia es a nivel de TRANSPORTE, no de protocolo wire. Demo: agora-net-brahman/examples/convergencia_minga.rs +``` + +## Protocolo de sync `/minga/sync/1.0.0` (anti-entropy simétrico request/response, NO gossip) + +``` +1. Challenge{nonce}×2 anti-replay, ambos peers +2. Hello{did, root_subtree_hash, sig} sig sobre (peer_nonce||my_nonce||did||root); si roots iguales ⇒ done +3. ProbeReq{subtree_hash} / ProbeRes{NodeProbe{level,keys,child_hashes}} descenso recursivo del MST, poda ramas idénticas +4. Fetch{hash} / Deliver{hash, StoredNode} receptor VERIFICA hash_stored==hash antes de insertar (anti-malicia) +5. AttestPush{Vec} propaga autoría tras Hello autenticado; cada firma Ed25519 verificada +6. RetractPush{Vec} tombstones firmados con RETRACTION_DOMAIN (anti-replay) +7. RootDeclaration{Vec} receptor RE-VERIFICA verify_root_alpha(node,α) contra contenido recibido +8. Done×2 sesión termina cuando ambos Done cruzan +CODIFICACIÓN: postcard. ROL: simétrico (sync_with activo / run_passive_accept pasivo). +``` + +## INVARIANTES + +``` +M-INV-1 Deliver se inserta sólo si hash_stored(stored)==hash. Contenido falso bajo hash legítimo = imposible. +M-INV-2 RootDeclaration se acepta sólo si verify_root_alpha(reconstruido, α) coincide. α-mappings maliciosos = rechazados. +M-INV-3 Atestaciones/Retracciones: firma Ed25519 verificada independientemente con la pubkey del Did. +M-INV-4 α-hash invariante bajo renombrado de variables ligadas (per-dialect). Convergencia estructural, no textual. +M-INV-5 write-through best-effort: si el IO a sled falla, la RAM sigue autoritativa; el próximo sync repopula. +``` + +## NAT / discovery — qué hay y qué falta + +``` +HAY: TCP+Noise (auth en transporte) · Kademlia DHT · identify (auto-inyecta listen-addrs) · stream multiplexado. + bootstrap automático en listen (sprint #4). +FALTA: UPnP/IGD · hole-punching · relay nodes · mDNS local. +IMPLICACIÓN: hoy peers en misma LAN o con multiaddr explícito (add_dht_peer). Diseñado para latencia doméstica, no CDN global. +``` + +## Relaciones inter-dominio + +``` +agora : COMPARTE el nodo BrahmanNet — un PeerId, una DHT, dos protocolos (sync + gossip). Convergencia de transporte. +wawa : MISMO content-addressing BLAKE3; minga-store "same shape" que wawa-fs; chunks en $XDG_DATA_HOME/minga/chunks/. +card-* : card-discovery (dentro de minga) consume el broker Brahman; card-net es la base de transporte compartida. +ayni : ayni-minga usa card-net/BrahmanNet como impl del trait Transporte (chat P2P soberano). +chasqui : sin vínculo de código directo; el broker chasqui es local (Unix socket), minga es la capa P2P real. +akasha : potencial indexación semántica de SemanticNode — no integrado. +``` + +## Estado vs aspiración + +``` +HECHO (8 sprints, 2026-05-29): ingest/sync/FUSE/CLI completo · α-hashing multi-dialect · blame/history/roots · + sign(vouching colaborativo) · retire(tombstone) · bundle v1+multi(zstd, magic MNGM/MNGZ) · serve(HTTP+Bearer auth) · + RootDeclaration con re-verificación α · DHT bootstrap automático · convergencia transporte con agora. + +PENDIENTE ÚNICO (trigger-driven): + #5/A MingaPeer genérico sobre NodeStore (backend sled directo sin cargar todo a RAM). + Bloqueado por costo de refactor (NodeStore::get→owned, cascada en SyncSession/tests). + Trigger: primer repo real >100k nodos. Hoy sin caso ⇒ diferido con criterio. + +ASPIRA_A (especulativo, no comprometido): + NAT traversal global (UPnP/hole-punching/relay) · mDNS local · más dialects (C++, etc.) · + CRDT colaborativo (Yrs) para edición real-time (diseño aprobado, no implementado) · + búsqueda semántica vía akasha · reputación de provisión vía ayni. + +NORTE_ARQUITECTÓNICO: + minga es la CAPA P2P REAL de la suite (la única con libp2p vivo, DHT, FUSE y verificación criptográfica end-to-end). + Versiona significado, no texto. Su destino es ser el sustrato de almacenamiento/sync distribuido SOBRE EL QUE corren + agora (confianza), ayni (chat) y eventualmente wawa (releases) — todos compartiendo el mismo nodo BrahmanNet. +``` + +--- + +**Síntesis de una línea para otra IA:** minga es un VCS semántico P2P content-addressed que parsea código a AST +normalizado y lo direcciona por BLAKE3 estructural (con α-hashing invariante bajo renombrado), sincronizado entre peers +por un protocolo anti-entropy simétrico de 8 mensajes con verificación criptográfica de cada nodo, montable como FUSE y +descubrible por una Kademlia DHT typed — y es la **única capa P2P realmente viva** de la suite (libp2p `BrahmanNet` +compartido con agora y ayni), cuyo norte es ser el sustrato de almacenamiento distribuido de todo el ecosistema. diff --git a/03_ukupacha/minga/LEEME.md b/03_ukupacha/minga/LEEME.md new file mode 100644 index 0000000..9096d41 --- /dev/null +++ b/03_ukupacha/minga/LEEME.md @@ -0,0 +1,37 @@ +# minga + +> `minga` (quechua: *trabajo comunitario voluntario*). Colaboración entre nodos. + +Red de pares para el monorepo. DHT + P2P + VFS distribuido. Cualquier nodo puede aportar storage o cómputo a la red de `minga`; los protocolos garantizan que la identidad de los bytes (BLAKE3) es la verdad, no el path. Compatible con la ingesta de `wawa` y con el gossip de `agora`. + +## Instalación + +```sh +# nodo standalone +cargo run --release -p minga-cli + +# explorer (ver peers + content) +cargo run --release -p minga-explorer-llimphi +``` + +## Compatibilidad + +- **Linux / macOS / Windows / Wawa** — Rust nativo + tokio para I/O. + +## Crates + +| Crate | Rol | +|---|---| +| [`minga-core`](minga-core/README.md) | Modelo: peer, chunk, address. | +| [`minga-dht`](minga-dht/README.md) | DHT (Kademlia adaptado). | +| [`minga-p2p`](minga-p2p/README.md) | Capa P2P (libp2p o propio). | +| [`minga-vfs`](minga-vfs/README.md) | VFS distribuido. | +| [`minga-store`](minga-store/README.md) | Storage local. | +| [`minga-cli`](minga-cli/README.md) | CLI. | +| [`minga-explorer-llimphi`](minga-explorer-llimphi/README.md) | UI: peers, content, tráfico. | + +## Consideraciones + +- **No es BitTorrent**: el modelo es content-addressed BLAKE3 (matchea wawa), no hash de torrent. +- **Privacidad por defecto**: nada se publica sin que el usuario lo marque como compartible. +- Diseñado para latencias domésticas/comunitarias, no para CDN globales. diff --git a/03_ukupacha/minga/README.md b/03_ukupacha/minga/README.md new file mode 100644 index 0000000..bcb566d --- /dev/null +++ b/03_ukupacha/minga/README.md @@ -0,0 +1,39 @@ +# minga + +> `minga` (Quechua: *voluntary community labor*). Collaboration between nodes. + +Peer-to-peer network for the monorepo. DHT + P2P + distributed VFS. Any node can contribute storage or compute; protocols guarantee that the bytes' identity (BLAKE3) is truth, not the path. Compatible with `wawa`'s ingestion and with `agora`'s gossip. + +## Install + +```sh +cargo run --release -p minga-cli +cargo run --release -p minga-explorer-llimphi +``` + +## Compatibility + +- **Linux / macOS / Windows / Wawa** — native Rust + tokio for I/O. + +Crates listed in [README.md](README.md). + +## Considerations + +- **Not BitTorrent**: model is content-addressed BLAKE3 (matches wawa), not torrent hash. +- **Privacy by default**: nothing is published without explicit user share. +- Designed for community/domestic latency, not global CDN. + +## Estado (2026-05-31) + +> Reporte técnico detallado en [REPORTE.md](REPORTE.md). Mapa arquitectónico en [ARQUITECTURA.md](ARQUITECTURA.md). + +### Hecho +- VCS semántico P2P funcionalmente completo: `minga-core` (AST + CAS + MST + atestaciones + α-hashing por lenguaje), `minga-store` (sled: nodes/attestations/mst/roots/timestamps/path-history/alpha-paths/retractions), `minga-dht` (DhtKey tipado), `minga-p2p` (MingaPeer libp2p con sync, Kademlia, RootDeclaration y RetractPush en el wire), `minga-vfs` (FUSE + pretty-printer Python indent-aware). +- CLI rica (`minga-cli`): init, ingest, ingest-dir, watch (autoremove), log, show (+ diff/sexp), diff, blame, history, roots, signers, sign (vouching), retire, verify, prune (GC), sync (DHT lookup), listen (announce-all-roots), mount. +- Bundle offline ("USB-stick mode"): export/import single + export-all/import-all multi-bundle con zstd, re-verificación criptográfica end-to-end. Daemon HTTP read-only `serve` (axum) con auth Bearer opcional (`--token`/`MINGA_SERVE_TOKEN`). +- Convergencia con ágora: `MingaPeer` adopta `Arc` compartido; un solo PeerId/listen sirve `/minga/sync/1.0.0` + `/agora/gossip/1.0.0`. Discovery de personas por `DhtKey::Persona` (Fase 2b). +- Frontends: `minga-explorer-llimphi` (dashboard con theme/lang reactivos vía wawa-config) + `shuma-module-minga` (tab del shell con raíces, verify, dot de retracciones); menús principal + contextuales (lote 2). `cargo check --workspace` verde. + +### Pendiente +- `MingaPeer` genérico sobre `NodeStore` (backend sled directo en lugar de cargar todo a RAM en sync). Diferido con criterio: trigger cuando un repo real supere ~100k nodos (sin caso hoy). +- Roadmap "Grafo de la Verdad" (ver memoria del proyecto): GossipSub + VFS-lazy-P2P, reputación, +9 lenguajes — fuera del backlog ya cerrado. diff --git a/03_ukupacha/minga/README.qu.md b/03_ukupacha/minga/README.qu.md new file mode 100644 index 0000000..44b8e72 --- /dev/null +++ b/03_ukupacha/minga/README.qu.md @@ -0,0 +1,26 @@ + + +# minga + +> `minga` (runa-simi: *ayllukunapa voluntario llank'ay*). Nodokuna pura llank'ay tinkuy. + +Monorepupa peer-pi peer ayllu-red. DHT + P2P + reqsisqa VFS. Sapan nodo storage utaq cómputo qun atin; protocolos byteskunaq identidad (BLAKE3) cheqaqmi, manan path-chu. `wawa` ingestawan + `agora` gossipwan tinkuy. + +## Churay + +```sh +cargo run --release -p minga-cli +cargo run --release -p minga-explorer-llimphi +``` + +## Tinkuy + +- **Linux / macOS / Windows / Wawa** — Rust + tokio I/O-paq. + +Crateskuna [README.md](README.md)-pi. + +## Yuyaykunaq + +- **Manan BitTorrent**: content-addressed BLAKE3 (wawa-wan tinkuq), manan torrent hash. +- **Privacy default**: mana imapas hawapi mana runaq comparte. +- Ayllu / wasi latencia, manan hatun CDN. diff --git a/03_ukupacha/minga/REPORTE.md b/03_ukupacha/minga/REPORTE.md new file mode 100644 index 0000000..454b60d --- /dev/null +++ b/03_ukupacha/minga/REPORTE.md @@ -0,0 +1,221 @@ +# minga — reporte técnico para IA + +> Estado: **2026-05-28** · rama `main` · compila limpio (`cargo check --workspace` pasa). +> Audiencia: sesión futura de Claude u otra IA que retome el VCS semántico. + +--- + +## 1. Mapa actualizado + +``` +03_ukupacha/minga/ +├── minga-core ← AST + CAS + MST + atestaciones + α-hashing per-language +├── minga-store ← sled: nodes, attestations, mst, roots (NUEVO), timestamps (NUEVO) +├── minga-dht ← DhtKey typed; DhtKey::for_hash NUEVO (sin re-blake3) +├── minga-p2p ← MingaPeer (libp2p) + ingest_with_dialect NUEVO + DhtKey en announce/find +├── minga-vfs ← FUSE + render_source (NUEVO: Python indent-aware) +├── minga-cli ← init, status, ingest, log NUEVO, show NUEVO, listen, sync (DHT NUEVO), watch (Remove NUEVO), mount +└── minga-explorer-llimphi ← dashboard + watcher wawa-config NUEVO +``` + +## 2. Cambios de este sprint + +### Cableado α-hash al ingest (#1) — alto impacto +- `PersistentRepo` ahora abre **5 trees**: `nodes`, `attestations`, `mst`, `roots`, `attestation_timestamps`. +- Nuevo `SledRootsStore`: `α_hash → (struct_hash, dialect)`. Indirección que separa la **identidad del archivo** (α-hash, estable bajo renombrado) del **CAS del grafo** (struct-hash). +- `cmd_ingest`/`cmd_watch` ahora computan `α = hash_alpha_with(dialect, &node)`, lo registran como raíz del MST, lo firman con la atestación, y guardan `α → struct` en `roots`. +- `IngestResult` expone tanto `alpha` como `struct_hash` y `dialect`. +- `RepoSource::get` resuelve transparentemente: si `hash` es α (root), redirige al struct; si no, lo busca directo en `nodes`. Esto preserva la navegación `cas/` para nodos internos. + +### DHT typed (#2) +- `minga-dht::DhtKey::for_hash(kind, [u8;32])` — nuevo constructor que **no re-blake3-ea** un hash existente. +- `MingaPeer::announce_provider`/`find_providers` ahora envuelven el `ContentHash` en `DhtKey::for_hash(RecordKind::Code, ...)`. Comparte la malla Kademlia con cards/personas sin colisión. + +### CLI `minga log` y `minga show` (#3) +- `minga log [path]`: lista atestaciones ordenadas por timestamp local (descendente). Si pasás `path`, marca con `*` la entrada cuyo α-hash coincide con el contenido actual del archivo. Salida: `* YYYY-MM-DD HH:MM <α-hash> [dialect] by `. +- `minga show [--sexp]`: pinta la fuente reconstruida (forma canónica) del nodo. Acepta α-hashes (raíces) y hashes estructurales del grafo CAS. Con `--sexp` devuelve el árbol literal. +- `SledTimestampStore`: timestamps locales de cuándo se observó cada atestación. **No** se transmite por wire — es metadata propia del peer. + +### `watch` con remove tracking (#4) +- `cmd_watch` mantiene un `HashMap` en memoria; el `initial_scan` lo popula con todos los archivos soportados. +- En `EventKind::Remove(_)` retira el α-hash del MST y de `roots` (vía nuevos métodos `SledMstStore::remove` y `SledRootsStore::remove`). +- Los **nodos del grafo CAS NO se eliminan** — pueden estar compartidos con otras raíces. Las atestaciones tampoco (siguen siendo prueba histórica). + +### DHT lookup en sync (#6) +- `cmd_sync ` detecta si `target` es un hex de 64 caracteres (α-hash) y, en ese caso, hace `peer.find_providers(hash)` en el DHT. Itera los providers retornados intentando `sync_with` hasta éxito o deadline. +- Nuevo error `CliError::NoProvidersForHash` cuando el DHT no devuelve nadie. Necesita al menos un peer bootstrap conocido (`add_dht_peer`). + +### Shebang detection (#7) +- `minga_core::parse::detect_by_shebang(source)`: reconoce `python*`, `node`, `deno`, `bun`, `tsx`, `ts-node`, `go`, `rustc`. Override por `--ext=ts` para deno. +- CLI: `detect_dialect` ahora prueba primero por extensión, después por shebang. +- `is_supported_source` (usado por `watch`) también consulta shebang — scripts sin extensión (`bin/foo`, `tool`) ahora se versionan. + +### Python pretty-printer indent-aware (#9) +- `render_source` detecta el root kind `module` y delega a `render_python`. +- Recorre el AST de tree-sitter Python reconociendo statements compuestos (`function_definition`, `class_definition`, `if_statement`, `for_statement`, `while_statement`, `with_statement`, `try_statement`, `match_statement` + variantes async/decorated). +- Para cada compound: separa el header (todo lo que no es `block`/cláusulas) y emite `header:` + `block` con `indent + 1`. Cláusulas anidadas (`elif_clause`, `else_clause`, `except_clause`, `finally_clause`, `case_clause`) se recursan al mismo nivel. +- Tests verifican `def`/`return`, `if`/`else`, y `class` con método (indentación 4/8). + +### Wawa-config watcher en explorer (#10) +- `minga-explorer-llimphi` ahora depende de `wawa-config`. +- `init` carga `WawaConfig::load()`, mappea `theme_variant` vía `canonical_theme_name` + `Theme::by_name`, aplica `accent_rgb`, llama `rimay_localize::set_locale(&cfg.lang)`. +- `ConfigWatcher::spawn` con closure que `handle.dispatch(Msg::WawaChanged(cfg))` — reactiva theme/lang sin reinicio. + +### `shuma-module-minga` (#8) — feature nueva +- Tab del shell shuma que muestra el repo Minga del cwd. +- Counts (`raíces / nodos / atestaciones / mst`) + lista de raíces recientes con su α-hash corto y dialect. +- Shortcut "Refresh" + monitor "minga · raíces" en el panel derecho. +- El chasis `shuma-shell-llimphi` registra el nuevo `Kind::Minga`, ramas en `update`/`view` (Main + DrawerTab + contributions), y el handler de `minga.refresh` que lanza `load_snapshot` en un thread. + +## 3. Diferido + +### MingaPeer genérico sobre NodeStore (#5) +**No implementado** — requiere generizar `PeerState`, `SyncSession`, `snapshot`, y `merge_into_state` sobre un trait `NodeStore` (que ya existe en `minga-core::store`). Toca el wire protocol indirectamente porque las sesiones de sync clonan el store entero. + +Razón para diferir: alto costo de refactor + tests, beneficio sólo se manifiesta en repos grandes que minga aún no tiene. Cuando el primer repo supere los 100k nodos, retomar. + +## 4. Comandos útiles + +```bash +# Init + ingest + log + show +minga -r ./.minga init +minga -r ./.minga ingest src/main.rs +minga -r ./.minga log src/main.rs # marca con * el α actual +minga -r ./.minga show + +# Watch (autoingest + autoremove) +minga -r ./.minga watch ./src + +# Sync por DHT (necesita peer bootstrap) +minga -r ./.minga sync # busca providers +minga -r ./.minga sync # conexión directa + +# Mount FUSE +mkdir mnt && minga -r ./.minga mount mnt +ls mnt/roots/ # un archivo por α-hash +cat mnt/roots/<α> # fuente reconstruida (Python indent-aware ahora) + +# Explorer Llimphi (con theme reactivo via wawa-config) +MINGA_REPO=./.minga cargo run -p minga-explorer-llimphi +``` + +## 5. Diseño preservado + +1. **Sync protocol intacto.** Los α-hashes del MST viajan como `ContentHash`es por el wire (32 bytes); el receptor no necesita re-verificar α — confía en la atestación firmada. La indirección α→struct es local a cada peer. +2. **`MemStore` sigue siendo el medio de sync** entre peers; `MingaPeer::open` carga todo a RAM como antes. La generización a `S: NodeStore` queda como item #5. +3. **Tree `roots` separado del MST.** El MST contiene sólo los α-hashes (claves), igual que antes — la nueva indirección `roots` es independiente. Esto preserva todos los tests del protocolo. +4. **`SledTimestampStore` es local.** Dos peers que ven la misma atestación tendrán timestamps distintos (cuando llegó a cada uno) — esto es deliberado: `minga log` es una vista local del historial. + +## 6. Sub-sprint posterior (5 items adicionales completados) + +| # | Tarea | Estado | +|---|---|---| +| 11 | **`minga diff`** entre dos hashes (LCS vía `similar` crate) | hecho — el test `rename_local_var_keeps_same_alpha_hash` valida que α se manifiesta end-to-end | +| 12 | **`minga retire`** — tombstone firmado (`Retraction` con `RETRACTION_DOMAIN` prefix; `SledRetractionStore` paralelo a atestaciones; quita del MST y `roots` pero conserva la atestación original como prueba histórica) | hecho | +| 13 | **`minga verify`** — `verify_root_alpha(node, claimed) -> Option` que prueba cada dialecto; el CLI reporta consistencia + drift con dialect registrado | hecho (la re-verificación al recibir-wire requiere modificar el protocolo de sync, documentado abajo) | +| 14 | **Click en raíz del módulo shuma-module-minga**: dispara `SelectRoot(hash)` → el chasis spawnea `load_root_source` en thread → resultado vía `SourceLoaded` → panel inferior con `render_source` | hecho — race-protect: si llega un click nuevo mientras carga el anterior, el resultado viejo se descarta | +| 15 | **Detección de dialect por contenido**: marcadores textuales por línea (`def`/`fn`/`func`/`function`/`interface`) + tie-break por ratio de nodos ERROR. `detect_dialect` ahora prueba ext → shebang → contenido | hecho | + +### #13 (verify) — alcance honesto + +La re-verificación se ofrece como primitiva (`alpha::verify_root_alpha`) y como subcomando (`minga verify `). Verifica **localmente** que una raíz del repo es consistente bajo algún dialect; útil tras sync con peers no-confiables. + +**No** intercepta automáticamente en el path de sync porque el wire actual no transmite dialect ni el binding α→struct. Para integrarlo ahí hay que extender `minga-p2p::session::Message` con una variante `RootDeclaration { alpha, struct_hash, dialect }`; queda para una fase futura. + +## 7. Tercer sprint (4 items más completados) + +| # | Tarea | Resultado | +|---|---|---| +| 16 | **`minga prune`** — mark-sweep GC del grafo CAS. Marca alcanzables desde `roots` siguiendo `children_of` (lectura liviana), borra los huérfanos de `nodes`. Idempotente. Atestaciones/retracciones/timestamps preservados (referencian α-hash, no struct). | hecho | +| 17 | **`minga show --diff-against `** — atajo a `minga diff` desde el subcomando `show`. Mutuamente excluyente con `--sexp`. | hecho | +| 18 | **`shuma-module-minga` shortcut Verify** — recorre raíces visibles, las verifica con `verify_root_alpha` en un thread. Marca cada `RootRow` con `verified: Option`, pintado con `·`/`✓`/`✘`. | hecho | +| 19 | **Sync de Retractions** — Wire extendido con `Message::RetractPush { retractions }`. `SyncSession::with_retractions` constructor nuevo. Push tras Hello autenticado. Idéntica idempotencia/verificación que atestaciones. `MingaPeer::open` carga retracciones desde disco; `merge_into_state` las mezcla y persiste. Test `sync_propagates_retractions_for_owned_content` cierra el round-trip; `forged_retraction_signature_is_rejected` verifica que firmas inválidas se cuentan como `rejected_retracts`. | hecho | + +### Cambios en el wire (importante para compat) + +`Message` ganó la variante `RetractPush { retractions: Vec }`. Repos sincronizados con peers que corran versión vieja del protocolo **descartarán** ese mensaje (postcard error en `decode`) y la sesión seguirá funcionando para atestaciones. No es un break-change explícito; sólo se pierde la propagación de retracciones contra peers que no lo entienden. + +`SyncSession::new` mantiene la firma vieja (con `RetractionStore::new()` interno); el nuevo `with_retractions` toma 5 args. `into_parts` sigue devolviendo 3-tupla por compat; `into_parts_with_retractions` agrega la cuarta. + +## 8. Cuarto sprint — B, D, E, F + descubribilidad CLI + +| # | Tarea | Resultado | +|---|---|---| +| B | **Wire-side α-verification** — `RootDeclaration { alpha, struct, dialect }` añadido al protocolo (`minga-p2p::session::Message`). El receptor re-verifica `verify_root_alpha` contra el contenido recibido; declaraciones inconsistentes se rechazan sin tocar `roots`. | hecho (commit `580875e`) | +| D | **`minga blame `** — historial path→α (nuevo `SledPathHistoryStore`) + diff línea-a-línea entre versiones consecutivas + propagación de atribución. Resultado: cada línea actual atada al α que la introdujo. | hecho (commit `750b6f9`) | +| E | **DHT bootstrap automático en `listen`** — `MingaPeer::announce_all_roots()` se llama tras `listen`; cualquier α local pasa a ser descubrible por `sync ` sin conocer multiaddr. | hecho (commit `b519700`) | +| F | **Dot rojo en `shuma-module-minga`** para raíces con retracciones pendientes — `SledRetractionStore::iter` reverse-indexado por α; render del módulo lo marca con `·` rojo al lado del α-hash. | hecho (commit `b519700`) | +| G | **`minga roots`** — lista todas las raíces con path conocido, dialect, fecha de última atestación y cantidad de firmas, ordenadas por actividad reciente. Reverse-index del `SledPathHistoryStore` para resolver α→path. Cierra el hueco entre `status` (counts) y `show ` (que requería conocer el hash). | hecho | +| H | **`minga history `** — dump cronológico descendente del historial path→α + dialect + marcador `current` (best-effort: parsea el archivo actual y compara α). Versión liviana del blame cuando sólo querés saber "cuándo cambió este archivo". | hecho | + +## 9. Quinto sprint — vouching colaborativo y bulk ingest + +| # | Tarea | Resultado | +|---|---|---| +| K | **`minga sign <α-hash>`** — emite una atestación bajo el keypair local sobre un α-hash existente. A diferencia de `ingest` (firma como efecto de versionar contenido propio), `sign` es vouching explícito: Alice ingiere, Bob sincroniza, Bob firma. La raíz queda con dos atestaciones independientes — habilita co-autoría semántica y aval de revisores. Idempotente: re-firmar con el mismo keypair reemplaza la entrada con bytes idénticos (no duplica). Avisa si el α no es raíz registrada (puede ser fragmento del CAS o raíz huérfana). | hecho | +| L | **`minga ingest-dir [--recursive]`** — versión one-shot del `initial_scan` interno de `watch`. Recorre el directorio, ingiere todos los archivos soportados, reporta `(seen, ingested, failed)`. En modo recursivo poda dot-dirs (`.git`, `.minga`, `.venv`) para evitar ruido. Hace lo que muchos usuarios harían con un `find … -exec minga ingest {} \;` pero sin el costo de re-abrir el repo sled por archivo. | hecho | + +## 10. Sexto sprint — vista de firmas y bundle offline + +| # | Tarea | Resultado | +|---|---|---| +| M | **`minga signers <α-hash>`** — lista de DIDs que han atestado la raíz, con timestamp local. Marca con `↺` quienes también firmaron una retracción posterior. Vista natural sobre lo que `cmd_sign` siembra; antes había que pasar por `cmd_log` y filtrar. | hecho | +| I | **`minga bundle export <α-hash> `** + **`minga bundle import `** — empaquetado offline ("USB-stick mode") con misma garantía criptográfica que el wire. El export hace BFS por el DAG, recolecta `StoredNode`s + atestaciones + retracciones, y serializa con postcard. El import re-verifica cada pieza: `put_chunked` rehashe los nodos antes de insertar; `hash_alpha_with` re-deriva el α y se compara contra el claimado; `Attestation::add`/`Retraction::add` ya verifican firma Ed25519. Idempotente bajo reintentos. Nuevo módulo `minga-cli::bundle` con `BundleV1` (versionado para forward-compat). | hecho | + +### Notas sobre el bundle (#I) + +- **Formato.** `BundleV1 { version, alpha, struct_hash, dialect_byte, nodes, attestations, retractions }` — todo postcard. Dialect viaja como `u8` para no atar el formato a una variante específica; un importador viejo recibe `UnknownDialect` (error claro) si llega un byte que no reconoce. +- **Dialect requerido en `roots`.** Si la raíz fue sincronizada bajo el wire pre-`RootDeclaration` (commit `580875e`) y no tiene dialect persistido, `export` falla con `BundleMissingDialect` — sin dialect el receptor no puede re-verificar el α. Re-ingerí esa raíz primero para registrar su dialect. +- **Path metadata excluida.** El bundle no transmite `SledPathHistoryStore` ni `SledTimestampStore` (locales por diseño, como en el wire de sync). El receptor pone timestamp = `now` al merge. +- **Atestaciones/retractions con `content != alpha`** se descartan silenciosamente en el import. No debería pasar bajo bundles bien formados; el filtro es defensivo. + +## 10.bis Convergencia con ágora (lateral) + +Aunque no toca el dominio de minga directamente, este sprint cerró el bridge entre los dos: ágora ahora puede correr sobre el mismo nodo libp2p que `MingaPeer`. Cambios relevantes para minga: + +- **`MingaPeer::node`** pasó de `LibP2pNode` (por valor) a `Arc` (compartido). +- Nuevos constructores: + - **`MingaPeer::open_with_node(keypair, path, node: Arc)`** — adopta un nodo libp2p ya existente en lugar de crear uno propio. El `open` clásico ahora delega a esto. + - **`MingaPeer::brahman_net() -> Arc`** — accessor para que otros consumidores (típicamente `agora_net_brahman::AgoraNet`) compartan el mismo nodo. +- Sin cambios en el wire de sync — la convergencia es a nivel de transport, no de protocolo. `/minga/sync/1.0.0` sigue idéntico; agora registra `/agora/gossip/1.0.0` en paralelo y libp2p stream behaviour los demultiplexa. + +Demo: `cargo run -p agora-net-brahman --example convergencia_minga` — un solo `PeerId` sirviendo ambos protocolos sobre un solo `listen`. + +## 11. Séptimo sprint — cierre del backlog abierto (2026-05-28) + +| # | Tarea | Resultado | +|---|---|---| +| O | **Round-trip test del bundle** — `tests/bundle_roundtrip.rs` en `minga-cli` con cuatro casos: round-trip básico (raíz + atestación), idempotencia del import, propagación de vouching multi-firma (A→B firma→C ve dos firmas), y propagación de retracciones bajo re-ingest. Era la red de seguridad mínima antes de tocar el formato. | hecho | +| N | **`minga bundle export-all`** + **`minga bundle import-all`** — multi-bundle con magic prefix `MNGM` para distinguir del `BundleV1` clásico, retro-compatible. Empaca todas las raíces del repo en un solo archivo postcard; raíces sin dialect persistido se reportan en `skipped_missing_dialect` sin abortar. Import detecta el formato por magic y agrega errores específicos (`ExpectedSingleBundle`/`ExpectedMultiBundle`) para que el usuario no tenga que adivinar qué archivo es. Reuso máximo: `build_bundle_for_root` y `import_one` son helpers compartidos entre single y multi. | hecho | +| J | **`SledAlphaPathsStore`** — índice inverso α→paths persistente en disco con clave compuesta `[α(32)][path]` y valor `ts_secs(8 be)`. Reemplaza el reverse-index que `cmd_roots` reconstruía en RAM cada llamada. Write-through en `cmd_ingest` (ambos call-sites: `commands.rs:103-106` y `commands.rs:1318-1322`). Migración perezosa en `PersistentRepo::open`: si el tree está vacío pero `path_history` tiene entradas, se rebuildea una vez. Test de migración en `minga-store/tests/alpha_paths_rebuild.rs`. | hecho | +| C | **`minga serve `** — daemon HTTP read-only sobre axum. Endpoints: `GET /status`, `GET /roots`, `GET /roots/:α/show[?sexp=1]`, `GET /roots/:α/signers`, `GET /roots/:α/history?path=`. Mapeo de errores: `HashNotFound`/`PathNotIngested`→404, `InvalidHash`/`UnsupportedLanguage`→400, resto→500. Pasphrase en RAM durante el daemon (no se descifra por request). Tests en `tests/serve_http.rs` vía `tower::ServiceExt::oneshot` (sin abrir socket real). | hecho | + +### Diferido con criterio explícito + +| # | Tarea | Razón | +|---|---|---| +| A / #5 | `MingaPeer` genérico sobre `NodeStore` (backend sled directo en lugar de cargar todo a RAM) | El refactor requiere cambiar la firma `NodeStore::get(&self, h) -> Option<&StoredNode>` a `Option` (owned) porque sled devuelve `IVec`s temporales — eso cascadea por `session.rs`, `peer.rs`, los tests del protocolo, y `MemStore` mismo. El payoff sólo aparece cuando el repo supera ~100k nodos (sin caso real hoy). Permanece como "tomar cuando se justifique"; el trigger sigue siendo el mismo del 6º sprint. | + +## 12. Octavo sprint — cierre de los 3 follow-ups (2026-05-29) + +| # | Tarea | Resultado | +|---|---|---| +| R | **`minga signers --since `** — filtro por timestamp local (reusa `SledTimestampStore`). El parser acepta `YYYY-MM-DD` (medianoche UTC) y duraciones relativas con sufijo `m/h/d/w` (`30d`, `12h`, `2w`). Atestaciones sin timestamp persistido (`ts_secs == 0`, legacy pre-`SledTimestampStore`) se excluyen cuando hay filtro — no podemos juzgar si son recientes. El endpoint `GET /roots/:α/signers` aceptó el query param `since=` (Unix epoch directo — el HTTP no asume formatos amigables). | hecho | +| Q | **Multi-bundle comprimido con zstd** — `export-all` ahora siempre escribe con header nuevo `MNGZ` y cuerpo zstd-comprimido (nivel 3, default rápido). `import-all` detecta y descomprime; archivos viejos con header `MNGM` siguen importando como antes (test `multi_bundle_legacy_mngm_still_imports` lo cubre). `BundleExportAllStats` ganó `uncompressed_bytes` para reportar el ratio sin tener que volver a serializar. Sin cambios en `BundleV1` single-bundle — no aportaba lo suficiente al overhead total. | hecho | +| P | **`minga serve --token `** — middleware axum que exige `Authorization: Bearer `; comparación constant-time vía XOR byte-a-byte para no filtrar el secreto por timing. Si `--token` no se pasa, se respeta también la env `MINGA_SERVE_TOKEN` (camino recomendado para no exponer el secreto en el `ps`). Sin token configurado, el daemon sigue corriendo abierto — razonable sólo para `127.0.0.1`. Tres tests cubren: sin auth header → 401, token incorrecto → 401, token correcto → 200. | hecho | + +### Notas de implementación + +- **Compresión: criterio del 3 vs 19.** Zstd nivel 3 da ~2–3× sobre postcard de código fuente real (probado con repo del workspace gioser). Subir a 19+ exprime un 20–30 % extra a costa de 5–10× tiempo CPU — no vale para un caso "dump completo del repo a USB" donde el cuello suele ser el disco, no el CPU. +- **Token: por qué Bearer y no Basic.** Bearer mantiene compat directa con curl/HTTP clients que ya hablan OAuth-style; no obliga al usuario a base64-ear nada. La comparación constant-time es una formalidad — el atacante con timing oracle sobre la red local ya tiene acceso al filesystem del daemon. +- **`--since` sin filtro abre la puerta a `--since` en `log`/`roots`.** No se implementó: el formato `Vec` ya viene ordenado por timestamp, así que un caller puede filtrar; el ahorro mínimo no justifica duplicar el parser. + +## 13. Estado final + +Todos los items del backlog histórico — más los 3 follow-ups del séptimo sprint — están cerrados o diferidos con criterio explícito. El único pendiente activo es: + +- **#5/A**: `MingaPeer` genérico sobre `NodeStore` (backend sled directo). Trigger: cuando un repo real pase de ~100k nodos. Sin caso concreto hoy. + +--- + +*Generado por Claude (Opus 4.7) — `2026-05-29`. Octavo sprint: 3 items, cierra el follow-up backlog del REPORTE. Minga es funcionalmente completa con VCS semántico P2P + bundle offline + multi-bundle comprimido + daemon HTTP con auth opcional + índice inverso persistente + verificación criptográfica end-to-end.* diff --git a/03_ukupacha/minga/card-discovery/Cargo.toml b/03_ukupacha/minga/card-discovery/Cargo.toml new file mode 100644 index 0000000..f2dc13c --- /dev/null +++ b/03_ukupacha/minga/card-discovery/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "card-discovery" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Brahman — búsqueda de Cards: índice local con filtros + escaneo de directorios + discovery P2P sobre minga-dht." + +[dependencies] +card-core = { workspace = true } +cards = { workspace = true } +minga-dht = { path = "../minga-dht" } +libp2p = { workspace = true } +ulid = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } diff --git a/03_ukupacha/minga/card-discovery/src/discovery.rs b/03_ukupacha/minga/card-discovery/src/discovery.rs new file mode 100644 index 0000000..79706c1 --- /dev/null +++ b/03_ukupacha/minga/card-discovery/src/discovery.rs @@ -0,0 +1,64 @@ +//! `CardDiscovery` — une el índice local de Cards con el DHT. + +use crate::index::CardIndex; +use minga_dht::{Dht, DhtKey}; +use libp2p::PeerId; + +/// Búsqueda de Cards: siempre local, opcionalmente sobre la malla P2P. +pub struct CardDiscovery { + /// Índice local consultable. + pub index: CardIndex, + dht: Option, +} + +impl CardDiscovery { + /// Discovery sólo-local (sin malla P2P). + pub fn local(index: CardIndex) -> Self { + Self { index, dht: None } + } + + /// Discovery local + DHT. + pub fn with_dht(index: CardIndex, dht: Dht) -> Self { + Self { index, dht: Some(dht) } + } + + /// `true` si hay malla P2P conectada. + pub fn has_dht(&self) -> bool { + self.dht.is_some() + } + + /// Anuncia al DHT cada Card local (clave = `DhtKey::card(id)`). + /// No-op si no hay DHT. + pub fn announce_all(&self) { + if let Some(dht) = &self.dht { + for card in self.index.all() { + dht.announce(&DhtKey::card(card.id.to_string())); + } + } + } + + /// Busca proveedores remotos de una Card por id. Vacío si no hay DHT. + pub async fn find_remote(&self, card_id: &str) -> Vec { + match &self.dht { + Some(dht) => dht.find(&DhtKey::card(card_id)).await, + None => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use card_core::Card; + + #[tokio::test] + async fn local_only_discovery_has_no_dht() { + let mut ix = CardIndex::new(); + ix.insert(Card::new("local-1")); + let disc = CardDiscovery::local(ix); + assert!(!disc.has_dht()); + disc.announce_all(); // no-op, no debe panickear + assert!(disc.find_remote("cualquiera").await.is_empty()); + assert_eq!(disc.index.len(), 1); + } +} diff --git a/03_ukupacha/minga/card-discovery/src/index.rs b/03_ukupacha/minga/card-discovery/src/index.rs new file mode 100644 index 0000000..1dcd23f --- /dev/null +++ b/03_ukupacha/minga/card-discovery/src/index.rs @@ -0,0 +1,116 @@ +//! Índice en memoria de Cards, con filtros de búsqueda. + +use card_core::{Capability, Card, CardKind}; +use ulid::Ulid; + +/// Colección consultable de Cards. +#[derive(Debug, Clone, Default)] +pub struct CardIndex { + cards: Vec, +} + +impl CardIndex { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, card: Card) { + self.cards.push(card); + } + + pub fn len(&self) -> usize { + self.cards.len() + } + + pub fn is_empty(&self) -> bool { + self.cards.is_empty() + } + + pub fn all(&self) -> &[Card] { + &self.cards + } + + /// Card por id exacto. + pub fn by_id(&self, id: Ulid) -> Option<&Card> { + self.cards.iter().find(|c| c.id == id) + } + + /// Cards cuyo label contiene `needle` (case-insensitive). + pub fn by_label(&self, needle: &str) -> Vec<&Card> { + let n = needle.to_lowercase(); + self.cards + .iter() + .filter(|c| c.label.to_lowercase().contains(&n)) + .collect() + } + + /// Cards de un `CardKind` dado. + pub fn by_kind(&self, kind: CardKind) -> Vec<&Card> { + self.cards.iter().filter(|c| c.kind == kind).collect() + } + + /// Cards que proveen la `Capability` dada. + pub fn providing(&self, cap: &Capability) -> Vec<&Card> { + self.cards + .iter() + .filter(|c| c.provides.contains(cap)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn card(label: &str) -> Card { + Card::new(label) + } + + #[test] + fn by_label_is_case_insensitive_substring() { + let mut ix = CardIndex::new(); + ix.insert(card("Broker Demo")); + ix.insert(card("file explorer")); + assert_eq!(ix.by_label("broker").len(), 1); + assert_eq!(ix.by_label("EXPLOR").len(), 1); + assert_eq!(ix.by_label("zzz").len(), 0); + } + + #[test] + fn by_id_finds_exact() { + let mut ix = CardIndex::new(); + let c = card("x"); + let id = c.id; + ix.insert(c); + assert!(ix.by_id(id).is_some()); + assert!(ix.by_id(Ulid::new()).is_none()); + } + + #[test] + fn providing_filters_by_capability() { + let mut spawner = card("spawner"); + spawner.provides.insert(Capability::Spawn); + let mut logger = card("logger"); + logger.provides.insert(Capability::Journal); + + let mut ix = CardIndex::new(); + ix.insert(spawner); + ix.insert(logger); + assert_eq!(ix.providing(&Capability::Spawn).len(), 1); + assert_eq!(ix.providing(&Capability::Journal).len(), 1); + assert_eq!(ix.providing(&Capability::FilesystemRoot).len(), 0); + } + + #[test] + fn by_kind_splits_ente_and_data() { + let mut ente = card("ente"); + ente.kind = CardKind::Ente; + let mut data = card("data"); + data.kind = CardKind::Data; + let mut ix = CardIndex::new(); + ix.insert(ente); + ix.insert(data); + assert_eq!(ix.by_kind(CardKind::Ente).len(), 1); + assert_eq!(ix.by_kind(CardKind::Data).len(), 1); + } +} diff --git a/03_ukupacha/minga/card-discovery/src/lib.rs b/03_ukupacha/minga/card-discovery/src/lib.rs new file mode 100644 index 0000000..567ccb8 --- /dev/null +++ b/03_ukupacha/minga/card-discovery/src/lib.rs @@ -0,0 +1,19 @@ +//! `brahman-card-discovery` — búsqueda de Cards local + DHT. +//! +//! - [`index`] — `CardIndex`: índice en memoria con filtros (label, +//! kind, capability, id). +//! - [`registry`] — `scan_dir`: carga Cards `*.json` de un directorio. +//! - [`discovery`] — `CardDiscovery`: une el índice local con la malla +//! P2P vía `brahman-dht`. +//! +//! Lo consume el widget card-browser de `nahual-shell` y `agora_app`. + +#![forbid(unsafe_code)] + +pub mod index; +pub mod registry; +pub mod discovery; + +pub use discovery::CardDiscovery; +pub use index::CardIndex; +pub use registry::scan_dir; diff --git a/03_ukupacha/minga/card-discovery/src/registry.rs b/03_ukupacha/minga/card-discovery/src/registry.rs new file mode 100644 index 0000000..62454eb --- /dev/null +++ b/03_ukupacha/minga/card-discovery/src/registry.rs @@ -0,0 +1,46 @@ +//! Registro local: escaneo de directorios con Cards en disco. + +use crate::index::CardIndex; +use std::path::Path; + +/// Escanea `dir` (no recursivo) cargando toda Card `*.json` válida. +/// Los archivos que no parsean como Card se saltan en silencio. +pub fn scan_dir(dir: &Path) -> std::io::Result { + let mut index = CardIndex::new(); + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + if let Ok(card) = cards::load_card_file(&path) { + index.insert(card); + } + } + } + Ok(index) +} + +#[cfg(test)] +mod tests { + use super::*; + use card_core::Card; + + #[test] + fn scans_only_valid_json_cards() { + let dir = tempfile::tempdir().unwrap(); + for name in ["alpha", "beta"] { + let card = Card::new(name); + let json = serde_json::to_string(&card).unwrap(); + std::fs::write(dir.path().join(format!("{name}.json")), json).unwrap(); + } + // Ruido que debe ignorarse. + std::fs::write(dir.path().join("readme.txt"), "no soy una card").unwrap(); + std::fs::write(dir.path().join("roto.json"), "{ no json }").unwrap(); + + let ix = scan_dir(dir.path()).unwrap(); + assert_eq!(ix.len(), 2); + } + + #[test] + fn missing_dir_is_an_error() { + assert!(scan_dir(Path::new("/no/existe/jamas")).is_err()); + } +} diff --git a/03_ukupacha/minga/minga-cli/Cargo.toml b/03_ukupacha/minga/minga-cli/Cargo.toml new file mode 100644 index 0000000..2362cf9 --- /dev/null +++ b/03_ukupacha/minga/minga-cli/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "minga-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "CLI de Minga: init, status, ingest, listen, sync, watch, mount." + +[[bin]] +name = "minga" +path = "src/main.rs" + +[dependencies] +minga-core = { path = "../minga-core" } +minga-p2p = { path = "../minga-p2p" } +minga-store = { path = "../minga-store" } +minga-vfs = { path = "../minga-vfs" } +clap = { workspace = true } +rpassword = { workspace = true } +tokio = { workspace = true } +thiserror = { workspace = true } +futures = { workspace = true } +libp2p = { workspace = true } +notify = { workspace = true } +similar = { workspace = true } +serde = { workspace = true } +postcard = { workspace = true } +axum = { workspace = true } +serde_json = { workspace = true } +zstd = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +tower = { workspace = true } +http-body-util = "0.1" diff --git a/03_ukupacha/minga/minga-cli/LEEME.md b/03_ukupacha/minga/minga-cli/LEEME.md new file mode 100644 index 0000000..a90f466 --- /dev/null +++ b/03_ukupacha/minga/minga-cli/LEEME.md @@ -0,0 +1,17 @@ +# minga-cli + +> CLI de [minga](../README.md). + +Comandos: `minga peer add/list`, `minga put/get`, `minga ls `, `minga share `. + +## Uso + +```sh +cargo run --release -p minga-cli -- peer list +cargo run --release -p minga-cli -- put /local/file +``` + +## Deps + +- Todos los `minga-*` +- `clap` diff --git a/03_ukupacha/minga/minga-cli/README.md b/03_ukupacha/minga/minga-cli/README.md new file mode 100644 index 0000000..9b5dacc --- /dev/null +++ b/03_ukupacha/minga/minga-cli/README.md @@ -0,0 +1,17 @@ +# minga-cli + +> CLI of [minga](../README.md). + +Commands: `minga peer add/list`, `minga put/get`, `minga ls `, `minga share `. + +## Usage + +```sh +cargo run --release -p minga-cli -- peer list +cargo run --release -p minga-cli -- put /local/file +``` + +## Deps + +- All `minga-*` +- `clap` diff --git a/03_ukupacha/minga/minga-cli/src/bundle.rs b/03_ukupacha/minga/minga-cli/src/bundle.rs new file mode 100644 index 0000000..0a0a554 --- /dev/null +++ b/03_ukupacha/minga/minga-cli/src/bundle.rs @@ -0,0 +1,455 @@ +//! Bundle: empaquetado offline de una raíz para transferencia sin red. +//! +//! Un `BundleV1` agrupa todo lo necesario para que un peer que recibe +//! el archivo pueda reconstruir e integrar la raíz sin contactar al +//! emisor: +//! - el α-hash, struct_hash y dialect (binding RootDeclaration); +//! - todos los `StoredNode`s alcanzables desde el struct_hash; +//! - todas las atestaciones firmadas sobre el α-hash; +//! - todas las retracciones firmadas sobre el α-hash. +//! +//! Se serializa con postcard — el mismo codec del wire de sync — y se +//! escribe a un archivo. El receptor verifica criptográficamente cada +//! pieza antes de mergear: +//! - cada `StoredNode` entra por `put_chunked` (re-hashea y compara); +//! - el α-hash se re-deriva de la raíz reconstruida bajo el dialect +//! declarado y se compara contra el claimado en el bundle; +//! - cada atestación / retracción re-verifica su firma Ed25519 en +//! `add()` antes de persistirse. +//! +//! Es el equivalente "USB-stick" al wire libp2p: misma garantía de +//! integridad, distinto transporte. + +use std::path::Path; + +use minga_core::{ + alpha::hash_alpha_with, hash_stored, parse, Attestation, ContentHash, Retraction, StoredNode, +}; +use minga_store::{keypair_file, PersistentRepo}; + +use crate::commands::{unix_now_secs, KEYPAIR_FILENAME, REPO_DIRNAME}; +use crate::error::CliError; + +/// Versión actual del formato. Se serializa explícitamente para que el +/// importador pueda rechazar (o adaptar) bundles de otra época. +pub const BUNDLE_VERSION: u32 = 1; + +/// Magic prefix de un multi-bundle (varias raíces empacadas juntas). +/// Si los primeros 4 bytes del archivo coinciden, el cuerpo restante es +/// un `BundleMultiV1` postcard; si no, el archivo entero es un +/// `BundleV1` clásico — esto preserva compat con bundles existentes +/// sin tocar su layout. +pub const MULTI_MAGIC: &[u8; 4] = b"MNGM"; +/// Variante zstd-comprimida del multi-bundle. El cuerpo tras el magic +/// es un stream zstd que, descomprimido, produce exactamente el postcard +/// de `BundleMultiV1`. El export nuevo siempre escribe esta variante; el +/// import detecta `MNGM` (legacy, sin compresión) y `MNGZ` (comprimido) +/// transparentemente — repos viejos siguen pudiendo importar y leer. +pub const MULTI_MAGIC_ZSTD: &[u8; 4] = b"MNGZ"; +pub const MULTI_VERSION: u32 = 1; + +/// Nivel de compresión zstd para el export. 3 es el default del crate +/// (rápido, ratio decente). Subir a 19+ exprime hasta 30 % extra pero +/// triplica el tiempo de export — no vale para un caso "dump completo +/// del repo a USB". +const ZSTD_LEVEL: i32 = 3; + +/// El bundle serializable. El layout es estable: cualquier cambio de +/// campos sube `BUNDLE_VERSION` y agrega una rama en `import`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BundleV1 { + pub version: u32, + pub alpha: ContentHash, + pub struct_hash: ContentHash, + /// Dialect serializado como byte para no atar el formato a una + /// variante específica de `Dialect`. Si el importador es más viejo + /// y no reconoce este byte, falla con `UnknownDialect` sin tocar + /// los stores. + pub dialect_byte: u8, + /// Todos los `StoredNode`s alcanzables desde `struct_hash` (DAG). + /// El orden es BFS por el emisor — el importador no lo necesita + /// (deduplica por hash) pero conservarlo facilita debug. + pub nodes: Vec, + pub attestations: Vec, + pub retractions: Vec, +} + +/// Estadísticas devueltas por `cmd_bundle_export`. +#[derive(Debug, Clone, Copy)] +pub struct BundleExportStats { + pub alpha: ContentHash, + pub nodes: usize, + pub attestations: usize, + pub retractions: usize, + pub bytes: usize, +} + +/// Wrapper de múltiples bundles en un solo archivo. Se serializa con +/// postcard precedido del prefijo `MULTI_MAGIC` para que el importador +/// pueda distinguirlo de un `BundleV1` plano. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BundleMultiV1 { + pub version: u32, + pub items: Vec, +} + +/// Estadísticas devueltas por `cmd_bundle_export_all`. +#[derive(Debug, Clone)] +pub struct BundleExportAllStats { + /// Raíces efectivamente empacadas. + pub roots: usize, + /// Raíces saltadas por falta de dialect persistido (typically + /// raíces sincronizadas bajo el wire pre-`RootDeclaration`). + pub skipped_missing_dialect: Vec, + pub total_nodes: usize, + pub total_attestations: usize, + pub total_retractions: usize, + /// Tamaño final del archivo en disco (post-compresión). + pub bytes: usize, + /// Tamaño del postcard plano antes de comprimir — útil para ver el + /// ratio cuando interesa. + pub uncompressed_bytes: usize, +} + +/// Estadísticas agregadas del import de un multi-bundle. +#[derive(Debug, Clone)] +pub struct BundleImportAllStats { + /// Resultado individual de cada raíz en el orden en que vino dentro + /// del multi-bundle. + pub items: Vec, +} + +impl BundleImportAllStats { + pub fn roots_new(&self) -> usize { + self.items.iter().filter(|s| s.root_was_new).count() + } + pub fn total_nodes_inserted(&self) -> usize { + self.items.iter().map(|s| s.nodes_inserted).sum() + } + pub fn total_attestations_added(&self) -> usize { + self.items.iter().map(|s| s.attestations_added).sum() + } + pub fn total_retractions_added(&self) -> usize { + self.items.iter().map(|s| s.retractions_added).sum() + } +} + +/// `minga bundle export <α-hash> `: serializa la raíz, todos los +/// nodos alcanzables, atestaciones y retractions en un archivo +/// postcard. Errores: +/// - `HashNotFound` si el α-hash no es una raíz local; +/// - `BundleMissingDialect` si la raíz fue sincronizada bajo el wire +/// viejo (pre-`RootDeclaration`) y no tiene dialect persistido — sin +/// dialect el receptor no puede re-verificar el α-hash. +pub fn cmd_bundle_export( + repo_path: &Path, + passphrase: &str, + hash_hex: &str, + out: &Path, +) -> Result { + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let alpha = crate::commands::parse_hash_hex(hash_hex)?; + let bundle = build_bundle_for_root(&repo, alpha)?; + + let bytes = postcard::to_allocvec(&bundle).map_err(|_| CliError::InvalidBundle)?; + std::fs::write(out, &bytes)?; + + Ok(BundleExportStats { + alpha: bundle.alpha, + nodes: bundle.nodes.len(), + attestations: bundle.attestations.len(), + retractions: bundle.retractions.len(), + bytes: bytes.len(), + }) +} + +/// Empaqueta todas las raíces del repo en un solo archivo (multi-bundle). +/// Raíces sin dialect persistido (sync'd bajo el wire viejo) se saltan y +/// se reportan en `skipped_missing_dialect` — el caller decide si eso es +/// fatal o se acepta como degradación. +pub fn cmd_bundle_export_all( + repo_path: &Path, + passphrase: &str, + out: &Path, +) -> Result { + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let mut items: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + for entry in repo.roots.iter() { + let (alpha, _struct_hash, dialect_opt) = entry?; + if dialect_opt.is_none() { + skipped.push(alpha); + continue; + } + items.push(build_bundle_for_root(&repo, alpha)?); + } + + let total_nodes: usize = items.iter().map(|b| b.nodes.len()).sum(); + let total_attestations: usize = items.iter().map(|b| b.attestations.len()).sum(); + let total_retractions: usize = items.iter().map(|b| b.retractions.len()).sum(); + let roots = items.len(); + + let multi = BundleMultiV1 { + version: MULTI_VERSION, + items, + }; + let body = postcard::to_allocvec(&multi).map_err(|_| CliError::InvalidBundle)?; + let uncompressed_bytes = body.len(); + let compressed = zstd::encode_all(body.as_slice(), ZSTD_LEVEL).map_err(CliError::Io)?; + + let mut bytes = Vec::with_capacity(MULTI_MAGIC_ZSTD.len() + compressed.len()); + bytes.extend_from_slice(MULTI_MAGIC_ZSTD); + bytes.extend_from_slice(&compressed); + std::fs::write(out, &bytes)?; + + Ok(BundleExportAllStats { + roots, + skipped_missing_dialect: skipped, + total_nodes, + total_attestations, + total_retractions, + bytes: bytes.len(), + uncompressed_bytes, + }) +} + +/// Construye un `BundleV1` para una raíz registrada en `roots`. Es el +/// core compartido entre el export single y el export-all: BFS por el +/// DAG estructural + agrega atestaciones y retracciones de esa α. +fn build_bundle_for_root( + repo: &PersistentRepo, + alpha: ContentHash, +) -> Result { + use std::collections::HashSet; + + let (struct_hash, dialect_opt) = repo + .roots + .get(&alpha)? + .ok_or(CliError::HashNotFound(alpha))?; + let dialect = dialect_opt.ok_or(CliError::BundleMissingDialect(alpha))?; + + // BFS por el DAG de StoredNodes desde la raíz estructural. Los + // hashes se dedupean en `visited`; el orden en `nodes` es estable + // por iteración pero no esencial — el importador no lo asume. + let mut visited: HashSet = HashSet::new(); + let mut nodes: Vec = Vec::new(); + let mut frontier: Vec = vec![struct_hash]; + while let Some(h) = frontier.pop() { + if !visited.insert(h) { + continue; + } + let stored = repo + .nodes + .get(&h)? + .ok_or(CliError::HashNotFound(h))?; + for c in &stored.children { + if !visited.contains(c) { + frontier.push(*c); + } + } + nodes.push(stored); + } + + let attestations = repo.attestations.get(&alpha)?; + let retractions = repo.retractions.get(&alpha)?; + + Ok(BundleV1 { + version: BUNDLE_VERSION, + alpha, + struct_hash, + dialect_byte: dialect.as_byte(), + nodes, + attestations, + retractions, + }) +} + +/// Estadísticas devueltas por `cmd_bundle_import`. +#[derive(Debug, Clone, Copy)] +pub struct BundleImportStats { + pub alpha: ContentHash, + /// `StoredNode`s recién agregados al `nodes` tree (los que ya + /// estaban se omiten silenciosamente). + pub nodes_inserted: usize, + /// Atestaciones nuevas (no había una para `(content, author)`). + pub attestations_added: usize, + /// Atestaciones cuya firma falló — descartadas sin tocar el store. + pub attestations_rejected: usize, + pub retractions_added: usize, + pub retractions_rejected: usize, + /// `true` cuando la raíz quedó registrada en `roots`/`mst` (i.e. + /// no estaba ya). `false` para imports idempotentes. + pub root_was_new: bool, +} + +/// `minga bundle import `: deserializa un bundle, verifica +/// criptográficamente cada pieza, y mergea idempotentemente en los +/// stores locales. Si algo falla (versión incompatible, dialect +/// desconocido, α-hash inconsistente, postcard malformado), aborta +/// sin haber tocado nada — los `StoredNode`s pasan por `put_chunked` +/// que verifica el hash, pero el tree es append-only así que reintentos +/// son seguros. +pub fn cmd_bundle_import( + repo_path: &Path, + passphrase: &str, + in_path: &Path, +) -> Result { + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let bytes = std::fs::read(in_path)?; + if is_multi_bundle_magic(&bytes) { + return Err(CliError::ExpectedSingleBundle); + } + let bundle: BundleV1 = postcard::from_bytes(&bytes).map_err(|_| CliError::InvalidBundle)?; + let stats = import_one(&repo, bundle)?; + repo.flush()?; + Ok(stats) +} + +/// `true` si los primeros bytes son `MNGM` (multi-bundle legacy) o +/// `MNGZ` (multi-bundle comprimido). Helper compartido entre +/// `cmd_bundle_import` (que lo usa para rechazar multi) y la detección +/// previa al strip del prefijo en `import_all`. +fn is_multi_bundle_magic(bytes: &[u8]) -> bool { + bytes.len() >= MULTI_MAGIC.len() + && (&bytes[..MULTI_MAGIC.len()] == MULTI_MAGIC + || &bytes[..MULTI_MAGIC_ZSTD.len()] == MULTI_MAGIC_ZSTD) +} + +/// Importa un multi-bundle (formato `MULTI_MAGIC + BundleMultiV1`). Si +/// el archivo es un single-bundle clásico, lo reportamos como error +/// para que el caller elija entre `bundle import` y `bundle import-all`. +pub fn cmd_bundle_import_all( + repo_path: &Path, + passphrase: &str, + in_path: &Path, +) -> Result { + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let bytes = std::fs::read(in_path)?; + let body: Vec = if bytes.len() >= MULTI_MAGIC_ZSTD.len() + && &bytes[..MULTI_MAGIC_ZSTD.len()] == MULTI_MAGIC_ZSTD + { + zstd::decode_all(&bytes[MULTI_MAGIC_ZSTD.len()..]).map_err(CliError::Io)? + } else if bytes.len() >= MULTI_MAGIC.len() && &bytes[..MULTI_MAGIC.len()] == MULTI_MAGIC { + bytes[MULTI_MAGIC.len()..].to_vec() + } else { + return Err(CliError::ExpectedMultiBundle); + }; + let multi: BundleMultiV1 = + postcard::from_bytes(&body).map_err(|_| CliError::InvalidBundle)?; + if multi.version != MULTI_VERSION { + return Err(CliError::UnsupportedBundleVersion(multi.version)); + } + + let mut items = Vec::with_capacity(multi.items.len()); + for bundle in multi.items { + items.push(import_one(&repo, bundle)?); + } + repo.flush()?; + Ok(BundleImportAllStats { items }) +} + +/// Core compartido entre import single y multi. No flushea (el caller +/// lo hace una sola vez al final para amortizar I/O en el multi). +fn import_one(repo: &PersistentRepo, bundle: BundleV1) -> Result { + if bundle.version != BUNDLE_VERSION { + return Err(CliError::UnsupportedBundleVersion(bundle.version)); + } + + let dialect = parse::Dialect::from_byte(bundle.dialect_byte) + .ok_or(CliError::UnknownDialect(bundle.dialect_byte))?; + + // 1) Insertar nodos con verificación de hash. put_chunked rechaza + // si `hash_stored(stored) != hash`, así que un bundle adulterado + // se detecta en la primera entrada inconsistente. + let mut nodes_inserted = 0usize; + for stored in &bundle.nodes { + let h = hash_stored(stored); + if !repo.nodes.contains(&h)? { + repo.nodes.put_chunked(h, stored)?; + nodes_inserted += 1; + } + } + + // 2) Reconstruir el SemanticNode de la raíz para re-derivar α. + let root_node = repo + .nodes + .reconstruct(&bundle.struct_hash)? + .ok_or(CliError::HashNotFound(bundle.struct_hash))?; + let computed_alpha = hash_alpha_with(dialect, &root_node); + if computed_alpha != bundle.alpha { + return Err(CliError::BundleAlphaMismatch { + struct_hash: bundle.struct_hash, + claimed_alpha: bundle.alpha, + }); + } + + // 3) Registrar la raíz si es nueva. `roots.put` y `mst.insert` son + // idempotentes — el flag `root_was_new` lo computamos antes. + let root_was_new = !repo.roots.contains(&bundle.alpha)?; + repo.roots.put(bundle.alpha, bundle.struct_hash, dialect)?; + repo.mst.insert(bundle.alpha)?; + + // 4) Atestaciones — `add()` re-verifica firma Ed25519. Las que + // tengan content != bundle.alpha (no deberían existir, pero…) se + // descartan: la atestación es sobre OTRA raíz, no nos sirve acá. + let now_secs = unix_now_secs(); + let mut atts_added = 0usize; + let mut atts_rejected = 0usize; + for att in bundle.attestations { + if att.content != bundle.alpha { + atts_rejected += 1; + continue; + } + let existed = repo + .attestations + .get(&att.content)? + .iter() + .any(|a| a.author == att.author); + match repo.attestations.add(att.clone()) { + Ok(()) => { + if !existed { + atts_added += 1; + } + let _ = repo.timestamps.put(&att.content, &att.author, now_secs); + } + Err(_) => atts_rejected += 1, + } + } + + // 5) Retracciones — misma lógica, mismo filtro por content. + let mut rets_added = 0usize; + let mut rets_rejected = 0usize; + for r in bundle.retractions { + if r.content != bundle.alpha { + rets_rejected += 1; + continue; + } + let existed = repo.retractions.contains(&r.content, &r.author)?; + match repo.retractions.add(r) { + Ok(()) => { + if !existed { + rets_added += 1; + } + } + Err(_) => rets_rejected += 1, + } + } + + Ok(BundleImportStats { + alpha: bundle.alpha, + nodes_inserted, + attestations_added: atts_added, + attestations_rejected: atts_rejected, + retractions_added: rets_added, + retractions_rejected: rets_rejected, + root_was_new, + }) +} diff --git a/03_ukupacha/minga/minga-cli/src/commands.rs b/03_ukupacha/minga/minga-cli/src/commands.rs new file mode 100644 index 0000000..08b7d1d --- /dev/null +++ b/03_ukupacha/minga/minga-cli/src/commands.rs @@ -0,0 +1,1354 @@ +//! Implementaciones de los subcomandos. Funciones puras que retornan +//! datos estructurados — el binario las llama y formatea la salida. +//! +//! Layout en disco bajo `repo_path/`: +//! - `keypair` — la `Keypair` del peer cifrada con passphrase. +//! - `repo/` — directorio sled con `nodes`, `attestations`, `mst`. + +use std::fs; +use std::path::Path; +use std::time::Duration; + +use libp2p::{multiaddr::Protocol, Multiaddr, PeerId}; +use minga_core::{ + alpha::hash_alpha_with, parse, Attestation, ContentHash, Did, Keypair, Retraction, +}; +use minga_p2p::MingaPeer; +use minga_store::{keypair_file, PersistentRepo}; + +use crate::error::CliError; + +pub const KEYPAIR_FILENAME: &str = "keypair"; +pub const REPO_DIRNAME: &str = "repo"; + +#[derive(Debug, Clone)] +pub struct RepoStatus { + pub did: Did, + pub mst_len: usize, + pub nodes_len: usize, + pub attestations_len: usize, + pub roots_len: usize, +} + +#[derive(Debug, Clone)] +pub struct IngestResult { + /// α-hash de la raíz: identidad del archivo, estable bajo + /// renombrado de variables ligadas. + pub alpha: ContentHash, + /// Hash estructural de la raíz dentro del grafo CAS. + pub struct_hash: ContentHash, + pub did: Did, + pub dialect: parse::Dialect, +} + +/// `minga init`: genera un keypair fresco, crea el repo persistente, +/// y guarda el keypair cifrado. +pub fn cmd_init(repo_path: &Path, passphrase: &str) -> Result { + if repo_path.exists() { + // Si el directorio existe pero está vacío, lo aceptamos. + // Si tiene cualquier cosa, abortamos para no pisar un repo. + let mut entries = fs::read_dir(repo_path)?; + if entries.next().is_some() { + return Err(CliError::AlreadyExists(repo_path.to_path_buf())); + } + } else { + fs::create_dir_all(repo_path)?; + } + + let keypair = Keypair::generate(); + keypair_file::save(&keypair, repo_path.join(KEYPAIR_FILENAME), passphrase)?; + + // Crear el repo sled vacío. Se cierra al final del scope; el + // siguiente comando lo reabre. + let _ = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + Ok(keypair.did()) +} + +/// `minga status`: descifra el keypair, abre el repo, devuelve +/// estadísticas básicas. +pub fn cmd_status(repo_path: &Path, passphrase: &str) -> Result { + let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + Ok(RepoStatus { + did: keypair.did(), + mst_len: repo.mst.len(), + nodes_len: repo.nodes.len(), + attestations_len: repo.attestations.len(), + roots_len: repo.roots.len(), + }) +} + +/// `minga ingest `: parsea el archivo con tree-sitter, inserta +/// el AST en el grafo CAS, calcula su α-hash (estable bajo renombrado +/// de variables ligadas), lo registra como raíz del MST y crea una +/// atestación firmada por el dueño del keypair (auto-firma de autoría). +pub fn cmd_ingest( + repo_path: &Path, + passphrase: &str, + file: &Path, +) -> Result { + let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let source = fs::read_to_string(file)?; + let dialect = detect_dialect(file)?; + let node = dialect.parse(&source)?; + let (alpha, struct_hash) = ingest_node_alpha(&repo, &keypair, dialect, &node)?; + // Anexamos al historial path → α para que `minga blame` pueda + // atribuir cada línea actual al α que la introdujo. Errores de + // canonicalize se tratan como "no hay historial" — la ingesta + // funcional ya está completa. + if let Some(path_key) = canonical_path_key(file) { + let now_secs = unix_now_secs(); + let _ = repo.paths.append(&path_key, alpha, now_secs); + let _ = repo.alpha_paths.record(alpha, &path_key, now_secs); + } + repo.flush()?; + + Ok(IngestResult { + alpha, + struct_hash, + did: keypair.did(), + dialect, + }) +} + +/// Canonicaliza `path` y lo convierte a String para usar como clave +/// del historial. `None` si el archivo no existe o `canonicalize` +/// falla (filesystem inusual). El callsite debe degradar +/// silenciosamente — la ingesta principal no depende de esto. +fn canonical_path_key(path: &Path) -> Option { + let abs = fs::canonicalize(path).ok()?; + Some(abs.to_string_lossy().into_owned()) +} + +pub(crate) fn unix_now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Ingiere un nodo ya parseado: lo desempaqueta en el grafo CAS, +/// calcula su α-hash, lo registra como raíz y atesta autoría. Compartido +/// entre `cmd_ingest` y el bucle interno de `cmd_watch`. +fn ingest_node_alpha( + repo: &PersistentRepo, + keypair: &Keypair, + dialect: parse::Dialect, + node: &minga_core::SemanticNode, +) -> Result<(ContentHash, ContentHash), CliError> { + let struct_hash = repo.nodes.put(node)?; + let alpha = hash_alpha_with(dialect, node); + repo.roots.put(alpha, struct_hash, dialect)?; + repo.mst.insert(alpha)?; + let att = Attestation::create(keypair, alpha); + repo.attestations.add(att.clone())?; + let now_secs = unix_now_secs(); + repo.timestamps.put(&att.content, &att.author, now_secs)?; + Ok((alpha, struct_hash)) +} + +/// Resultado de `cmd_sign`: confirmación con metadata sobre si la firma +/// es nueva (vouching genuino) o redundante (idempotencia). +#[derive(Debug, Clone)] +pub struct SignResult { + pub alpha: ContentHash, + pub author: Did, + /// `false` si el repo ya tenía una atestación local de este author + /// sobre `alpha` (re-firmar es idempotente, no se duplica entrada). + /// `true` cuando esta es la primera vez que este DID firma esa raíz. + pub is_new_attestation: bool, + /// `true` si el α-hash está en el tree `roots`. Cuando es `false`, + /// se firma igual — útil para vouching de fragmentos del CAS o de + /// raíces que aún no llegaron por sync — pero el CLI lo avisa al + /// usuario para que no firme algo desconocido por error de tipeo. + pub is_known_root: bool, +} + +/// `minga sign <α-hash>`: emite una atestación bajo el keypair local +/// sobre un α-hash existente. A diferencia de `ingest` (que firma como +/// efecto secundario de versionar contenido propio), `sign` es +/// **vouching explícito**: Alice ingiere, Bob sincroniza, Bob firma +/// con `sign` — la raíz queda con dos atestaciones independientes, +/// permitiendo "co-autoría" semántica o aval de revisores. +/// +/// Re-firmar la misma raíz con el mismo keypair es idempotente: +/// `SledAttestationStore` indexa por `content || author`, así que la +/// segunda inserción reemplaza la primera con bytes idénticos. +pub fn cmd_sign( + repo_path: &Path, + passphrase: &str, + hash_hex: &str, +) -> Result { + let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let alpha = parse_hash_hex(hash_hex)?; + let did = keypair.did(); + + // ¿Ya habíamos firmado este α con este DID? Sólo para reportar al + // caller; la firma se emite igual y el store la deduplica sola. + let already = repo + .attestations + .get(&alpha)? + .iter() + .any(|a| a.author == did); + let is_known_root = repo.roots.contains(&alpha)?; + + let att = Attestation::create(&keypair, alpha); + repo.attestations.add(att.clone())?; + repo.timestamps + .put(&att.content, &att.author, unix_now_secs())?; + repo.flush()?; + + Ok(SignResult { + alpha, + author: did, + is_new_attestation: !already, + is_known_root, + }) +} + +/// Una entrada de `cmd_signers`: quién firmó la raíz, cuándo, y si +/// también la retractó. +#[derive(Debug, Clone)] +pub struct SignerEntry { + pub author: Did, + /// Timestamp local de cuándo se observó la atestación. `0` si no + /// hay timestamp (atestación vieja sin entry en + /// `SledTimestampStore`). + pub ts_secs: u64, + /// `true` si el mismo `author` también firmó una `Retraction` sobre + /// esta raíz — declara que avaló y luego revocó. La atestación + /// original sigue presente como prueba histórica. + pub retracted: bool, +} + +/// `minga signers <α-hash>`: lista los DIDs que han atestado una raíz. +/// Complementa `cmd_sign` ofreciendo la vista "quién avaló esto" sin +/// pasar por `cmd_log` (que mezcla todas las raíces). +/// +/// Salida ordenada por timestamp local descendente (más reciente +/// primero). Marca con `retracted = true` a los DIDs que también +/// emitieron una retracción — útil para visualizar cambios de postura +/// en la cadena de aval. +/// +/// `since_secs` filtra firmas observadas antes de ese instante Unix +/// (utilidad: feed "qué hay nuevo en este repo" para integraciones tipo +/// shuma/minga-explorer). Atestaciones sin timestamp (`ts_secs == 0`, +/// típicas en repos viejos sin `SledTimestampStore`) se excluyen cuando +/// se aplica el filtro — no podemos decir si son recientes. +pub fn cmd_signers( + repo_path: &Path, + passphrase: &str, + hash_hex: &str, + since_secs: Option, +) -> Result, CliError> { + use std::collections::HashSet; + + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let alpha = parse_hash_hex(hash_hex)?; + + let atts = repo.attestations.get(&alpha)?; + let retractions = repo.retractions.get(&alpha)?; + let retract_authors: HashSet = retractions.into_iter().map(|r| r.author).collect(); + + let mut entries: Vec = atts + .into_iter() + .map(|a| { + let ts = repo + .timestamps + .get(&a.content, &a.author) + .ok() + .flatten() + .unwrap_or(0); + SignerEntry { + author: a.author, + ts_secs: ts, + retracted: retract_authors.contains(&a.author), + } + }) + .filter(|e| match since_secs { + Some(cut) => e.ts_secs >= cut, + None => true, + }) + .collect(); + + entries.sort_by(|a, b| b.ts_secs.cmp(&a.ts_secs).then(a.author.0.cmp(&b.author.0))); + Ok(entries) +} + +/// Una entrada del log: atestación + timestamp de cuándo se observó. +#[derive(Debug, Clone)] +pub struct LogEntry { + pub alpha: ContentHash, + pub struct_hash: Option, + pub dialect: Option, + pub author: Did, + pub ts_secs: u64, + /// Si `true`, esta entrada coincide con el archivo señalado por el + /// caller (vía `cmd_log` con `Some(path)`). Sólo se calcula cuando + /// hay path: en `cmd_log(None)` siempre es `false`. + pub current: bool, +} + +/// `minga log [path]`: enumera las atestaciones del repo ordenadas por +/// timestamp descendente. Si `path` es `Some`, computa su α-hash actual +/// y marca la entrada coincidente con `current = true`. +pub fn cmd_log( + repo_path: &Path, + passphrase: &str, + path: Option<&Path>, +) -> Result, CliError> { + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let current_alpha = match path { + Some(p) => { + let source = fs::read_to_string(p)?; + let dialect = detect_dialect(p)?; + let node = dialect.parse(&source)?; + Some(hash_alpha_with(dialect, &node)) + } + None => None, + }; + + let mut entries: Vec = Vec::new(); + for att in repo.attestations.iter() { + let att = att?; + let ts = repo + .timestamps + .get(&att.content, &att.author)? + .unwrap_or(0); + let (struct_hash, dialect) = match repo.roots.get(&att.content)? { + Some((sh, dl)) => (Some(sh), dl), + None => (None, None), + }; + let current = current_alpha + .as_ref() + .map(|a| a == &att.content) + .unwrap_or(false); + entries.push(LogEntry { + alpha: att.content, + struct_hash, + dialect, + author: att.author, + ts_secs: ts, + current, + }); + } + // Más recientes primero. Empate por hash para orden estable. + entries.sort_by(|a, b| b.ts_secs.cmp(&a.ts_secs).then(a.alpha.0.cmp(&b.alpha.0))); + Ok(entries) +} + +/// Resultado de `cmd_show`: la fuente reconstruida (forma canónica) o +/// el árbol como S-expression, según `mode`. +#[derive(Debug, Clone)] +pub struct ShowResult { + pub alpha: Option, + pub struct_hash: ContentHash, + pub dialect: Option, + /// `true` si el `hash` recibido era un α-hash (raíz registrada). + /// `false` si era un hash estructural directo. + pub is_root: bool, + pub rendered: String, +} + +/// `minga show `: pinta el contenido del nodo identificado por +/// `hash`. Acepta α-hashes (raíces) y hashes estructurales del grafo +/// interno. `as_sexp = true` devuelve el árbol literal del store; `false` +/// (default) devuelve el código fuente reconstruido en forma canónica. +pub fn cmd_show( + repo_path: &Path, + passphrase: &str, + hash_hex: &str, + as_sexp: bool, +) -> Result { + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let hash = parse_hash_hex(hash_hex)?; + + // ¿Es α-hash de una raíz registrada? + let (alpha, struct_hash, dialect, is_root) = match repo.roots.get(&hash)? { + Some((sh, dl)) => (Some(hash), sh, dl, true), + None => (None, hash, None, false), + }; + + let stored_node = repo + .nodes + .reconstruct(&struct_hash)? + .ok_or(CliError::HashNotFound(struct_hash))?; + + let rendered = if as_sexp { + minga_vfs::render_sexp(&stored_node) + } else { + minga_vfs::render_source(&stored_node) + }; + + Ok(ShowResult { + alpha, + struct_hash, + dialect, + is_root, + rendered, + }) +} + +pub(crate) fn parse_hash_hex(s: &str) -> Result { + let bytes = hex_decode_32(s).ok_or(CliError::InvalidHash(s.to_string()))?; + Ok(ContentHash(bytes)) +} + +/// Resuelve un hash que puede ser α (raíz) o struct (nodo interno) al +/// struct-hash con el cual lookupar en `nodes`. Si es raíz, también +/// retorna el dialect persistido. +fn resolve_hash( + repo: &PersistentRepo, + hash: ContentHash, +) -> Result<(ContentHash, Option, bool), CliError> { + match repo.roots.get(&hash)? { + Some((sh, dl)) => Ok((sh, dl, true)), + None => Ok((hash, None, false)), + } +} + +/// Una línea del diff. `Same` se preserva tal cual; `Add`/`Remove` se +/// marcan con `+`/`-` en la salida unified. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiffLine { + Same(String), + Add(String), + Remove(String), +} + +/// Resultado de `cmd_diff`: metadata de ambos lados + líneas resultantes. +#[derive(Debug, Clone)] +pub struct DiffResult { + pub left_hash: ContentHash, + pub right_hash: ContentHash, + pub left_dialect: Option, + pub right_dialect: Option, + pub left_is_root: bool, + pub right_is_root: bool, + pub lines: Vec, + /// Cuántas líneas son `Add`. + pub additions: usize, + /// Cuántas líneas son `Remove`. + pub deletions: usize, +} + +/// `minga diff `: reconstruye ambos nodos y emite el +/// diff unified entre sus `render_source`. Acepta α-hashes (raíces) y +/// hashes estructurales. +pub fn cmd_diff( + repo_path: &Path, + passphrase: &str, + left_hex: &str, + right_hex: &str, +) -> Result { + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let left_hash = parse_hash_hex(left_hex)?; + let right_hash = parse_hash_hex(right_hex)?; + + let (left_struct, left_dialect, left_is_root) = resolve_hash(&repo, left_hash)?; + let (right_struct, right_dialect, right_is_root) = resolve_hash(&repo, right_hash)?; + + let left_node = repo + .nodes + .reconstruct(&left_struct)? + .ok_or(CliError::HashNotFound(left_struct))?; + let right_node = repo + .nodes + .reconstruct(&right_struct)? + .ok_or(CliError::HashNotFound(right_struct))?; + + let left_text = minga_vfs::render_source(&left_node); + let right_text = minga_vfs::render_source(&right_node); + + let diff = similar::TextDiff::from_lines(&left_text, &right_text); + let mut lines = Vec::new(); + let mut additions = 0; + let mut deletions = 0; + for change in diff.iter_all_changes() { + let text = change.to_string(); + // `similar` incluye el `\n` final en cada línea — lo conservamos + // para que el caller pueda imprimir sin reflowing. + match change.tag() { + similar::ChangeTag::Equal => lines.push(DiffLine::Same(text)), + similar::ChangeTag::Insert => { + additions += 1; + lines.push(DiffLine::Add(text)); + } + similar::ChangeTag::Delete => { + deletions += 1; + lines.push(DiffLine::Remove(text)); + } + } + } + + Ok(DiffResult { + left_hash, + right_hash, + left_dialect, + right_dialect, + left_is_root, + right_is_root, + lines, + additions, + deletions, + }) +} + +fn hex_decode_32(s: &str) -> Option<[u8; 32]> { + if s.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for (i, chunk) in s.as_bytes().chunks(2).enumerate() { + let hi = nibble(chunk[0])?; + let lo = nibble(chunk[1])?; + out[i] = (hi << 4) | lo; + } + Some(out) +} + +fn nibble(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +/// Una línea de `cmd_blame`: el texto tal como aparece en el archivo +/// actual, más el α-hash que la introdujo (la primera versión del +/// archivo que la contenía) con su timestamp y autor. +#[derive(Debug, Clone)] +pub struct BlameLine { + pub text: String, + pub alpha: ContentHash, + pub ts_secs: u64, + pub author: Did, +} + +/// `minga blame `: para cada línea del archivo actual, devuelve +/// el α-hash que la introdujo. Reconstruye la cadena de versiones del +/// path desde su historial, ejecuta diffs línea-a-línea entre versiones +/// consecutivas, y propaga la atribución hacia adelante: las líneas +/// nuevas en una versión se atribuyen a ella; las preservadas heredan +/// la atribución de la versión anterior. +/// +/// Necesita que el path haya sido ingerido al menos una vez (vía +/// `minga ingest` o el `cmd_watch`). El archivo en disco se ignora — +/// la blame es contra la **última** versión registrada en el historial, +/// no contra la copia actual no-ingerida. +pub fn cmd_blame( + repo_path: &Path, + passphrase: &str, + file: &Path, +) -> Result, CliError> { + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let path_key = canonical_path_key(file) + .ok_or_else(|| CliError::PathNotIngested(file.to_path_buf()))?; + let history = repo.paths.history(&path_key)?; + if history.is_empty() { + return Err(CliError::PathNotIngested(file.to_path_buf())); + } + + // Atribución por línea para la versión "current" del walk. La + // representamos como Vec<(line_text, attribution_index)>, donde + // attribution_index apunta a una entrada de `history` (el α que + // introdujo esa línea). Empezamos con la versión más vieja: todas + // sus líneas se atribuyen a su propio α. + let oldest_source = source_for_alpha(&repo, &history[0].0)?; + let mut current_lines: Vec<(String, usize)> = oldest_source + .lines() + .map(|l| (l.to_string(), 0_usize)) + .collect(); + + // Avanzamos por la historia: en cada paso, computamos diff entre + // current_lines y la fuente de la siguiente versión, y construimos + // la nueva lista de líneas atribuidas preservando attributions de + // las que no cambiaron y asignando el nuevo α a las insertadas. + for (idx, (alpha, _ts)) in history.iter().enumerate().skip(1) { + let new_source = source_for_alpha(&repo, alpha)?; + let current_text: String = current_lines + .iter() + .map(|(t, _)| t.as_str()) + .collect::>() + .join("\n"); + let diff = similar::TextDiff::from_lines(¤t_text, &new_source); + let mut next: Vec<(String, usize)> = Vec::new(); + let mut old_idx = 0usize; + for change in diff.iter_all_changes() { + // `similar` agrega `\n` al final de cada línea — lo + // quitamos para uniformidad con la entrada original + // (que vino de .lines()). + let mut text = change.to_string(); + if text.ends_with('\n') { + text.pop(); + } + match change.tag() { + similar::ChangeTag::Equal => { + let attr = current_lines + .get(old_idx) + .map(|(_, a)| *a) + .unwrap_or(idx); + next.push((text, attr)); + old_idx += 1; + } + similar::ChangeTag::Delete => { + old_idx += 1; + } + similar::ChangeTag::Insert => { + next.push((text, idx)); + } + } + } + current_lines = next; + } + + // Resolvemos cada attribution_index a su BlameLine completa. + // Para `author`/`ts` consultamos la atestación local sobre el α. + let mut out = Vec::with_capacity(current_lines.len()); + for (text, attr_idx) in current_lines { + let (alpha, ts) = history[attr_idx]; + let author = first_author_for(&repo, &alpha).unwrap_or(Did([0u8; 32])); + out.push(BlameLine { + text, + alpha, + ts_secs: ts, + author, + }); + } + Ok(out) +} + +/// Reconstruye la fuente canónica del α-hash de una raíz. Resuelve +/// vía `roots` (α → struct), reconstruye y renderea. +fn source_for_alpha( + repo: &PersistentRepo, + alpha: &ContentHash, +) -> Result { + let (struct_hash, _, _) = resolve_hash(repo, *alpha)?; + let node = repo + .nodes + .reconstruct(&struct_hash)? + .ok_or(CliError::HashNotFound(struct_hash))?; + Ok(minga_vfs::render_source(&node)) +} + +/// Devuelve el primer DID que firmó una atestación sobre `alpha`. +/// `None` si la raíz no tiene atestaciones registradas localmente. +fn first_author_for(repo: &PersistentRepo, alpha: &ContentHash) -> Option { + let atts = repo.attestations.get(alpha).ok()?; + atts.first().map(|a| a.author) +} + +/// Una fila de `cmd_roots`: una raíz registrada en el repo con metadata +/// agregada de paths e historial de atestaciones. +#[derive(Debug, Clone)] +pub struct RootRow { + pub alpha: ContentHash, + pub struct_hash: ContentHash, + pub dialect: Option, + /// Path local donde se ingirió este α por última vez. `None` si la + /// raíz vino por sync sin pasar nunca por un path local, o si el + /// historial path→α no la cubre. + pub path: Option, + /// Timestamp Unix de la atestación más reciente sobre esta raíz + /// (cualquier autor). `0` si no hay timestamps locales registrados. + pub last_seen_secs: u64, + /// Cuántas atestaciones distintas hay almacenadas localmente. + pub attestations: usize, +} + +/// `minga roots`: lista todas las raíces registradas en el repo con +/// path conocido, dialect, fecha de última atestación y cantidad de +/// firmas. Ordenado por `last_seen_secs` descendente — empate por +/// α-hash para estabilidad. +/// +/// Cierra la asimetría histórica entre `status` (sólo da counts) y +/// `show ` (que exige conocer el hash de antemano): permite +/// descubrir las raíces de un repo sin levantar el explorer Llimphi +/// ni el módulo shuma. +pub fn cmd_roots(repo_path: &Path, passphrase: &str) -> Result, CliError> { + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let mut rows = Vec::new(); + for r in repo.roots.iter() { + let (alpha, struct_hash, dialect) = r?; + let atts = repo.attestations.get(&alpha)?; + let attestations = atts.len(); + let last_seen_secs = atts + .iter() + .filter_map(|a| repo.timestamps.get(&a.content, &a.author).ok().flatten()) + .max() + .unwrap_or(0); + // Reverse-index persistente: lookup directo por prefijo α en + // lugar de reconstruir el mapa en RAM. Ver + // `minga_store::SledAlphaPathsStore`. + let path = repo.alpha_paths.most_recent_path(&alpha)?; + rows.push(RootRow { + alpha, + struct_hash, + dialect, + path, + last_seen_secs, + attestations, + }); + } + rows.sort_by(|a, b| { + b.last_seen_secs + .cmp(&a.last_seen_secs) + .then(a.alpha.0.cmp(&b.alpha.0)) + }); + Ok(rows) +} + +/// Una fila de `cmd_history`: una versión histórica de un path. +#[derive(Debug, Clone)] +pub struct HistoryEntry { + pub alpha: ContentHash, + pub ts_secs: u64, + pub dialect: Option, + /// `true` si el α-hash actual del archivo en disco coincide con esta + /// entrada. Si el archivo no existe o no se puede parsear hoy, todas + /// las filas vienen con `current = false`. + pub current: bool, +} + +/// `minga history `: dumpea el historial path→α (poblado por +/// `ingest`/`watch`) para `path`. Útil para ver cuándo cambió un +/// archivo sin reconstruir el blame completo (que ya requiere correr +/// diff línea-a-línea entre cada par de versiones consecutivas). +/// +/// Salida cronológica **descendente** (la versión más reciente arriba), +/// igual que `cmd_log`. Si el archivo todavía existe en disco y su +/// α-hash actual coincide con alguna entrada, esa entrada lleva +/// `current = true`. +pub fn cmd_history( + repo_path: &Path, + passphrase: &str, + file: &Path, +) -> Result, CliError> { + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let path_key = canonical_path_key(file) + .ok_or_else(|| CliError::PathNotIngested(file.to_path_buf()))?; + let history = repo.paths.history(&path_key)?; + if history.is_empty() { + return Err(CliError::PathNotIngested(file.to_path_buf())); + } + + // α-hash del contenido actual en disco, si parseamos sin error. + // Best-effort: si el archivo se movió o ya no parsea, todas las + // filas salen sin el marcador `current`. + let current_alpha = file + .exists() + .then(|| try_current_alpha(file)) + .flatten(); + + let mut out = Vec::with_capacity(history.len()); + for (alpha, ts) in history { + let dialect = repo.roots.get(&alpha)?.and_then(|(_, d)| d); + let current = current_alpha.as_ref().map(|a| a == &alpha).unwrap_or(false); + out.push(HistoryEntry { + alpha, + ts_secs: ts, + dialect, + current, + }); + } + // Más reciente primero, idéntica convención que `cmd_log`. + out.reverse(); + Ok(out) +} + +/// Best-effort: lee `file`, detecta dialect y devuelve su α-hash actual. +/// Cualquier error (io, dialect, parse) → `None` — el caller degrada +/// silenciosamente al modo "sin marcador current". +fn try_current_alpha(file: &Path) -> Option { + let source = fs::read_to_string(file).ok()?; + let dialect = detect_dialect(file).ok()?; + let node = dialect.parse(&source).ok()?; + Some(hash_alpha_with(dialect, &node)) +} + +/// Resultado de `cmd_verify_root`: si la raíz es consistente y bajo +/// qué dialecto (independiente del que figure en `roots`). +#[derive(Debug, Clone)] +pub struct VerifyResult { + pub alpha: ContentHash, + pub struct_hash: ContentHash, + /// Dialecto registrado en el tree `roots` al momento de ingerir. + /// `None` si la raíz no estaba registrada (caso "huérfano": + /// puede pasar tras sync si el wire no transmite el binding). + pub stored_dialect: Option, + /// Dialecto bajo el cual `hash_alpha_with(d, &node) == alpha`. + /// `None` significa **inconsistente** — el α-hash claimado no + /// se corresponde con el contenido del nodo bajo ningún + /// dialecto soportado. + pub verified_dialect: Option, +} + +impl VerifyResult { + pub fn is_consistent(&self) -> bool { + self.verified_dialect.is_some() + } + + pub fn matches_stored(&self) -> bool { + match (self.stored_dialect, self.verified_dialect) { + (Some(a), Some(b)) => a == b, + (None, Some(_)) => true, // sin info previa, lo verificado es lo bueno + _ => false, + } + } +} + +/// `minga verify `: reconstruye el nodo al que apunta `hash` +/// (asumido α-hash de una raíz) y verifica que algún dialect produce +/// ese hash sobre ese contenido. Útil para auditar repos sincronizados +/// donde el remitente no es 100 % confiable. +pub fn cmd_verify_root( + repo_path: &Path, + passphrase: &str, + hash_hex: &str, +) -> Result { + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let alpha = parse_hash_hex(hash_hex)?; + let (struct_hash, stored_dialect, _is_root) = resolve_hash(&repo, alpha)?; + + let node = repo + .nodes + .reconstruct(&struct_hash)? + .ok_or(CliError::HashNotFound(struct_hash))?; + + let verified_dialect = minga_core::alpha::verify_root_alpha(&node, &alpha); + + Ok(VerifyResult { + alpha, + struct_hash, + stored_dialect, + verified_dialect, + }) +} + +/// Resultado de `cmd_prune`: cuántos nodos había antes, cuántos +/// quedaron vivos (alcanzables desde alguna raíz), cuántos se borraron. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PruneStats { + pub before: usize, + pub alive: usize, + pub removed: usize, + pub roots: usize, +} + +/// `minga prune`: mark-sweep del grafo CAS. Marca todos los nodos +/// alcanzables desde alguna raíz del tree `roots` (siguiendo los +/// `children` recursivamente) y borra del tree `nodes` los que no +/// quedaron marcados. Idempotente: correr dos veces seguidas no +/// elimina nada en la segunda pasada. +/// +/// Las atestaciones, retracciones y timestamps quedan intactos — +/// referencian α-hashes (no struct-hashes) y son históricos. +pub fn cmd_prune(repo_path: &Path, passphrase: &str) -> Result { + use std::collections::HashSet; + + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let before = repo.nodes.len(); + + // 1. Recolectar los struct-hashes raíz a partir de `roots`. + let mut roots_set: HashSet = HashSet::new(); + let mut roots_count = 0usize; + for r in repo.roots.iter() { + let (_alpha, struct_hash, _dialect) = r?; + roots_set.insert(struct_hash); + roots_count += 1; + } + + // 2. Mark: BFS desde cada raíz por `children_of` (lee sólo los + // hashes, sin reconstruir el SemanticNode completo). + let mut alive_set: HashSet = HashSet::new(); + let mut frontier: Vec = roots_set.into_iter().collect(); + while let Some(h) = frontier.pop() { + if !alive_set.insert(h) { + continue; // ya visitado + } + if let Some(children) = repo.nodes.children_of(&h)? { + for c in children { + if !alive_set.contains(&c) { + frontier.push(c); + } + } + } + // Si `children_of` devolvió None, el hash es huérfano (en el + // tree `roots` pero NO en `nodes` — debería ser imposible bajo + // ingest sano; pasamos de él en silencio). + } + let alive = alive_set.len(); + + // 3. Sweep: borrar todo lo que no quedó vivo. + let all_hashes: Vec = repo + .nodes + .iter_hashes() + .collect::, _>>()?; + let mut removed = 0usize; + for h in &all_hashes { + if !alive_set.contains(h) { + if repo.nodes.remove(h)? { + removed += 1; + } + } + } + repo.flush()?; + + Ok(PruneStats { + before, + alive, + removed, + roots: roots_count, + }) +} + +/// Resultado de `cmd_retire`: confirmación con metadata. +#[derive(Debug, Clone)] +pub struct RetireResult { + pub alpha: ContentHash, + pub author: Did, + /// `true` si la raíz existía en el MST antes de la retracción + /// (caso esperado). `false` si el hash no era una raíz conocida + /// (la retracción se firma igual — funciona como "declaración de + /// no autoría" útil para sync, aunque el efecto local es nulo). + pub was_root: bool, +} + +/// `minga retire `: emite una atestación negativa firmada +/// declarando que el dueño del keypair ya no respalda `hash`. Quita la +/// entrada del MST y de `roots`, persiste la retracción en su tree +/// propio. Las atestaciones originales NO se borran (siguen como +/// prueba histórica de que en algún momento se firmaron). +pub fn cmd_retire( + repo_path: &Path, + passphrase: &str, + hash_hex: &str, +) -> Result { + let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let alpha = parse_hash_hex(hash_hex)?; + let was_root = repo.roots.contains(&alpha)?; + + let retraction = Retraction::create(&keypair, alpha); + repo.retractions.add(retraction)?; + + // Quitar del MST y de roots (los nodos del CAS quedan: pueden estar + // referenciados por otras raíces o ser navegables vía cas/). + repo.mst.remove(&alpha)?; + repo.roots.remove(&alpha)?; + repo.flush()?; + + Ok(RetireResult { + alpha, + author: keypair.did(), + was_root, + }) +} + +/// `minga mount `: monta el repositorio como un filesystem FUSE +/// de sólo lectura. Cada hash del store se vuelve un archivo +/// navegable con `ls`/`cat`. Bloquea hasta que se desmonte el punto +/// (`fusermount -u ` o una señal al proceso). +pub fn cmd_mount( + repo_path: &Path, + passphrase: &str, + mountpoint: &Path, +) -> Result<(), CliError> { + // Cargar el keypair valida la passphrase: montar es navegar el + // repo, así que pedimos la misma credencial que `status`/`watch`. + let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + minga_vfs::mount(minga_vfs::RepoSource::new(repo), mountpoint)?; + Ok(()) +} + +/// Detecta el dialecto del archivo en tres pasos cada vez más caros: +/// 1. **Extensión** (`.rs`, `.py`, …) — sin abrir el archivo. +/// 2. **Shebang** (primera línea) — un read_line. +/// 3. **Contenido** — parsea con cada gramática y elige la que produce +/// el AST con menos errores. Para esto sí leemos el archivo entero. +/// +/// Error si los tres pasos fallan. +fn detect_dialect(file: &Path) -> Result { + let ext = file + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + if let Some(d) = parse::detect_by_extension(ext) { + return Ok(d); + } + // Shebang: leer sólo la primera línea. + if let Ok(f) = fs::File::open(file) { + use std::io::{BufRead, BufReader}; + let mut first = String::new(); + if BufReader::new(f).read_line(&mut first).is_ok() { + if let Some(d) = parse::detect_by_shebang(&first) { + return Ok(d); + } + } + } + // Fallback caro: leer el contenido y probar cada parser. + if let Ok(source) = fs::read_to_string(file) { + if let Some(d) = parse::detect_by_content(&source) { + return Ok(d); + } + } + Err(CliError::UnsupportedLanguage { + path: file.to_path_buf(), + extension: ext.to_string(), + }) +} + +/// `minga listen `: arranca el peer, escucha en `addr`, y +/// acepta sincronizaciones entrantes hasta que el proceso se cierre. +pub async fn cmd_listen( + repo_path: &Path, + passphrase: &str, + addr: &str, +) -> Result { + let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let did = keypair.did(); + let peer = MingaPeer::open(keypair, repo_path.join(REPO_DIRNAME))?; + let multi: Multiaddr = addr + .parse() + .map_err(|e: libp2p::multiaddr::Error| CliError::Multiaddr(e.to_string()))?; + let actual = peer.listen(multi).await; + let _accept = peer.run_passive_accept(); + + // Bootstrap del DHT: anuncia todas las raíces locales como + // proveedores en Kademlia. Los peers que busquen el α-hash de un + // archivo que tengamos podrán descubrirnos sin conocer nuestro + // multiaddr de antemano (siempre que compartan al menos un peer + // bootstrap de la malla `brahman-net`). + let announced = peer.announce_all_roots().await; + + // Bloqueamos para siempre mientras la task de accept procesa + // sincronizaciones. El usuario cierra con Ctrl+C. + println!("Escuchando en: {}", actual); + println!("DID Minga: {}", did); + println!("PeerID libp2p: {}", peer.peer_id()); + if announced > 0 { + println!("Anunciadas {} raíces en el DHT", announced); + } + futures::future::pending::<()>().await; + + Ok(actual) +} + +/// `minga sync `: dializa al peer y ejecuta una sincronización +/// completa con él. `target` puede ser: +/// - un **multiaddr libp2p** con `/p2p/` — se conecta directo; +/// - un **α-hash en hex** (64 caracteres) — busca proveedores en el DHT +/// (vía [`minga_p2p::MingaPeer::find_providers`]) y sincroniza con el +/// primero que responda. Hay que tener al menos un peer bootstrap +/// conocido (vía `add_dht_peer`) o el lookup no devuelve resultados. +pub async fn cmd_sync( + repo_path: &Path, + passphrase: &str, + target: &str, +) -> Result<(), CliError> { + let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let peer = MingaPeer::open(keypair, repo_path.join(REPO_DIRNAME))?; + + // ¿Es un α-hash hex? Si lo es, vamos por la rama DHT. + if target.len() == 64 && target.chars().all(|c| c.is_ascii_hexdigit()) { + let hash = parse_hash_hex(target)?; + let providers = peer.find_providers(hash).await; + if providers.is_empty() { + return Err(CliError::NoProvidersForHash(hash)); + } + let deadline = std::time::Instant::now() + Duration::from_secs(10); + for pid in providers { + if peer.sync_with(pid).await.is_ok() { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + return Err(CliError::SyncTimeout); + } + } + return Err(CliError::SyncTimeout); + } + + // Rama multiaddr clásica. + let multi: Multiaddr = target + .parse() + .map_err(|e: libp2p::multiaddr::Error| CliError::Multiaddr(e.to_string()))?; + let peer_id = extract_peer_id(&multi).ok_or(CliError::NoPeerIdInMultiaddr)?; + + peer.dial(multi); + + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + if peer.sync_with(peer_id).await.is_ok() { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + return Err(CliError::SyncTimeout); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +fn extract_peer_id(addr: &Multiaddr) -> Option { + addr.iter().find_map(|p| match p { + Protocol::P2p(peer_id) => Some(peer_id), + _ => None, + }) +} + +/// Estadísticas devueltas por `cmd_ingest_dir`: cuántos archivos +/// soportados se vieron, cuántos se ingirieron sin error, y la lista +/// de fallos para que el CLI los reporte. +#[derive(Debug, Clone)] +pub struct BulkIngestStats { + /// Archivos que pasaron `is_supported_source` (extensión o shebang). + pub seen: usize, + /// De esos, cuántos terminaron en el grafo. + pub ingested: usize, + /// Fallos individuales — la ingesta sigue tras un error. + pub failed: Vec<(std::path::PathBuf, String)>, +} + +/// `minga ingest-dir [--recursive]`: ingiere todos los archivos +/// soportados de un directorio en una sola pasada. Es básicamente +/// `initial_scan` (el bootstrap interno de `watch`) expuesto como +/// one-shot, para versionar un repo entero sin dejar el watcher +/// corriendo. +/// +/// En modo recursivo, **omite directorios ocultos** (los que empiezan +/// con `.`): evita pisar `.git`, `.minga`, `.venv` y similares — son +/// fuentes de ruido (archivos generados, repos anidados). Si necesitás +/// versionar un dot-dir explícitamente, llamalo con `--recursive` desde +/// dentro o pasalo como `dir` raíz. +pub fn cmd_ingest_dir( + repo_path: &Path, + passphrase: &str, + dir: &Path, + recursive: bool, +) -> Result { + let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + let mut stats = BulkIngestStats { + seen: 0, + ingested: 0, + failed: Vec::new(), + }; + walk_and_ingest(&repo, &keypair, dir, recursive, &mut stats); + repo.flush()?; + Ok(stats) +} + +fn walk_and_ingest( + repo: &PersistentRepo, + keypair: &Keypair, + dir: &Path, + recursive: bool, + stats: &mut BulkIngestStats, +) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let p = entry.path(); + // Saltar dot-dirs sólo en descenso recursivo. El directorio raíz + // pasado por el usuario puede ser oculto y se respeta. + if p.is_dir() { + if recursive && !is_hidden_dirname(&p) { + walk_and_ingest(repo, keypair, &p, recursive, stats); + } + continue; + } + if is_supported_source(&p) { + stats.seen += 1; + match ingest_into_repo(repo, keypair, &p) { + Ok(_) => stats.ingested += 1, + Err(e) => stats.failed.push((p, e.to_string())), + } + } + } +} + +/// `true` si el último componente del path empieza con `.`. Usamos esto +/// para podar descenso en `ingest-dir --recursive` y evitar `.git`, +/// `.minga`, `.venv`, etc. +fn is_hidden_dirname(p: &Path) -> bool { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with('.')) + .unwrap_or(false) +} + +/// `minga watch `: vigila un directorio, re-parsea y re-ingesta +/// cualquier archivo soportado que se cree o modifique. Si un archivo +/// se borra, retira su última raíz del MST y de `roots`. Los nodos del +/// grafo CAS NO se eliminan (pueden estar compartidos con otras raíces). +pub async fn cmd_watch( + repo_path: &Path, + passphrase: &str, + watch_dir: &Path, +) -> Result<(), CliError> { + use std::collections::HashMap; + + let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?; + let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?; + + // Tracker en memoria: path → α-hash más reciente ingerido en esta + // sesión. Necesario para resolver el "remove": cuando notify nos + // dice "este path desapareció" sabemos cuál hash retirar. + let mut path_to_alpha: HashMap = HashMap::new(); + + // Pasada inicial: ingerimos todos los archivos soportados ya + // presentes y registramos su α-hash en el tracker. + initial_scan(&repo, &keypair, watch_dir, &mut path_to_alpha); + + // Canal entre el callback síncrono de notify y el bucle async. + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let mut watcher = notify::recommended_watcher( + move |res: Result| { + if let Ok(event) = res { + let _ = tx.send(event); + } + }, + )?; + notify::Watcher::watch(&mut watcher, watch_dir, notify::RecursiveMode::Recursive)?; + + while let Some(event) = rx.recv().await { + match event.kind { + notify::EventKind::Create(_) | notify::EventKind::Modify(_) => { + for path in &event.paths { + if is_supported_source(path) { + match ingest_into_repo(&repo, &keypair, path) { + Ok(hash) => { + path_to_alpha.insert(path.clone(), hash); + eprintln!("ingerido: {} → {}", path.display(), hash); + } + Err(e) => { + eprintln!( + "warning: {} no se pudo ingerir: {}", + path.display(), + e + ); + } + } + } + } + } + notify::EventKind::Remove(_) => { + for path in &event.paths { + if let Some(hash) = path_to_alpha.remove(path) { + match retire_root(&repo, &hash) { + Ok(()) => eprintln!("retirado: {} (era {})", path.display(), hash), + Err(e) => eprintln!( + "warning: no se pudo retirar {}: {}", + path.display(), + e + ), + } + } + } + } + _ => {} + } + } + + Ok(()) +} + +fn initial_scan( + repo: &PersistentRepo, + keypair: &Keypair, + dir: &Path, + tracker: &mut std::collections::HashMap, +) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let p = entry.path(); + if is_supported_source(&p) { + if let Ok(hash) = ingest_into_repo(repo, keypair, &p) { + tracker.insert(p, hash); + } + } + } +} + +fn ingest_into_repo( + repo: &PersistentRepo, + keypair: &Keypair, + file: &Path, +) -> Result { + let source = fs::read_to_string(file)?; + let dialect = detect_dialect(file)?; + let node = dialect.parse(&source)?; + let (alpha, _struct_hash) = ingest_node_alpha(repo, keypair, dialect, &node)?; + if let Some(path_key) = canonical_path_key(file) { + let ts = unix_now_secs(); + let _ = repo.paths.append(&path_key, alpha, ts); + let _ = repo.alpha_paths.record(alpha, &path_key, ts); + } + repo.flush()?; + Ok(alpha) +} + +/// Retira una raíz del MST y del tree `roots`. **No** borra los nodos +/// del grafo CAS — quedan disponibles para `cas/` y por si otra +/// raíz los referencia. Las atestaciones tampoco se borran: registran +/// que el contenido existió en algún momento. +fn retire_root(repo: &PersistentRepo, alpha: &ContentHash) -> Result<(), CliError> { + repo.mst.remove(alpha)?; + repo.roots.remove(alpha)?; + repo.flush()?; + Ok(()) +} + +/// Detecta si un archivo debe ingerirse: existe, es regular, y o bien +/// su extensión corresponde a un dialecto soportado, o bien su primera +/// línea tiene un shebang reconocible. `watch` se apoya en esto para +/// no llamar a `read_to_string` sobre archivos ajenos. +fn is_supported_source(path: &Path) -> bool { + if !path.is_file() { + return false; + } + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if parse::detect_by_extension(ext).is_some() { + return true; + } + if let Ok(f) = fs::File::open(path) { + use std::io::{BufRead, BufReader}; + let mut first = String::new(); + if BufReader::new(f).read_line(&mut first).is_ok() { + return parse::detect_by_shebang(&first).is_some(); + } + } + false +} diff --git a/03_ukupacha/minga/minga-cli/src/error.rs b/03_ukupacha/minga/minga-cli/src/error.rs new file mode 100644 index 0000000..1806631 --- /dev/null +++ b/03_ukupacha/minga/minga-cli/src/error.rs @@ -0,0 +1,109 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum CliError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + + #[error("keypair file: {0}")] + KeypairFile(#[from] minga_store::KeypairFileError), + + #[error("store: {0}")] + Store(#[from] minga_store::StoreError), + + #[error("attestation: {0}")] + Attestation(#[from] minga_core::AttestationError), + + #[error("parse: {0}")] + Parse(#[from] minga_core::parse::ParseError), + + #[error("network: {0}")] + Network(#[from] minga_p2p::NodeError), + + #[error("peer open: {0}")] + PeerOpen(#[from] minga_p2p::PeerOpenError), + + #[error("peer sync: {0}")] + PeerSync(#[from] minga_p2p::PeerSyncError), + + #[error("multiaddr inválido: {0}")] + Multiaddr(String), + + #[error("el directorio del repo ya existe: {0}")] + AlreadyExists(PathBuf), + + #[error("el multiaddr no incluye `/p2p/`")] + NoPeerIdInMultiaddr, + + #[error("timeout esperando conexión")] + SyncTimeout, + + #[error("notify (file watcher): {0}")] + Notify(#[from] notify::Error), + + #[error( + "lenguaje no soportado para {path}: extensión '{extension}' no mapea \ + a ningún dialecto conocido (rs, py, pyi, ts, js, mjs, cjs, go)" + )] + UnsupportedLanguage { path: PathBuf, extension: String }, + + #[error("hash hex inválido: '{0}' (esperado 64 caracteres hex)")] + InvalidHash(String), + + #[error("hash no encontrado en el repo: {0}")] + HashNotFound(minga_core::ContentHash), + + #[error("ningún peer del DHT anuncia ser proveedor de {0}")] + NoProvidersForHash(minga_core::ContentHash), + + #[error( + "el path {0} no tiene historial de ingesta — corré `minga ingest` o \ + `minga watch` primero" + )] + PathNotIngested(PathBuf), + + #[error("bundle malformado: postcard no pudo decodificarlo")] + InvalidBundle, + + #[error( + "versión de bundle {0} no soportada por este binario (esperado 1) — \ + actualizá minga o re-exportá desde la versión que generó el archivo" + )] + UnsupportedBundleVersion(u32), + + #[error( + "el dialecto del bundle (byte {0}) no es reconocido por este binario \ + — el archivo lo generó una versión más nueva con soporte para un \ + lenguaje que esta no entiende" + )] + UnknownDialect(u8), + + #[error( + "la raíz del bundle ({struct_hash}) no produce el α-hash declarado \ + ({claimed_alpha}) bajo el dialecto declarado — bundle corrupto o \ + falsificado" + )] + BundleAlphaMismatch { + struct_hash: minga_core::ContentHash, + claimed_alpha: minga_core::ContentHash, + }, + + #[error( + "la raíz {0} no tiene dialect registrado en `roots` — exportá una \ + raíz que haya sido ingerida (no sincronizada bajo el viejo wire \ + pre-RootDeclaration)" + )] + BundleMissingDialect(minga_core::ContentHash), + + #[error( + "el archivo es un multi-bundle — usá `minga bundle import-all` para \ + importar todas las raíces de una vez" + )] + ExpectedSingleBundle, + + #[error( + "el archivo es un bundle single — usá `minga bundle import` para \ + importar una raíz individual" + )] + ExpectedMultiBundle, +} diff --git a/03_ukupacha/minga/minga-cli/src/lib.rs b/03_ukupacha/minga/minga-cli/src/lib.rs new file mode 100644 index 0000000..1a15d94 --- /dev/null +++ b/03_ukupacha/minga/minga-cli/src/lib.rs @@ -0,0 +1,26 @@ +//! `minga-cli`: subcomandos del CLI de Minga. +//! +//! La CLI expone funciones puras (`commands`) que retornan `Result` +//! con la información estructurada. El binario `minga` (en `main.rs`) +//! solo parsea argumentos, prompts de passphrase, y formatea la +//! salida. Esa separación hace los comandos directamente testeables +//! sin spawn de subprocesos. + +pub mod bundle; +pub mod commands; +pub mod error; +pub mod serve; + +pub use bundle::{ + cmd_bundle_export, cmd_bundle_export_all, cmd_bundle_import, cmd_bundle_import_all, + BundleExportAllStats, BundleExportStats, BundleImportAllStats, BundleImportStats, +}; +pub use commands::{ + cmd_blame, cmd_diff, cmd_history, cmd_ingest, cmd_ingest_dir, cmd_init, cmd_listen, cmd_log, + cmd_mount, cmd_prune, cmd_retire, cmd_roots, cmd_show, cmd_sign, cmd_signers, cmd_status, + cmd_sync, cmd_verify_root, cmd_watch, BlameLine, BulkIngestStats, DiffLine, DiffResult, + HistoryEntry, IngestResult, LogEntry, PruneStats, RepoStatus, RetireResult, RootRow, + ShowResult, SignResult, SignerEntry, VerifyResult, +}; +pub use error::CliError; +pub use serve::cmd_serve; diff --git a/03_ukupacha/minga/minga-cli/src/main.rs b/03_ukupacha/minga/minga-cli/src/main.rs new file mode 100644 index 0000000..a261df6 --- /dev/null +++ b/03_ukupacha/minga/minga-cli/src/main.rs @@ -0,0 +1,713 @@ +//! Binario `minga`: argument parsing y formateo de salida. + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use minga_cli::{ + cmd_blame, cmd_bundle_export, cmd_bundle_export_all, cmd_bundle_import, cmd_bundle_import_all, + cmd_diff, cmd_history, cmd_ingest, cmd_ingest_dir, cmd_init, cmd_listen, cmd_log, cmd_mount, + cmd_prune, cmd_retire, cmd_roots, cmd_serve, cmd_show, cmd_sign, cmd_signers, cmd_status, + cmd_sync, cmd_verify_root, cmd_watch, CliError, DiffLine, +}; + +#[derive(Parser)] +#[command( + name = "minga", + version, + about = "Minga: VCS semántico P2P. Versiona AST, no líneas." +)] +struct Cli { + /// Ruta del repositorio Minga. Por defecto: `.minga` en el cwd. + #[arg(short, long, default_value = ".minga", global = true)] + repo: PathBuf, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Inicializa un nuevo repo: genera keypair Ed25519, lo cifra con + /// passphrase, y crea el almacén persistente vacío. + Init, + + /// Muestra DID, tamaño del MST, nodos almacenados y atestaciones. + Status, + + /// Parsea un archivo Rust, lo añade al MST y firma una atestación + /// de autoría con la identidad del repo. + Ingest { + /// Ruta del archivo .rs a ingerir. + file: PathBuf, + }, + + /// Ingiere todos los archivos soportados de un directorio en una + /// sola pasada (sin dejar `watch` corriendo). Útil para versionar un + /// repo entero on-demand. + IngestDir { + /// Directorio a recorrer. + dir: PathBuf, + /// Descender recursivamente. Los dot-dirs (`.git`, `.minga`, …) + /// se saltan durante el descenso para evitar ruido. + #[arg(short, long)] + recursive: bool, + }, + + /// Firma una atestación bajo el keypair local sobre un α-hash + /// existente — vouching colaborativo. A diferencia de `ingest`, no + /// crea contenido nuevo: sólo declara aval sobre algo ya en el repo + /// (típicamente traído por `sync` desde otro peer). + Sign { + /// α-hash en hex (64 caracteres). + hash: String, + }, + + /// Escucha conexiones de peers en una multiaddr libp2p y acepta + /// sincronizaciones entrantes hasta Ctrl+C. + Listen { + /// Multiaddr libp2p, ej. `/ip4/0.0.0.0/tcp/4001`. + addr: String, + }, + + /// Sincroniza una vez con un peer remoto (multiaddr con `/p2p/`). + Sync { + /// Multiaddr completo, ej. `/ip4/1.2.3.4/tcp/4001/p2p/12D3KooW...`. + peer: String, + }, + + /// Vigila un directorio y re-ingiere automáticamente cualquier + /// archivo `.rs` que se cree o modifique. Minga como VCS de fondo: + /// el usuario escribe en su editor y el código queda versionado. + Watch { + /// Directorio a vigilar. + dir: PathBuf, + }, + + /// Monta el repositorio como filesystem FUSE de sólo lectura. + /// Cada hash del store se vuelve un archivo navegable con + /// `ls`/`cat`. Bloquea hasta `fusermount -u `. + Mount { + /// Punto de montaje: un directorio existente. + point: PathBuf, + }, + + /// Lista atestaciones del repo ordenadas por timestamp descendente. + /// Si se pasa un archivo, marca la entrada cuyo α-hash coincide + /// con el contenido actual del archivo. + Log { + /// Archivo cuyo α-hash debe marcarse como "current" (opcional). + file: Option, + }, + + /// Pinta el contenido del nodo identificado por `hash`. Acepta + /// α-hashes (raíces) y hashes estructurales del grafo interno. + Show { + /// Hash en hex (64 caracteres). + hash: String, + /// Si se pasa, devuelve el árbol como S-expression en vez de + /// la fuente reconstruida. + #[arg(long)] + sexp: bool, + /// Compara contra otro hash (atajo de `minga diff`). Mutuamente + /// excluyente con `--sexp`. + #[arg(long = "diff-against")] + diff_against: Option, + }, + + /// Compara dos versiones del repo (típicamente dos α-hashes) y + /// muestra el diff unified de sus `render_source`. + Diff { + /// Hash izquierdo en hex. + left: String, + /// Hash derecho en hex. + right: String, + }, + + /// Retira una raíz: emite una atestación negativa firmada por el + /// keypair del repo y quita el α-hash del MST/`roots`. Las + /// atestaciones originales se conservan como prueba histórica. + Retire { + /// α-hash en hex (64 caracteres). + hash: String, + }, + + /// Verifica que el α-hash de una raíz local es consistente con su + /// contenido bajo algún dialecto soportado. Útil para auditar + /// raíces traídas por sync (cuyo dialect no necesariamente está + /// registrado, o cuyo remitente puede no ser confiable). + Verify { + /// α-hash en hex (64 caracteres). + hash: String, + }, + + /// Recolector de basura del grafo CAS: borra nodos no alcanzables + /// desde ninguna raíz (típicamente quedan tras `retire`/`watch` + /// Remove). Idempotente. + Prune, + + /// Para cada línea del archivo registrado, muestra el α-hash que la + /// introdujo. Reconstruye la cadena de versiones del path desde su + /// historial (poblado por `ingest`/`watch`) y propaga la atribución + /// hacia adelante con diffs línea-a-línea. + Blame { + /// Archivo cuyo historial atribuir. + file: PathBuf, + }, + + /// Lista todas las raíces del repo con su path conocido, dialect, + /// fecha de última atestación y cantidad de firmas. Ordenado por + /// actividad reciente — útil cuando no recordás un α-hash. + Roots, + + /// Historial cronológico (más reciente primero) de un path: cada + /// α-hash que pasó por esa ruta vía `ingest`/`watch`. Marca con `*` + /// la entrada cuyo α coincide con el contenido actual del archivo. + History { + /// Archivo cuyo historial mostrar. + file: PathBuf, + }, + + /// Lista los DIDs que han atestado un α-hash, con timestamp local + /// de cuándo se observó la firma. Marca quienes también firmaron + /// una retracción posterior. + Signers { + /// α-hash en hex (64 caracteres). + hash: String, + /// Filtra firmas observadas desde ese instante. Acepta: + /// `YYYY-MM-DD` (medianoche UTC), o un relativo con sufijo + /// `m`/`h`/`d`/`w` (`30d`, `12h`, `2w`). Atestaciones sin + /// timestamp local (legacy) quedan fuera del filtro. + #[arg(long)] + since: Option, + }, + + /// Bundle: empaquetar / desempaquetar una raíz para transferencia + /// offline (USB-stick) — mismo nivel de verificación criptográfica + /// que el wire libp2p, sin necesidad de red. + #[command(subcommand)] + Bundle(BundleCommand), + + /// Levanta un daemon HTTP read-only sobre el repo. Útil para + /// integrar minga con frontends no-Rust (web, mobile, otro shell). + /// El passphrase se mantiene en memoria mientras el daemon corre. + Serve { + /// Socket de escucha (ej. `127.0.0.1:7777`). + addr: String, + /// Si se pasa, exige `Authorization: Bearer ` en cada + /// request — sin token, el daemon corre abierto (ok sólo en + /// `127.0.0.1`). Para no exponer el token en el `ps`, + /// también se acepta vía env `MINGA_SERVE_TOKEN`. + #[arg(long)] + token: Option, + }, +} + +#[derive(Subcommand)] +enum BundleCommand { + /// Empaqueta α-hash + nodos alcanzables + atestaciones + retracciones + /// en un archivo postcard portable. + Export { + /// α-hash en hex (64 caracteres). + hash: String, + /// Ruta de salida del bundle (sobreescribe si existe). + out: PathBuf, + }, + + /// Empaqueta TODAS las raíces del repo en un solo multi-bundle. + /// Las raíces sin dialect persistido (sync'd bajo wire pre-RootDeclaration) + /// se saltan; se reportan al final. + ExportAll { + /// Ruta de salida del multi-bundle (sobreescribe si existe). + out: PathBuf, + }, + + /// Lee un bundle single, re-verifica criptográficamente cada pieza, + /// y mergea idempotentemente en los stores locales. + Import { + /// Ruta del bundle a importar. + file: PathBuf, + }, + + /// Lee un multi-bundle y mergea cada raíz contenida con las mismas + /// garantías que `import`. Idempotente. + ImportAll { + /// Ruta del multi-bundle a importar. + file: PathBuf, + }, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {}", e); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), CliError> { + let cli = Cli::parse(); + + match cli.command { + Command::Init => { + let pass = prompt_passphrase_with_confirm()?; + let did = cmd_init(&cli.repo, &pass)?; + println!("Repo inicializado en {}", cli.repo.display()); + println!("DID: {}", did); + } + Command::Status => { + let pass = prompt_passphrase()?; + let s = cmd_status(&cli.repo, &pass)?; + println!("DID: {}", s.did); + println!("Raíces (α): {}", s.roots_len); + println!("MST: {} claves", s.mst_len); + println!("Nodos almacenados: {}", s.nodes_len); + println!("Atestaciones: {}", s.attestations_len); + } + Command::Ingest { file } => { + let pass = prompt_passphrase()?; + let r = cmd_ingest(&cli.repo, &pass, &file)?; + println!("Ingerido: {} ({})", file.display(), r.dialect.name()); + println!("α-hash: {}", r.alpha); + println!("struct: {}", r.struct_hash); + println!("Firmado por: {}", r.did); + } + Command::IngestDir { dir, recursive } => { + let pass = prompt_passphrase()?; + let s = cmd_ingest_dir(&cli.repo, &pass, &dir, recursive)?; + println!( + "Bulk ingest en {}: {} archivos vistos, {} ingeridos, {} fallos", + dir.display(), + s.seen, + s.ingested, + s.failed.len() + ); + for (p, msg) in s.failed { + eprintln!(" ✘ {}: {}", p.display(), msg); + } + } + Command::Sign { hash } => { + let pass = prompt_passphrase()?; + let r = cmd_sign(&cli.repo, &pass, &hash)?; + if r.is_new_attestation { + println!("Firmado α-hash {} por {}", r.alpha, r.author); + } else { + println!( + "Atestación ya existente para {} bajo {} (idempotente, no se duplica)", + r.alpha, r.author + ); + } + if !r.is_known_root { + println!( + "⚠ aviso: {} no está registrado como raíz local (firmaste igual)", + r.alpha + ); + } + } + Command::Listen { addr } => { + let pass = prompt_passphrase()?; + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; + rt.block_on(cmd_listen(&cli.repo, &pass, &addr))?; + } + Command::Sync { peer } => { + let pass = prompt_passphrase()?; + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; + rt.block_on(cmd_sync(&cli.repo, &pass, &peer))?; + println!("Sync completo."); + } + Command::Watch { dir } => { + let pass = prompt_passphrase()?; + println!("Vigilando {}. Ctrl+C para parar.", dir.display()); + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; + rt.block_on(cmd_watch(&cli.repo, &pass, &dir))?; + } + Command::Mount { point } => { + let pass = prompt_passphrase()?; + println!( + "Montando {} en {}. `fusermount -u {}` para desmontar.", + cli.repo.display(), + point.display(), + point.display() + ); + cmd_mount(&cli.repo, &pass, &point)?; + } + Command::Log { file } => { + let pass = prompt_passphrase()?; + let entries = cmd_log(&cli.repo, &pass, file.as_deref())?; + if entries.is_empty() { + println!("(repo sin atestaciones)"); + } + for e in entries { + let mark = if e.current { "*" } else { " " }; + let when = format_ts(e.ts_secs); + let dialect = e.dialect.map(|d| d.name()).unwrap_or("?"); + println!("{} {} {} [{}] by {}", mark, when, e.alpha, dialect, e.author); + } + } + Command::Prune => { + let pass = prompt_passphrase()?; + let s = cmd_prune(&cli.repo, &pass)?; + println!( + "Prune: {} raíces · {} nodos antes · {} alcanzables · {} borrados", + s.roots, s.before, s.alive, s.removed + ); + } + Command::Verify { hash } => { + let pass = prompt_passphrase()?; + let v = cmd_verify_root(&cli.repo, &pass, &hash)?; + println!("α-hash: {}", v.alpha); + println!("struct: {}", v.struct_hash); + println!( + "registrado: {}", + v.stored_dialect.map(|d| d.name()).unwrap_or("(huérfano — sin entrada en `roots`)") + ); + match v.verified_dialect { + Some(d) => println!("verificado: OK como {}", d.name()), + None => { + println!("verificado: ✘ INCONSISTENTE — ningún dialecto produce ese α-hash"); + std::process::exit(2); + } + } + if !v.matches_stored() && v.stored_dialect.is_some() { + println!("⚠ aviso: dialect registrado ≠ verificado (posible drift)"); + } + } + Command::Retire { hash } => { + let pass = prompt_passphrase()?; + let r = cmd_retire(&cli.repo, &pass, &hash)?; + if r.was_root { + println!("Retirada raíz {}", r.alpha); + } else { + println!( + "Atestación negativa firmada para {} (no era raíz local)", + r.alpha + ); + } + println!("Firmada por: {}", r.author); + } + Command::Diff { left, right } => { + let pass = prompt_passphrase()?; + let d = cmd_diff(&cli.repo, &pass, &left, &right)?; + let left_kind = if d.left_is_root { "α" } else { "struct" }; + let right_kind = if d.right_is_root { "α" } else { "struct" }; + eprintln!("--- {} ({})", d.left_hash, left_kind); + eprintln!("+++ {} ({})", d.right_hash, right_kind); + eprintln!( + "@@ +{} −{} @@", + d.additions, d.deletions + ); + for line in d.lines { + match line { + DiffLine::Same(t) => print!(" {t}"), + DiffLine::Add(t) => print!("+{t}"), + DiffLine::Remove(t) => print!("-{t}"), + } + } + } + Command::Show { + hash, + sexp, + diff_against, + } => { + let pass = prompt_passphrase()?; + if let Some(other) = diff_against { + // Atajo: show --diff-against ≡ minga diff . + if sexp { + eprintln!("--sexp y --diff-against son mutuamente excluyentes"); + return Err(CliError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "flags incompatibles", + ))); + } + let d = cmd_diff(&cli.repo, &pass, &hash, &other)?; + eprintln!("--- {}", d.left_hash); + eprintln!("+++ {}", d.right_hash); + eprintln!("@@ +{} −{} @@", d.additions, d.deletions); + for line in d.lines { + match line { + DiffLine::Same(t) => print!(" {t}"), + DiffLine::Add(t) => print!("+{t}"), + DiffLine::Remove(t) => print!("-{t}"), + } + } + } else { + let r = cmd_show(&cli.repo, &pass, &hash, sexp)?; + if r.is_root { + let dialect = r.dialect.map(|d| d.name()).unwrap_or("?"); + eprintln!( + "# raíz α={} → struct={} ({})", + r.alpha.unwrap(), + r.struct_hash, + dialect + ); + } else { + eprintln!("# nodo estructural {}", r.struct_hash); + } + print!("{}", r.rendered); + } + } + Command::Blame { file } => { + let pass = prompt_passphrase()?; + let lines = cmd_blame(&cli.repo, &pass, &file)?; + for line in lines { + let short: String = line.alpha.to_string().chars().take(12).collect(); + let when = format_ts(line.ts_secs); + println!("{} {} {} | {}", short, when, line.author, line.text); + } + } + Command::Roots => { + let pass = prompt_passphrase()?; + let rows = cmd_roots(&cli.repo, &pass)?; + if rows.is_empty() { + println!("(repo sin raíces)"); + } + for r in rows { + let when = format_ts(r.last_seen_secs); + let dialect = r.dialect.map(|d| d.name()).unwrap_or("?"); + let short: String = r.alpha.to_string().chars().take(12).collect(); + let path = r.path.as_deref().unwrap_or("(sin path local)"); + println!( + "{} {} [{:<6}] ×{} {}", + short, when, dialect, r.attestations, path + ); + } + } + Command::History { file } => { + let pass = prompt_passphrase()?; + let entries = cmd_history(&cli.repo, &pass, &file)?; + for e in entries { + let mark = if e.current { "*" } else { " " }; + let when = format_ts(e.ts_secs); + let dialect = e.dialect.map(|d| d.name()).unwrap_or("?"); + println!("{} {} {} [{}]", mark, when, e.alpha, dialect); + } + } + Command::Signers { hash, since } => { + let pass = prompt_passphrase()?; + let since_secs = match since.as_deref() { + Some(s) => Some(parse_since(s)?), + None => None, + }; + let entries = cmd_signers(&cli.repo, &pass, &hash, since_secs)?; + if entries.is_empty() { + let extra = match since_secs { + Some(_) => " bajo el filtro --since", + None => "", + }; + println!("(sin atestaciones locales para ese α-hash{extra})"); + } + for e in entries { + let when = format_ts(e.ts_secs); + let marker = if e.retracted { "↺" } else { " " }; + println!("{} {} {}", marker, when, e.author); + } + } + Command::Bundle(BundleCommand::Export { hash, out }) => { + let pass = prompt_passphrase()?; + let s = cmd_bundle_export(&cli.repo, &pass, &hash, &out)?; + println!("Bundle escrito en {}", out.display()); + println!(" α-hash: {}", s.alpha); + println!(" nodos: {}", s.nodes); + println!(" atestaciones: {}", s.attestations); + println!(" retractions: {}", s.retractions); + println!(" tamaño: {} bytes", s.bytes); + } + Command::Bundle(BundleCommand::Import { file }) => { + let pass = prompt_passphrase()?; + let s = cmd_bundle_import(&cli.repo, &pass, &file)?; + println!("Bundle importado: α-hash {}", s.alpha); + if s.root_was_new { + println!(" raíz nueva, registrada en MST y `roots`"); + } else { + println!(" raíz ya conocida (idempotente)"); + } + println!(" nodos insertados: {}", s.nodes_inserted); + println!( + " atestaciones: {} nuevas, {} rechazadas", + s.attestations_added, s.attestations_rejected + ); + println!( + " retractions: {} nuevas, {} rechazadas", + s.retractions_added, s.retractions_rejected + ); + } + Command::Bundle(BundleCommand::ExportAll { out }) => { + let pass = prompt_passphrase()?; + let s = cmd_bundle_export_all(&cli.repo, &pass, &out)?; + println!("Multi-bundle escrito en {}", out.display()); + println!(" raíces: {}", s.roots); + println!(" nodos: {}", s.total_nodes); + println!(" atestaciones: {}", s.total_attestations); + println!(" retractions: {}", s.total_retractions); + println!( + " tamaño: {} bytes (zstd · raw {} bytes, ratio {:.2}×)", + s.bytes, + s.uncompressed_bytes, + if s.bytes == 0 { + 0.0 + } else { + s.uncompressed_bytes as f64 / s.bytes as f64 + } + ); + if !s.skipped_missing_dialect.is_empty() { + println!( + " ⚠ {} raíces saltadas (sin dialect registrado):", + s.skipped_missing_dialect.len() + ); + for h in s.skipped_missing_dialect { + println!(" {}", h); + } + } + } + Command::Serve { addr, token } => { + let pass = prompt_passphrase()?; + // Fallback al env si --token no se pasó. La env es el camino + // recomendado para evitar exponer el secreto en el `ps`. + let env_tok = std::env::var("MINGA_SERVE_TOKEN").ok(); + let token = token.or(env_tok); + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; + rt.block_on(cmd_serve(&cli.repo, &pass, &addr, token.as_deref()))?; + } + Command::Bundle(BundleCommand::ImportAll { file }) => { + let pass = prompt_passphrase()?; + let s = cmd_bundle_import_all(&cli.repo, &pass, &file)?; + println!( + "Multi-bundle importado: {} raíces ({} nuevas)", + s.items.len(), + s.roots_new() + ); + println!(" nodos insertados: {}", s.total_nodes_inserted()); + println!( + " atestaciones nuevas: {}", + s.total_attestations_added() + ); + println!( + " retractions nuevas: {}", + s.total_retractions_added() + ); + } + } + Ok(()) +} + +/// Formato compacto del timestamp Unix → `YYYY-MM-DD HH:MM` UTC. +fn format_ts(secs: u64) -> String { + if secs == 0 { + return " (sin fecha) ".to_string(); + } + // Convertir segundos Unix a una fecha legible UTC sin chrono. + // Algoritmo: días civiles desde epoch + descomposición Howard Hinnant. + let days = (secs / 86_400) as i64; + let secs_of_day = secs % 86_400; + let h = secs_of_day / 3600; + let m = (secs_of_day % 3600) / 60; + let (y, mo, d) = civil_from_days(days + 719_468); + format!("{:04}-{:02}-{:02} {:02}:{:02}", y, mo, d, h, m) +} + +/// Resuelve `--since` a un instante Unix (UTC). Acepta: +/// - `YYYY-MM-DD` → medianoche UTC de ese día; +/// - sufijo de duración `Nm` / `Nh` / `Nd` / `Nw` → `now - N`. +/// +/// El parser es estricto para no confundir un typo con una fecha +/// silenciosamente válida; cualquier formato inesperado produce +/// `InvalidInput` con un mensaje claro. +fn parse_since(s: &str) -> Result { + let s = s.trim(); + // Forma absoluta: YYYY-MM-DD. + if let Some((y, rest)) = s.split_once('-') { + if let Some((mo, d)) = rest.split_once('-') { + let y: i64 = y.parse().map_err(|_| since_err(s))?; + let mo: u32 = mo.parse().map_err(|_| since_err(s))?; + let d: u32 = d.parse().map_err(|_| since_err(s))?; + if !(1..=12).contains(&mo) || !(1..=31).contains(&d) { + return Err(since_err(s)); + } + let days = days_from_civil(y, mo, d); + let unix_days = days - 719_468; + if unix_days < 0 { + return Err(since_err(s)); + } + return Ok((unix_days as u64) * 86_400); + } + } + // Forma relativa: digit+ + sufijo. + let last = s.chars().last().ok_or_else(|| since_err(s))?; + let (num, mult) = match last { + 'm' => (&s[..s.len() - 1], 60u64), + 'h' => (&s[..s.len() - 1], 3_600u64), + 'd' => (&s[..s.len() - 1], 86_400u64), + 'w' => (&s[..s.len() - 1], 7 * 86_400u64), + _ => return Err(since_err(s)), + }; + let n: u64 = num.parse().map_err(|_| since_err(s))?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + Ok(now.saturating_sub(n.saturating_mul(mult))) +} + +fn since_err(input: &str) -> CliError { + CliError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "--since '{input}' inválido: usá YYYY-MM-DD o un relativo \ + como 30d / 12h / 2w / 15m" + ), + )) +} + +/// Inversa de `civil_from_days`: cuenta de días absolutos según el +/// algoritmo de Howard Hinnant. Resultado se interpreta a Unix days +/// restándole 719_468. +fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = (y - era * 400) as u64; + let m = m as u64; + let d = d as u64; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe as i64 +} + +/// Howard Hinnant — días desde Mar 1, 0000 (sistema proléptico) a (Y, M, D). +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +fn prompt_passphrase() -> Result { + let pass = rpassword::prompt_password("Passphrase: ") + .map_err(CliError::Io)?; + Ok(pass) +} + +fn prompt_passphrase_with_confirm() -> Result { + let pass = rpassword::prompt_password("Passphrase nueva: ") + .map_err(CliError::Io)?; + let conf = rpassword::prompt_password("Confirma: ") + .map_err(CliError::Io)?; + if pass != conf { + return Err(CliError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "passphrases no coinciden", + ))); + } + Ok(pass) +} diff --git a/03_ukupacha/minga/minga-cli/src/serve.rs b/03_ukupacha/minga/minga-cli/src/serve.rs new file mode 100644 index 0000000..dbdd7c4 --- /dev/null +++ b/03_ukupacha/minga/minga-cli/src/serve.rs @@ -0,0 +1,291 @@ +//! Daemon HTTP read-only sobre el repo Minga local. +//! +//! Sólo expone consultas — no permite modificar el repo (sería el job de +//! una API authenticada, fuera del alcance de este MVP). Ítem #C del +//! REPORTE: paralelo a `shuma-gateway`, sirve como puente hacia +//! frontends no-Llimphi (web, mobile, otro shell) que quieran leer +//! roots, history o blame sin embeber `minga-cli` como librería. +//! +//! Endpoints: +//! - `GET /status` — counts del repo +//! - `GET /roots` — lista completa de raíces con metadata +//! - `GET /roots/:alpha/show` — fuente reconstruida (text/plain) +//! - `GET /roots/:alpha/show?sexp=1` — S-expression del árbol +//! - `GET /roots/:alpha/signers` — DIDs que han firmado +//! - `GET /roots/:alpha/history?path=` — historial cronológico +//! +//! El passphrase del keypair se pide UNA vez al arrancar el daemon y se +//! mantiene en memoria — todas las requests reaprovechan el load. No es +//! ideal (el keypair real ni siquiera se necesita para read-only, sólo +//! para descifrar y abrir el repo), pero es el path corto al MVP. + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use axum::extract::{Path as AxumPath, Query, Request, State}; +use axum::http::{header, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Deserialize; + +use crate::commands::{cmd_history, cmd_roots, cmd_show, cmd_signers, cmd_status}; +use crate::error::CliError; + +#[derive(Clone)] +struct AppState { + repo: Arc, + passphrase: Arc, +} + +/// Arranca el daemon HTTP. Bloquea hasta Ctrl+C (o hasta que axum cierre +/// por algún error de bind). +/// +/// Si `token` es `Some`, cada request debe llegar con un header +/// `Authorization: Bearer `; comparación constant-time. Sin +/// `token`, el daemon corre como antes (read-only sin auth — razonable +/// sólo en `127.0.0.1`). +pub async fn cmd_serve( + repo_path: &std::path::Path, + passphrase: &str, + addr: &str, + token: Option<&str>, +) -> Result<(), CliError> { + // Sanity check: que el repo se pueda abrir y la passphrase sea + // correcta. Si esto falla, devolvemos el error al CLI sin levantar + // el server — mejor que dejarlo arrancar y devolver 500 a la + // primera request. + let _ = cmd_status(repo_path, passphrase)?; + + let state = AppState { + repo: Arc::new(repo_path.to_path_buf()), + passphrase: Arc::new(passphrase.to_string()), + }; + + let app = build_router(state, token.map(|t| t.to_string())); + + let sock: SocketAddr = addr.parse().map_err(|_| { + CliError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("addr inválida: {addr}"), + )) + })?; + let listener = tokio::net::TcpListener::bind(sock).await.map_err(CliError::Io)?; + let auth_state = match token { + Some(_) => "con token requerido", + None => "sin auth — bindeá a 127.0.0.1", + }; + eprintln!("minga serve escuchando en http://{} ({})", sock, auth_state); + axum::serve(listener, app).await.map_err(CliError::Io)?; + Ok(()) +} + +/// Construye el router con las rutas + (opcionalmente) la layer de +/// auth. Comparte la base entre `cmd_serve` y `build_router_for_test`. +fn build_router(state: AppState, token: Option) -> Router { + let mut r = Router::new() + .route("/status", get(get_status)) + .route("/roots", get(get_roots)) + .route("/roots/:alpha/show", get(get_show)) + .route("/roots/:alpha/signers", get(get_signers)) + .route("/roots/:alpha/history", get(get_history)) + .with_state(state); + if let Some(tok) = token { + let expected = Arc::new(tok); + r = r.layer(middleware::from_fn(move |req, next| { + let expected = expected.clone(); + async move { require_bearer(expected, req, next).await } + })); + } + r +} + +/// Middleware: rechaza con 401 si falta el header o no hace match con +/// `expected`. La comparación es constant-time vía `subtle` indirecto +/// (XOR byte-a-byte sobre slices del mismo largo). +async fn require_bearer(expected: Arc, req: Request, next: Next) -> Response { + let Some(h) = req.headers().get(header::AUTHORIZATION) else { + return unauthorized("missing Authorization header"); + }; + let Ok(val) = h.to_str() else { + return unauthorized("invalid Authorization header"); + }; + let Some(tok) = val.strip_prefix("Bearer ") else { + return unauthorized("expected Bearer scheme"); + }; + if constant_time_eq(tok.as_bytes(), expected.as_bytes()) { + next.run(req).await + } else { + unauthorized("invalid token") + } +} + +fn unauthorized(msg: &str) -> Response { + (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": msg }))).into_response() +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +async fn get_status(State(s): State) -> Result, ApiError> { + let st = cmd_status(&s.repo, &s.passphrase)?; + Ok(Json(serde_json::json!({ + "did": st.did.to_string(), + "roots": st.roots_len, + "mst": st.mst_len, + "nodes": st.nodes_len, + "attestations": st.attestations_len, + }))) +} + +async fn get_roots(State(s): State) -> Result, ApiError> { + let rows = cmd_roots(&s.repo, &s.passphrase)?; + let items: Vec<_> = rows + .into_iter() + .map(|r| { + serde_json::json!({ + "alpha": r.alpha.to_string(), + "struct_hash": r.struct_hash.to_string(), + "dialect": r.dialect.map(|d| d.name()), + "path": r.path, + "last_seen_secs": r.last_seen_secs, + "attestations": r.attestations, + }) + }) + .collect(); + Ok(Json(serde_json::json!({ "items": items }))) +} + +#[derive(Deserialize)] +struct ShowQuery { + sexp: Option, +} + +async fn get_show( + State(s): State, + AxumPath(alpha): AxumPath, + Query(q): Query, +) -> Result { + let sexp = matches!(q.sexp, Some(n) if n != 0); + let r = cmd_show(&s.repo, &s.passphrase, &alpha, sexp)?; + Ok(( + [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], + r.rendered, + ) + .into_response()) +} + +#[derive(Deserialize)] +struct SignersQuery { + /// Cutoff Unix timestamp; sólo se incluyen firmas con + /// `ts_secs >= since`. Mismo significado que `--since` del CLI. + since: Option, +} + +async fn get_signers( + State(s): State, + AxumPath(alpha): AxumPath, + Query(q): Query, +) -> Result, ApiError> { + let entries = cmd_signers(&s.repo, &s.passphrase, &alpha, q.since)?; + let items: Vec<_> = entries + .into_iter() + .map(|e| { + serde_json::json!({ + "author": e.author.to_string(), + "ts_secs": e.ts_secs, + "retracted": e.retracted, + }) + }) + .collect(); + Ok(Json(serde_json::json!({ "alpha": alpha, "items": items }))) +} + +#[derive(Deserialize)] +struct HistoryQuery { + path: String, +} + +async fn get_history( + State(s): State, + AxumPath(_alpha_unused): AxumPath, + Query(q): Query, +) -> Result, ApiError> { + // El `:alpha` del path no se usa: la API ergonomicamente reusa el + // namespace `/roots/:alpha/...` pero `history` opera por path local. + let entries = cmd_history(&s.repo, &s.passphrase, std::path::Path::new(&q.path))?; + let items: Vec<_> = entries + .into_iter() + .map(|e| { + serde_json::json!({ + "alpha": e.alpha.to_string(), + "ts_secs": e.ts_secs, + "dialect": e.dialect.map(|d| d.name()), + "current": e.current, + }) + }) + .collect(); + Ok(Json(serde_json::json!({ "path": q.path, "items": items }))) +} + +/// Wrapper que mapea `CliError` a HTTP. Errores "de usuario" +/// (HashNotFound, PathNotIngested, InvalidHash) van como 4xx; el resto +/// como 500. +struct ApiError(CliError); + +impl From for ApiError { + fn from(e: CliError) -> Self { + ApiError(e) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, body) = match &self.0 { + CliError::HashNotFound(_) | CliError::PathNotIngested(_) => { + (StatusCode::NOT_FOUND, self.0.to_string()) + } + CliError::InvalidHash(_) | CliError::UnsupportedLanguage { .. } => { + (StatusCode::BAD_REQUEST, self.0.to_string()) + } + _ => (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()), + }; + (status, Json(serde_json::json!({ "error": body }))).into_response() + } +} + +/// Sólo para que un test pueda llamarlo y armar requests sin levantar +/// un socket. Devuelve el `Router` configurado contra `repo_path`. +#[doc(hidden)] +pub fn build_router_for_test(repo_path: PathBuf, passphrase: String) -> Router { + let state = AppState { + repo: Arc::new(repo_path), + passphrase: Arc::new(passphrase), + }; + build_router(state, None) +} + +/// Variante con token activo, para tests de auth. +#[doc(hidden)] +pub fn build_router_for_test_with_token( + repo_path: PathBuf, + passphrase: String, + token: String, +) -> Router { + let state = AppState { + repo: Arc::new(repo_path), + passphrase: Arc::new(passphrase), + }; + build_router(state, Some(token)) +} + diff --git a/03_ukupacha/minga/minga-cli/tests/bundle_roundtrip.rs b/03_ukupacha/minga/minga-cli/tests/bundle_roundtrip.rs new file mode 100644 index 0000000..240abae --- /dev/null +++ b/03_ukupacha/minga/minga-cli/tests/bundle_roundtrip.rs @@ -0,0 +1,330 @@ +//! Round-trip de bundle: export en repo A → import en repo B vacío, +//! verificando que la raíz, sus atestaciones y su contenido sobreviven +//! intactos y que el import es idempotente. +//! +//! Ítem #O del REPORTE: la red de seguridad mínima para detectar +//! regresiones en el formato `BundleV1` o en el path de +//! verificación criptográfica al importar. + +use std::fs; + +use minga_cli::{ + cmd_bundle_export, cmd_bundle_export_all, cmd_bundle_import, cmd_bundle_import_all, cmd_init, + cmd_ingest, cmd_retire, cmd_show, cmd_sign, cmd_status, CliError, +}; +use tempfile::TempDir; + +#[test] +fn bundle_roundtrip_preserves_root_and_attestation() { + let dir = TempDir::new().unwrap(); + + // Repo A: emisor. + let repo_a = dir.path().join("a"); + let did_a = cmd_init(&repo_a, "pa").unwrap(); + let src = dir.path().join("f.rs"); + fs::write(&src, "fn f() -> i32 { 42 }").unwrap(); + let ing = cmd_ingest(&repo_a, "pa", &src).unwrap(); + assert_eq!(ing.did, did_a); + + // Export. + let bundle_path = dir.path().join("f.bundle"); + let exp = cmd_bundle_export(&repo_a, "pa", &ing.alpha.to_string(), &bundle_path).unwrap(); + assert_eq!(exp.alpha, ing.alpha); + assert_eq!(exp.attestations, 1, "ingest deja una autoatestación"); + assert!(exp.nodes >= 1); + assert!(bundle_path.exists()); + + // Repo B: receptor vacío. + let repo_b = dir.path().join("b"); + let did_b = cmd_init(&repo_b, "pb").unwrap(); + assert_ne!(did_a, did_b, "los DIDs deben diferir entre repos"); + + // Import. + let imp = cmd_bundle_import(&repo_b, "pb", &bundle_path).unwrap(); + assert_eq!(imp.alpha, ing.alpha, "α preservado tras round-trip"); + assert!(imp.root_was_new); + assert!(imp.nodes_inserted >= 1); + assert_eq!(imp.attestations_added, 1); + assert_eq!(imp.attestations_rejected, 0); + assert_eq!(imp.retractions_added, 0); + + // El status de B refleja exactamente lo que A tenía sobre esa raíz. + let s = cmd_status(&repo_b, "pb").unwrap(); + assert_eq!(s.roots_len, 1); + assert_eq!(s.mst_len, 1); + assert_eq!(s.attestations_len, 1); + + // El contenido reconstruido en B coincide con el de A. + let show_a = cmd_show(&repo_a, "pa", &ing.alpha.to_string(), false).unwrap(); + let show_b = cmd_show(&repo_b, "pb", &ing.alpha.to_string(), false).unwrap(); + assert_eq!(show_a.rendered, show_b.rendered); +} + +#[test] +fn bundle_import_is_idempotent() { + let dir = TempDir::new().unwrap(); + + let repo_a = dir.path().join("a"); + cmd_init(&repo_a, "p").unwrap(); + let src = dir.path().join("g.rs"); + fs::write(&src, "fn g() -> i32 { 1 }").unwrap(); + let ing = cmd_ingest(&repo_a, "p", &src).unwrap(); + let bundle_path = dir.path().join("g.bundle"); + cmd_bundle_export(&repo_a, "p", &ing.alpha.to_string(), &bundle_path).unwrap(); + + let repo_b = dir.path().join("b"); + cmd_init(&repo_b, "p").unwrap(); + + let imp1 = cmd_bundle_import(&repo_b, "p", &bundle_path).unwrap(); + assert!(imp1.root_was_new); + + let imp2 = cmd_bundle_import(&repo_b, "p", &bundle_path).unwrap(); + assert!(!imp2.root_was_new, "segunda importación: raíz ya conocida"); + assert_eq!(imp2.nodes_inserted, 0, "nodos deduplicados"); + assert_eq!(imp2.attestations_added, 0, "atestación deduplicada"); + assert_eq!(imp2.retractions_added, 0); + + let s = cmd_status(&repo_b, "p").unwrap(); + assert_eq!(s.roots_len, 1); + assert_eq!(s.attestations_len, 1); +} + +#[test] +fn bundle_roundtrip_propagates_vouching_attestations() { + // Dos peers atestan la misma raíz; el bundle transporta ambas + // firmas a un tercero limpio. + let dir = TempDir::new().unwrap(); + + let repo_a = dir.path().join("a"); + cmd_init(&repo_a, "pa").unwrap(); + let src = dir.path().join("h.rs"); + fs::write(&src, "fn h() -> i32 { 7 }").unwrap(); + let ing = cmd_ingest(&repo_a, "pa", &src).unwrap(); + + // Ronda intermedia: bundle a→b, b firma como segundo vouching, bundle b→c. + let pkg_ab = dir.path().join("h.ab.bundle"); + cmd_bundle_export(&repo_a, "pa", &ing.alpha.to_string(), &pkg_ab).unwrap(); + + let repo_b = dir.path().join("b"); + cmd_init(&repo_b, "pb").unwrap(); + cmd_bundle_import(&repo_b, "pb", &pkg_ab).unwrap(); + let sign_b = cmd_sign(&repo_b, "pb", &ing.alpha.to_string()).unwrap(); + assert!(sign_b.is_new_attestation); + assert!(sign_b.is_known_root); + + let pkg_bc = dir.path().join("h.bc.bundle"); + let exp_bc = cmd_bundle_export(&repo_b, "pb", &ing.alpha.to_string(), &pkg_bc).unwrap(); + assert_eq!(exp_bc.attestations, 2, "ambas firmas viajan en el bundle"); + + let repo_c = dir.path().join("c"); + cmd_init(&repo_c, "pc").unwrap(); + let imp_c = cmd_bundle_import(&repo_c, "pc", &pkg_bc).unwrap(); + assert_eq!(imp_c.attestations_added, 2); + assert_eq!(imp_c.attestations_rejected, 0); + + let s = cmd_status(&repo_c, "pc").unwrap(); + assert_eq!(s.attestations_len, 2); + assert_eq!(s.roots_len, 1); +} + +#[test] +fn bundle_roundtrip_carries_retractions() { + let dir = TempDir::new().unwrap(); + + let repo_a = dir.path().join("a"); + cmd_init(&repo_a, "pa").unwrap(); + let src = dir.path().join("r.rs"); + fs::write(&src, "fn r() -> i32 { 9 }").unwrap(); + let ing = cmd_ingest(&repo_a, "pa", &src).unwrap(); + + // Bundle base hacia B (antes de retractar — necesitamos α en `roots` + // para que el export funcione). + let pkg = dir.path().join("r.bundle"); + cmd_bundle_export(&repo_a, "pa", &ing.alpha.to_string(), &pkg).unwrap(); + let repo_b = dir.path().join("b"); + cmd_init(&repo_b, "pb").unwrap(); + cmd_bundle_import(&repo_b, "pb", &pkg).unwrap(); + + // A retracta su propia raíz y exporta de nuevo para arrastrar la + // retracción. Nota: retire la saca de `roots`, así que el export se + // hace ANTES sobre la versión retractada — la retracción persiste en + // su tree pese al cleanup del MST… revisemos. + cmd_retire(&repo_a, "pa", &ing.alpha.to_string()).unwrap(); + // Tras retire, α salió de roots; export fallaría con HashNotFound. + // Reingerimos para volver a registrar la raíz; la retracción sigue + // en su tree por diseño (es prueba histórica) y debería viajar. + cmd_ingest(&repo_a, "pa", &src).unwrap(); + + let pkg2 = dir.path().join("r.2.bundle"); + let exp2 = cmd_bundle_export(&repo_a, "pa", &ing.alpha.to_string(), &pkg2).unwrap(); + assert_eq!(exp2.retractions, 1, "la retracción sigue en el tree y viaja"); + + let repo_c = dir.path().join("c"); + cmd_init(&repo_c, "pc").unwrap(); + let imp_c = cmd_bundle_import(&repo_c, "pc", &pkg2).unwrap(); + assert_eq!(imp_c.retractions_added, 1); + assert_eq!(imp_c.retractions_rejected, 0); +} + +#[test] +fn multi_bundle_round_trip_carries_all_roots() { + // A tiene tres archivos ingeridos; un multi-bundle los lleva a B + // de un solo viaje, B termina con las mismas tres raíces. + let dir = TempDir::new().unwrap(); + + let repo_a = dir.path().join("a"); + cmd_init(&repo_a, "p").unwrap(); + let files = [ + ("uno.rs", "fn one() -> i32 { 1 }"), + ("dos.rs", "fn two() -> i32 { 2 }"), + ("tres.rs", "fn three() -> i32 { 3 }"), + ]; + let mut alphas = Vec::new(); + for (n, src) in &files { + let p = dir.path().join(n); + std::fs::write(&p, src).unwrap(); + alphas.push(cmd_ingest(&repo_a, "p", &p).unwrap().alpha); + } + + let pkg = dir.path().join("triple.bundle"); + let exp = cmd_bundle_export_all(&repo_a, "p", &pkg).unwrap(); + assert_eq!(exp.roots, 3); + assert!(exp.skipped_missing_dialect.is_empty()); + assert_eq!(exp.total_attestations, 3); + + let repo_b = dir.path().join("b"); + cmd_init(&repo_b, "p").unwrap(); + let imp = cmd_bundle_import_all(&repo_b, "p", &pkg).unwrap(); + assert_eq!(imp.items.len(), 3); + assert_eq!(imp.roots_new(), 3); + assert_eq!(imp.total_attestations_added(), 3); + + let s = cmd_status(&repo_b, "p").unwrap(); + assert_eq!(s.roots_len, 3); + assert_eq!(s.attestations_len, 3); + for a in &alphas { + let shown = cmd_show(&repo_b, "p", &a.to_string(), false).unwrap(); + assert_eq!(shown.alpha, Some(*a)); + } +} + +#[test] +fn multi_bundle_import_is_idempotent() { + let dir = TempDir::new().unwrap(); + let repo_a = dir.path().join("a"); + cmd_init(&repo_a, "p").unwrap(); + let p1 = dir.path().join("a1.rs"); + std::fs::write(&p1, "fn a1() -> i32 { 1 }").unwrap(); + cmd_ingest(&repo_a, "p", &p1).unwrap(); + let p2 = dir.path().join("a2.rs"); + std::fs::write(&p2, "fn a2() -> i32 { 2 }").unwrap(); + cmd_ingest(&repo_a, "p", &p2).unwrap(); + + let pkg = dir.path().join("multi.bundle"); + cmd_bundle_export_all(&repo_a, "p", &pkg).unwrap(); + let repo_b = dir.path().join("b"); + cmd_init(&repo_b, "p").unwrap(); + + let r1 = cmd_bundle_import_all(&repo_b, "p", &pkg).unwrap(); + assert_eq!(r1.roots_new(), 2); + let r2 = cmd_bundle_import_all(&repo_b, "p", &pkg).unwrap(); + assert_eq!(r2.roots_new(), 0, "segunda corrida: nada nuevo"); + assert_eq!(r2.total_nodes_inserted(), 0); + assert_eq!(r2.total_attestations_added(), 0); +} + +#[test] +fn multi_bundle_zstd_roundtrip_keeps_data_and_reports_ratio() { + // El export-all comprime con zstd; el import-all debe descomprimir y + // recuperar exactamente las mismas raíces. uncompressed_bytes > + // bytes salvo en el degenerado del archivo casi vacío. + let dir = TempDir::new().unwrap(); + let repo_a = dir.path().join("a"); + cmd_init(&repo_a, "p").unwrap(); + let mut alphas = Vec::new(); + for i in 0..5 { + let p = dir.path().join(format!("a{i}.rs")); + std::fs::write(&p, format!("fn a{i}() -> i32 {{ {i} }}")).unwrap(); + alphas.push(cmd_ingest(&repo_a, "p", &p).unwrap().alpha); + } + + let pkg = dir.path().join("z.bundle"); + let exp = cmd_bundle_export_all(&repo_a, "p", &pkg).unwrap(); + assert_eq!(exp.roots, 5); + assert!( + exp.uncompressed_bytes > 0, + "el postcard raw no puede ser vacío para 5 raíces" + ); + // No exigimos ratio > 1: con datos muy chicos zstd a veces ni + // comprime. Pero sí exigimos que el header haya cambiado a MNGZ. + let on_disk = std::fs::read(&pkg).unwrap(); + assert_eq!(&on_disk[..4], b"MNGZ", "export-all nuevo emite MNGZ"); + + let repo_b = dir.path().join("b"); + cmd_init(&repo_b, "p").unwrap(); + let imp = cmd_bundle_import_all(&repo_b, "p", &pkg).unwrap(); + assert_eq!(imp.roots_new(), 5); +} + +#[test] +fn multi_bundle_legacy_mngm_still_imports() { + // Construimos a mano un archivo `MNGM` + postcard sin compresión + // (simulando un bundle generado por una versión vieja) y + // verificamos que el import nuevo lo acepta. Sin esto, romperíamos + // a usuarios con archivos pre-zstd en disco. + use minga_cli::bundle::{BundleMultiV1, MULTI_MAGIC}; + + let dir = TempDir::new().unwrap(); + let repo_a = dir.path().join("a"); + cmd_init(&repo_a, "p").unwrap(); + let p = dir.path().join("legacy.rs"); + std::fs::write(&p, "fn legacy() -> i32 { 1 }").unwrap(); + let ing = cmd_ingest(&repo_a, "p", &p).unwrap(); + + // Reusamos la implementación existente para armar un BundleV1 single, + // y lo envolvemos a mano con el header MNGM legacy. + let single = dir.path().join("legacy.single"); + cmd_bundle_export(&repo_a, "p", &ing.alpha.to_string(), &single).unwrap(); + let single_bytes = std::fs::read(&single).unwrap(); + let bundle: minga_cli::bundle::BundleV1 = postcard::from_bytes(&single_bytes).unwrap(); + let multi = BundleMultiV1 { + version: 1, + items: vec![bundle], + }; + let body = postcard::to_allocvec(&multi).unwrap(); + let mut legacy = Vec::with_capacity(MULTI_MAGIC.len() + body.len()); + legacy.extend_from_slice(MULTI_MAGIC); + legacy.extend_from_slice(&body); + let pkg = dir.path().join("legacy.mngm"); + std::fs::write(&pkg, &legacy).unwrap(); + + let repo_b = dir.path().join("b"); + cmd_init(&repo_b, "p").unwrap(); + let imp = cmd_bundle_import_all(&repo_b, "p", &pkg).unwrap(); + assert_eq!(imp.roots_new(), 1); +} + +#[test] +fn multi_bundle_rejects_single_bundle_and_viceversa() { + // Cruzar single ↔ multi import debe devolver el error específico, + // no `InvalidBundle`. Eso le ahorra al usuario el "qué archivo es + // esto" cuando ya tiene la respuesta a mano. + let dir = TempDir::new().unwrap(); + let repo_a = dir.path().join("a"); + cmd_init(&repo_a, "p").unwrap(); + let p = dir.path().join("x.rs"); + std::fs::write(&p, "fn x() -> i32 { 0 }").unwrap(); + let ing = cmd_ingest(&repo_a, "p", &p).unwrap(); + + let single = dir.path().join("s.bundle"); + cmd_bundle_export(&repo_a, "p", &ing.alpha.to_string(), &single).unwrap(); + let multi = dir.path().join("m.bundle"); + cmd_bundle_export_all(&repo_a, "p", &multi).unwrap(); + + let repo_b = dir.path().join("b"); + cmd_init(&repo_b, "p").unwrap(); + let err1 = cmd_bundle_import(&repo_b, "p", &multi).unwrap_err(); + assert!(matches!(err1, CliError::ExpectedSingleBundle)); + let err2 = cmd_bundle_import_all(&repo_b, "p", &single).unwrap_err(); + assert!(matches!(err2, CliError::ExpectedMultiBundle)); +} diff --git a/03_ukupacha/minga/minga-cli/tests/cli_smoke.rs b/03_ukupacha/minga/minga-cli/tests/cli_smoke.rs new file mode 100644 index 0000000..738eef1 --- /dev/null +++ b/03_ukupacha/minga/minga-cli/tests/cli_smoke.rs @@ -0,0 +1,356 @@ +//! Smoke tests del CLI: init → ingest → status, todo persistido. + +use std::fs; + +use minga_cli::{cmd_ingest, cmd_init, cmd_status, CliError}; +use tempfile::TempDir; + +#[test] +fn init_creates_keypair_and_repo() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + let did = cmd_init(&repo, "passphrase-secreta").unwrap(); + + // El keypair existe en disco. + assert!(repo.join("keypair").exists()); + // El repo sled existe (es un directorio). + assert!(repo.join("repo").is_dir()); + // El DID retornado es no-trivial. + assert_ne!(did, minga_core::Did([0u8; 32])); +} + +#[test] +fn init_refuses_existing_non_empty_directory() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + fs::create_dir(&repo).unwrap(); + fs::write(repo.join("garbage"), b"hello").unwrap(); + let r = cmd_init(&repo, "p"); + assert!(matches!(r, Err(CliError::AlreadyExists(_)))); +} + +#[test] +fn status_shows_empty_state_after_init() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "p").unwrap(); + let s = cmd_status(&repo, "p").unwrap(); + assert_eq!(s.mst_len, 0); + assert_eq!(s.nodes_len, 0); + assert_eq!(s.attestations_len, 0); +} + +#[test] +fn status_with_wrong_passphrase_errors() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "correcta").unwrap(); + let r = cmd_status(&repo, "incorrecta"); + assert!(r.is_err()); +} + +#[test] +fn ingest_persists_function_with_self_attestation() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + let did = cmd_init(&repo, "p").unwrap(); + + // Escribir un archivo Rust de ejemplo. + let src = dir.path().join("ejemplo.rs"); + fs::write(&src, "fn add(x: i32, y: i32) -> i32 { x + y }").unwrap(); + + let r = cmd_ingest(&repo, "p", &src).unwrap(); + assert_eq!(r.did, did, "la firma debe ser del repo, no de otro"); + + let s = cmd_status(&repo, "p").unwrap(); + assert_eq!(s.mst_len, 1); + assert!(s.nodes_len > 1, "el AST tiene más de un nodo"); + assert_eq!(s.attestations_len, 1, "una autoatestación"); +} + +#[test] +fn ingest_persists_across_runs() { + // Simulamos "reiniciar el proceso": cmd_init en una llamada, + // cmd_ingest en otra (que reabre el repo). + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "p").unwrap(); + + let src1 = dir.path().join("uno.rs"); + fs::write(&src1, "fn one() -> i32 { 1 }").unwrap(); + cmd_ingest(&repo, "p", &src1).unwrap(); + + let src2 = dir.path().join("dos.rs"); + fs::write(&src2, "fn two() -> i32 { 2 }").unwrap(); + cmd_ingest(&repo, "p", &src2).unwrap(); + + let s = cmd_status(&repo, "p").unwrap(); + assert_eq!(s.mst_len, 2); + assert_eq!(s.attestations_len, 2); +} + +#[test] +fn ingest_same_file_twice_is_idempotent() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "p").unwrap(); + + let src = dir.path().join("f.rs"); + fs::write(&src, "fn f() -> i32 { 42 }").unwrap(); + + let r1 = cmd_ingest(&repo, "p", &src).unwrap(); + let r2 = cmd_ingest(&repo, "p", &src).unwrap(); + assert_eq!(r1.alpha, r2.alpha); + assert_eq!(r1.struct_hash, r2.struct_hash); + + let s = cmd_status(&repo, "p").unwrap(); + // El MST tiene 1 entrada (mismo hash). Atestaciones también: 1 + // por (autor, contenido) — idempotente. + assert_eq!(s.mst_len, 1); + assert_eq!(s.attestations_len, 1); +} + +#[test] +fn rename_local_var_keeps_same_alpha_hash() { + // Item #1 manifestándose: dos archivos Rust α-equivalentes (sólo + // difieren en el nombre de la variable ligada) producen el mismo + // α-hash → mismo MST → mismo "archivo" desde el punto de vista + // del VCS semántico. + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "p").unwrap(); + + let a = dir.path().join("a.rs"); + fs::write(&a, "fn f() -> i32 { let x = 1; x }").unwrap(); + let b = dir.path().join("b.rs"); + fs::write(&b, "fn f() -> i32 { let y = 1; y }").unwrap(); + + let r1 = cmd_ingest(&repo, "p", &a).unwrap(); + let r2 = cmd_ingest(&repo, "p", &b).unwrap(); + assert_eq!( + r1.alpha, r2.alpha, + "α-equivalencia: cambiar nombre de variable ligada no cambia el α-hash" + ); + assert_ne!( + r1.struct_hash, r2.struct_hash, + "estructuralmente sí difieren (los leaf_text de los `identifier` son distintos)" + ); + + let s = cmd_status(&repo, "p").unwrap(); + assert_eq!(s.mst_len, 1, "una sola raíz canónica en el MST"); + assert_eq!(s.roots_len, 1); +} + +#[test] +fn diff_detects_changes_between_versions() { + use minga_cli::{cmd_diff, DiffLine}; + + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "p").unwrap(); + + let v1 = dir.path().join("v1.rs"); + fs::write(&v1, "fn add(a: i32, b: i32) -> i32 { a + b }").unwrap(); + let v2 = dir.path().join("v2.rs"); + // Genuinamente distinto (no sólo rename): cambia el cuerpo. + fs::write(&v2, "fn add(a: i32, b: i32) -> i32 { a - b }").unwrap(); + + let r1 = cmd_ingest(&repo, "p", &v1).unwrap(); + let r2 = cmd_ingest(&repo, "p", &v2).unwrap(); + assert_ne!(r1.alpha, r2.alpha, "cambio sustantivo cambia el α-hash"); + + let d = cmd_diff(&repo, "p", &r1.alpha.to_string(), &r2.alpha.to_string()).unwrap(); + assert!(d.additions > 0 || d.deletions > 0, "debe haber cambios visibles"); + assert!(d.left_is_root && d.right_is_root, "ambos son raíces"); + assert!(d.lines.iter().any(|l| matches!(l, DiffLine::Add(_) | DiffLine::Remove(_)))); +} + +#[test] +fn retire_removes_root_and_persists_signed_retraction() { + use minga_cli::{cmd_retire, cmd_status}; + + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "p").unwrap(); + + let src = dir.path().join("f.rs"); + fs::write(&src, "fn f() -> i32 { 1 }").unwrap(); + let ing = cmd_ingest(&repo, "p", &src).unwrap(); + assert_eq!(cmd_status(&repo, "p").unwrap().roots_len, 1); + + let r = cmd_retire(&repo, "p", &ing.alpha.to_string()).unwrap(); + assert!(r.was_root); + assert_eq!(r.alpha, ing.alpha); + + let s = cmd_status(&repo, "p").unwrap(); + assert_eq!(s.roots_len, 0, "raíz retirada del tree roots"); + assert_eq!(s.mst_len, 0, "raíz retirada del MST"); + // La atestación original NO se borra: sigue siendo prueba de + // que el autor firmó este hash en algún momento. + assert_eq!(s.attestations_len, 1); +} + +#[test] +fn retire_unknown_hash_still_signs_negative_attestation() { + use minga_cli::cmd_retire; + // Útil para sync: un peer puede firmar "yo no respaldo X" sobre + // un hash que llegó por la red sin que tenga que existir en su + // tree local de roots. + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "p").unwrap(); + + let fake = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + let r = cmd_retire(&repo, "p", fake).unwrap(); + assert!(!r.was_root); +} + +#[test] +fn verify_root_matches_dialect_used_to_ingest() { + use minga_cli::cmd_verify_root; + + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "p").unwrap(); + + let src = dir.path().join("f.py"); + fs::write(&src, "def f():\n return 1\n").unwrap(); + let ing = cmd_ingest(&repo, "p", &src).unwrap(); + + let v = cmd_verify_root(&repo, "p", &ing.alpha.to_string()).unwrap(); + assert!(v.is_consistent(), "el α verificado debe matchear"); + assert_eq!(v.verified_dialect, Some(minga_core::parse::Dialect::Python)); + assert_eq!(v.stored_dialect, Some(minga_core::parse::Dialect::Python)); + assert!(v.matches_stored()); +} + +#[test] +fn prune_removes_unreachable_nodes_after_retire() { + use minga_cli::{cmd_prune, cmd_retire, cmd_status}; + + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "p").unwrap(); + + // Ingerimos dos archivos distintos: dejan ambos AST en el grafo CAS. + let a = dir.path().join("a.rs"); + fs::write(&a, "fn a() -> i32 { 1 }").unwrap(); + let b = dir.path().join("b.rs"); + fs::write(&b, "fn b() -> i32 { 2 }").unwrap(); + let r_a = cmd_ingest(&repo, "p", &a).unwrap(); + cmd_ingest(&repo, "p", &b).unwrap(); + + let nodes_with_both = cmd_status(&repo, "p").unwrap().nodes_len; + + // Retiramos una raíz: el AST queda huérfano (los nodos siguen). + cmd_retire(&repo, "p", &r_a.alpha.to_string()).unwrap(); + let s_after_retire = cmd_status(&repo, "p").unwrap(); + assert_eq!(s_after_retire.roots_len, 1); + assert_eq!( + s_after_retire.nodes_len, nodes_with_both, + "retire NO borra nodos del CAS — el GC lo hace después" + ); + + // Prune: borra los huérfanos. + let stats = cmd_prune(&repo, "p").unwrap(); + assert_eq!(stats.roots, 1); + assert_eq!(stats.before, nodes_with_both); + assert!(stats.removed > 0, "alguna parte del AST de a.rs debe haber quedado huérfana"); + assert!(stats.alive < stats.before); + + // Segunda pasada: idempotente. + let stats2 = cmd_prune(&repo, "p").unwrap(); + assert_eq!(stats2.removed, 0, "prune idempotente"); + assert_eq!(stats2.before, stats.alive); +} + +#[test] +fn blame_attributes_lines_across_two_revisions() { + // El archivo arranca con una función; después le agregamos otra. + // `minga blame` debe atribuir las líneas de la función original + // al primer α-hash, y las de la nueva al segundo. + use minga_cli::cmd_blame; + + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "p").unwrap(); + + let src = dir.path().join("evolución.rs"); + fs::write(&src, "fn first() -> i32 { 1 }\n").unwrap(); + let r1 = cmd_ingest(&repo, "p", &src).unwrap(); + + // Sobrescribimos el archivo: misma función + una nueva. + fs::write( + &src, + "fn first() -> i32 { 1 }\nfn second() -> i32 { 2 }\n", + ) + .unwrap(); + let r2 = cmd_ingest(&repo, "p", &src).unwrap(); + assert_ne!(r1.alpha, r2.alpha, "el contenido cambió, el α debe cambiar"); + + let blame = cmd_blame(&repo, "p", &src).unwrap(); + assert!(!blame.is_empty(), "blame no debe estar vacío"); + + // Al menos una línea atribuida a r1.alpha (la `first`). + assert!( + blame.iter().any(|l| l.alpha == r1.alpha), + "alguna línea original debe atribuirse a r1: {:?}", + blame.iter().map(|l| (&l.text, l.alpha.to_string())).collect::>() + ); + // Al menos una línea atribuida a r2.alpha (la `second`). + assert!( + blame.iter().any(|l| l.alpha == r2.alpha), + "alguna línea nueva debe atribuirse a r2" + ); +} + +#[test] +fn signers_since_filters_old_attestations() { + // `cmd_signers` con `Some(now+horizonte)` debe filtrar todo: la + // firma local quedó persistida con `ts_secs == now`, así que un + // corte muy futuro la deja afuera. Y un corte de hace una hora la + // incluye. Verifica el filtro sin necesidad de manipular tiempo. + use minga_cli::cmd_signers; + + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "p").unwrap(); + let src = dir.path().join("s.rs"); + fs::write(&src, "fn s() -> i32 { 1 }").unwrap(); + let ing = cmd_ingest(&repo, "p", &src).unwrap(); + + // Sin filtro: 1 firma. + let all = cmd_signers(&repo, "p", &ing.alpha.to_string(), None).unwrap(); + assert_eq!(all.len(), 1); + + // Filtro al futuro: 0 firmas. + let future = u64::MAX / 2; + let none = cmd_signers(&repo, "p", &ing.alpha.to_string(), Some(future)).unwrap(); + assert!(none.is_empty()); + + // Filtro a "hace una hora": 1 firma (la nuestra es de hace segundos). + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let recent = cmd_signers(&repo, "p", &ing.alpha.to_string(), Some(now - 3600)).unwrap(); + assert_eq!(recent.len(), 1); +} + +#[test] +fn blame_errors_for_path_without_history() { + use minga_cli::cmd_blame; + + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + cmd_init(&repo, "p").unwrap(); + + let src = dir.path().join("nunca.rs"); + fs::write(&src, "fn x() -> i32 { 0 }").unwrap(); + + let err = cmd_blame(&repo, "p", &src).unwrap_err(); + assert!( + matches!(err, minga_cli::CliError::PathNotIngested(_)), + "esperaba PathNotIngested, obtuvo {err}" + ); +} diff --git a/03_ukupacha/minga/minga-cli/tests/serve_http.rs b/03_ukupacha/minga/minga-cli/tests/serve_http.rs new file mode 100644 index 0000000..d8c5945 --- /dev/null +++ b/03_ukupacha/minga/minga-cli/tests/serve_http.rs @@ -0,0 +1,256 @@ +//! Smoke del daemon HTTP: levanta el router en proceso, manda requests +//! sintéticas via `tower::ServiceExt::oneshot`, valida JSON. +//! +//! No abre un socket real — el server arranca en tests E2E sería más +//! ruido que valor. + +use std::fs; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use minga_cli::serve::{build_router_for_test, build_router_for_test_with_token}; +use minga_cli::{cmd_init, cmd_ingest}; +use serde_json::Value; +use tempfile::TempDir; +use tower::ServiceExt; + +fn populate_repo() -> (TempDir, String) { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("r"); + cmd_init(&repo, "p").unwrap(); + let src = dir.path().join("hola.rs"); + fs::write(&src, "fn hola() -> i32 { 1 }").unwrap(); + let alpha = cmd_ingest(&repo, "p", &src).unwrap().alpha.to_string(); + let r = repo.to_string_lossy().to_string(); + std::mem::forget(dir); + let kept = TempDir::new().unwrap(); + // El dir original ya tiene contenido pero no podemos retornarlo + // post-forget; re-encapsulamos. + (kept, format!("{}|{}", r, alpha)) +} + +#[tokio::test] +async fn http_status_returns_counts() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("r"); + cmd_init(&repo, "p").unwrap(); + let src = dir.path().join("a.rs"); + fs::write(&src, "fn a() -> i32 { 1 }").unwrap(); + cmd_ingest(&repo, "p", &src).unwrap(); + + let app = build_router_for_test(repo.clone(), "p".to_string()); + let res = app + .oneshot(Request::builder().uri("/status").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + let json: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["roots"], 1); + assert_eq!(json["attestations"], 1); +} + +#[tokio::test] +async fn http_roots_lists_ingested_alpha() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("r"); + cmd_init(&repo, "p").unwrap(); + let src = dir.path().join("b.rs"); + fs::write(&src, "fn b() -> i32 { 9 }").unwrap(); + let ing = cmd_ingest(&repo, "p", &src).unwrap(); + + let app = build_router_for_test(repo.clone(), "p".to_string()); + let res = app + .oneshot(Request::builder().uri("/roots").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + let json: Value = serde_json::from_slice(&bytes).unwrap(); + let items = json["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["alpha"], ing.alpha.to_string()); + assert_eq!(items[0]["dialect"], "rust"); + assert_eq!(items[0]["attestations"], 1); +} + +#[tokio::test] +async fn http_show_returns_rendered_source() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("r"); + cmd_init(&repo, "p").unwrap(); + let src = dir.path().join("c.rs"); + fs::write(&src, "fn c() -> i32 { 7 }").unwrap(); + let ing = cmd_ingest(&repo, "p", &src).unwrap(); + + let app = build_router_for_test(repo.clone(), "p".to_string()); + let res = app + .oneshot( + Request::builder() + .uri(format!("/roots/{}/show", ing.alpha)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(body.contains("fn"), "render incluye 'fn'; got: {body}"); +} + +#[tokio::test] +async fn http_show_unknown_alpha_is_404() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("r"); + cmd_init(&repo, "p").unwrap(); + + let app = build_router_for_test(repo.clone(), "p".to_string()); + let fake = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + let res = app + .oneshot( + Request::builder() + .uri(format!("/roots/{fake}/show")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn http_show_invalid_hash_is_400() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("r"); + cmd_init(&repo, "p").unwrap(); + + let app = build_router_for_test(repo.clone(), "p".to_string()); + let res = app + .oneshot( + Request::builder() + .uri("/roots/no-soy-un-hash/show") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn http_signers_returns_local_author() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("r"); + let did = cmd_init(&repo, "p").unwrap(); + let src = dir.path().join("s.rs"); + fs::write(&src, "fn s() -> i32 { 4 }").unwrap(); + let ing = cmd_ingest(&repo, "p", &src).unwrap(); + + let app = build_router_for_test(repo.clone(), "p".to_string()); + let res = app + .oneshot( + Request::builder() + .uri(format!("/roots/{}/signers", ing.alpha)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + let json: Value = serde_json::from_slice(&bytes).unwrap(); + let items = json["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["author"], did.to_string()); +} + +#[tokio::test] +async fn http_without_token_rejects_unauthenticated() { + // Token activado pero el request no manda Authorization → 401. + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("r"); + cmd_init(&repo, "p").unwrap(); + + let app = build_router_for_test_with_token(repo, "p".to_string(), "secret-42".to_string()); + let res = app + .oneshot(Request::builder().uri("/status").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn http_with_wrong_token_rejects() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("r"); + cmd_init(&repo, "p").unwrap(); + + let app = build_router_for_test_with_token(repo, "p".to_string(), "secret-42".to_string()); + let res = app + .oneshot( + Request::builder() + .uri("/status") + .header("authorization", "Bearer secret-NO") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn http_with_correct_token_passes() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("r"); + cmd_init(&repo, "p").unwrap(); + + let app = build_router_for_test_with_token(repo, "p".to_string(), "secret-42".to_string()); + let res = app + .oneshot( + Request::builder() + .uri("/status") + .header("authorization", "Bearer secret-42") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); +} + +#[tokio::test] +async fn http_signers_since_filters_in_query() { + // Endpoint /roots/:α/signers?since= debe devolver lista vacía. + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("r"); + cmd_init(&repo, "p").unwrap(); + let src = dir.path().join("z.rs"); + fs::write(&src, "fn z() -> i32 { 8 }").unwrap(); + let ing = cmd_ingest(&repo, "p", &src).unwrap(); + + let app = build_router_for_test(repo, "p".to_string()); + // since muy en el futuro → 0 firmas + let huge = u64::MAX / 2; + let res = app + .oneshot( + Request::builder() + .uri(format!("/roots/{}/signers?since={}", ing.alpha, huge)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + let json: Value = serde_json::from_slice(&bytes).unwrap(); + assert!(json["items"].as_array().unwrap().is_empty()); +} + +// Silenciamos el unused warning del helper de exploración. +#[allow(dead_code)] +fn _quiet_populate_unused() { + let _ = populate_repo; +} diff --git a/03_ukupacha/minga/minga-cli/tests/watcher.rs b/03_ukupacha/minga/minga-cli/tests/watcher.rs new file mode 100644 index 0000000..616ce40 --- /dev/null +++ b/03_ukupacha/minga/minga-cli/tests/watcher.rs @@ -0,0 +1,129 @@ +//! Tests del file watcher: el "puente humano" que convierte Minga en +//! un VCS de fondo — el usuario edita archivos y Minga los versiona +//! sin acción explícita. + +use std::fs; +use std::time::Duration; + +use minga_cli::{cmd_init, cmd_status, cmd_watch}; +use tempfile::TempDir; + +/// Espera hasta que el `cmd_status` reporte `expected` claves en MST, +/// o hasta `timeout`. Devuelve `true` si se alcanzó la cuenta. +async fn wait_until_mst_size( + repo: &std::path::Path, + pass: &str, + expected: usize, + timeout: Duration, +) -> bool { + let deadline = std::time::Instant::now() + timeout; + loop { + if let Ok(s) = cmd_status(repo, pass) { + if s.mst_len >= expected { + return true; + } + } + if std::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(80)).await; + } +} + +#[tokio::test] +async fn watcher_initial_scan_picks_up_existing_files() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + let watch = dir.path().join("src"); + fs::create_dir(&watch).unwrap(); + cmd_init(&repo, "p").unwrap(); + + // Escribimos archivos ANTES de arrancar el watcher. + fs::write(watch.join("a.rs"), "fn a() -> i32 { 1 }").unwrap(); + fs::write(watch.join("b.rs"), "fn b() -> i32 { 2 }").unwrap(); + + // Arrancamos el watcher en una task. La pasada inicial debería + // ingerir ambos. + let repo_clone = repo.clone(); + let handle = tokio::spawn(async move { + let _ = cmd_watch(&repo_clone, "p", &watch).await; + }); + + // Damos margen para la pasada inicial. cmd_watch tiene el repo + // abierto, pero cmd_status no puede mientras tanto (sled lock). + // Solución: cancelamos el watcher antes de medir. + tokio::time::sleep(Duration::from_millis(500)).await; + handle.abort(); + let _ = handle.await; + tokio::time::sleep(Duration::from_millis(150)).await; + + let s = cmd_status(&repo, "p").unwrap(); + assert_eq!(s.mst_len, 2, "esperaba 2 funciones del initial scan"); + assert_eq!(s.attestations_len, 2); +} + +#[tokio::test] +async fn watcher_ingests_new_file_after_creation() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + let watch = dir.path().join("src"); + fs::create_dir(&watch).unwrap(); + cmd_init(&repo, "p").unwrap(); + + // Watcher arranca con directorio vacío. + let repo_clone = repo.clone(); + let watch_clone = watch.clone(); + let handle = tokio::spawn(async move { + let _ = cmd_watch(&repo_clone, "p", &watch_clone).await; + }); + + // Margen para que el watcher se inicialice y registre con notify. + tokio::time::sleep(Duration::from_millis(300)).await; + + // Creamos un archivo. notify debería emitir un evento y el + // watcher debería ingerirlo. + fs::write(watch.join("new.rs"), "fn new() -> i32 { 42 }").unwrap(); + + // Esperamos a que el evento se procese. + tokio::time::sleep(Duration::from_millis(800)).await; + + // Detenemos el watcher para liberar el lock de sled antes de + // hacer cmd_status. + handle.abort(); + let _ = handle.await; + tokio::time::sleep(Duration::from_millis(150)).await; + + // Polling con timeout — algunos sistemas de archivos tienen + // latencia de eventos. + assert!( + wait_until_mst_size(&repo, "p", 1, Duration::from_secs(3)).await, + "el watcher no ingirió el archivo creado", + ); +} + +#[tokio::test] +async fn watcher_ignores_non_rs_files() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().join("repo"); + let watch = dir.path().join("src"); + fs::create_dir(&watch).unwrap(); + cmd_init(&repo, "p").unwrap(); + + // Pre-poblamos con un .rs y varios archivos no-Rust. + fs::write(watch.join("real.rs"), "fn real() -> i32 { 0 }").unwrap(); + fs::write(watch.join("readme.md"), "# proyecto").unwrap(); + fs::write(watch.join("data.json"), "{}").unwrap(); + + let repo_clone = repo.clone(); + let handle = tokio::spawn(async move { + let _ = cmd_watch(&repo_clone, "p", &watch).await; + }); + + tokio::time::sleep(Duration::from_millis(500)).await; + handle.abort(); + let _ = handle.await; + tokio::time::sleep(Duration::from_millis(150)).await; + + let s = cmd_status(&repo, "p").unwrap(); + assert_eq!(s.mst_len, 1, "solo el .rs debe haberse ingerido"); +} diff --git a/03_ukupacha/minga/minga-core/Cargo.toml b/03_ukupacha/minga/minga-core/Cargo.toml new file mode 100644 index 0000000..c3b7427 --- /dev/null +++ b/03_ukupacha/minga/minga-core/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "minga-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Minga core: semantic AST, content addressing, Merkle Search Tree. Pure logic, no IO." + +[dependencies] +tree-sitter = { workspace = true } +tree-sitter-rust = { workspace = true } +tree-sitter-python = { workspace = true } +tree-sitter-typescript = { workspace = true } +tree-sitter-javascript = { workspace = true } +tree-sitter-go = { workspace = true } +blake3 = { workspace = true } +thiserror = { workspace = true } +ed25519-dalek = { workspace = true } +rand = { workspace = true } +serde = { workspace = true } +serde-big-array = { workspace = true } +aes-gcm = { workspace = true } +argon2 = { workspace = true } + +[dev-dependencies] +blake3 = { workspace = true } +postcard = { workspace = true } diff --git a/03_ukupacha/minga/minga-core/LEEME.md b/03_ukupacha/minga/minga-core/LEEME.md new file mode 100644 index 0000000..6f4c403 --- /dev/null +++ b/03_ukupacha/minga/minga-core/LEEME.md @@ -0,0 +1,9 @@ +# minga-core + +> Modelo de [minga](../README.md): peer, chunk, address. + +`Peer { id: PublicKey, addr }`, `Chunk { hash: Blake3, data }`, `Address = Blake3`. Tipos sin red — los importan core y transport. + +## Deps + +- `serde`, `blake3`, `ed25519-dalek` diff --git a/03_ukupacha/minga/minga-core/README.md b/03_ukupacha/minga/minga-core/README.md new file mode 100644 index 0000000..c4f1584 --- /dev/null +++ b/03_ukupacha/minga/minga-core/README.md @@ -0,0 +1,9 @@ +# minga-core + +> Model of [minga](../README.md): peer, chunk, address. + +`Peer { id: PublicKey, addr }`, `Chunk { hash: Blake3, data }`, `Address = Blake3`. Types without network — imported by core and transport. + +## Deps + +- `serde`, `blake3`, `ed25519-dalek` diff --git a/03_ukupacha/minga/minga-core/src/alpha/common.rs b/03_ukupacha/minga/minga-core/src/alpha/common.rs new file mode 100644 index 0000000..e7d0e32 --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/alpha/common.rs @@ -0,0 +1,105 @@ +//! Primitives compartidos entre todos los profiles α-hashing. +//! +//! Cada profile per-language (rust, python, ecmascript, go) tiene su +//! propia lógica de "qué nodos introducen binders" y "cómo distinguir +//! binders de constructors". Pero el format del wire del hash +//! (TAG_LEAF, TAG_BINDER, índice de Bruijn) es universal: lo emitimos +//! desde acá para garantizar que dos lenguajes con la misma +//! estructura semántica produzcan hashes comparables a nivel de bits. + +use crate::ast::SemanticNode; +use blake3::Hasher; + +pub const TAG_NO_LEAF: u8 = 0; +pub const TAG_LEAF: u8 = 1; +pub const TAG_BINDER: u8 = 2; +pub const TAG_REF_BOUND: u8 = 3; +pub const TAG_REF_FREE: u8 = 4; + +/// Emite el kind del nodo + presencia/ausencia de field_name. +pub fn write_kind_and_field(h: &mut Hasher, node: &SemanticNode) { + write_str(h, &node.kind); + match &node.field_name { + Some(f) => { + h.update(&[1]); + write_str(h, f); + } + None => { + h.update(&[0]); + } + } +} + +pub fn write_str(h: &mut Hasher, s: &str) { + h.update(&(s.len() as u64).to_le_bytes()); + h.update(s.as_bytes()); +} + +/// Emite el marker de leaf: TAG_LEAF + bytes del leaf si lo hay, +/// TAG_NO_LEAF si no. +pub fn emit_leaf_marker(h: &mut Hasher, node: &SemanticNode) { + match &node.leaf_text { + Some(t) => { + h.update(&[TAG_LEAF]); + h.update(&(t.len() as u64).to_le_bytes()); + h.update(t); + } + None => { + h.update(&[TAG_NO_LEAF]); + } + } +} + +/// Emite un binder anónimo: el contenido textual NO afecta el hash. +/// Esta es la primitiva de α-equivalencia: dos términos que sólo +/// difieren en nombres de variables ligadas hashean idénticos. +pub fn emit_binder_body(h: &mut Hasher) { + h.update(&[TAG_NO_LEAF]); + h.update(&[TAG_BINDER]); + h.update(&[0u8; 8]); +} + +/// Emite el kind del nodo + binder body. Atajo para nodos cuyo único +/// rol es ser binder (e.g. un identifier en posición de pattern). +pub fn emit_binder_node(h: &mut Hasher, node: &SemanticNode) { + write_kind_and_field(h, node); + emit_binder_body(h); +} + +/// Emite un identifier referencia: si está en scope, índice de +/// Bruijn (offset desde la cima); si no, nombre literal (variable +/// libre). +pub fn emit_identifier_ref(h: &mut Hasher, node: &SemanticNode, scope: &[String]) { + h.update(&[TAG_NO_LEAF]); + if let Some(t) = &node.leaf_text { + if let Ok(name) = std::str::from_utf8(t) { + if let Some(i) = scope.iter().rposition(|n| n == name) { + let de_bruijn = (scope.len() - 1 - i) as u64; + h.update(&[TAG_REF_BOUND]); + h.update(&de_bruijn.to_le_bytes()); + } else { + h.update(&[TAG_REF_FREE]); + h.update(&(t.len() as u64).to_le_bytes()); + h.update(t); + } + } else { + h.update(&[TAG_REF_FREE]); + h.update(&(t.len() as u64).to_le_bytes()); + h.update(t); + } + } else { + h.update(&[TAG_REF_FREE]); + h.update(&[0u8; 8]); + } + h.update(&[0u8; 8]); +} + +/// Push el nombre del identifier al vector de binders, si tiene +/// leaf_text válido. Helper común para todos los `collect_binders`. +pub fn push_identifier_name(node: &SemanticNode, out: &mut Vec) { + if let Some(t) = &node.leaf_text { + if let Ok(s) = std::str::from_utf8(t) { + out.push(s.to_string()); + } + } +} diff --git a/03_ukupacha/minga/minga-core/src/alpha/ecmascript.rs b/03_ukupacha/minga/minga-core/src/alpha/ecmascript.rs new file mode 100644 index 0000000..7b89864 --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/alpha/ecmascript.rs @@ -0,0 +1,365 @@ +//! α-hashing per-language para JavaScript / TypeScript. +//! +//! Las dos gramáticas comparten la mayoría de los kinds (TypeScript +//! es JS + type annotations), así que un solo profile las cubre. El +//! caller (`hash_alpha_with`) despacha tanto `Dialect::JavaScript` +//! como `Dialect::TypeScript` acá. +//! +//! Cobertura: +//! - **`function_declaration`**, **`function_expression`**, +//! **`method_definition`**, **`generator_function_declaration`**: +//! parameters introducen binders al body. +//! - **`arrow_function`**: parameters (formal_parameters O identifier +//! directo si es shorthand `x => ...`) introducen binder(es) al body. +//! - **`statement_block`**: cualquier `lexical_declaration` (let/const) +//! o `variable_declaration` (var) dentro del block introduce binders +//! al resto del block. +//! - **`for_in_statement`** (cubre tanto `for (x in obj)` como +//! `for (x of arr)` en tree-sitter-javascript): el `left` es +//! binder al `body`. +//! - **`for_statement`**: el `initializer` (lexical_declaration) +//! introduce binder(es) al `condition`, `increment` y `body`. +//! - **`catch_clause`**: el `parameter` introduce binder al `body`. +//! +//! TypeScript-specific: `type` annotations (`x: number`) viajan como +//! children con field=type que se feedean por el path normal — el +//! tipo afecta el hash (cambiar de `number` a `string` rompe +//! α-equivalencia, intencionalmente). +//! +//! Pendientes (scope acotado): +//! - Destructuring (`const {a, b} = obj`, `const [x, y] = arr`). +//! - Class fields y constructor con `this.x = ...`. +//! - Hoisting de `var` a function scope (hoy se trata como block-scoped). + +use crate::alpha::common::{ + emit_binder_body, emit_identifier_ref, emit_leaf_marker, push_identifier_name, + write_kind_and_field, TAG_NO_LEAF, +}; +use crate::ast::SemanticNode; +use crate::cas::ContentHash; +use blake3::Hasher; + +pub fn hash_node_alpha_ecmascript(node: &SemanticNode) -> ContentHash { + let mut h = Hasher::new(); + let mut scope: Vec = Vec::new(); + feed(&mut h, node, &mut scope); + ContentHash(*h.finalize().as_bytes()) +} + +fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + match node.kind.as_str() { + "function_declaration" + | "function_expression" + | "generator_function_declaration" + | "generator_function" + | "method_definition" => feed_callable(h, node, scope), + "arrow_function" => feed_arrow(h, node, scope), + "statement_block" => feed_block(h, node, scope), + "for_in_statement" => feed_for_in(h, node, scope), + "for_statement" => feed_for(h, node, scope), + "catch_clause" => feed_catch(h, node, scope), + // Lexical declarations dispatcheadas también desde feed + // general, no sólo desde feed_block. Necesario para + // for_statement (initializer) y otros contextos donde una + // declaration aparece sin ser hijo directo de un block. + "lexical_declaration" | "variable_declaration" => feed_var_decl(h, node, scope), + "identifier" => emit_identifier_ref(h, node, scope), + _ => feed_default(h, node, scope), + } +} + +fn feed_default(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + emit_leaf_marker(h, node); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + feed(h, c, scope); + } +} + +/// Callable estándar: parameters → body. +fn feed_callable(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("parameters") { + collect_formal_param_binders(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("parameters") => feed_formal_params(h, c, scope), + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +/// Arrow function: dos formas. `x => body` (single identifier) o +/// `(x, y) => body` (formal_parameters). Detectamos cuál. +fn feed_arrow(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + match c.field_name.as_deref() { + Some("parameter") => { + // `x => ...` — el identifier solo. + if c.kind == "identifier" { + push_identifier_name(c, &mut binders); + } + } + Some("parameters") => { + collect_formal_param_binders(c, &mut binders); + } + _ => {} + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("parameter") => emit_arrow_single_binder(h, c), + Some("parameters") => feed_formal_params(h, c, scope), + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +fn emit_arrow_single_binder(h: &mut Hasher, node: &SemanticNode) { + write_kind_and_field(h, node); + if node.kind == "identifier" { + emit_binder_body(h); + } else { + // Otra forma (rare); fallback al feed normal sin binder. + emit_leaf_marker(h, node); + h.update(&(node.children.len() as u64).to_le_bytes()); + } +} + +/// Statement block: `let`/`const`/`var` declarations introducen +/// binders al resto del block (lexical scope). +fn feed_block(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let scope_before = scope.len(); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.kind.as_str() { + "lexical_declaration" | "variable_declaration" => { + feed_var_decl(h, c, scope); + collect_var_decl_binders(c, scope); + } + _ => feed(h, c, scope), + } + } + scope.truncate(scope_before); +} + +/// Procesa una let/const/var declaration: el `value` se evalúa en el +/// scope previo (los binders aún no existen para sí mismos); el +/// `name` se emite como binder anónimo. +fn feed_var_decl(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.kind == "variable_declarator" { + feed_declarator(h, c, scope); + } else { + feed(h, c, scope); + } + } +} + +fn feed_declarator(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("name") if c.kind == "identifier" => emit_named_binder(h, c), + _ => feed(h, c, scope), + } + } +} + +fn collect_var_decl_binders(node: &SemanticNode, out: &mut Vec) { + for c in &node.children { + if c.kind == "variable_declarator" { + for cc in &c.children { + if cc.field_name.as_deref() == Some("name") && cc.kind == "identifier" { + push_identifier_name(cc, out); + } + } + } + } +} + +/// `for (x of arr)` o `for (x in obj)`. left = identifier (con +/// posible kind=const/let prefix para lexical decl), right = expr, +/// body = block. +fn feed_for_in(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("left") && c.kind == "identifier" { + push_identifier_name(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("left") if c.kind == "identifier" => emit_named_binder(h, c), + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +/// `for (let i = 0; i < n; i++) { body }`. El initializer (lexical +/// decl) introduce binders que viven en condition + increment + body. +fn feed_for(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("initializer") + && (c.kind == "lexical_declaration" || c.kind == "variable_declaration") + { + collect_var_decl_binders(c, &mut binders); + } + } + + let scope_before = scope.len(); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("initializer") => { + feed(h, c, scope); + // Tras procesar el initializer extendemos scope para + // que condition/increment/body lo vean. + scope.extend(binders.iter().cloned()); + } + _ => feed(h, c, scope), + } + } + scope.truncate(scope_before); +} + +/// `catch (e) { body }`. parameter es identifier → binder al body. +fn feed_catch(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("parameter") && c.kind == "identifier" { + push_identifier_name(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("parameter") if c.kind == "identifier" => emit_named_binder(h, c), + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +/// formal_parameters de function declarations. Soporta: +/// - `identifier` (param simple). +/// - `required_parameter` (TypeScript: `x: number`). +/// - `optional_parameter` (TypeScript: `x?: number`). +/// - `rest_pattern` / `rest_parameter` (`...rest`). +fn feed_formal_params(h: &mut Hasher, params: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, params); + h.update(&[TAG_NO_LEAF]); + h.update(&(params.children.len() as u64).to_le_bytes()); + for c in ¶ms.children { + match c.kind.as_str() { + "identifier" => emit_named_binder(h, c), + "required_parameter" | "optional_parameter" => { + feed_typed_param(h, c, scope); + } + "rest_pattern" | "rest_parameter" => { + feed_rest_param(h, c, scope); + } + _ => feed(h, c, scope), + } + } +} + +fn feed_typed_param(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + let mut named_binder = false; + for c in &node.children { + if !named_binder && c.kind == "identifier" { + emit_named_binder(h, c); + named_binder = true; + } else { + feed(h, c, scope); + } + } +} + +fn feed_rest_param(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.kind == "identifier" { + emit_named_binder(h, c); + } else { + feed(h, c, scope); + } + } +} + +fn collect_formal_param_binders(params: &SemanticNode, out: &mut Vec) { + for c in ¶ms.children { + match c.kind.as_str() { + "identifier" => push_identifier_name(c, out), + "required_parameter" | "optional_parameter" | "rest_pattern" | "rest_parameter" => { + if let Some(ident) = c.children.iter().find(|cc| cc.kind == "identifier") { + push_identifier_name(ident, out); + } + } + _ => {} + } + } +} + +fn emit_named_binder(h: &mut Hasher, node: &SemanticNode) { + write_kind_and_field(h, node); + emit_binder_body(h); +} diff --git a/03_ukupacha/minga/minga-core/src/alpha/go.rs b/03_ukupacha/minga/minga-core/src/alpha/go.rs new file mode 100644 index 0000000..396a61c --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/alpha/go.rs @@ -0,0 +1,283 @@ +//! α-hashing per-language para Go. +//! +//! Cobertura: +//! - **`function_declaration`**, **`method_declaration`**, +//! **`func_literal`** (closure): `parameter_list` introduce +//! binder(es) al `body`. +//! - **`parameter_declaration`**: puede agrupar varios names con un +//! tipo (`a, b int`). Cada `name` es binder; `type` viaja como +//! referencia. +//! - **`block`**: `short_var_declaration` (`x := ...`) introduce +//! binders al resto del block. +//! - **`for_statement`** con **`range_clause`** (`for k, v := range m`): +//! los identifiers del `left` son binders al `body`. +//! - **`for_statement`** con **`for_clause`** (C-style `for i := 0; i < n; i++`): +//! el `initializer` (short_var_declaration) introduce binders al +//! condition + update + body. +//! - **`if_statement`** con **`initializer`**: binders del +//! short_var_declaration viven en condition + consequence + alternative. +//! +//! Pendientes (scope acotado): +//! - `var_declaration` (`var x = ...`) tratado como literal por +//! ahora; introduce binder al scope envolvente igual que +//! short_var_declaration pero distinto kind. +//! - `type_switch_statement` con assertion binding. +//! - `select` statements con send/receive binding. + +use crate::alpha::common::{ + emit_binder_body, emit_identifier_ref, emit_leaf_marker, push_identifier_name, + write_kind_and_field, TAG_NO_LEAF, +}; +use crate::ast::SemanticNode; +use crate::cas::ContentHash; +use blake3::Hasher; + +pub fn hash_node_alpha_go(node: &SemanticNode) -> ContentHash { + let mut h = Hasher::new(); + let mut scope: Vec = Vec::new(); + feed(&mut h, node, &mut scope); + ContentHash(*h.finalize().as_bytes()) +} + +fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + match node.kind.as_str() { + "function_declaration" | "method_declaration" | "func_literal" => { + feed_callable(h, node, scope) + } + "block" => feed_block(h, node, scope), + "for_statement" => feed_for_statement(h, node, scope), + "if_statement" => feed_if_statement(h, node, scope), + // Dispatcheados también fuera de block/for/if para que sus + // identifiers se emitan como binders cuando aparecen en + // contextos como range_clause o initializer de if/for. + "short_var_declaration" => feed_short_var_decl(h, node, scope), + "range_clause" => feed_range_clause(h, node, scope), + "identifier" => emit_identifier_ref(h, node, scope), + _ => feed_default(h, node, scope), + } +} + +/// `for k, v := range m` — el `left` (expression_list) tiene +/// identifiers que son binders. El `right` se evalúa como referencia +/// normal (es la fuente de iteración). +fn feed_range_clause(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.field_name.as_deref() == Some("left") { + feed_short_var_left(h, c); + } else { + feed(h, c, scope); + } + } +} + +fn feed_default(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + emit_leaf_marker(h, node); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + feed(h, c, scope); + } +} + +fn feed_callable(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("parameters") { + collect_parameter_list_binders(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("parameters") => feed_parameter_list(h, c, scope), + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +fn feed_parameter_list(h: &mut Hasher, params: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, params); + h.update(&[TAG_NO_LEAF]); + h.update(&(params.children.len() as u64).to_le_bytes()); + for c in ¶ms.children { + if c.kind == "parameter_declaration" { + feed_parameter_declaration(h, c, scope); + } else { + feed(h, c, scope); + } + } +} + +/// `a, b int` — todos los `name=identifier` son binders; `type` +/// viaja como referencia normal (puede mencionar tipos importados). +fn feed_parameter_declaration(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.field_name.as_deref() == Some("name") && c.kind == "identifier" { + emit_named_binder(h, c); + } else { + feed(h, c, scope); + } + } +} + +fn collect_parameter_list_binders(params: &SemanticNode, out: &mut Vec) { + for c in ¶ms.children { + if c.kind == "parameter_declaration" { + for cc in &c.children { + if cc.field_name.as_deref() == Some("name") && cc.kind == "identifier" { + push_identifier_name(cc, out); + } + } + } + } +} + +/// Block: `short_var_declaration` introduce binders al resto. +fn feed_block(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let scope_before = scope.len(); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.kind == "short_var_declaration" { + feed_short_var_decl(h, c, scope); + collect_short_var_binders(c, scope); + } else { + feed(h, c, scope); + } + } + scope.truncate(scope_before); +} + +fn feed_short_var_decl(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.field_name.as_deref() == Some("left") { + feed_short_var_left(h, c); + } else { + feed(h, c, scope); + } + } +} + +fn feed_short_var_left(h: &mut Hasher, node: &SemanticNode) { + write_kind_and_field(h, node); + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.kind == "identifier" { + emit_named_binder(h, c); + } else { + // separadores ',' y otros tokens — emit literal. + emit_leaf_marker(h, c); + h.update(&(c.children.len() as u64).to_le_bytes()); + } + } +} + +fn collect_short_var_binders(node: &SemanticNode, out: &mut Vec) { + for c in &node.children { + if c.field_name.as_deref() == Some("left") { + for cc in &c.children { + if cc.kind == "identifier" { + push_identifier_name(cc, out); + } + } + } + } +} + +/// `for k, v := range m { body }` o `for i := 0; i < n; i++ { body }`. +fn feed_for_statement(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + match c.kind.as_str() { + "range_clause" => { + for cc in &c.children { + if cc.field_name.as_deref() == Some("left") { + for ccc in &cc.children { + if ccc.kind == "identifier" { + push_identifier_name(ccc, &mut binders); + } + } + } + } + } + "for_clause" => { + for cc in &c.children { + if cc.field_name.as_deref() == Some("initializer") + && cc.kind == "short_var_declaration" + { + collect_short_var_binders(cc, &mut binders); + } + } + } + _ => {} + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +/// `if x := init(); cond { ... } else { ... }`. El initializer +/// introduce binders que viven en condition + consequence + +/// alternative. +fn feed_if_statement(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("initializer") + && c.kind == "short_var_declaration" + { + collect_short_var_binders(c, &mut binders); + } + } + + let scope_before = scope.len(); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("initializer") => { + feed(h, c, scope); + scope.extend(binders.iter().cloned()); + } + _ => feed(h, c, scope), + } + } + scope.truncate(scope_before); +} + +fn emit_named_binder(h: &mut Hasher, node: &SemanticNode) { + write_kind_and_field(h, node); + emit_binder_body(h); +} diff --git a/03_ukupacha/minga/minga-core/src/alpha/mod.rs b/03_ukupacha/minga/minga-core/src/alpha/mod.rs new file mode 100644 index 0000000..ebfc698 --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/alpha/mod.rs @@ -0,0 +1,69 @@ +//! Hash α-equivalente per-language. +//! +//! Cada dialecto soportado por [`crate::parse`] tiene su propio +//! profile en este módulo. Todos comparten primitives de wire en +//! [`common`] para garantizar comparabilidad bit-a-bit del hash +//! entre lenguajes con la misma estructura semántica. +//! +//! ## API +//! +//! - [`hash_node_alpha`] — alias histórico. Asume Rust. Mantenido +//! por compat con callers viejos (`alpha::hash_node_alpha` sigue +//! apuntando a Rust). +//! - [`hash_alpha_with`] — toma [`crate::parse::Dialect`] y delega +//! al profile correspondiente. + +pub mod common; +pub mod ecmascript; +pub mod go; +pub mod python; +pub mod rust; + +pub use rust::hash_node_alpha; + +use crate::ast::SemanticNode; +use crate::cas::ContentHash; +use crate::parse::Dialect; + +/// Calcula el hash α-equivalente de `node` usando el profile del +/// `dialect`. Cada profile entiende los binders propios de su +/// lenguaje (def/lambda/comprehensions en Python, function/arrow en +/// JS/TS, func/range en Go, etc.). +/// +/// Para callers que ya saben que están en Rust, [`hash_node_alpha`] +/// es atajo equivalente. +pub fn hash_alpha_with(dialect: Dialect, node: &SemanticNode) -> ContentHash { + match dialect { + Dialect::Rust => rust::hash_node_alpha(node), + Dialect::Python => python::hash_node_alpha_python(node), + Dialect::TypeScript => ecmascript::hash_node_alpha_ecmascript(node), + Dialect::JavaScript => ecmascript::hash_node_alpha_ecmascript(node), + Dialect::Go => go::hash_node_alpha_go(node), + } +} + +/// Verifica que `claimed_alpha` sea el α-hash de `node` bajo *algún* +/// dialecto soportado. Devuelve el dialecto que coincide (raro tener +/// más de uno: los profiles α producen hashes distintos por las +/// constantes de wire de cada profile). Si ningún dialecto matchea, +/// devuelve `None` — la raíz está inconsistente con su contenido. +/// +/// Usado al auditar un repo (sea sincronizado o ingerido) sin +/// confiar en el `dialect` persistido en `SledRootsStore`: si el +/// repo se trajo del wire de un peer no-confiable, ésta es la forma +/// de validar que el α-hash que figura en el MST corresponde de +/// verdad al contenido del nodo. +pub fn verify_root_alpha(node: &SemanticNode, claimed_alpha: &ContentHash) -> Option { + for d in [ + Dialect::Rust, + Dialect::Python, + Dialect::TypeScript, + Dialect::JavaScript, + Dialect::Go, + ] { + if &hash_alpha_with(d, node) == claimed_alpha { + return Some(d); + } + } + None +} diff --git a/03_ukupacha/minga/minga-core/src/alpha/python.rs b/03_ukupacha/minga/minga-core/src/alpha/python.rs new file mode 100644 index 0000000..7e5f713 --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/alpha/python.rs @@ -0,0 +1,387 @@ +//! α-hashing per-language para Python. +//! +//! Cobertura: +//! - **`function_definition`** y **`lambda`**: parámetros introducen +//! binders al body. Soporta defaults (`def f(x=1)`) y type hints +//! (`def f(x: int)`) — el binder es el identifier; el default y el +//! type viajan como expresiones referenciables al scope previo. +//! - **`for_statement`**: el `left` (identifier o tuple_pattern) +//! introduce binder(es) al `body`. +//! - **Comprehensions**: `list_comprehension`, `set_comprehension`, +//! `dictionary_comprehension`, `generator_expression`. Cada +//! `for_in_clause` introduce binder(es) que viven en el `body` + +//! `if_clause`s + `for_in_clause`s siguientes (semántica de scope +//! incremental de Python). +//! - **`with_statement`**: `with X() as y:` introduce `y` al body. +//! +//! Python NO distingue binders por capitalización (a diferencia de +//! Rust con `Some` vs `x`). En posición de parámetro/for-target, +//! todo identifier es binder. +//! +//! Pendientes (no cubiertos hoy, scope acotado): +//! - `class_definition` y métodos (`self` no es binder explícito en +//! la firma; el primer parámetro recibe nombre arbitrario). +//! - `assignment` como introductor de scope (Python no tiene `let` +//! explícito; un `x = 1` agrega x al scope global o local del +//! bloque envolvente — manejarlo bien requiere análisis de scope +//! que va más allá del α-hashing tradicional). +//! - Nested defaults, walrus operator (`:=`), starred patterns. + +use crate::alpha::common::{ + emit_binder_body, emit_identifier_ref, emit_leaf_marker, push_identifier_name, + write_kind_and_field, TAG_NO_LEAF, +}; +use crate::ast::SemanticNode; +use crate::cas::ContentHash; +use blake3::Hasher; + +pub fn hash_node_alpha_python(node: &SemanticNode) -> ContentHash { + let mut h = Hasher::new(); + let mut scope: Vec = Vec::new(); + feed(&mut h, node, &mut scope); + ContentHash(*h.finalize().as_bytes()) +} + +fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + match node.kind.as_str() { + "function_definition" => feed_function_definition(h, node, scope), + "lambda" => feed_lambda(h, node, scope), + "for_statement" => feed_for_statement(h, node, scope), + "list_comprehension" + | "set_comprehension" + | "dictionary_comprehension" + | "generator_expression" => feed_comprehension(h, node, scope), + "with_statement" => feed_with_statement(h, node, scope), + // Cuando un as_pattern_target aparece (típicamente dentro de + // un with_clause), sus identifiers son binders. El scope ya + // se extendió en feed_with_statement antes de llegar al body; + // pero el target mismo necesita emitir binders anónimos para + // que el hash no varíe con el nombre. + "as_pattern_target" => feed_target_as_binders(h, node), + "identifier" => emit_identifier_ref(h, node, scope), + _ => feed_default(h, node, scope), + } +} + +fn feed_default(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + emit_leaf_marker(h, node); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + feed(h, c, scope); + } +} + +/// `def f(x, y=1, z: int): body` → params son binders al body. +/// El `name` (identifier de la función) se trata como literal — no +/// es un binder local (es publicado al scope envolvente, no manejado +/// acá). +fn feed_function_definition(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("parameters") { + collect_param_binders(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("parameters") => feed_params(h, c, scope), + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + Some("name") => { + // Nombre de la función: viaja como literal (afecta el + // hash, no es α-anónimo). Mismo tratamiento que en + // Rust con `function_item.name`. + feed_as_literal(h, c); + } + _ => feed(h, c, scope), + } + } +} + +/// `lambda x, y: body` — params binders al body. +fn feed_lambda(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("parameters") { + collect_param_binders(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("parameters") => feed_params(h, c, scope), + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +/// `for x in iterable: body` — x es binder al body. +fn feed_for_statement(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("left") { + collect_target_binders(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("left") => feed_target_as_binders(h, c), + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +/// `[expr for x in xs if cond]` — los `for_in_clause` y `if_clause` +/// se procesan en orden: cada `for_in_clause` añade binders que +/// viven en lo siguiente. El `body` (la expresión final) ve TODOS +/// los binders acumulados. +fn feed_comprehension(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + // Recolectamos TODOS los binders de TODAS las for_in_clauses. + // Python evalúa la comprehension de izquierda a derecha pero el + // body ve todo; α-hashing colapsa eso a "todos visibles en body". + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.kind == "for_in_clause" { + for cc in &c.children { + if cc.field_name.as_deref() == Some("left") { + collect_target_binders(cc, &mut binders); + } + } + } + } + + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.kind == "for_in_clause" { + feed_for_in_clause(h, c, scope); + } else { + feed(h, c, scope); + } + } + + scope.truncate(scope_before); +} + +/// `for x in xs` dentro de una comprehension. El `left` es binder +/// (anónimo); el `right` se evalúa en el scope previo (sin x). +/// Pero como `feed_comprehension` ya extendió el scope antes de +/// llamarnos, x sí está en scope para el right de un `for X in expr` +/// posterior — semántica correcta de comprehensions de Python. +fn feed_for_in_clause(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.field_name.as_deref() == Some("left") { + feed_target_as_binders(h, c); + } else { + feed(h, c, scope); + } + } +} + +/// `with X() as y, Z() as w: body` — los `as` introducen binders al body. +fn feed_with_statement(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.kind == "with_clause" { + collect_with_clause_binders(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +fn collect_with_clause_binders(node: &SemanticNode, out: &mut Vec) { + // En tree-sitter-python, with_item.value puede ser un as_pattern + // que tiene su propio alias. Recursamos para encontrar cualquier + // as_pattern_target en el subárbol. + for c in &node.children { + if c.kind == "with_item" { + collect_as_pattern_targets(c, out); + } + } +} + +fn collect_as_pattern_targets(node: &SemanticNode, out: &mut Vec) { + if node.kind == "as_pattern_target" { + collect_target_binders(node, out); + return; + } + for c in &node.children { + collect_as_pattern_targets(c, out); + } +} + +/// Los parameters de def/lambda se procesan emitiendo cada +/// identifier como binder anónimo. Defaults / type hints / *args / +/// **kwargs se preservan literalmente (afectan el hash). +fn feed_params(h: &mut Hasher, params: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, params); + h.update(&[TAG_NO_LEAF]); + h.update(&(params.children.len() as u64).to_le_bytes()); + for c in ¶ms.children { + match c.kind.as_str() { + "identifier" => emit_param_binder(h, c), + "typed_parameter" | "default_parameter" | "typed_default_parameter" => { + feed_complex_param(h, c, scope); + } + "list_splat_pattern" | "dictionary_splat_pattern" => { + // *args, **kwargs: el binder es el identifier interno. + feed_splat_param(h, c); + } + _ => feed(h, c, scope), + } + } +} + +fn emit_param_binder(h: &mut Hasher, ident: &SemanticNode) { + write_kind_and_field(h, ident); + emit_binder_body(h); +} + +/// `x: int`, `x = 1`, `x: int = 1` — el primer identifier es binder; +/// el resto (type, default) son referenciables. +fn feed_complex_param(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + let mut named_binder = false; + for c in &node.children { + if !named_binder && c.kind == "identifier" { + emit_param_binder(h, c); + named_binder = true; + } else { + feed(h, c, scope); + } + } +} + +fn feed_splat_param(h: &mut Hasher, node: &SemanticNode) { + write_kind_and_field(h, node); + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.kind == "identifier" { + emit_param_binder(h, c); + } else { + feed_as_literal(h, c); + } + } +} + +fn collect_param_binders(params: &SemanticNode, out: &mut Vec) { + for c in ¶ms.children { + match c.kind.as_str() { + "identifier" => push_identifier_name(c, out), + "typed_parameter" | "default_parameter" | "typed_default_parameter" => { + if let Some(ident) = c.children.iter().find(|cc| cc.kind == "identifier") { + push_identifier_name(ident, out); + } + } + "list_splat_pattern" | "dictionary_splat_pattern" => { + if let Some(ident) = c.children.iter().find(|cc| cc.kind == "identifier") { + push_identifier_name(ident, out); + } + } + _ => {} + } + } +} + +/// El `left` de `for x in xs:` o de `with X as y:` puede ser un +/// identifier solo o una tupla destructurada (`for k, v in ...`). +fn collect_target_binders(target: &SemanticNode, out: &mut Vec) { + match target.kind.as_str() { + "identifier" => push_identifier_name(target, out), + "tuple_pattern" | "pattern_list" | "list_pattern" => { + for c in &target.children { + collect_target_binders(c, out); + } + } + _ => { + // Recursamos por si hay subnodos relevantes (e.g. parens). + for c in &target.children { + collect_target_binders(c, out); + } + } + } +} + +/// Emit del target como binders anónimos. Mismo recorrido que collect. +fn feed_target_as_binders(h: &mut Hasher, target: &SemanticNode) { + write_kind_and_field(h, target); + match target.kind.as_str() { + "identifier" => emit_binder_body(h), + "tuple_pattern" | "pattern_list" | "list_pattern" => { + h.update(&[TAG_NO_LEAF]); + h.update(&(target.children.len() as u64).to_le_bytes()); + for c in &target.children { + feed_target_as_binders(h, c); + } + } + _ => { + // Fallback: literal (preserva la estructura textual). + emit_leaf_marker(h, target); + h.update(&(target.children.len() as u64).to_le_bytes()); + for c in &target.children { + feed_target_as_binders(h, c); + } + } + } +} + +fn feed_as_literal(h: &mut Hasher, node: &SemanticNode) { + write_kind_and_field(h, node); + emit_leaf_marker(h, node); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + feed_as_literal(h, c); + } +} diff --git a/03_ukupacha/minga/minga-core/src/alpha/rust.rs b/03_ukupacha/minga/minga-core/src/alpha/rust.rs new file mode 100644 index 0000000..5e39f18 --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/alpha/rust.rs @@ -0,0 +1,562 @@ +//! Hash α-equivalente. +//! +//! Dos términos que difieren *solo* en los nombres de variables ligadas +//! producen el mismo hash. Los nombres de funciones, los identificadores +//! libres y los constructores (variantes, tipos) **sí** afectan al hash: +//! forman parte de la interfaz pública o discriminan el término. +//! +//! Implementación: durante el recorrido se mantiene una pila de scopes. +//! Al encontrar un binder reconocido, su nombre se empuja sobre la pila; +//! al salir del scope, se descarta. Las referencias a identificadores se +//! buscan desde la cima: +//! - si están, se emite un índice estilo de Bruijn (offset desde la cima); +//! - si no, se emite el nombre literal (variable libre). +//! +//! **Distinción binder vs. constructor:** dentro de un patrón, un +//! `identifier` puede ser binder (`x`, `mi_var`) o constructor / variante +//! (`None`, `Ok`, `MAX_VAL`). La gramática no los distingue; usamos la +//! convención de Rust: minúscula inicial (o `_` seguido de letra) = binder, +//! mayúscula inicial = constructor. Cuando el grammar marca explícitamente +//! `field_name = "pattern"` (parámetros, lets), forzamos binder. +//! +//! **Cobertura del MVP:** +//! - Parámetros de `function_item` y `closure_expression`. +//! - Bindings de `let_declaration` dentro de `block`, con desestructura. +//! - Variable de `for_expression`. +//! - Brazos de `match` (`match_arm` con guarda; cada arm es un scope +//! independiente). +//! - Patrones: `tuple_pattern`, `tuple_struct_pattern`, `struct_pattern`, +//! `field_pattern` (forma completa y shorthand), `captured_pattern` +//! (`n @ pat`), `range_pattern`, `slice_pattern`, `ref_pattern`, +//! `reference_pattern`, `mut_pattern`. +//! +//! **Cobertura adicional (este módulo cierra el plan):** +//! - `if_expression` y `while_expression` detectan `let_condition` +//! en su `condition` y propagan los binders al `consequence`/`body`. +//! Cubre `if let`, `while let` y let-chains (`let X && let Y`). +//! - `let_declaration` con `alternative` (let-else): el alternative +//! se procesa en el scope SIN los binders del pattern (Rust no +//! los ve en la rama de fallo). Funciona naturalmente porque +//! `feed_let` no extiende scope; el block padre lo hace después. +//! - `or_pattern`: todos los lados tienen los mismos binders (Rust +//! enforcement); recolectamos sólo del primer alternativo para +//! evitar duplicados, emitimos feed_pattern para cada uno. + +use crate::alpha::common::{ + emit_binder_body, emit_binder_node, emit_identifier_ref, emit_leaf_marker, + push_identifier_name, write_kind_and_field, TAG_NO_LEAF, +}; +use crate::ast::SemanticNode; +use crate::cas::ContentHash; +use blake3::Hasher; + +pub fn hash_node_alpha(node: &SemanticNode) -> ContentHash { + let mut h = Hasher::new(); + let mut scope: Vec = Vec::new(); + feed(&mut h, node, &mut scope); + ContentHash(*h.finalize().as_bytes()) +} + +fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + + match node.kind.as_str() { + "function_item" | "closure_expression" => feed_callable(h, node, scope), + "block" => feed_block(h, node, scope), + "for_expression" => feed_for(h, node, scope), + "if_expression" => feed_if_expression(h, node, scope), + "while_expression" => feed_while_expression(h, node, scope), + "let_condition" => feed_let_condition(h, node, scope), + "match_arm" => feed_match_arm(h, node, scope), + "identifier" if node.field_name.as_deref() == Some("pattern") => emit_binder_body(h), + "identifier" => emit_identifier_ref(h, node, scope), + _ => feed_default(h, node, scope), + } +} + +/// Dentro de un `let_condition` (`if let X = expr`, `while let X = expr`, +/// let-chains), el `pattern` debe pasar por `feed_pattern` para que los +/// identifiers del pattern se emitan como TAG_BINDER (anónimos), no +/// como referencias libres. El `value` y demás children van por feed +/// normal. +fn feed_let_condition(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.field_name.as_deref() == Some("pattern") { + feed_pattern(h, c); + } else { + feed(h, c, scope); + } + } +} + +/// Maneja `if let X = expr { ... }` y let-chains (`if let X = a && let Y = b`). +/// Los binders del/los `let_condition`(s) se acumulan y se propagan +/// SÓLO al `consequence` (no al `alternative`, que es el `else`). +fn feed_if_expression(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("condition") { + collect_let_condition_binders(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("consequence") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +/// Maneja `while let X = expr { ... }`. Los binders del `let_condition` +/// se propagan SÓLO al `body` (no al `condition` mismo, que se evalúa +/// con el scope previo). +fn feed_while_expression(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("condition") { + collect_let_condition_binders(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +/// Recolecta binders de patterns dentro de cualquier `let_condition` +/// nested en `node`. Para let-chains (`let X = a && let Y = b`), +/// recursa en el árbol del condition para capturar todos. +fn collect_let_condition_binders(node: &SemanticNode, out: &mut Vec) { + if node.kind == "let_condition" { + for c in &node.children { + if c.field_name.as_deref() == Some("pattern") { + collect_pattern_binders(c, out); + } + } + } + for c in &node.children { + collect_let_condition_binders(c, out); + } +} + +fn feed_default(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + emit_leaf_marker(h, node); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + feed(h, c, scope); + } +} + +fn feed_callable(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("parameters") { + collect_callable_binders(c, &mut binders); + } + } + + let scope_before = scope.len(); + scope.extend(binders); + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.field_name.as_deref() == Some("parameters") { + feed_callable_params(h, c); + } else { + feed(h, c, scope); + } + } + + scope.truncate(scope_before); +} + +fn feed_block(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let scope_before = scope.len(); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.kind == "let_declaration" { + feed_let(h, c, scope); + for cc in &c.children { + if cc.field_name.as_deref() == Some("pattern") { + collect_pattern_binders(cc, scope); + } + } + } else { + feed(h, c, scope); + } + } + scope.truncate(scope_before); +} + +fn feed_let(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, node); + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.field_name.as_deref() == Some("pattern") { + feed_pattern(h, c); + } else { + feed(h, c, scope); + } + } +} + +fn feed_for(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("pattern") { + collect_pattern_binders(c, &mut binders); + } + } + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + match c.field_name.as_deref() { + Some("pattern") => feed_pattern(h, c), + Some("body") => { + let scope_before = scope.len(); + scope.extend(binders.iter().cloned()); + feed(h, c, scope); + scope.truncate(scope_before); + } + _ => feed(h, c, scope), + } + } +} + +fn feed_match_arm(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec) { + h.update(&[TAG_NO_LEAF]); + + let mut binders: Vec = Vec::new(); + for c in &node.children { + if c.field_name.as_deref() == Some("pattern") { + collect_match_pattern_binders(c, &mut binders); + } + } + + let scope_before = scope.len(); + scope.extend(binders); + + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.field_name.as_deref() == Some("pattern") { + if c.kind == "match_pattern" { + feed_match_pattern_split(h, c, scope); + } else { + feed_pattern(h, c); + } + } else { + feed(h, c, scope); + } + } + + scope.truncate(scope_before); +} + +fn feed_match_pattern_split(h: &mut Hasher, mp: &SemanticNode, scope: &mut Vec) { + write_kind_and_field(h, mp); + emit_leaf_marker(h, mp); + h.update(&(mp.children.len() as u64).to_le_bytes()); + for c in &mp.children { + if c.field_name.as_deref() == Some("condition") { + feed(h, c, scope); + } else { + feed_pattern(h, c); + } + } +} + +fn collect_match_pattern_binders(p: &SemanticNode, out: &mut Vec) { + if p.kind == "match_pattern" { + for c in &p.children { + if c.field_name.as_deref() != Some("condition") { + collect_pattern_binders(c, out); + } + } + } else { + collect_pattern_binders(p, out); + } +} + +fn feed_callable_params(h: &mut Hasher, params: &SemanticNode) { + write_kind_and_field(h, params); + h.update(&[TAG_NO_LEAF]); + h.update(&(params.children.len() as u64).to_le_bytes()); + for c in ¶ms.children { + match c.kind.as_str() { + "parameter" => feed_parameter(h, c), + _ => feed_pattern(h, c), + } + } +} + +fn feed_parameter(h: &mut Hasher, node: &SemanticNode) { + write_kind_and_field(h, node); + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.field_name.as_deref() == Some("pattern") { + feed_pattern(h, c); + } else { + feed_as_literal(h, c); + } + } +} + +/// Pattern-aware emitter. Within a pattern, identifiers split into two +/// roles: binders (introduce a new local) and constructors (variant or +/// path references). The disambiguation rule mirrors Rust's: a `pattern` +/// field forces binder; otherwise lowercase initial = binder, uppercase = +/// constructor. +fn feed_pattern(h: &mut Hasher, node: &SemanticNode) { + write_kind_and_field(h, node); + match node.kind.as_str() { + "identifier" => { + if is_binder_identifier(node) { + emit_binder_body(h); + } else { + emit_leaf_marker(h, node); + h.update(&[0u8; 8]); + } + } + "tuple_pattern" | "ref_pattern" | "reference_pattern" | "mut_pattern" | "slice_pattern" => { + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + feed_pattern(h, c); + } + } + "or_pattern" => { + // Cada lado del or-pattern debe introducir el mismo set + // de binders (Rust enforcement). Emitimos cada rama pero + // sólo recolectaremos binders de la primera — + // la responsabilidad recae en `collect_pattern_binders`. + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + feed_pattern(h, c); + } + } + "tuple_struct_pattern" => { + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.field_name.as_deref() == Some("type") { + feed_as_literal(h, c); + } else { + feed_pattern(h, c); + } + } + } + "struct_pattern" => { + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + if c.field_name.as_deref() == Some("type") { + feed_as_literal(h, c); + } else if c.kind == "field_pattern" { + feed_field_pattern(h, c); + } else { + feed_as_literal(h, c); + } + } + } + "captured_pattern" => { + h.update(&[TAG_NO_LEAF]); + h.update(&(node.children.len() as u64).to_le_bytes()); + let mut named_binder = false; + for c in &node.children { + if !named_binder && c.kind == "identifier" { + emit_binder_node(h, c); + named_binder = true; + } else { + feed_pattern(h, c); + } + } + } + _ => feed_as_literal(h, node), + } +} + +fn feed_field_pattern(h: &mut Hasher, fp: &SemanticNode) { + write_kind_and_field(h, fp); + let has_pattern = fp + .children + .iter() + .any(|c| c.field_name.as_deref() == Some("pattern")); + h.update(&[TAG_NO_LEAF]); + h.update(&(fp.children.len() as u64).to_le_bytes()); + for c in &fp.children { + if has_pattern { + if c.field_name.as_deref() == Some("pattern") { + feed_pattern(h, c); + } else { + feed_as_literal(h, c); + } + } else if matches!( + c.kind.as_str(), + "identifier" | "shorthand_field_identifier" | "field_identifier" + ) { + emit_binder_node(h, c); + } else { + feed_as_literal(h, c); + } + } +} + +fn feed_as_literal(h: &mut Hasher, node: &SemanticNode) { + write_kind_and_field(h, node); + emit_leaf_marker(h, node); + h.update(&(node.children.len() as u64).to_le_bytes()); + for c in &node.children { + feed_as_literal(h, c); + } +} + +fn collect_callable_binders(params: &SemanticNode, out: &mut Vec) { + for c in ¶ms.children { + match c.kind.as_str() { + "parameter" => { + for cc in &c.children { + if cc.field_name.as_deref() == Some("pattern") { + collect_pattern_binders(cc, out); + } + } + } + _ => collect_pattern_binders(c, out), + } + } +} + +fn collect_pattern_binders(p: &SemanticNode, out: &mut Vec) { + match p.kind.as_str() { + "identifier" => { + if is_binder_identifier(p) { + push_identifier_name(p, out); + } + } + "tuple_pattern" | "ref_pattern" | "reference_pattern" | "mut_pattern" | "slice_pattern" => { + for c in &p.children { + collect_pattern_binders(c, out); + } + } + "or_pattern" => { + // Sólo recolectamos del primer alternativo: Rust exige + // que todos los lados introduzcan exactamente los mismos + // binders, así que el primero es representativo. Iterar + // todos duplicaría los nombres y rompería los índices + // de Bruijn en el cuerpo. + if let Some(first) = p + .children + .iter() + .find(|c| !matches!(c.kind.as_str(), "|" | "or")) + { + collect_pattern_binders(first, out); + } + } + "tuple_struct_pattern" => { + for c in &p.children { + if c.field_name.as_deref() != Some("type") { + collect_pattern_binders(c, out); + } + } + } + "struct_pattern" => { + for c in &p.children { + if c.kind == "field_pattern" { + collect_field_pattern_binders(c, out); + } + } + } + "captured_pattern" => { + let mut named_binder = false; + for c in &p.children { + if !named_binder && c.kind == "identifier" { + push_identifier_name(c, out); + named_binder = true; + } else { + collect_pattern_binders(c, out); + } + } + } + _ => {} + } +} + +fn collect_field_pattern_binders(fp: &SemanticNode, out: &mut Vec) { + let has_pattern = fp + .children + .iter() + .any(|c| c.field_name.as_deref() == Some("pattern")); + if has_pattern { + for c in &fp.children { + if c.field_name.as_deref() == Some("pattern") { + collect_pattern_binders(c, out); + } + } + } else { + for c in &fp.children { + if matches!( + c.kind.as_str(), + "identifier" | "shorthand_field_identifier" | "field_identifier" + ) { + push_identifier_name(c, out); + } + } + } +} + +/// Determina si un `identifier` en posición de patrón se interpreta como +/// binder. Reglas (específicas de Rust): +/// - Si tiene `field_name == "pattern"` (parámetros, lets), siempre es binder. +/// - Si su nombre comienza con minúscula, es binder. +/// - Si comienza con `_` seguido de letra/dígito, es binder (convención +/// Rust para "intencionalmente sin usar"). +/// - Resto: constructor / variante / constante (literal). +fn is_binder_identifier(node: &SemanticNode) -> bool { + if node.field_name.as_deref() == Some("pattern") { + return true; + } + let Some(t) = &node.leaf_text else { return false }; + let Ok(s) = std::str::from_utf8(t) else { return false }; + is_binder_name(s) +} + +fn is_binder_name(s: &str) -> bool { + let mut chars = s.chars(); + match chars.next() { + Some('_') => chars + .next() + .map_or(false, |c| c.is_lowercase() || c.is_ascii_digit() || c == '_'), + Some(c) => c.is_lowercase(), + None => false, + } +} diff --git a/03_ukupacha/minga/minga-core/src/ast.rs b/03_ukupacha/minga/minga-core/src/ast.rs new file mode 100644 index 0000000..c45cfef --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/ast.rs @@ -0,0 +1,52 @@ +use tree_sitter::Node; + +/// Nodo de AST normalizado: descarta posiciones, whitespace y trivia +/// (comentarios marcados como `extra` en la gramática). Dos fragmentos de +/// código semánticamente equivalentes producen árboles idénticos. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemanticNode { + pub kind: String, + pub field_name: Option, + pub leaf_text: Option>, + pub children: Vec, +} + +impl SemanticNode { + pub fn from_tree_sitter(node: Node<'_>, source: &[u8]) -> Self { + Self::build(node, source, None) + } + + fn build(node: Node<'_>, source: &[u8], field_name: Option) -> Self { + let kind = node.kind().to_string(); + let mut children = Vec::new(); + + // Incluimos todos los hijos no-`extra`: nombrados (rules de la + // gramática) y anónimos (tokens literales como operadores y + // separadores). Lo único que descartamos son `extras` — + // comentarios y whitespace en gramáticas tree-sitter — que es + // exactamente la invariancia que queremos: dos formas con el + // mismo contenido y estructura producen el mismo árbol. + let mut cursor = node.walk(); + if cursor.goto_first_child() { + loop { + let child = cursor.node(); + if !child.is_extra() { + let field = cursor.field_name().map(|s| s.to_string()); + children.push(Self::build(child, source, field)); + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + let leaf_text = if children.is_empty() { + let range = node.byte_range(); + Some(source[range].to_vec()) + } else { + None + }; + + SemanticNode { kind, field_name, leaf_text, children } + } +} diff --git a/03_ukupacha/minga/minga-core/src/attestation.rs b/03_ukupacha/minga/minga-core/src/attestation.rs new file mode 100644 index 0000000..960b140 --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/attestation.rs @@ -0,0 +1,127 @@ +//! Atestaciones firmadas: la sustancia material de la atribución +//! irrefutable. Una `Attestation` es una firma criptográfica sobre un +//! `ContentHash` que vincula a su autor (un `Did`) con un fragmento +//! concreto de contenido del repositorio. +//! +//! Modelo: cada hash del MST puede tener cero o más atestaciones, +//! provenientes de autores distintos. La existencia de una atestación +//! válida prueba que el dueño de cierta clave privada **vio y firmó +//! exactamente ese hash** — no puede negarlo después sin admitir que +//! filtró su llave. Es el equivalente a un commit firmado en Git pero +//! a granularidad arbitraria: una función, un módulo, o un estado del +//! repositorio entero. +//! +//! `AttestationStore` solo acepta atestaciones criptográficamente +//! válidas: el `add` rechaza cualquier intento de inyectar firmas +//! falsificadas. Esto convierte al store en una fuente confiable de +//! la pregunta "¿quién ha respaldado este contenido?". + +use crate::cas::ContentHash; +use crate::identity::{Did, Keypair, Signature}; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Attestation { + pub content: ContentHash, + pub author: Did, + pub signature: Signature, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttestationError { + InvalidSignature, +} + +impl std::fmt::Display for AttestationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidSignature => write!(f, "firma de la atestación no verifica"), + } + } +} + +impl std::error::Error for AttestationError {} + +impl Attestation { + /// Crea una atestación firmando el `ContentHash` con la `Keypair` + /// del autor. El `Did` queda registrado a partir de la `Keypair` + /// — no se acepta un `Did` arbitrario, lo que descarta de raíz + /// las atestaciones donde alguien dice ser otro. + pub fn create(keypair: &Keypair, content: ContentHash) -> Self { + Self { + content, + author: keypair.did(), + signature: keypair.sign(&content.0), + } + } + + /// Verifica que `signature` es una firma válida sobre `content` + /// hecha con la llave privada del `author`. Cualquier modificación + /// de cualquiera de los tres campos invalida la atestación. + pub fn verify(&self) -> bool { + self.author.verify(&self.content.0, &self.signature) + } +} + +/// Registro de atestaciones por `ContentHash`. +/// +/// Idempotente por `(author, content)`: insertar dos veces la misma +/// atestación no la duplica. Pero un mismo `ContentHash` puede tener +/// atestaciones de **autores distintos** — es la base de los "filtros +/// de convergencia" del spec, donde el peso de un cambio se mide por +/// cuántas identidades reputadas lo respaldan. +#[derive(Debug, Default, Clone)] +pub struct AttestationStore { + by_content: HashMap>, +} + +impl AttestationStore { + pub fn new() -> Self { + Self::default() + } + + /// Inserta una atestación. Devuelve `Err(InvalidSignature)` si la + /// firma no verifica — el store NUNCA almacena firmas rotas, así + /// que cualquier consulta posterior puede confiar en lo que lee. + pub fn add(&mut self, att: Attestation) -> Result<(), AttestationError> { + if !att.verify() { + return Err(AttestationError::InvalidSignature); + } + let entry = self.by_content.entry(att.content).or_default(); + if !entry.iter().any(|a| a.author == att.author) { + entry.push(att); + } + Ok(()) + } + + pub fn get(&self, content: &ContentHash) -> &[Attestation] { + self.by_content + .get(content) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + /// Conjunto de DIDs que han atestado este contenido. Cada autor + /// aparece como máximo una vez (deduplicación por `add`). + pub fn authors_of(&self, content: &ContentHash) -> Vec { + self.by_content + .get(content) + .map(|v| v.iter().map(|a| a.author).collect()) + .unwrap_or_default() + } + + pub fn len(&self) -> usize { + self.by_content.values().map(Vec::len).sum() + } + + pub fn is_empty(&self) -> bool { + self.by_content.values().all(Vec::is_empty) + } + + /// Itera todas las atestaciones del store (orden no especificado). + /// Usado por el protocolo de sync para enumerar lo que tenemos y + /// empujarlo al peer. + pub fn all(&self) -> impl Iterator + '_ { + self.by_content.values().flat_map(|v| v.iter()) + } +} diff --git a/03_ukupacha/minga/minga-core/src/cas.rs b/03_ukupacha/minga/minga-core/src/cas.rs new file mode 100644 index 0000000..ce290e4 --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/cas.rs @@ -0,0 +1,95 @@ +use crate::ast::SemanticNode; +use blake3::Hasher; + +/// Hash de 32 bytes que identifica unívocamente un `SemanticNode` por su +/// estructura lógica. Dos nodos con misma estructura → mismo hash, sin +/// importar format, comentarios o posición en el archivo fuente. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, +)] +#[serde(transparent)] +pub struct ContentHash(pub [u8; 32]); + +impl ContentHash { + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl std::fmt::Display for ContentHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for b in &self.0 { + write!(f, "{:02x}", b)?; + } + Ok(()) + } +} + +/// Hash Merkle de un `SemanticNode`. El hash es función pura de +/// `(kind, field_name, leaf_text, &[child_hash])`. Esquema estricto: +/// los hijos contribuyen como hash, no como bytestream completo. Eso +/// permite verificar un nodo recibido por la red **sin tener** sus +/// hijos: basta con tener los hashes de los hijos (que vienen en el +/// `StoredNode.children`) y reproducir esta función. +pub fn hash_node(node: &SemanticNode) -> ContentHash { + let child_hashes: Vec = node.children.iter().map(hash_node).collect(); + hash_components( + &node.kind, + node.field_name.as_deref(), + node.leaf_text.as_deref(), + &child_hashes, + ) +} + +/// Primitiva canónica del hash estructural. Es la única definición +/// authoritativa: cualquier otra función que produzca un hash de +/// contenido debe expresarse encima de ésta. Garantiza que +/// `hash_node(&semantic)` y `hash_stored(&stored)` coincidan bit a bit +/// para representaciones equivalentes del mismo árbol. +pub fn hash_components( + kind: &str, + field_name: Option<&str>, + leaf_text: Option<&[u8]>, + child_hashes: &[ContentHash], +) -> ContentHash { + let mut h = Hasher::new(); + write_str(&mut h, kind); + match field_name { + Some(f) => { + h.update(&[1]); + write_str(&mut h, f); + } + None => { + h.update(&[0]); + } + } + match leaf_text { + Some(t) => { + h.update(&[1]); + h.update(&(t.len() as u64).to_le_bytes()); + h.update(t); + } + None => { + h.update(&[0]); + } + } + h.update(&(child_hashes.len() as u64).to_le_bytes()); + for ch in child_hashes { + h.update(&ch.0); + } + ContentHash(*h.finalize().as_bytes()) +} + +fn write_str(h: &mut Hasher, s: &str) { + h.update(&(s.len() as u64).to_le_bytes()); + h.update(s.as_bytes()); +} diff --git a/03_ukupacha/minga/minga-core/src/identity.rs b/03_ukupacha/minga/minga-core/src/identity.rs new file mode 100644 index 0000000..8624291 --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/identity.rs @@ -0,0 +1,223 @@ +//! Identidad self-sovereign basada en Ed25519. +//! +//! Cada peer (y cada autor humano o agente IA) se identifica por un +//! `Did` — el bytestring de su clave pública Ed25519. La clave privada +//! vive en su `Keypair` y nunca sale del nodo. Firmar un mensaje con la +//! `Keypair` produce una `Signature` que cualquiera con el `Did` puede +//! verificar — la atribución es irrefutable bajo el modelo +//! criptográfico estándar (asumiendo que la clave privada no fugó). +//! +//! El esquema es deliberadamente minimalista: no hay rotación de +//! claves, ni revocación, ni metadatos en el DID. Esas capas (DID +//! Documents, métodos `did:web`/`did:ion`, claves de firma versus de +//! cifrado, etc.) se construyen encima cuando la complejidad del +//! producto lo justifique. Por ahora, el `Did` ES la clave pública. + +use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit, Nonce}; +use argon2::Argon2; +use ed25519_dalek::{ + Signature as Ed25519Sig, Signer, SigningKey, Verifier, VerifyingKey, SECRET_KEY_LENGTH, + SIGNATURE_LENGTH, +}; +use rand::rngs::OsRng; +use rand::RngCore; + +/// Cabecera del format de keypair cifrado en disco. +const KEYPAIR_MAGIC: &[u8; 8] = b"MINGAKEY"; +const KEYPAIR_VERSION: u8 = 1; +const ARGON2_SALT_LEN: usize = 16; +const AES_NONCE_LEN: usize = 12; +const KEYPAIR_HEADER_LEN: usize = 8 + 1 + ARGON2_SALT_LEN + AES_NONCE_LEN; + +#[derive(Debug, thiserror::Error)] +pub enum KeypairCryptoError { + #[error("format inválido: faltan magic / versión / longitud")] + InvalidFormat, + + #[error("passphrase incorrecta o cifrado manipulado")] + DecryptFailed, + + #[error("argon2: {0}")] + Argon2(String), +} + +/// Decentralized Identifier: 32 bytes de la clave pública Ed25519. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, +)] +#[serde(transparent)] +pub struct Did(pub [u8; SECRET_KEY_LENGTH]); + +impl Did { + pub fn as_bytes(&self) -> &[u8; SECRET_KEY_LENGTH] { + &self.0 + } + + /// Verifica que `sig` sea una firma válida sobre `msg` producida + /// con la llave privada correspondiente a este DID. Devuelve + /// `false` ante cualquier irregularidad: bytes de DID que no son + /// un punto válido en la curva, firma malformada, mensaje que no + /// coincide. + pub fn verify(&self, msg: &[u8], sig: &Signature) -> bool { + let Ok(vk) = VerifyingKey::from_bytes(&self.0) else { + return false; + }; + let ed_sig = Ed25519Sig::from_bytes(&sig.0); + vk.verify(msg, &ed_sig).is_ok() + } +} + +impl std::fmt::Display for Did { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "did:key:")?; + for b in &self.0 { + write!(f, "{:02x}", b)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub struct Signature( + #[serde(with = "serde_big_array::BigArray")] pub [u8; SIGNATURE_LENGTH], +); + +impl Signature { + pub fn as_bytes(&self) -> &[u8; SIGNATURE_LENGTH] { + &self.0 + } +} + +/// Llave criptográfica completa: priva (para firmar) + pública (para +/// que otros verifiquen). Por convención llamamos `Did` al lado público +/// expuesto al mundo, pero el `Keypair` mantiene ambos lados juntos. +#[derive(Clone)] +pub struct Keypair { + signing: SigningKey, +} + +impl Keypair { + /// Genera un nuevo `Keypair` usando aleatoriedad del sistema + /// operativo (`/dev/urandom` en Unix, `BCryptGenRandom` en + /// Windows). Para producción. + pub fn generate() -> Self { + let mut seed = [0u8; SECRET_KEY_LENGTH]; + OsRng.fill_bytes(&mut seed); + Self::from_seed(&seed) + } + + /// Reconstruye un `Keypair` desde una semilla de 32 bytes. Misma + /// semilla → mismo `Keypair` (mismo `Did`, mismas firmas). Útil + /// para tests reproducibles y para escenarios donde la semilla + /// proviene de otra fuente determinista (HKDF, BIP39, etc.). + pub fn from_seed(seed: &[u8; SECRET_KEY_LENGTH]) -> Self { + Self { + signing: SigningKey::from_bytes(seed), + } + } + + pub fn did(&self) -> Did { + Did(self.signing.verifying_key().to_bytes()) + } + + pub fn sign(&self, msg: &[u8]) -> Signature { + Signature(self.signing.sign(msg).to_bytes()) + } + + /// Cifra la parte privada del keypair con una passphrase humana. + /// Esquema: + /// + /// 1. Genera un salt aleatorio de 16 bytes y un nonce de 12 bytes. + /// 2. Deriva una clave AES-256 desde la passphrase vía Argon2id + /// (parámetros por defecto OWASP). + /// 3. Cifra los 32 bytes de la clave secreta con AES-256-GCM + /// (autenticado: integrity built-in). + /// 4. Compone el blob: + /// `MAGIC(8) || VERSION(1) || SALT(16) || NONCE(12) || CIPHERTEXT+TAG(48)`. + /// + /// Total: 85 bytes. La passphrase nunca se almacena; quien no la + /// conozca no puede recuperar la identidad. + pub fn encrypt(&self, passphrase: &str) -> Result, KeypairCryptoError> { + let mut salt = [0u8; ARGON2_SALT_LEN]; + let mut nonce_bytes = [0u8; AES_NONCE_LEN]; + OsRng.fill_bytes(&mut salt); + OsRng.fill_bytes(&mut nonce_bytes); + + let aes_key = derive_aes_key(passphrase, &salt)?; + + let cipher = Aes256Gcm::new_from_slice(&aes_key) + .map_err(|_| KeypairCryptoError::DecryptFailed)?; + let nonce = Nonce::from_slice(&nonce_bytes); + let secret_bytes = self.signing.to_bytes(); + let ciphertext = cipher + .encrypt(nonce, secret_bytes.as_ref()) + .map_err(|_| KeypairCryptoError::DecryptFailed)?; + + let mut out = Vec::with_capacity(KEYPAIR_HEADER_LEN + ciphertext.len()); + out.extend_from_slice(KEYPAIR_MAGIC); + out.push(KEYPAIR_VERSION); + out.extend_from_slice(&salt); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(out) + } + + /// Descifra un keypair cifrado con `encrypt`. Falla con + /// `DecryptFailed` si la passphrase es incorrecta **o** si los + /// bytes han sido manipulados (AES-GCM detecta ambas vías). + pub fn decrypt(bytes: &[u8], passphrase: &str) -> Result { + if bytes.len() < KEYPAIR_HEADER_LEN { + return Err(KeypairCryptoError::InvalidFormat); + } + if &bytes[..8] != KEYPAIR_MAGIC { + return Err(KeypairCryptoError::InvalidFormat); + } + if bytes[8] != KEYPAIR_VERSION { + return Err(KeypairCryptoError::InvalidFormat); + } + + let salt = &bytes[9..9 + ARGON2_SALT_LEN]; + let nonce_bytes = &bytes[9 + ARGON2_SALT_LEN..KEYPAIR_HEADER_LEN]; + let ciphertext = &bytes[KEYPAIR_HEADER_LEN..]; + + let aes_key = derive_aes_key(passphrase, salt)?; + let cipher = Aes256Gcm::new_from_slice(&aes_key) + .map_err(|_| KeypairCryptoError::DecryptFailed)?; + let nonce = Nonce::from_slice(nonce_bytes); + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|_| KeypairCryptoError::DecryptFailed)?; + + if plaintext.len() != SECRET_KEY_LENGTH { + return Err(KeypairCryptoError::InvalidFormat); + } + let mut seed = [0u8; SECRET_KEY_LENGTH]; + seed.copy_from_slice(&plaintext); + Ok(Self::from_seed(&seed)) + } +} + +fn derive_aes_key(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], KeypairCryptoError> { + let mut key = [0u8; 32]; + Argon2::default() + .hash_password_into(passphrase.as_bytes(), salt, &mut key) + .map_err(|e| KeypairCryptoError::Argon2(e.to_string()))?; + Ok(key) +} + +impl std::fmt::Debug for Keypair { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Nunca exponemos la parte privada en debug. Solo el DID. + write!(f, "Keypair {{ did: {} }}", self.did()) + } +} diff --git a/03_ukupacha/minga/minga-core/src/lib.rs b/03_ukupacha/minga/minga-core/src/lib.rs new file mode 100644 index 0000000..3582a03 --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/lib.rs @@ -0,0 +1,27 @@ +//! Núcleo puro de Minga: AST normalizado, direccionamiento por contenido +//! semántico y Merkle Search Tree. Sin IO, sin red, sin filesystem. +//! +//! La separación es deliberada: este crate jamás importa libp2p, fuser ni +//! ningún tipo asociado a un canal de IO. Si algo aquí necesita IO, el +//! contrato se expone como trait y la implementación vive en otro crate. + +pub mod alpha; +pub mod ast; +pub mod attestation; +pub mod cas; +pub mod identity; +pub mod mst; +pub mod parse; +pub mod retraction; +pub mod root_decl; +pub mod store; + +pub use alpha::hash_node_alpha; +pub use ast::SemanticNode; +pub use attestation::{Attestation, AttestationError, AttestationStore}; +pub use cas::{hash_components, hash_node, ContentHash}; +pub use identity::{Did, Keypair, KeypairCryptoError, Signature}; +pub use mst::{empty_subtree_hash, Mst, MstDiff, NodeProbe}; +pub use retraction::{Retraction, RetractionError, RetractionStore, RETRACTION_DOMAIN}; +pub use root_decl::RootDecl; +pub use store::{hash_stored, MemStore, NodeStore, StoredNode}; diff --git a/03_ukupacha/minga/minga-core/src/mst.rs b/03_ukupacha/minga/minga-core/src/mst.rs new file mode 100644 index 0000000..86d990e --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/mst.rs @@ -0,0 +1,457 @@ +//! Merkle Search Tree (MST). +//! +//! Estructura B-árbol probabilística sobre hashes, en la que el "nivel" de +//! cada clave se deriva determinísticamente de su propio hash (cantidad de +//! nibbles cero al inicio). Eso da dos propiedades clave: +//! +//! * **Independencia del orden de inserción.** El conjunto `{a, b, c}` +//! siempre produce el mismo árbol y el mismo `root_hash`, sin importar +//! en qué orden se insertaron las claves. +//! * **Comparación logarítmica.** Dos repositorios pueden saber si tienen +//! el mismo conjunto de hashes con un único byte (`root_hash`); y, si +//! difieren, descender solo por las ramas con hashes distintos. +//! +//! Esta implementación es completa para insert/contains/iter y produce un +//! `root_hash` Merkle correcto. La operación de `diff` mínima (delta de +//! sincronización P2P) se construirá encima cuando exista `minga-p2p`. + +use crate::cas::ContentHash; +use blake3::Hasher; +use std::collections::HashMap; +use std::sync::OnceLock; + +/// Resumen estructural de un nodo interno del MST: nivel al que viven +/// sus claves, las claves a ese nivel, y el hash de cada uno de sus +/// hijos (subárboles). Esto es lo que un peer transmite cuando otro le +/// pregunta por la forma de un subárbol durante una sincronización +/// recursiva: bandwidth proporcional a la divergencia, no al tamaño. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct NodeProbe { + pub level: u32, + pub keys: Vec, + pub child_hashes: Vec, +} + +/// Hash canónico del subárbol vacío (el "neutro" del MST). Cualquier +/// peer puede computarlo localmente sin tocar la red, lo que permite +/// reconocer ramas vacías en el otro lado sin pedir un probe. +pub fn empty_subtree_hash() -> ContentHash { + static H: OnceLock = OnceLock::new(); + *H.get_or_init(|| { + let mut h = Hasher::new(); + h.update(b"E"); + ContentHash(*h.finalize().as_bytes()) + }) +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Mst { + root: Subtree, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +enum Subtree { + #[default] + Empty, + Node(Box), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NodeData { + level: u32, + keys: Vec, + children: Vec, +} + +/// Nivel determinístico de un hash: número de nibbles (4 bits) cero al +/// inicio. Distribución geométrica con base 16, lo que da árbol balanceado +/// en expectativa con profundidad logarítmica. +fn level_of(h: &ContentHash) -> u32 { + let mut count = 0u32; + for &b in &h.0 { + if b == 0 { + count += 2; + } else if b < 0x10 { + count += 1; + break; + } else { + break; + } + } + count +} + +impl Mst { + pub fn new() -> Self { + Self { root: Subtree::Empty } + } + + /// Inserta `h`. Devuelve `true` si era una clave nueva. + pub fn insert(&mut self, h: ContentHash) -> bool { + let l = level_of(&h); + let root = std::mem::take(&mut self.root); + let (new_root, inserted) = insert_in(root, h, l); + self.root = new_root; + inserted + } + + pub fn contains(&self, h: &ContentHash) -> bool { + contains_in(&self.root, h) + } + + pub fn len(&self) -> usize { + len_of(&self.root) + } + + pub fn is_empty(&self) -> bool { + matches!(self.root, Subtree::Empty) + } + + /// Recorrido in-order: claves emitidas en orden ascendente por hash. + pub fn iter(&self) -> Iter<'_> { + let mut it = Iter { stack: Vec::new() }; + it.descend_left(&self.root); + it + } + + /// Hash Merkle del árbol completo. Dos MSTs con el mismo conjunto de + /// claves tienen el mismo `root_hash`, sin importar orden de inserción. + pub fn root_hash(&self) -> ContentHash { + subtree_hash(&self.root) + } + + /// Construye un índice `subtree_hash -> NodeProbe` cubriendo cada + /// nodo interno del árbol. Sirve a un peer como tabla de respuestas + /// instantáneas a `ProbeReq`s del otro lado: dado un hash que el + /// peer recibió de nosotros (en un Hello o un ProbeRes previo), + /// podemos reconstituir su `NodeProbe` en `O(1)`. + pub fn build_probe_index(&self) -> HashMap { + let mut idx = HashMap::new(); + index_subtree(&self.root, &mut idx); + idx + } + + /// Diferencia simétrica entre `self` y `other`. Devuelve las claves + /// que están en `self` pero no en `other`, y viceversa. + /// + /// Aprovecha la estructura Merkle: cualquier subárbol cuya raíz + /// hashee igual entre ambos lados se descarta sin descender. Cuando + /// dos nodos comparten nivel y separadores, recurrimos en paralelo + /// sobre sus hijos — cada par idéntico se poda por hash. Cuando la + /// estructura diverge (niveles distintos o separadores distintos en + /// el mismo nivel), enumeramos las claves de ambos y hacemos merge + /// ordenado. + /// + /// El resultado siempre viene ordenado por hash ascendente, lo que + /// permite a un peer P2P hacer streaming de los bloques que faltan + /// en orden estable y deduplicar mientras los recibe. + pub fn diff(&self, other: &Mst) -> MstDiff { + let mut d = MstDiff::default(); + diff_subtrees(&self.root, &other.root, &mut d.only_in_self, &mut d.only_in_other); + d + } +} + +/// Resultado de comparar dos MSTs. `is_empty()` ⇔ ambos representan el +/// mismo conjunto. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct MstDiff { + pub only_in_self: Vec, + pub only_in_other: Vec, +} + +impl MstDiff { + pub fn is_empty(&self) -> bool { + self.only_in_self.is_empty() && self.only_in_other.is_empty() + } + + pub fn total(&self) -> usize { + self.only_in_self.len() + self.only_in_other.len() + } +} + +fn contains_in(t: &Subtree, h: &ContentHash) -> bool { + match t { + Subtree::Empty => false, + Subtree::Node(n) => match n.keys.binary_search(h) { + Ok(_) => true, + Err(i) => contains_in(&n.children[i], h), + }, + } +} + +fn len_of(t: &Subtree) -> usize { + match t { + Subtree::Empty => 0, + Subtree::Node(n) => n.keys.len() + n.children.iter().map(len_of).sum::(), + } +} + +fn subtree_hash(t: &Subtree) -> ContentHash { + let mut h = Hasher::new(); + match t { + Subtree::Empty => { + h.update(b"E"); + } + Subtree::Node(n) => { + h.update(b"N"); + h.update(&n.level.to_le_bytes()); + h.update(&(n.keys.len() as u64).to_le_bytes()); + for k in &n.keys { + h.update(&k.0); + } + for c in &n.children { + h.update(&subtree_hash(c).0); + } + } + } + ContentHash(*h.finalize().as_bytes()) +} + +/// Inserta `h` (de nivel `l`) en el subárbol `t`. Devuelve el nuevo +/// subárbol y si fue una inserción real (no duplicado). +fn insert_in(t: Subtree, h: ContentHash, l: u32) -> (Subtree, bool) { + match t { + Subtree::Empty => { + let node = NodeData { + level: l, + keys: vec![h], + children: vec![Subtree::Empty, Subtree::Empty], + }; + (Subtree::Node(Box::new(node)), true) + } + Subtree::Node(boxed) => { + let n = *boxed; + if l > n.level { + // Nueva clave de nivel mayor: parte el árbol actual y la + // promueve a nueva raíz. + let (left, right) = split_at(Subtree::Node(Box::new(n)), &h); + let new_root = NodeData { + level: l, + keys: vec![h], + children: vec![left, right], + }; + (Subtree::Node(Box::new(new_root)), true) + } else if l == n.level { + match n.keys.binary_search(&h) { + Ok(_) => (Subtree::Node(Box::new(n)), false), + Err(i) => { + let NodeData { level, mut keys, mut children } = n; + let middle = std::mem::replace(&mut children[i], Subtree::Empty); + let (left, right) = split_at(middle, &h); + keys.insert(i, h); + children[i] = left; + children.insert(i + 1, right); + ( + Subtree::Node(Box::new(NodeData { level, keys, children })), + true, + ) + } + } + } else { + // l < n.level: la clave nueva pertenece a un subárbol bajo + // el separador correspondiente. + let i = match n.keys.binary_search(&h) { + Ok(_) => unreachable!( + "colisión: clave de nivel inferior coincide con separador de nivel superior" + ), + Err(i) => i, + }; + let NodeData { level, keys, mut children } = n; + let child = std::mem::replace(&mut children[i], Subtree::Empty); + let (new_child, inserted) = insert_in(child, h, l); + children[i] = new_child; + ( + Subtree::Node(Box::new(NodeData { level, keys, children })), + inserted, + ) + } + } + } +} + +/// Parte `t` en (claves < pivot, claves > pivot). Pre-condición: el nivel +/// de cada subárbol involucrado es estrictamente menor que el del pivot +/// (que vive arriba). El pivot mismo no aparece en el resultado. +fn split_at(t: Subtree, pivot: &ContentHash) -> (Subtree, Subtree) { + match t { + Subtree::Empty => (Subtree::Empty, Subtree::Empty), + Subtree::Node(boxed) => { + let n = *boxed; + let i = match n.keys.binary_search(pivot) { + Ok(_) => unreachable!("pivot coincide con clave de nivel inferior"), + Err(i) => i, + }; + let NodeData { level, keys, children } = n; + + let mut left_keys = keys.clone(); + left_keys.truncate(i); + let mut right_keys = keys; + right_keys.drain(..i); + + let mut left_children: Vec = Vec::with_capacity(i + 1); + let mut right_children: Vec = Vec::with_capacity(level as usize + 1); + + let mut iter = children.into_iter(); + for _ in 0..i { + left_children.push(iter.next().expect("invariante: children > i")); + } + let middle = iter.next().expect("invariante: existe children[i]"); + let (l_mid, r_mid) = split_at(middle, pivot); + left_children.push(l_mid); + right_children.push(r_mid); + for c in iter { + right_children.push(c); + } + + let left = if left_keys.is_empty() { + left_children.pop().unwrap_or(Subtree::Empty) + } else { + Subtree::Node(Box::new(NodeData { + level, + keys: left_keys, + children: left_children, + })) + }; + let right = if right_keys.is_empty() { + right_children.pop().unwrap_or(Subtree::Empty) + } else { + Subtree::Node(Box::new(NodeData { + level, + keys: right_keys, + children: right_children, + })) + }; + (left, right) + } + } +} + +fn index_subtree(t: &Subtree, idx: &mut HashMap) { + if let Subtree::Node(n) = t { + let child_hashes: Vec = n.children.iter().map(subtree_hash).collect(); + let probe = NodeProbe { + level: n.level, + keys: n.keys.clone(), + child_hashes, + }; + idx.insert(subtree_hash(t), probe); + for c in &n.children { + index_subtree(c, idx); + } + } +} + +fn diff_subtrees( + t1: &Subtree, + t2: &Subtree, + only_in_1: &mut Vec, + only_in_2: &mut Vec, +) { + // Short-circuit por hash Merkle: si los dos subárboles colapsan al + // mismo hash de 32 bytes, representan el mismo conjunto. Una sola + // comparación poda toda la rama. Aplicado recursivamente, en árboles + // mayormente iguales el coste es proporcional a la divergencia, no al + // tamaño total. + if subtree_hash(t1) == subtree_hash(t2) { + return; + } + match (t1, t2) { + (Subtree::Empty, _) => collect_all(t2, only_in_2), + (_, Subtree::Empty) => collect_all(t1, only_in_1), + (Subtree::Node(n1), Subtree::Node(n2)) => { + if n1.level == n2.level && n1.keys == n2.keys { + // Mismo nivel y mismos separadores: los hijos se alinean + // posicionalmente. Recurrimos en paralelo — cada par + // idéntico se podará en su llamada por el hash de Merkle. + for (c1, c2) in n1.children.iter().zip(n2.children.iter()) { + diff_subtrees(c1, c2, only_in_1, only_in_2); + } + } else { + // Estructura divergente. Enumeramos ambos lados ordenados + // y hacemos merge. Correcto pero sin más poda Merkle: una + // futura iteración con `split_at` por cada separador del + // nivel mayor recuperaría la poda en el caso desalineado. + let mut k1 = Vec::with_capacity(len_of(t1)); + let mut k2 = Vec::with_capacity(len_of(t2)); + collect_all(t1, &mut k1); + collect_all(t2, &mut k2); + merge_diff_sorted(&k1, &k2, only_in_1, only_in_2); + } + } + } +} + +fn collect_all(t: &Subtree, out: &mut Vec) { + if let Subtree::Node(n) = t { + for i in 0..n.keys.len() { + collect_all(&n.children[i], out); + out.push(n.keys[i]); + } + collect_all(&n.children[n.keys.len()], out); + } +} + +fn merge_diff_sorted( + a: &[ContentHash], + b: &[ContentHash], + only_a: &mut Vec, + only_b: &mut Vec, +) { + let mut i = 0; + let mut j = 0; + while i < a.len() && j < b.len() { + match a[i].cmp(&b[j]) { + std::cmp::Ordering::Less => { + only_a.push(a[i]); + i += 1; + } + std::cmp::Ordering::Greater => { + only_b.push(b[j]); + j += 1; + } + std::cmp::Ordering::Equal => { + i += 1; + j += 1; + } + } + } + only_a.extend_from_slice(&a[i..]); + only_b.extend_from_slice(&b[j..]); +} + +pub struct Iter<'a> { + /// Cada frame es (nodo, próximo índice de clave a emitir). Cuando se + /// pushea un frame, ya descendimos por su hijo izquierdo (children[0]). + stack: Vec<(&'a NodeData, usize)>, +} + +impl<'a> Iter<'a> { + fn descend_left(&mut self, t: &'a Subtree) { + let mut cur = t; + while let Subtree::Node(n) = cur { + self.stack.push((n.as_ref(), 0)); + cur = &n.children[0]; + } + } +} + +impl<'a> Iterator for Iter<'a> { + type Item = &'a ContentHash; + + fn next(&mut self) -> Option { + loop { + let (node, ki) = { + let top = self.stack.last()?; + (top.0, top.1) + }; + if ki < node.keys.len() { + self.stack.last_mut().unwrap().1 = ki + 1; + self.descend_left(&node.children[ki + 1]); + return Some(&node.keys[ki]); + } else { + self.stack.pop(); + } + } + } +} diff --git a/03_ukupacha/minga/minga-core/src/parse.rs b/03_ukupacha/minga/minga-core/src/parse.rs new file mode 100644 index 0000000..be34f36 --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/parse.rs @@ -0,0 +1,523 @@ +//! Adaptadores de parsing por dialecto. +//! +//! Cada función devuelve un [`SemanticNode`] normalizado a partir del +//! source code. La normalización vive en `ast::SemanticNode::from_tree_sitter` +//! y es agnóstica al lenguaje — cualquier tree-sitter grammar produce +//! el mismo shape de árbol semántico (sin whitespace, sin comentarios). +//! +//! Lenguajes soportados (cada uno son ~6 LOC + dep tree-sitter-X): +//! - [`rust`] — Rust completo (con α-hashing en `alpha::hash_node_alpha`). +//! - [`python`] — Python 3.x. +//! - [`typescript`] — TypeScript (no TSX). +//! - [`javascript`] — JavaScript / ECMAScript. +//! - [`go`] — Go. +//! +//! Para hashing α-equivalente, sólo Rust tiene implementación dedicada +//! hoy. Otros lenguajes caen al [`crate::cas::hash_node`] estructural, +//! que es α-NO-equivalente: dos versiones del mismo término que +//! difieren en nombres de variables ligadas tendrán hashes distintos. +//! Suficiente para detección de cambios; no para detección de +//! equivalencia semántica. +//! +//! ## Auto-detección por extensión +//! +//! [`detect_by_extension`] mapea `.rs` → Rust, `.py` → Python, etc. +//! Útil para `minga ingest` cuando el caller no quiere especificar +//! el dialecto a mano. + +use crate::ast::SemanticNode; +use thiserror::Error; +use tree_sitter::{Language, Parser}; + +#[derive(Debug, Error)] +pub enum ParseError { + #[error("tree-sitter no pudo configurar el lenguaje")] + Language, + #[error("tree-sitter no produjo árbol para la entrada")] + NoTree, +} + +/// Identificadores estables de cada dialecto soportado. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Dialect { + Rust, + Python, + TypeScript, + JavaScript, + Go, +} + +impl Dialect { + /// Nombre canónico para logging / display. + pub fn name(self) -> &'static str { + match self { + Dialect::Rust => "rust", + Dialect::Python => "python", + Dialect::TypeScript => "typescript", + Dialect::JavaScript => "javascript", + Dialect::Go => "go", + } + } + + /// Byte estable para persistir/transmitir el dialecto. Numérico para + /// no depender del orden de la enum si se agregan lenguajes. + pub fn as_byte(self) -> u8 { + match self { + Dialect::Rust => 1, + Dialect::Python => 2, + Dialect::TypeScript => 3, + Dialect::JavaScript => 4, + Dialect::Go => 5, + } + } + + /// Inversa de [`Dialect::as_byte`]. `None` si el byte no corresponde + /// a un dialecto conocido por esta versión. + pub fn from_byte(b: u8) -> Option { + Some(match b { + 1 => Dialect::Rust, + 2 => Dialect::Python, + 3 => Dialect::TypeScript, + 4 => Dialect::JavaScript, + 5 => Dialect::Go, + _ => return None, + }) + } + + /// Parsea `source` con la gramática de este dialecto. + pub fn parse(self, source: &str) -> Result { + match self { + Dialect::Rust => rust(source), + Dialect::Python => python(source), + Dialect::TypeScript => typescript(source), + Dialect::JavaScript => javascript(source), + Dialect::Go => go(source), + } + } +} + +/// Mapea una extensión de archivo (sin el `.`) al dialecto correspondiente. +/// `None` si la extensión no corresponde a un lenguaje soportado. +/// +/// ``` +/// use minga_core::parse::{detect_by_extension, Dialect}; +/// assert_eq!(detect_by_extension("rs"), Some(Dialect::Rust)); +/// assert_eq!(detect_by_extension("py"), Some(Dialect::Python)); +/// assert_eq!(detect_by_extension("unknown"), None); +/// ``` +pub fn detect_by_extension(ext: &str) -> Option { + match ext.to_ascii_lowercase().as_str() { + "rs" => Some(Dialect::Rust), + "py" | "pyi" => Some(Dialect::Python), + "ts" => Some(Dialect::TypeScript), + "js" | "mjs" | "cjs" => Some(Dialect::JavaScript), + "go" => Some(Dialect::Go), + _ => None, + } +} + +/// Detecta el dialecto leyendo la primera línea como shebang. Reconoce +/// las formas habituales: +/// - `#!/usr/bin/env python3` → Python +/// - `#!/usr/bin/python3.11` → Python +/// - `#!/usr/bin/env node` / `deno` → JavaScript +/// - `#!/usr/bin/env -S deno run --ext=ts` / `tsx` → TypeScript +/// - `#!/usr/bin/env bash` / `sh` → `None` (no soportado) +/// +/// Sólo mira la **primera línea**: si no comienza por `#!`, devuelve +/// `None` sin tocar el resto del buffer. +pub fn detect_by_shebang(source: &str) -> Option { + let first = source.lines().next()?; + let rest = first.strip_prefix("#!")?.trim(); + let interpreter = last_token(rest); + let lower = interpreter.to_ascii_lowercase(); + let trimmed = lower.trim_start_matches(|c: char| c == '/' || c.is_ascii_alphanumeric() == false); + let last_segment = lower.rsplit('/').next().unwrap_or(&lower); + // Coincidencia laxa por sufijo: cubre versiones como `python3.11`. + let cand = if last_segment.starts_with("python") { + Some(Dialect::Python) + } else if last_segment == "node" || last_segment == "deno" || last_segment == "bun" { + // Por defecto JS; el ext flag se evalúa abajo. + Some(Dialect::JavaScript) + } else if last_segment == "tsx" || last_segment == "ts-node" { + Some(Dialect::TypeScript) + } else if last_segment.ends_with("rustc") { + Some(Dialect::Rust) + } else if last_segment == "go" { + Some(Dialect::Go) + } else { + None + }; + // Override por `--ext=ts` en la cadena (env -S deno run --ext=ts). + if rest.contains("--ext=ts") || rest.contains("--ext ts") { + return Some(Dialect::TypeScript); + } + let _ = trimmed; // suppress unused + cand +} + +/// Detecta el dialecto **por contenido**. Combina dos señales: +/// +/// 1. **Marcadores textuales** distintivos por lenguaje (palabras +/// clave en posición de declaración: `def `/`class ` para Python, +/// `fn `/`impl `/`pub ` para Rust, `func `/`package ` para Go, +/// `function `/`interface `/anotaciones de tipo TS para JS/TS). +/// Tree-sitter es muy permisivo — acepta casi cualquier cosa con +/// pocos `ERROR`, así que no se puede confiar sólo en eso. +/// 2. **Ratio de nodos `ERROR`** al parsear con la gramática candidata. +/// Sirve como tie-break: si dos lenguajes empatan en marcadores, +/// gana el que produce el AST más limpio. +/// +/// Si ningún candidato consigue un parse limpio (≤ 5 % errores) o +/// ningún marcador textual identifica un lenguaje, devuelve `None`. +pub fn detect_by_content(source: &str) -> Option { + if source.trim().is_empty() { + return None; + } + // Marcadores textuales por lenguaje. Cada lenguaje suma puntos + // según cuántos marcadores aparecen en el source. + let mut scores: [(Dialect, u32); 5] = [ + (Dialect::Rust, 0), + (Dialect::Python, 0), + (Dialect::TypeScript, 0), + (Dialect::JavaScript, 0), + (Dialect::Go, 0), + ]; + for line in source.lines() { + let t = line.trim_start(); + // Rust: `fn `/`pub fn `/`impl `/`use `/`mod `/`let `. + if t.starts_with("fn ") + || t.starts_with("pub fn ") + || t.starts_with("pub(crate) fn ") + || t.starts_with("impl ") + || t.starts_with("use ") + || t.starts_with("mod ") + || t.starts_with("let ") + || t.starts_with("struct ") + || t.starts_with("enum ") + || t.starts_with("trait ") + { + scores[0].1 += 1; + } + // Python: `def `/`class `/`import `/`from `/`elif `. + if t.starts_with("def ") + || t.starts_with("class ") + || t.starts_with("import ") + || t.starts_with("from ") + || t.starts_with("elif ") + || t.starts_with("async def ") + { + scores[1].1 += 1; + } + // TypeScript: anotaciones `: \w+`, `interface `, `type `, + // `enum `. JS no usa la mayoría de éstos. + if t.starts_with("interface ") + || t.starts_with("type ") + || t.starts_with("enum ") + { + scores[2].1 += 1; + } + // JavaScript/TypeScript ambos. + if t.starts_with("function ") + || t.starts_with("const ") + || t.starts_with("let ") + || t.starts_with("var ") + || t.starts_with("export ") + || t.starts_with("import {") + { + // `let ` también lo usa Rust — dejamos que el score Rust + // gane si hay otros indicadores; aquí sólo sumamos a JS + // si no parece Rust en otros aspectos. Para simplicidad, + // sumamos a JS y a Rust independientemente y dejamos que + // el tie-break de errores decida. + scores[3].1 += 1; + } + // Go. + if t.starts_with("func ") + || t.starts_with("package ") + || t.starts_with("import (") + || t.starts_with("type ") && t.contains(" struct ") + { + scores[4].1 += 1; + } + } + + // Si nadie tiene marcadores, no podemos decidir. + let max_score = scores.iter().map(|(_, n)| *n).max().unwrap_or(0); + if max_score == 0 { + return None; + } + + // Top candidatos: los que empatan en el score más alto. + let top: Vec = scores + .iter() + .filter(|(_, n)| *n == max_score) + .map(|(d, _)| *d) + .collect(); + + // Si hay un único top, devolverlo (sin verificar errores — + // marcadores robustos ya identifican el lenguaje). + if top.len() == 1 { + return Some(top[0]); + } + + // Empate: parsear con cada candidato y elegir el de menos errores. + let mut best: Option<(Dialect, f32)> = None; + for d in top { + if let Ok(node) = d.parse(source) { + let (errors, total) = count_errors(&node); + if total == 0 { + continue; + } + let ratio = errors as f32 / total as f32; + if best.map_or(true, |(_, r)| ratio < r) { + best = Some((d, ratio)); + } + } + } + best.and_then(|(d, r)| if r <= 0.05 { Some(d) } else { None }) +} + +/// Cuenta nodos `ERROR`/`MISSING` y el total de nodos en el subárbol. +/// Tree-sitter marca con `kind == "ERROR"` cualquier sección que la +/// gramática no pudo absorber; `is_named() == false` y un nombre vacío +/// suele indicar tokens missing. +fn count_errors(node: &SemanticNode) -> (usize, usize) { + let mut errors = 0usize; + let mut total = 0usize; + walk_count(node, &mut errors, &mut total); + (errors, total) +} + +fn walk_count(n: &SemanticNode, errors: &mut usize, total: &mut usize) { + *total += 1; + if n.kind == "ERROR" { + *errors += 1; + } + for c in &n.children { + walk_count(c, errors, total); + } +} + +/// Devuelve el último "token" (separado por whitespace) de la cadena. +/// Para shebangs `#!/usr/bin/env python3 -u` queremos el `python3`, +/// pero también `#!/usr/bin/python3.11` (sin `env`) — el truco es: +/// si hay `env`, tomar lo siguiente; si no, el path completo y luego +/// el último segmento. Esta función devuelve el penúltimo token cuando +/// el primero parece un path absoluto a `env`. +fn last_token(s: &str) -> &str { + let mut tokens = s.split_whitespace().filter(|t| !t.starts_with('-')); + let first = tokens.next().unwrap_or(""); + if first.ends_with("/env") || first == "env" { + tokens.next().unwrap_or(first) + } else { + first + } +} + +fn parse_with(lang: Language, source: &str) -> Result { + let mut parser = Parser::new(); + parser.set_language(&lang).map_err(|_| ParseError::Language)?; + let tree = parser.parse(source, None).ok_or(ParseError::NoTree)?; + Ok(SemanticNode::from_tree_sitter(tree.root_node(), source.as_bytes())) +} + +pub fn rust(source: &str) -> Result { + parse_with(tree_sitter_rust::LANGUAGE.into(), source) +} + +pub fn python(source: &str) -> Result { + parse_with(tree_sitter_python::LANGUAGE.into(), source) +} + +pub fn typescript(source: &str) -> Result { + parse_with(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), source) +} + +pub fn javascript(source: &str) -> Result { + parse_with(tree_sitter_javascript::LANGUAGE.into(), source) +} + +pub fn go(source: &str) -> Result { + parse_with(tree_sitter_go::LANGUAGE.into(), source) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_parses(d: Dialect, source: &str) -> SemanticNode { + let node = d.parse(source).expect("parse should succeed"); + // Sanity: el root siempre tiene al menos un child para code real. + assert!( + !node.children.is_empty(), + "{}: root node sin children — parse posiblemente vacío", + d.name() + ); + node + } + + #[test] + fn rust_parses_basic() { + assert_parses(Dialect::Rust, "fn add(a: i32, b: i32) -> i32 { a + b }"); + } + + #[test] + fn python_parses_basic() { + assert_parses( + Dialect::Python, + "def add(a: int, b: int) -> int:\n return a + b\n", + ); + } + + #[test] + fn typescript_parses_basic() { + assert_parses( + Dialect::TypeScript, + "function add(a: number, b: number): number { return a + b; }", + ); + } + + #[test] + fn javascript_parses_basic() { + assert_parses( + Dialect::JavaScript, + "function add(a, b) { return a + b; }", + ); + } + + #[test] + fn go_parses_basic() { + assert_parses( + Dialect::Go, + "package main\n\nfunc add(a, b int) int {\n return a + b\n}\n", + ); + } + + #[test] + fn detect_extension_canonical() { + assert_eq!(detect_by_extension("rs"), Some(Dialect::Rust)); + assert_eq!(detect_by_extension("py"), Some(Dialect::Python)); + assert_eq!(detect_by_extension("pyi"), Some(Dialect::Python)); + assert_eq!(detect_by_extension("ts"), Some(Dialect::TypeScript)); + assert_eq!(detect_by_extension("js"), Some(Dialect::JavaScript)); + assert_eq!(detect_by_extension("mjs"), Some(Dialect::JavaScript)); + assert_eq!(detect_by_extension("cjs"), Some(Dialect::JavaScript)); + assert_eq!(detect_by_extension("go"), Some(Dialect::Go)); + assert_eq!(detect_by_extension("unknown"), None); + assert_eq!(detect_by_extension(""), None); + } + + #[test] + fn detect_shebang_python_env() { + assert_eq!( + detect_by_shebang("#!/usr/bin/env python3\nprint(1)\n"), + Some(Dialect::Python) + ); + } + + #[test] + fn detect_shebang_python_direct() { + assert_eq!( + detect_by_shebang("#!/usr/bin/python3.11\n"), + Some(Dialect::Python) + ); + } + + #[test] + fn detect_shebang_node() { + assert_eq!( + detect_by_shebang("#!/usr/bin/env node\nconsole.log(1)\n"), + Some(Dialect::JavaScript) + ); + } + + #[test] + fn detect_shebang_deno_with_ext_ts() { + assert_eq!( + detect_by_shebang("#!/usr/bin/env -S deno run --ext=ts\n"), + Some(Dialect::TypeScript) + ); + } + + #[test] + fn detect_content_rust_clean() { + assert_eq!( + detect_by_content("fn main() { let x = 1; println!(\"{}\", x); }"), + Some(Dialect::Rust) + ); + } + + #[test] + fn detect_content_python_clean() { + assert_eq!( + detect_by_content("def add(a, b):\n return a + b\n"), + Some(Dialect::Python) + ); + } + + #[test] + fn detect_content_go_clean() { + assert_eq!( + detect_by_content("package main\n\nfunc add(a, b int) int { return a + b }\n"), + Some(Dialect::Go) + ); + } + + #[test] + fn detect_content_returns_none_for_garbage() { + // Texto que ninguna gramática absorbe sin un montón de ERRORs. + let garbage = "++++ @@@@ ###% %%%% [[[[[[[[[ ............"; + assert_eq!(detect_by_content(garbage), None); + } + + #[test] + fn detect_content_handles_empty() { + assert_eq!(detect_by_content(""), None); + assert_eq!(detect_by_content(" \n\t "), None); + } + + #[test] + fn detect_shebang_no_match_for_bash() { + assert_eq!(detect_by_shebang("#!/bin/bash\necho hola\n"), None); + } + + #[test] + fn detect_shebang_requires_hashbang() { + // No es shebang — empieza con `//` (comentario JS), no debe matchear. + assert_eq!(detect_by_shebang("// nope\n"), None); + } + + #[test] + fn detect_extension_case_insensitive() { + assert_eq!(detect_by_extension("RS"), Some(Dialect::Rust)); + assert_eq!(detect_by_extension("Py"), Some(Dialect::Python)); + assert_eq!(detect_by_extension("TS"), Some(Dialect::TypeScript)); + } + + #[test] + fn dialect_name_canonical() { + assert_eq!(Dialect::Rust.name(), "rust"); + assert_eq!(Dialect::Python.name(), "python"); + assert_eq!(Dialect::TypeScript.name(), "typescript"); + assert_eq!(Dialect::JavaScript.name(), "javascript"); + assert_eq!(Dialect::Go.name(), "go"); + } + + #[test] + fn structural_hash_distinguishes_languages() { + // Mismo "shape" textual pero distintos lenguajes producen + // árboles distintos (las gramáticas no coinciden) y por tanto + // hashes estructurales distintos. Importante para evitar + // colisiones en el CAS cuando el mismo source se ingiere + // bajo dialectos distintos. + use crate::cas::hash_node; + let py = Dialect::Python.parse("x = 1").unwrap(); + let js = Dialect::JavaScript.parse("x = 1").unwrap(); + assert_ne!( + hash_node(&py), + hash_node(&js), + "py y js deberían tener hashes distintos para el mismo source" + ); + } +} diff --git a/03_ukupacha/minga/minga-core/src/retraction.rs b/03_ukupacha/minga/minga-core/src/retraction.rs new file mode 100644 index 0000000..8097a00 --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/retraction.rs @@ -0,0 +1,176 @@ +//! Retracciones firmadas: la contraparte de las [`Attestation`]es. +//! +//! Una `Retraction` registra que el dueño de cierta clave privada +//! **ya no respalda** un contenido concreto. No borra la atestación +//! original (sigue siendo prueba histórica de que en algún momento la +//! firmó), pero permite a quien consulte el repo saber que el autor +//! posteriormente la retiró. +//! +//! ## Por qué es una firma separada +//! +//! La firma cubre un mensaje distinto al de [`Attestation::create`]: +//! concretamente `b"minga.retract:" ++ content_hash`. Esto evita que +//! una atestación válida pueda re-empaquetarse como retracción +//! reutilizando su firma — sin el prefijo, las dos llaves serían +//! intercambiables. +//! +//! ## Modelo +//! +//! Idempotente por `(author, content)` igual que `AttestationStore`. +//! El `RetractionStore` rechaza firmas inválidas — el verificador +//! sabe que cualquier cosa que lea pasó el check criptográfico. + +use crate::cas::ContentHash; +use crate::identity::{Did, Keypair, Signature}; +use std::collections::HashMap; + +/// Prefijo de dominio para que la firma de una retracción no colisione +/// con la de una atestación: los mensajes firmados son distintos. +pub const RETRACTION_DOMAIN: &[u8] = b"minga.retract:"; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Retraction { + pub content: ContentHash, + pub author: Did, + pub signature: Signature, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetractionError { + InvalidSignature, +} + +impl std::fmt::Display for RetractionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidSignature => write!(f, "firma de la retracción no verifica"), + } + } +} + +impl std::error::Error for RetractionError {} + +impl Retraction { + /// Construye y firma una retracción para `content`. + pub fn create(keypair: &Keypair, content: ContentHash) -> Self { + let mut msg = Vec::with_capacity(RETRACTION_DOMAIN.len() + 32); + msg.extend_from_slice(RETRACTION_DOMAIN); + msg.extend_from_slice(&content.0); + Self { + content, + author: keypair.did(), + signature: keypair.sign(&msg), + } + } + + /// Verifica la firma. La cobertura es sobre `RETRACTION_DOMAIN ++ + /// content_hash`, no sobre el `content_hash` solo — eso evita que + /// una `Attestation::signature` se pueda reutilizar como + /// `Retraction::signature` y viceversa. + pub fn verify(&self) -> bool { + let mut msg = Vec::with_capacity(RETRACTION_DOMAIN.len() + 32); + msg.extend_from_slice(RETRACTION_DOMAIN); + msg.extend_from_slice(&self.content.0); + self.author.verify(&msg, &self.signature) + } +} + +/// Registro in-memory de retracciones, espejo de [`crate::AttestationStore`]. +/// +/// Idempotente por `(author, content)`. Rechaza firmas inválidas. Un +/// mismo `content_hash` puede tener retracciones de autores distintos. +#[derive(Debug, Default, Clone)] +pub struct RetractionStore { + by_content: HashMap>, +} + +impl RetractionStore { + pub fn new() -> Self { + Self::default() + } + + /// Inserta una retracción. `Err(InvalidSignature)` si la firma no + /// verifica — el store nunca almacena firmas rotas. + pub fn add(&mut self, r: Retraction) -> Result<(), RetractionError> { + if !r.verify() { + return Err(RetractionError::InvalidSignature); + } + let entry = self.by_content.entry(r.content).or_default(); + if !entry.iter().any(|x| x.author == r.author) { + entry.push(r); + } + Ok(()) + } + + pub fn get(&self, content: &ContentHash) -> &[Retraction] { + self.by_content + .get(content) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + /// Conjunto de DIDs que han retirado este contenido. + pub fn authors_of(&self, content: &ContentHash) -> Vec { + self.by_content + .get(content) + .map(|v| v.iter().map(|a| a.author).collect()) + .unwrap_or_default() + } + + pub fn len(&self) -> usize { + self.by_content.values().map(Vec::len).sum() + } + + pub fn is_empty(&self) -> bool { + self.by_content.values().all(Vec::is_empty) + } + + /// Itera todas las retracciones (orden no especificado). + pub fn all(&self) -> impl Iterator + '_ { + self.by_content.values().flat_map(|v| v.iter()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_then_verify_is_ok() { + let kp = Keypair::generate(); + let h = ContentHash([7u8; 32]); + let r = Retraction::create(&kp, h); + assert!(r.verify()); + assert_eq!(r.author, kp.did()); + assert_eq!(r.content, h); + } + + #[test] + fn attestation_signature_cannot_be_replayed_as_retraction() { + // Una `Attestation` firma directamente el `content_hash`. Una + // `Retraction` firma `RETRACTION_DOMAIN ++ content_hash`. Por + // lo tanto la firma de una NO sirve para la otra: el prefijo + // de dominio rompe la equivalencia. + let kp = Keypair::generate(); + let h = ContentHash([42u8; 32]); + let att = crate::Attestation::create(&kp, h); + + // Intentamos fabricar una retracción reutilizando la firma de + // la atestación. + let forged = Retraction { + content: h, + author: kp.did(), + signature: att.signature, + }; + assert!(!forged.verify(), "la firma de Attestation no debe verificar como Retraction"); + } + + #[test] + fn tampered_content_does_not_verify() { + let kp = Keypair::generate(); + let h = ContentHash([1u8; 32]); + let mut r = Retraction::create(&kp, h); + r.content = ContentHash([2u8; 32]); + assert!(!r.verify()); + } +} diff --git a/03_ukupacha/minga/minga-core/src/root_decl.rs b/03_ukupacha/minga/minga-core/src/root_decl.rs new file mode 100644 index 0000000..8ae723b --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/root_decl.rs @@ -0,0 +1,45 @@ +//! `RootDecl`: declaración de una raíz que viaja por el wire de sync. +//! +//! Un peer que conoce una raíz local (`α_hash → struct_hash` bajo cierto +//! `dialect`) puede empujarla a su contraparte como una `RootDecl`. El +//! receptor **re-verifica** que `α_hash` corresponda realmente al +//! `struct_hash` bajo el `dialect` declarado, llamando a +//! [`crate::alpha::verify_root_alpha`] tras reconstruir el `SemanticNode` +//! del CAS local. Sólo las declaraciones que verifican entran al +//! `SledRootsStore` del receptor. +//! +//! El dialecto se transmite como `u8` (vía [`crate::parse::Dialect::as_byte`]) +//! en vez de derivar serde sobre `Dialect`: el byte es estable bajo +//! reordenamiento o adición de variantes en la enum, igual que ya se hace +//! para persistencia en `SledRootsStore`. + +use crate::cas::ContentHash; +use crate::parse::Dialect; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct RootDecl { + pub alpha: ContentHash, + pub struct_hash: ContentHash, + /// Dialect en su forma byte estable ([`Dialect::as_byte`]). Un byte + /// desconocido (versión futura del protocolo introduciendo un nuevo + /// lenguaje) hace que el receptor descarte la declaración sin + /// verificar, sin tumbar la sesión. + pub dialect_byte: u8, +} + +impl RootDecl { + pub fn new(alpha: ContentHash, struct_hash: ContentHash, dialect: Dialect) -> Self { + Self { + alpha, + struct_hash, + dialect_byte: dialect.as_byte(), + } + } + + /// Decodifica el dialecto al enum. `None` si el byte no corresponde + /// a un dialecto conocido por esta versión del binario — el receptor + /// debe contar la declaración como rechazada en ese caso. + pub fn dialect(&self) -> Option { + Dialect::from_byte(self.dialect_byte) + } +} diff --git a/03_ukupacha/minga/minga-core/src/store.rs b/03_ukupacha/minga/minga-core/src/store.rs new file mode 100644 index 0000000..1eb0c93 --- /dev/null +++ b/03_ukupacha/minga/minga-core/src/store.rs @@ -0,0 +1,144 @@ +//! Almacén de nodos direccionados por contenido. +//! +//! Cada `SemanticNode` se descompone en `StoredNode`s donde los hijos son +//! referencias por hash, no estructuras inline. Así dos subárboles con la +//! misma estructura se almacenan una sola vez, sin importar en cuántos +//! lugares aparezcan en el repositorio. Esa es la diferencia entre "Git +//! semántico" y "diff de líneas". +//! +//! `NodeStore` es el contrato; `MemStore` es la implementación de +//! referencia, en memoria, agnóstica de IO. Un futuro `SledStore` o +//! `RocksStore` vivirá en otro crate y se enchufará vía este trait sin +//! tocar el resto del núcleo. + +use crate::ast::SemanticNode; +use crate::cas::{self, ContentHash}; +use std::collections::HashMap; + +/// Forma "stored": idéntica a `SemanticNode` excepto que los hijos son +/// hashes en vez de estructuras anidadas. Es el format canónico en +/// reposo y el que permite la deduplicación. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct StoredNode { + pub kind: String, + pub field_name: Option, + pub leaf_text: Option>, + pub children: Vec, +} + +/// Hash de un `StoredNode`, idéntico al `hash_node` del `SemanticNode` +/// equivalente. Permite a un protocolo de wire verificar que el nodo +/// que le entregaron tiene efectivamente el hash que se le anunció, +/// sin necesidad de reconstruir descendientes. +pub fn hash_stored(stored: &StoredNode) -> ContentHash { + cas::hash_components( + &stored.kind, + stored.field_name.as_deref(), + stored.leaf_text.as_deref(), + &stored.children, + ) +} + +pub trait NodeStore { + /// Inserta un árbol completo. Recursivamente desempaqueta los hijos + /// y devuelve el hash de la raíz. Idempotente: insertar el mismo + /// árbol dos veces no aumenta el tamaño. + fn put(&mut self, node: &SemanticNode) -> ContentHash; + + /// Inserta un nodo ya troceado por su hash. No recurre en hijos: el + /// llamador es responsable de garantizar que estarán presentes (lo + /// hace típicamente un protocolo de sync que va recibiendo nodos en + /// orden y solicita los faltantes a medida que descubre referencias). + fn put_chunked(&mut self, hash: ContentHash, stored: StoredNode); + + fn get(&self, h: &ContentHash) -> Option<&StoredNode>; + + fn contains(&self, h: &ContentHash) -> bool { + self.get(h).is_some() + } + + /// Reconstruye el `SemanticNode` original a partir de su hash, + /// resolviendo recursivamente los hijos. `None` si algún hash no se + /// encuentra (almacén incompleto, inconsistente). + fn reconstruct(&self, h: &ContentHash) -> Option; + + /// Itera todas las parejas `(hash, stored_node)` del store. Sin + /// orden garantizado. Usado para mergear stores tras una sesión + /// de sync (un peer recibe los nodos del otro en su sesión, y + /// luego los volcamos al store compartido). + fn iter(&self) -> Box + '_>; + + fn len(&self) -> usize; + + fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +#[derive(Debug, Default, Clone)] +pub struct MemStore { + map: HashMap, +} + +impl MemStore { + pub fn new() -> Self { + Self::default() + } +} + +impl NodeStore for MemStore { + fn put(&mut self, node: &SemanticNode) -> ContentHash { + // Recorrido bottom-up: primero los hijos (devuelven su hash), + // luego compongo el hash del padre desde sus child_hashes + // mediante la primitiva canónica de cas. Cada subárbol se + // hashea exactamente una vez — sin recomputar `hash_node` sobre + // el árbol entero del padre. + let mut child_hashes = Vec::with_capacity(node.children.len()); + for c in &node.children { + child_hashes.push(self.put(c)); + } + let h = cas::hash_components( + &node.kind, + node.field_name.as_deref(), + node.leaf_text.as_deref(), + &child_hashes, + ); + self.map.entry(h).or_insert_with(|| StoredNode { + kind: node.kind.clone(), + field_name: node.field_name.clone(), + leaf_text: node.leaf_text.clone(), + children: child_hashes, + }); + h + } + + fn put_chunked(&mut self, hash: ContentHash, stored: StoredNode) { + self.map.entry(hash).or_insert(stored); + } + + fn get(&self, h: &ContentHash) -> Option<&StoredNode> { + self.map.get(h) + } + + fn iter(&self) -> Box + '_> { + Box::new(self.map.iter()) + } + + fn reconstruct(&self, h: &ContentHash) -> Option { + let s = self.map.get(h)?; + let mut children = Vec::with_capacity(s.children.len()); + for ch in &s.children { + children.push(self.reconstruct(ch)?); + } + Some(SemanticNode { + kind: s.kind.clone(), + field_name: s.field_name.clone(), + leaf_text: s.leaf_text.clone(), + children, + }) + } + + fn len(&self) -> usize { + self.map.len() + } +} diff --git a/03_ukupacha/minga/minga-core/tests/alpha_invariants.rs b/03_ukupacha/minga/minga-core/tests/alpha_invariants.rs new file mode 100644 index 0000000..2fc1903 --- /dev/null +++ b/03_ukupacha/minga/minga-core/tests/alpha_invariants.rs @@ -0,0 +1,367 @@ +//! Invariantes del hash α-equivalente. +//! +//! El hash α debe ser estable bajo renombre de variables ligadas y romper +//! con cualquier cambio que afecte la *intención* del término: nombre de +//! la función, tipos en la firma, posición de argumentos, identidad de +//! variables libres. + +use minga_core::{alpha::hash_node_alpha, parse}; + +#[test] +fn alpha_param_rename_invariant() { + let a = parse::rust("fn f(x: i32) -> i32 { x + 1 }").unwrap(); + let b = parse::rust("fn f(y: i32) -> i32 { y + 1 }").unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_let_rename_invariant() { + let a = parse::rust("fn f() -> i32 { let x = 1; x + 2 }").unwrap(); + let b = parse::rust("fn f() -> i32 { let y = 1; y + 2 }").unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_param_swap_with_rename_invariant() { + let a = parse::rust("fn f(x: i32, y: i32) -> i32 { x - y }").unwrap(); + let b = parse::rust("fn f(a: i32, b: i32) -> i32 { a - b }").unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_shadowing_let_invariant() { + let a = parse::rust("fn f() -> i32 { let x = 1; let x = x + 1; x }").unwrap(); + let b = parse::rust("fn f() -> i32 { let a = 1; let b = a + 1; b }").unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_function_name_matters() { + let a = parse::rust("fn f(x: i32) -> i32 { x }").unwrap(); + let b = parse::rust("fn g(x: i32) -> i32 { x }").unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_signature_type_matters() { + let a = parse::rust("fn f(x: i32) -> i32 { x }").unwrap(); + let b = parse::rust("fn f(x: i64) -> i64 { x }").unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_body_change_matters() { + let a = parse::rust("fn f(x: i32) -> i32 { x + 1 }").unwrap(); + let b = parse::rust("fn f(x: i32) -> i32 { x + 2 }").unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_free_variable_identity_matters() { + let a = parse::rust("fn f() { foo() }").unwrap(); + let b = parse::rust("fn f() { bar() }").unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_distinguishes_bound_vs_free() { + // En el primero `x` es parámetro (ligado); en el segundo `x` es libre. + let a = parse::rust("fn f(x: i32) -> i32 { x }").unwrap(); + let b = parse::rust("fn f() -> i32 { x }").unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_param_order_matters() { + let a = parse::rust("fn f(x: i32, y: i32) -> i32 { x - y }").unwrap(); + let b = parse::rust("fn f(x: i32, y: i32) -> i32 { y - x }").unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_diverges_from_structural_under_rename() { + // Bajo renombre, el hash estructural rompe pero el α se conserva. Esto + // demuestra que α añade poder discriminatorio en una dimensión nueva + // (intención) ortogonal a la sintaxis. + use minga_core::cas::hash_node; + let a = parse::rust("fn f(x: i32) -> i32 { x + 1 }").unwrap(); + let b = parse::rust("fn f(z: i32) -> i32 { z + 1 }").unwrap(); + assert_ne!(hash_node(&a), hash_node(&b)); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_closure_param_rename_invariant() { + let a = parse::rust("fn f() -> i32 { let g = |x: i32| x + 1; g(0) }").unwrap(); + let b = parse::rust("fn f() -> i32 { let g = |y: i32| y + 1; g(0) }").unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_closure_captures_outer_binding() { + // El cierre captura `z` (renombrable) del entorno; renombrar tanto el + // exterior como el parámetro debe seguir produciendo el mismo hash. + let a = parse::rust("fn f() -> i32 { let z = 1; let g = |x: i32| x + z; g(0) }").unwrap(); + let b = parse::rust("fn f() -> i32 { let q = 1; let g = |y: i32| y + q; g(0) }").unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_closure_distinguishes_captured_vs_free() { + // En el primero `z` es ligado en el scope exterior (parámetro de `f`); + // en el segundo `z` es libre. Aunque la forma del cierre coincide, + // la identidad del término difiere. + let a = parse::rust("fn f(z: i32) -> i32 { let g = |x: i32| x + z; g(0) }").unwrap(); + let b = parse::rust("fn f() -> i32 { let g = |x: i32| x + z; g(0) }").unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_for_loop_var_rename_invariant() { + let a = parse::rust("fn f(v: Vec) -> i32 { let mut s = 0; for x in v { s += x } s }") + .unwrap(); + let b = parse::rust("fn f(v: Vec) -> i32 { let mut s = 0; for y in v { s += y } s }") + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_tuple_destructure_rename_invariant() { + let a = parse::rust("fn f() -> i32 { let (a, b) = (1, 2); a + b }").unwrap(); + let b = parse::rust("fn f() -> i32 { let (x, y) = (1, 2); x + y }").unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_tuple_destructure_position_matters() { + // (a, b) y (a, b) pero el cuerpo usa b - a vs a - b: distintos. + let a = parse::rust("fn f() -> i32 { let (x, y) = (1, 2); x - y }").unwrap(); + let b = parse::rust("fn f() -> i32 { let (x, y) = (1, 2); y - x }").unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_mut_pattern_rename_invariant() { + let a = parse::rust("fn f() -> i32 { let mut x = 1; x += 2; x }").unwrap(); + let b = parse::rust("fn f() -> i32 { let mut z = 1; z += 2; z }").unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_match_simple_arm_rename_invariant() { + let a = parse::rust("fn f(v: i32) -> i32 { match v { x => x + 1, _ => 0 } }").unwrap(); + let b = parse::rust("fn f(v: i32) -> i32 { match v { y => y + 1, _ => 0 } }").unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_match_arms_have_independent_scope() { + // Arm 1 introduce `x`; arm 2 introduce `y`. Ambos renombrables sin + // afectarse mutuamente. + let a = parse::rust("fn f(v: i32) -> i32 { match v { x => x, y => y + 1, _ => 0 } }").unwrap(); + let b = parse::rust("fn f(v: i32) -> i32 { match v { a => a, b => b + 1, _ => 0 } }").unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_match_constructor_distinguishes_arms() { + // Some vs Ok: distintos constructores; el hash debe reflejarlo. + let a = + parse::rust("fn f(v: Option) -> i32 { match v { Some(x) => x, _ => 0 } }").unwrap(); + let b = + parse::rust("fn f(v: Result) -> i32 { match v { Ok(x) => x, _ => 0 } }").unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_match_tuple_struct_binder_rename_invariant() { + let a = + parse::rust("fn f(v: Option) -> i32 { match v { Some(x) => x + 1, None => 0 } }") + .unwrap(); + let b = + parse::rust("fn f(v: Option) -> i32 { match v { Some(y) => y + 1, None => 0 } }") + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_match_struct_pattern_rename_invariant() { + let a = parse::rust( + "struct P{x:i32,y:i32} fn f(p: P) -> i32 { match p { P { x: a, y: b } => a + b } }", + ) + .unwrap(); + let b = parse::rust( + "struct P{x:i32,y:i32} fn f(p: P) -> i32 { match p { P { x: c, y: d } => c + d } }", + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_match_struct_pattern_field_name_matters() { + // Renombrar el campo (la "x" antes del `:`) cambia la identidad: es + // parte de la firma del struct, no un binder. + let a = parse::rust( + "struct P{x:i32,y:i32} fn f(p: P) -> i32 { match p { P { x: a, y: b } => a + b } }", + ) + .unwrap(); + let b = parse::rust( + "struct P{x:i32,y:i32} fn f(p: P) -> i32 { match p { P { y: a, x: b } => a + b } }", + ) + .unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_match_guard_binder_rename_invariant() { + let a = parse::rust("fn f(v: i32) -> i32 { match v { x if x > 0 => x, _ => 0 } }").unwrap(); + let b = parse::rust("fn f(v: i32) -> i32 { match v { y if y > 0 => y, _ => 0 } }").unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_match_guard_op_distinguishes() { + let a = parse::rust("fn f(v: i32) -> i32 { match v { x if x > 0 => x, _ => 0 } }").unwrap(); + let b = parse::rust("fn f(v: i32) -> i32 { match v { x if x < 0 => x, _ => 0 } }").unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_match_captured_pattern_rename_invariant() { + let a = parse::rust("fn f(v: i32) -> i32 { match v { n @ 1..=5 => n, _ => 0 } }").unwrap(); + let b = parse::rust("fn f(v: i32) -> i32 { match v { m @ 1..=5 => m, _ => 0 } }").unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_match_captured_range_changes_hash() { + let a = parse::rust("fn f(v: i32) -> i32 { match v { n @ 1..=5 => n, _ => 0 } }").unwrap(); + let b = parse::rust("fn f(v: i32) -> i32 { match v { n @ 1..=9 => n, _ => 0 } }").unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_match_constructor_vs_binder() { + // En el primero, `None` es discriminator (mayúscula); en el segundo, + // `x` es un catch-all binder. Estructural y semánticamente distintos. + let a = + parse::rust("fn f(v: Option) -> i32 { match v { None => 0, Some(z) => z } }").unwrap(); + let b = + parse::rust("fn f(v: Option) -> i32 { match v { x => 0, Some(z) => z } }").unwrap(); + assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +// ==================================================================== +// Pendientes documentados — cierre del MVP de α-Rust. +// ==================================================================== + +#[test] +fn alpha_if_let_binder_rename_invariant() { + // El binder de `if let Some(x) = v` participa sólo del consequence. + // Renombrar x por y no debe afectar el hash. + let a = parse::rust( + "fn f(v: Option) -> i32 { if let Some(x) = v { x + 1 } else { 0 } }", + ) + .unwrap(); + let b = parse::rust( + "fn f(v: Option) -> i32 { if let Some(y) = v { y + 1 } else { 0 } }", + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_if_let_else_does_not_see_binder() { + // Sanity: el binder NO debe visitar el `else` (alternative). En + // `if let Some(x) = v { ... } else { v }`, el `else` ve `v` libre. + // Si renombramos sólo en el consequence, da el mismo hash. + let a = parse::rust( + "fn f(v: Option) -> i32 { if let Some(x) = v { x } else { 0 } }", + ) + .unwrap(); + let b = parse::rust( + "fn f(v: Option) -> i32 { if let Some(y) = v { y } else { 0 } }", + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_while_let_binder_rename_invariant() { + // El binder del while-let vive sólo en el body. + let a = parse::rust( + "fn f(mut it: Option) -> i32 { let mut total = 0; while let Some(x) = it { total += x; it = None; } total }", + ) + .unwrap(); + let b = parse::rust( + "fn f(mut it: Option) -> i32 { let mut total = 0; while let Some(y) = it { total += y; it = None; } total }", + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_let_else_binder_rename_invariant() { + // let-else: el binder vive sólo después del let, no en el else. + let a = parse::rust( + "fn f(v: Option) -> i32 { let Some(x) = v else { return 0 }; x + 1 }", + ) + .unwrap(); + let b = parse::rust( + "fn f(v: Option) -> i32 { let Some(y) = v else { return 0 }; y + 1 }", + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_or_pattern_binder_rename_invariant() { + // En un or-pattern (`Some(x) | Other(x)`), todos los lados + // introducen el mismo binder. Renombrar afecta a TODOS los lados + // a la vez. El hash se mantiene. + let a = parse::rust( + r#" + enum E { A(i32), B(i32) } + fn f(v: E) -> i32 { match v { E::A(x) | E::B(x) => x } } + "#, + ) + .unwrap(); + let b = parse::rust( + r#" + enum E { A(i32), B(i32) } + fn f(v: E) -> i32 { match v { E::A(y) | E::B(y) => y } } + "#, + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b)); +} + +#[test] +fn alpha_let_chain_binders_propagate_to_consequence() { + // Let-chain: dos let-conditions con &&. Ambos binders viven en + // el consequence. Renombrar ambos da mismo hash. + let a = parse::rust( + "fn f(a: Option, b: Option) -> i32 { if let Some(x) = a && let Some(y) = b { x + y } else { 0 } }", + ) + .unwrap(); + let c = parse::rust( + "fn f(a: Option, b: Option) -> i32 { if let Some(p) = a && let Some(q) = b { p + q } else { 0 } }", + ) + .unwrap(); + assert_eq!(hash_node_alpha(&a), hash_node_alpha(&c)); +} + +#[test] +fn alpha_if_let_does_not_collide_with_unrelated_program() { + // Sanity negativo: dos programas con `if let` distintos + // (operación distinta) NO deben dar el mismo hash. + let plus = parse::rust( + "fn f(v: Option) -> i32 { if let Some(x) = v { x + 1 } else { 0 } }", + ) + .unwrap(); + let minus = parse::rust( + "fn f(v: Option) -> i32 { if let Some(x) = v { x - 1 } else { 0 } }", + ) + .unwrap(); + assert_ne!(hash_node_alpha(&plus), hash_node_alpha(&minus)); +} diff --git a/03_ukupacha/minga/minga-core/tests/alpha_polyglot.rs b/03_ukupacha/minga/minga-core/tests/alpha_polyglot.rs new file mode 100644 index 0000000..c39d6cc --- /dev/null +++ b/03_ukupacha/minga/minga-core/tests/alpha_polyglot.rs @@ -0,0 +1,307 @@ +//! α-equivalencia para Python, TypeScript, JavaScript, Go. +//! +//! Mismas propiedades que `alpha_invariants.rs` para Rust: +//! - Renombre de variables ligadas → mismo hash. +//! - Cambio de estructura / nombres libres → hash distinto. + +use minga_core::alpha::hash_alpha_with; +use minga_core::parse::Dialect; + +fn h(d: Dialect, src: &str) -> minga_core::cas::ContentHash { + let n = d.parse(src).expect("parse OK"); + hash_alpha_with(d, &n) +} + +// ============================================================================ +// Python +// ============================================================================ + +#[test] +fn python_def_param_rename_invariant() { + let a = h(Dialect::Python, "def f(x):\n return x + 1\n"); + let b = h(Dialect::Python, "def f(y):\n return y + 1\n"); + assert_eq!(a, b); +} + +#[test] +fn python_def_function_name_matters() { + let a = h(Dialect::Python, "def f(x):\n return x\n"); + let b = h(Dialect::Python, "def g(x):\n return x\n"); + assert_ne!(a, b, "el nombre de la función NO es α-anónimo"); +} + +#[test] +fn python_lambda_rename_invariant() { + let a = h(Dialect::Python, "f = lambda x: x + 1\n"); + let b = h(Dialect::Python, "f = lambda y: y + 1\n"); + assert_eq!(a, b); +} + +#[test] +fn python_for_loop_rename_invariant() { + let a = h( + Dialect::Python, + "for x in xs:\n print(x)\n", + ); + let b = h( + Dialect::Python, + "for y in xs:\n print(y)\n", + ); + assert_eq!(a, b); +} + +#[test] +fn python_for_iterable_name_matters() { + let a = h( + Dialect::Python, + "for x in xs:\n print(x)\n", + ); + let b = h( + Dialect::Python, + "for x in ys:\n print(x)\n", + ); + assert_ne!(a, b, "el iterable es variable libre, su nombre importa"); +} + +#[test] +fn python_list_comprehension_rename_invariant() { + let a = h(Dialect::Python, "result = [x*2 for x in xs]\n"); + let b = h(Dialect::Python, "result = [y*2 for y in xs]\n"); + assert_eq!(a, b); +} + +#[test] +fn python_nested_comprehension_rename_invariant() { + // Doble for_in_clause: x e y son binders. + let a = h( + Dialect::Python, + "result = [(x, y) for x in xs for y in ys]\n", + ); + let b = h( + Dialect::Python, + "result = [(a, b) for a in xs for b in ys]\n", + ); + assert_eq!(a, b); +} + +#[test] +fn python_with_statement_rename_invariant() { + let a = h( + Dialect::Python, + "with open(p) as f:\n f.read()\n", + ); + let b = h( + Dialect::Python, + "with open(p) as g:\n g.read()\n", + ); + assert_eq!(a, b); +} + +#[test] +fn python_lambda_does_not_collide_with_unrelated() { + let plus = h(Dialect::Python, "f = lambda x: x + 1\n"); + let minus = h(Dialect::Python, "f = lambda x: x - 1\n"); + assert_ne!(plus, minus, "operación distinta debe dar hash distinto"); +} + +// ============================================================================ +// JavaScript / TypeScript (mismo profile) +// ============================================================================ + +#[test] +fn js_function_rename_invariant() { + let a = h(Dialect::JavaScript, "function f(x) { return x + 1; }"); + let b = h(Dialect::JavaScript, "function f(y) { return y + 1; }"); + assert_eq!(a, b); +} + +#[test] +fn js_function_name_matters() { + let a = h(Dialect::JavaScript, "function f(x) { return x; }"); + let b = h(Dialect::JavaScript, "function g(x) { return x; }"); + assert_ne!(a, b); +} + +#[test] +fn js_arrow_function_rename_invariant() { + let a = h(Dialect::JavaScript, "const f = (x) => x + 1;"); + let b = h(Dialect::JavaScript, "const f = (y) => y + 1;"); + assert_eq!(a, b); +} + +#[test] +fn js_arrow_shorthand_rename_invariant() { + // `x => ...` (sin paréntesis) — single identifier. + let a = h(Dialect::JavaScript, "const f = x => x + 1;"); + let b = h(Dialect::JavaScript, "const f = y => y + 1;"); + assert_eq!(a, b); +} + +#[test] +fn js_let_const_rename_invariant() { + let a = h(Dialect::JavaScript, "function f() { const x = 1; return x + 2; }"); + let b = h(Dialect::JavaScript, "function f() { const y = 1; return y + 2; }"); + assert_eq!(a, b); +} + +#[test] +fn js_for_of_rename_invariant() { + let a = h( + Dialect::JavaScript, + "function f() { for (const x of xs) { use(x); } }", + ); + let b = h( + Dialect::JavaScript, + "function f() { for (const y of xs) { use(y); } }", + ); + assert_eq!(a, b); +} + +#[test] +fn js_for_classic_rename_invariant() { + let a = h( + Dialect::JavaScript, + "function f() { for (let i = 0; i < n; i++) { use(i); } }", + ); + let b = h( + Dialect::JavaScript, + "function f() { for (let j = 0; j < n; j++) { use(j); } }", + ); + assert_eq!(a, b); +} + +#[test] +fn js_catch_rename_invariant() { + let a = h( + Dialect::JavaScript, + "function f() { try { x(); } catch (e) { log(e); } }", + ); + let b = h( + Dialect::JavaScript, + "function f() { try { x(); } catch (err) { log(err); } }", + ); + assert_eq!(a, b); +} + +#[test] +fn ts_typed_param_rename_invariant() { + // El TIPO afecta el hash, pero el nombre del parámetro no. + let a = h( + Dialect::TypeScript, + "function f(x: number): number { return x + 1; }", + ); + let b = h( + Dialect::TypeScript, + "function f(y: number): number { return y + 1; }", + ); + assert_eq!(a, b); +} + +#[test] +fn ts_typed_param_type_matters() { + let int_v = h( + Dialect::TypeScript, + "function f(x: number): number { return x; }", + ); + let str_v = h( + Dialect::TypeScript, + "function f(x: string): string { return x; }", + ); + assert_ne!(int_v, str_v, "el tipo afecta semántica"); +} + +// ============================================================================ +// Go +// ============================================================================ + +#[test] +fn go_function_rename_invariant() { + let a = h( + Dialect::Go, + "package main\nfunc add(a, b int) int { return a + b }\n", + ); + let b = h( + Dialect::Go, + "package main\nfunc add(x, y int) int { return x + y }\n", + ); + assert_eq!(a, b); +} + +#[test] +fn go_function_name_matters() { + let a = h( + Dialect::Go, + "package main\nfunc add(a, b int) int { return a + b }\n", + ); + let b = h( + Dialect::Go, + "package main\nfunc sub(a, b int) int { return a + b }\n", + ); + assert_ne!(a, b); +} + +#[test] +fn go_short_var_decl_rename_invariant() { + let a = h( + Dialect::Go, + "package main\nfunc main() { x := compute(); use(x) }\n", + ); + let b = h( + Dialect::Go, + "package main\nfunc main() { y := compute(); use(y) }\n", + ); + assert_eq!(a, b); +} + +#[test] +fn go_range_clause_rename_invariant() { + let a = h( + Dialect::Go, + "package main\nfunc main() { for k, v := range m { use(k, v) } }\n", + ); + let b = h( + Dialect::Go, + "package main\nfunc main() { for x, y := range m { use(x, y) } }\n", + ); + assert_eq!(a, b); +} + +#[test] +fn go_if_init_rename_invariant() { + let a = h( + Dialect::Go, + "package main\nfunc main() { if x := lookup(); x > 0 { use(x) } }\n", + ); + let b = h( + Dialect::Go, + "package main\nfunc main() { if y := lookup(); y > 0 { use(y) } }\n", + ); + assert_eq!(a, b); +} + +#[test] +fn go_func_literal_closure_rename_invariant() { + let a = h( + Dialect::Go, + "package main\nvar f = func(x int) int { return x + 1 }\n", + ); + let b = h( + Dialect::Go, + "package main\nvar f = func(y int) int { return y + 1 }\n", + ); + assert_eq!(a, b); +} + +// ============================================================================ +// Cross-language sanity +// ============================================================================ + +#[test] +fn structurally_similar_programs_in_different_languages_have_distinct_hashes() { + // `def f(x): return x+1` en Python vs `function f(x){return x+1}` en JS. + // Mismo "shape" en idea pero distintas gramáticas → distintos kinds → + // distintos hashes. Importante para evitar colisiones cross-language. + let py = h(Dialect::Python, "def f(x):\n return x + 1\n"); + let js = h(Dialect::JavaScript, "function f(x) { return x + 1; }"); + assert_ne!(py, js); +} diff --git a/03_ukupacha/minga/minga-core/tests/attestation_invariants.rs b/03_ukupacha/minga/minga-core/tests/attestation_invariants.rs new file mode 100644 index 0000000..3e692d2 --- /dev/null +++ b/03_ukupacha/minga/minga-core/tests/attestation_invariants.rs @@ -0,0 +1,136 @@ +//! Invariantes de las atestaciones firmadas y del `AttestationStore`. +//! +//! La tesis del módulo: una atestación válida es una **prueba** +//! criptográfica de autoría, no una declaración. El store nunca +//! almacena pruebas falsas — cualquier intento de inyectar una firma +//! corrupta se rechaza al ingresar, no al consultar. + +use minga_core::{Attestation, AttestationError, AttestationStore, ContentHash, Keypair}; + +fn kp(seed: u8) -> Keypair { + Keypair::from_seed(&[seed; 32]) +} + +fn ch(seed: u8) -> ContentHash { + ContentHash([seed; 32]) +} + +#[test] +fn create_then_verify_succeeds() { + let alice = kp(1); + let att = Attestation::create(&alice, ch(7)); + assert!(att.verify()); + assert_eq!(att.author, alice.did()); + assert_eq!(att.content, ch(7)); +} + +#[test] +fn modifying_content_invalidates() { + let alice = kp(1); + let mut att = Attestation::create(&alice, ch(7)); + att.content = ch(8); + assert!(!att.verify()); +} + +#[test] +fn modifying_signature_invalidates() { + let alice = kp(1); + let mut att = Attestation::create(&alice, ch(7)); + att.signature.0[0] ^= 0xFF; + assert!(!att.verify()); +} + +#[test] +fn modifying_author_invalidates() { + let alice = kp(1); + let bob = kp(2); + let mut att = Attestation::create(&alice, ch(7)); + att.author = bob.did(); + assert!(!att.verify()); +} + +#[test] +fn store_accepts_valid_attestation() { + let alice = kp(1); + let att = Attestation::create(&alice, ch(5)); + let mut store = AttestationStore::new(); + assert!(store.add(att.clone()).is_ok()); + assert_eq!(store.len(), 1); + assert_eq!(store.get(&ch(5)), &[att][..]); +} + +#[test] +fn store_rejects_invalid_signature() { + let alice = kp(1); + let mut att = Attestation::create(&alice, ch(5)); + att.signature.0[10] ^= 1; + let mut store = AttestationStore::new(); + assert_eq!(store.add(att), Err(AttestationError::InvalidSignature)); + assert_eq!(store.len(), 0); +} + +#[test] +fn store_rejects_swapped_content() { + // Atestación creada para `ch(1)`, modificada para reclamar `ch(2)`. + // La firma sigue siendo válida sobre `ch(1)` pero ahora el content + // dice `ch(2)` — no verifica. + let alice = kp(1); + let mut att = Attestation::create(&alice, ch(1)); + att.content = ch(2); + let mut store = AttestationStore::new(); + assert!(store.add(att).is_err()); +} + +#[test] +fn store_is_idempotent_for_same_author_content() { + let alice = kp(1); + let att = Attestation::create(&alice, ch(5)); + let mut store = AttestationStore::new(); + store.add(att.clone()).unwrap(); + store.add(att.clone()).unwrap(); + store.add(att).unwrap(); + assert_eq!(store.len(), 1); +} + +#[test] +fn store_keeps_multiple_authors_per_content() { + let alice = kp(1); + let bob = kp(2); + let carol = kp(3); + let h = ch(99); + let mut store = AttestationStore::new(); + store.add(Attestation::create(&alice, h)).unwrap(); + store.add(Attestation::create(&bob, h)).unwrap(); + store.add(Attestation::create(&carol, h)).unwrap(); + assert_eq!(store.len(), 3); + assert_eq!(store.get(&h).len(), 3); + + let authors = store.authors_of(&h); + assert_eq!(authors.len(), 3); + assert!(authors.contains(&alice.did())); + assert!(authors.contains(&bob.did())); + assert!(authors.contains(&carol.did())); +} + +#[test] +fn authors_of_for_unknown_content_is_empty() { + let store = AttestationStore::new(); + assert!(store.authors_of(&ch(0)).is_empty()); + assert_eq!(store.get(&ch(0)).len(), 0); +} + +#[test] +fn distinct_authors_distinct_signatures_same_content() { + // Firmar el mismo `ContentHash` con dos llaves distintas produce + // firmas distintas (Ed25519 es determinista por llave, así que la + // diferencia viene de la llave, no de un nonce aleatorio). + let alice = kp(1); + let bob = kp(2); + let h = ch(50); + let a1 = Attestation::create(&alice, h); + let a2 = Attestation::create(&bob, h); + assert_ne!(a1.signature, a2.signature); + assert_ne!(a1.author, a2.author); + assert!(a1.verify()); + assert!(a2.verify()); +} diff --git a/03_ukupacha/minga/minga-core/tests/cas_invariants.rs b/03_ukupacha/minga/minga-core/tests/cas_invariants.rs new file mode 100644 index 0000000..6b57a2c --- /dev/null +++ b/03_ukupacha/minga/minga-core/tests/cas_invariants.rs @@ -0,0 +1,64 @@ +//! Invariantes del direccionamiento por contenido semántico. +//! +//! Estos tests definen la *tesis matemática* del núcleo: qué cambios deben +//! preservar el hash y qué cambios deben romperlo. Si alguno falla, la +//! garantía fundacional de Minga está rota. + +use minga_core::{cas::hash_node, parse}; + +#[test] +fn whitespace_invariant() { + let a = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap(); + let b = parse::rust("fn add(x:i32,y:i32)->i32{x+y}").unwrap(); + let c = parse::rust("fn add( x : i32 , y : i32 )\n -> i32\n{\n x + y\n}").unwrap(); + assert_eq!(hash_node(&a), hash_node(&b)); + assert_eq!(hash_node(&a), hash_node(&c)); +} + +#[test] +fn comment_invariant() { + let a = parse::rust("fn f() { 1 + 2 }").unwrap(); + let b = parse::rust("fn f() { /* comentario */ 1 + 2 // cola\n }").unwrap(); + let c = parse::rust("// arriba\nfn f() {\n // dentro\n 1 + 2\n}\n").unwrap(); + assert_eq!(hash_node(&a), hash_node(&b)); + assert_eq!(hash_node(&a), hash_node(&c)); +} + +#[test] +fn body_change_breaks_hash() { + let a = parse::rust("fn f() { 1 + 2 }").unwrap(); + let b = parse::rust("fn f() { 1 + 3 }").unwrap(); + assert_ne!(hash_node(&a), hash_node(&b)); +} + +#[test] +fn rename_breaks_hash_for_now() { + // Capa base: renombrar identificadores cambia el hash. La identidad + // por intención (alpha-equivalencia: mismo cuerpo módulo nombres + // ligados) es una capa superior que se construirá encima. + let a = parse::rust("fn add(x: i32) -> i32 { x }").unwrap(); + let b = parse::rust("fn add(y: i32) -> i32 { y }").unwrap(); + assert_ne!(hash_node(&a), hash_node(&b)); +} + +#[test] +fn signature_change_breaks_hash() { + let a = parse::rust("fn f(x: i32) -> i32 { x }").unwrap(); + let b = parse::rust("fn f(x: i64) -> i64 { x }").unwrap(); + assert_ne!(hash_node(&a), hash_node(&b)); +} + +#[test] +fn order_matters() { + // Reordenar dos funciones top-level cambia el hash del archivo entero + // (el árbol del source_file tiene hijos ordenados). El hash de cada + // función individual debe permanecer estable. + let file_a = parse::rust("fn a() {} fn b() {}").unwrap(); + let file_b = parse::rust("fn b() {} fn a() {}").unwrap(); + assert_ne!(hash_node(&file_a), hash_node(&file_b)); + + // Pero las funciones individuales (segundo nivel) sí coinciden cruzadas: + let fa = &file_a.children[0]; // fn a + let fb_in_b = &file_b.children[1]; // fn a en file_b + assert_eq!(hash_node(fa), hash_node(fb_in_b)); +} diff --git a/03_ukupacha/minga/minga-core/tests/identity_invariants.rs b/03_ukupacha/minga/minga-core/tests/identity_invariants.rs new file mode 100644 index 0000000..3563e7d --- /dev/null +++ b/03_ukupacha/minga/minga-core/tests/identity_invariants.rs @@ -0,0 +1,172 @@ +//! Invariantes de la identidad criptográfica: roundtrip de firma, +//! determinismo desde semilla, detección de manipulaciones. + +use minga_core::{Did, Keypair, KeypairCryptoError, Signature}; + +fn kp(seed: u8) -> Keypair { + Keypair::from_seed(&[seed; 32]) +} + +#[test] +fn keypair_from_seed_is_deterministic() { + let a = kp(7); + let b = kp(7); + assert_eq!(a.did(), b.did()); + let msg = b"hola minga"; + assert_eq!(a.sign(msg), b.sign(msg)); +} + +#[test] +fn distinct_seeds_produce_distinct_dids() { + let a = kp(1); + let b = kp(2); + assert_ne!(a.did(), b.did()); +} + +#[test] +fn generate_produces_unique_dids() { + // Dos `generate()` consecutivos deben dar DIDs distintos con + // probabilidad abrumadora (chance de colisión ≈ 2^-256). + let a = Keypair::generate(); + let b = Keypair::generate(); + assert_ne!(a.did(), b.did()); +} + +#[test] +fn sign_verify_roundtrip() { + let k = kp(42); + let msg = b"mensaje arbitrario de longitud variable, con UTF-8: cafe \xc3\xa9"; + let sig = k.sign(msg); + assert!(k.did().verify(msg, &sig)); +} + +#[test] +fn verify_fails_with_wrong_did() { + let signer = kp(10); + let msg = b"contenido"; + let sig = signer.sign(msg); + let imposter = kp(11).did(); + assert!(!imposter.verify(msg, &sig)); +} + +#[test] +fn verify_fails_with_tampered_message() { + let k = kp(99); + let sig = k.sign(b"mensaje original"); + assert!(!k.did().verify(b"mensaje modificado", &sig)); +} + +#[test] +fn verify_fails_with_tampered_signature() { + let k = kp(99); + let mut sig = k.sign(b"x"); + sig.0[0] ^= 0xFF; + assert!(!k.did().verify(b"x", &sig)); +} + +#[test] +fn verify_handles_invalid_did_bytes() { + // Did con bytes que no forman un punto válido en la curva debería + // fallar verificación silenciosamente (sin pánico). + let bogus_did = Did([0xFF; 32]); + let sig = Signature([0u8; 64]); + assert!(!bogus_did.verify(b"anything", &sig)); +} + +#[test] +fn did_display_uses_did_key_prefix() { + let did = kp(0).did(); + let s = format!("{}", did); + assert!(s.starts_with("did:key:")); + assert_eq!(s.len(), "did:key:".len() + 64); // 32 bytes en hex = 64 chars +} + +#[test] +fn encrypt_decrypt_roundtrip_preserves_identity() { + let original = kp(7); + let blob = original.encrypt("contraseña-correcta").unwrap(); + let restored = Keypair::decrypt(&blob, "contraseña-correcta").unwrap(); + + // El DID se preserva: misma identidad pública. + assert_eq!(original.did(), restored.did()); + + // Y la capacidad de firmar — un mensaje firmado por uno verifica + // contra el DID del otro (porque son la misma llave). + let msg = b"prueba post-cifrado"; + let sig_original = original.sign(msg); + let sig_restored = restored.sign(msg); + assert_eq!(sig_original, sig_restored); + assert!(restored.did().verify(msg, &sig_original)); +} + +#[test] +fn decrypt_with_wrong_passphrase_fails() { + let kp = kp(11); + let blob = kp.encrypt("correcta").unwrap(); + let r = Keypair::decrypt(&blob, "incorrecta"); + assert!(matches!(r, Err(KeypairCryptoError::DecryptFailed))); +} + +#[test] +fn decrypt_rejects_tampered_ciphertext() { + // AES-GCM es authenticated: cualquier modificación del cipher + // (incluyendo el tag) hace fallar la verificación. + let kp = kp(13); + let mut blob = kp.encrypt("pass").unwrap(); + let last = blob.len() - 1; + blob[last] ^= 0xFF; + let r = Keypair::decrypt(&blob, "pass"); + assert!(matches!(r, Err(KeypairCryptoError::DecryptFailed))); +} + +#[test] +fn decrypt_rejects_invalid_format() { + assert!(matches!( + Keypair::decrypt(b"too short", "x"), + Err(KeypairCryptoError::InvalidFormat) + )); + let mut bogus = vec![0xFFu8; 100]; + bogus[0..8].copy_from_slice(b"NOTMINGA"); + assert!(matches!( + Keypair::decrypt(&bogus, "x"), + Err(KeypairCryptoError::InvalidFormat) + )); +} + +#[test] +fn distinct_passphrases_produce_distinct_blobs() { + // Cifrar la misma key con dos passphrases distintas produce blobs + // distintos (también porque salt y nonce son aleatorios — no es + // determinismo, es solo que no colisionan). + let kp = kp(17); + let a = kp.encrypt("alpha").unwrap(); + let b = kp.encrypt("beta").unwrap(); + assert_ne!(a, b); +} + +#[test] +fn re_encrypting_same_keypair_produces_distinct_blobs() { + // Salt y nonce aleatorios: el mismo keypair y la misma passphrase + // producen cipher distintos en cada llamada. Sin patrón observable. + let kp = kp(19); + let blob1 = kp.encrypt("p").unwrap(); + let blob2 = kp.encrypt("p").unwrap(); + assert_ne!(blob1, blob2); + // Pero ambos descifran a la misma identidad. + assert_eq!( + Keypair::decrypt(&blob1, "p").unwrap().did(), + Keypair::decrypt(&blob2, "p").unwrap().did() + ); +} + +#[test] +fn keypair_debug_does_not_leak_private_key() { + // El derive de Debug expondría los bytes secretos. Lo + // sobreescribimos para que solo muestre el DID. + let k = kp(1); + let s = format!("{:?}", k); + assert!(s.contains("did:key:")); + // No debería aparecer ningún byte de la semilla [1u8; 32] en hex + // contiguo (fragmento "010101..." sería sospechoso si emergiera). + assert!(!s.contains("0101010101010101")); +} diff --git a/03_ukupacha/minga/minga-core/tests/mst_invariants.rs b/03_ukupacha/minga/minga-core/tests/mst_invariants.rs new file mode 100644 index 0000000..05c35e7 --- /dev/null +++ b/03_ukupacha/minga/minga-core/tests/mst_invariants.rs @@ -0,0 +1,347 @@ +//! Invariantes del Merkle Search Tree. +//! +//! La tesis del MST: dado un mismo conjunto de hashes, el árbol y su +//! `root_hash` son únicos, sin importar el orden de inserción. Eso es lo +//! que permite a dos repositorios saber si convergen comparando un solo +//! hash de 32 bytes y, si difieren, descender solo por las ramas con +//! diferencias. + +use minga_core::{cas::ContentHash, mst::Mst}; + +fn ch(seed: u64) -> ContentHash { + // Usamos blake3 para que la distribución de niveles (nibbles cero al + // inicio) sea representativa, no degenerada. + let h = blake3::hash(&seed.to_le_bytes()); + ContentHash(*h.as_bytes()) +} + +#[test] +fn mst_empty() { + let m = Mst::new(); + assert!(m.is_empty()); + assert_eq!(m.len(), 0); + assert_eq!(m.iter().count(), 0); +} + +#[test] +fn mst_insert_single() { + let mut m = Mst::new(); + let h = ch(1); + assert!(m.insert(h)); + assert!(!m.insert(h)); // duplicado: no-op + assert_eq!(m.len(), 1); + assert!(m.contains(&h)); + assert!(!m.contains(&ch(2))); +} + +#[test] +fn mst_iter_yields_sorted_keys() { + let mut m = Mst::new(); + let mut hashes: Vec = (0..32u64).map(ch).collect(); + for h in &hashes { + m.insert(*h); + } + let collected: Vec = m.iter().copied().collect(); + hashes.sort(); + assert_eq!(collected, hashes); +} + +#[test] +fn mst_history_independence() { + // Mismo conjunto, tres órdenes de inserción distintos: orden natural, + // inverso, y reordenado por byte arbitrario. Los tres deben producir + // exactamente el mismo árbol. + let hashes: Vec = (0..50u64).map(ch).collect(); + + let mut m_natural = Mst::new(); + for h in &hashes { + m_natural.insert(*h); + } + + let mut m_reverse = Mst::new(); + for h in hashes.iter().rev() { + m_reverse.insert(*h); + } + + let mut shuffled = hashes.clone(); + shuffled.sort_by_key(|h| h.0[7]); + let mut m_shuffled = Mst::new(); + for h in &shuffled { + m_shuffled.insert(*h); + } + + assert_eq!(m_natural.len(), 50); + assert_eq!(m_natural.len(), m_reverse.len()); + assert_eq!(m_natural.len(), m_shuffled.len()); + + assert_eq!(m_natural.root_hash(), m_reverse.root_hash()); + assert_eq!(m_natural.root_hash(), m_shuffled.root_hash()); + + let s_natural: Vec = m_natural.iter().copied().collect(); + let s_reverse: Vec = m_reverse.iter().copied().collect(); + let s_shuffled: Vec = m_shuffled.iter().copied().collect(); + assert_eq!(s_natural, s_reverse); + assert_eq!(s_natural, s_shuffled); +} + +#[test] +fn mst_set_difference_changes_root() { + let mut m1 = Mst::new(); + m1.insert(ch(1)); + m1.insert(ch(2)); + + let mut m2 = Mst::new(); + m2.insert(ch(1)); + m2.insert(ch(3)); + + let mut m3 = Mst::new(); + m3.insert(ch(1)); + m3.insert(ch(2)); + + assert_ne!(m1.root_hash(), m2.root_hash()); + assert_eq!(m1.root_hash(), m3.root_hash()); +} + +#[test] +fn mst_root_hash_changes_with_size() { + let mut m = Mst::new(); + let h0 = m.root_hash(); + m.insert(ch(1)); + let h1 = m.root_hash(); + m.insert(ch(2)); + let h2 = m.root_hash(); + assert_ne!(h0, h1); + assert_ne!(h1, h2); +} + +#[test] +fn mst_contains_after_many_inserts() { + let mut m = Mst::new(); + let hashes: Vec = (0..200u64).map(ch).collect(); + for h in &hashes { + m.insert(*h); + } + for h in &hashes { + assert!(m.contains(h), "falta clave {h}"); + } + assert!(!m.contains(&ch(9999))); + assert_eq!(m.len(), 200); +} + +#[test] +fn mst_no_duplicates_inflate_size() { + let mut m = Mst::new(); + for _ in 0..10 { + m.insert(ch(42)); + } + assert_eq!(m.len(), 1); +} + +#[test] +fn mst_diff_identical_is_empty() { + let hs: Vec<_> = (0..30u64).map(ch).collect(); + let mut a = Mst::new(); + let mut b = Mst::new(); + for h in &hs { + a.insert(*h); + b.insert(*h); + } + let d = a.diff(&b); + assert!(d.is_empty()); + assert_eq!(d.total(), 0); +} + +#[test] +fn mst_diff_history_independent() { + // Mismo conjunto en orden distinto: diff vacío. Aquí estresa el + // short-circuit de Merkle: con 1000 claves construidas en órdenes + // opuestos, la igualdad debe detectarse en una sola comparación. + let hs: Vec<_> = (0..1000u64).map(ch).collect(); + let mut a = Mst::new(); + for h in &hs { + a.insert(*h); + } + let mut b = Mst::new(); + for h in hs.iter().rev() { + b.insert(*h); + } + assert!(a.diff(&b).is_empty()); +} + +#[test] +fn mst_diff_one_empty_yields_other() { + let hs: Vec<_> = (0..10u64).map(ch).collect(); + let empty = Mst::new(); + let mut full = Mst::new(); + for h in &hs { + full.insert(*h); + } + + let d_full_vs_empty = full.diff(&empty); + assert_eq!(d_full_vs_empty.only_in_self.len(), 10); + assert!(d_full_vs_empty.only_in_other.is_empty()); + + let d_empty_vs_full = empty.diff(&full); + assert!(d_empty_vs_full.only_in_self.is_empty()); + assert_eq!(d_empty_vs_full.only_in_other.len(), 10); +} + +#[test] +fn mst_diff_disjoint_sets() { + let only_a: Vec<_> = (0..15u64).map(ch).collect(); + let only_b: Vec<_> = (100..115u64).map(ch).collect(); + let mut a = Mst::new(); + for h in &only_a { + a.insert(*h); + } + let mut b = Mst::new(); + for h in &only_b { + b.insert(*h); + } + let d = a.diff(&b); + assert_eq!(d.only_in_self.len(), 15); + assert_eq!(d.only_in_other.len(), 15); + + // El conjunto reportado debe coincidir exactamente con los inputs. + let mut got_a = d.only_in_self.clone(); + let mut got_b = d.only_in_other.clone(); + got_a.sort(); + got_b.sort(); + let mut want_a = only_a.clone(); + let mut want_b = only_b.clone(); + want_a.sort(); + want_b.sort(); + assert_eq!(got_a, want_a); + assert_eq!(got_b, want_b); +} + +#[test] +fn mst_diff_partial_overlap() { + let common: Vec<_> = (0..40u64).map(ch).collect(); + let only_a: Vec<_> = (40..50u64).map(ch).collect(); + let only_b: Vec<_> = (50..58u64).map(ch).collect(); + + let mut a = Mst::new(); + for h in common.iter().chain(only_a.iter()) { + a.insert(*h); + } + let mut b = Mst::new(); + for h in common.iter().chain(only_b.iter()) { + b.insert(*h); + } + + let d = a.diff(&b); + // Las claves comunes no aparecen en el diff; solo las únicas. + assert_eq!(d.only_in_self.len(), only_a.len()); + assert_eq!(d.only_in_other.len(), only_b.len()); +} + +#[test] +fn mst_diff_is_symmetric() { + let a_keys: Vec<_> = (0..20u64).map(ch).collect(); + let b_keys: Vec<_> = (10..30u64).map(ch).collect(); + let mut a = Mst::new(); + for h in &a_keys { + a.insert(*h); + } + let mut b = Mst::new(); + for h in &b_keys { + b.insert(*h); + } + let ab = a.diff(&b); + let ba = b.diff(&a); + assert_eq!(ab.only_in_self, ba.only_in_other); + assert_eq!(ab.only_in_other, ba.only_in_self); +} + +#[test] +fn mst_diff_output_is_sorted() { + // Sin importar la divergencia, el output viene ordenado por hash. + let a_keys: Vec<_> = (0..25u64).map(ch).collect(); + let b_keys: Vec<_> = (15..40u64).map(ch).collect(); + let mut a = Mst::new(); + for h in &a_keys { + a.insert(*h); + } + let mut b = Mst::new(); + for h in &b_keys { + b.insert(*h); + } + let d = a.diff(&b); + let mut sorted = d.only_in_self.clone(); + sorted.sort(); + assert_eq!(d.only_in_self, sorted); + let mut sorted2 = d.only_in_other.clone(); + sorted2.sort(); + assert_eq!(d.only_in_other, sorted2); +} + +#[test] +fn mst_diff_apply_converges() { + // La propiedad fundacional para sincronización P2P: si cada peer + // calcula el diff y aplica las claves que le faltan, ambos + // convergen al mismo conjunto y el segundo diff es vacío. + let common: Vec<_> = (0..50u64).map(ch).collect(); + let only_a: Vec<_> = (50..70u64).map(ch).collect(); + let only_b: Vec<_> = (70..85u64).map(ch).collect(); + + let mut a = Mst::new(); + for h in common.iter().chain(only_a.iter()) { + a.insert(*h); + } + let mut b = Mst::new(); + for h in common.iter().chain(only_b.iter()) { + b.insert(*h); + } + + let d = a.diff(&b); + + for h in &d.only_in_other { + a.insert(*h); + } + for h in &d.only_in_self { + b.insert(*h); + } + + assert_eq!(a.root_hash(), b.root_hash()); + assert!(a.diff(&b).is_empty()); + assert_eq!(a.len(), common.len() + only_a.len() + only_b.len()); +} + +#[test] +fn mst_diff_single_key_change() { + // Repos casi idénticos, diferenciados por una sola clave. El + // short-circuit de Merkle debería podar todo lo demás. No medimos + // el coste aquí (es un test de corrección), pero verificamos que + // el resultado es exactamente la diferencia esperada. + let hs: Vec<_> = (0..200u64).map(ch).collect(); + let mut a = Mst::new(); + for h in &hs { + a.insert(*h); + } + let mut b = a.clone(); + let extra = ch(9999); + b.insert(extra); + + let d = a.diff(&b); + assert!(d.only_in_self.is_empty()); + assert_eq!(d.only_in_other, vec![extra]); +} + +#[test] +fn mst_levels_distribute_naturally() { + // Sanity: con 1000 claves blake3, esperamos que algunas tengan nivel + // > 0 (probabilidad de >= 1 nibble cero al inicio ≈ 1/16, así que + // ~62 claves esperadas). Si el árbol es de un solo nivel, algo en la + // promoción/split está mal. + let mut m = Mst::new(); + for i in 0..1000u64 { + m.insert(ch(i)); + } + assert_eq!(m.len(), 1000); + // Si todas las claves estuvieran al mismo nivel, el árbol sería un + // único nodo gigante y `root_hash` sería trivialmente reconstruible. + // No es una verificación profunda, pero pillaría una regresión obvia. + assert!(m.contains(&ch(0))); + assert!(m.contains(&ch(999))); +} diff --git a/03_ukupacha/minga/minga-core/tests/serde_roundtrips.rs b/03_ukupacha/minga/minga-core/tests/serde_roundtrips.rs new file mode 100644 index 0000000..fff8279 --- /dev/null +++ b/03_ukupacha/minga/minga-core/tests/serde_roundtrips.rs @@ -0,0 +1,134 @@ +//! Tests de roundtrip de serialización para los tipos de wire. +//! +//! Cualquier tipo que cruce la red debe (a) (de)serializar bit-a-bit +//! igual sobre postcard, y (b) preservar todos sus invariantes +//! semánticos tras un viaje. Estos tests son la red de seguridad +//! contra cambios de schema accidentales que romperían la +//! compatibilidad on-the-wire. + +use minga_core::{Attestation, ContentHash, Keypair, NodeProbe, Signature, StoredNode}; + +fn roundtrip serde::Deserialize<'a> + PartialEq + std::fmt::Debug>( + value: &T, +) { + let bytes = postcard::to_allocvec(value).unwrap(); + let decoded: T = postcard::from_bytes(&bytes).unwrap(); + assert_eq!(value, &decoded); +} + +#[test] +fn content_hash_roundtrip() { + let h = ContentHash([42; 32]); + roundtrip(&h); + + // Codifica como exactamente 32 bytes (transparent sobre [u8; 32]). + let bytes = postcard::to_allocvec(&h).unwrap(); + assert_eq!(bytes.len(), 32); + assert_eq!(bytes, vec![42u8; 32]); +} + +#[test] +fn did_roundtrip() { + let kp = Keypair::from_seed(&[7; 32]); + let did = kp.did(); + roundtrip(&did); + let bytes = postcard::to_allocvec(&did).unwrap(); + assert_eq!(bytes.len(), 32); +} + +#[test] +fn signature_roundtrip() { + let kp = Keypair::from_seed(&[3; 32]); + let sig = kp.sign(b"mensaje"); + roundtrip(&sig); + // 64 bytes Ed25519 + cualquier overhead transparent. + let bytes = postcard::to_allocvec(&sig).unwrap(); + assert_eq!(bytes.len(), 64); +} + +#[test] +fn signature_roundtrip_preserves_verify() { + let kp = Keypair::from_seed(&[9; 32]); + let msg = b"el mensaje original"; + let sig = kp.sign(msg); + + let bytes = postcard::to_allocvec(&sig).unwrap(); + let decoded: Signature = postcard::from_bytes(&bytes).unwrap(); + + // El predicado criptográfico se preserva exactamente. + assert!(kp.did().verify(msg, &decoded)); +} + +#[test] +fn stored_node_roundtrip() { + let s = StoredNode { + kind: "function_item".to_string(), + field_name: Some("body".to_string()), + leaf_text: None, + children: vec![ContentHash([1; 32]), ContentHash([2; 32])], + }; + roundtrip(&s); +} + +#[test] +fn stored_node_with_leaf_roundtrip() { + let s = StoredNode { + kind: "integer_literal".to_string(), + field_name: None, + leaf_text: Some(b"42".to_vec()), + children: Vec::new(), + }; + roundtrip(&s); +} + +#[test] +fn attestation_roundtrip() { + let kp = Keypair::from_seed(&[5; 32]); + let att = Attestation::create(&kp, ContentHash([99; 32])); + roundtrip(&att); +} + +#[test] +fn attestation_roundtrip_preserves_verify() { + let kp = Keypair::from_seed(&[11; 32]); + let att = Attestation::create(&kp, ContentHash([77; 32])); + + let bytes = postcard::to_allocvec(&att).unwrap(); + let decoded: Attestation = postcard::from_bytes(&bytes).unwrap(); + + assert!(decoded.verify()); +} + +#[test] +fn node_probe_roundtrip() { + let probe = NodeProbe { + level: 3, + keys: vec![ContentHash([1; 32]), ContentHash([2; 32])], + child_hashes: vec![ + ContentHash([10; 32]), + ContentHash([20; 32]), + ContentHash([30; 32]), + ], + }; + roundtrip(&probe); +} + +#[test] +fn empty_collections_serialize_compactly() { + // postcard codifica longitudes con varint. Vec vacío = 1 byte (longitud 0). + let probe = NodeProbe { + level: 0, + keys: Vec::new(), + child_hashes: Vec::new(), + }; + let bytes = postcard::to_allocvec(&probe).unwrap(); + // postcard varint: u32(0) = 1 byte, vec_len(0) = 1 byte ×2 = 3 bytes total. + assert_eq!(bytes.len(), 3); +} + +#[test] +fn malformed_bytes_fail_decode() { + let bogus = vec![0xFFu8; 100]; + let result: Result = postcard::from_bytes(&bogus); + assert!(result.is_err()); +} diff --git a/03_ukupacha/minga/minga-core/tests/store_invariants.rs b/03_ukupacha/minga/minga-core/tests/store_invariants.rs new file mode 100644 index 0000000..a9eb7f4 --- /dev/null +++ b/03_ukupacha/minga/minga-core/tests/store_invariants.rs @@ -0,0 +1,129 @@ +//! Invariantes del NodeStore. +//! +//! El almacén tiene tres responsabilidades cruzadas que deben sostenerse +//! simultáneamente: +//! 1. **Round-trip exacto**: lo que entró sale igual. +//! 2. **Hash estable**: el hash que devuelve `put` coincide con +//! `cas::hash_node` del nodo original. +//! 3. **Deduplicación**: subárboles compartidos se almacenan una sola vez. + +use minga_core::{ + cas::hash_node, + parse, + store::{MemStore, NodeStore}, +}; + +#[test] +fn store_round_trip_preserves_tree() { + let original = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap(); + let mut store = MemStore::new(); + let h = store.put(&original); + let reconstructed = store.reconstruct(&h).unwrap(); + assert_eq!(reconstructed, original); +} + +#[test] +fn store_hash_matches_cas() { + let n = parse::rust("fn f() -> bool { true }").unwrap(); + let mut store = MemStore::new(); + let put_hash = store.put(&n); + assert_eq!(put_hash, hash_node(&n)); +} + +#[test] +fn store_idempotent_put() { + let n = parse::rust("fn f() { 1 + 2 + 3 }").unwrap(); + let mut store = MemStore::new(); + let h1 = store.put(&n); + let len_after_first = store.len(); + let h2 = store.put(&n); + let len_after_second = store.len(); + assert_eq!(h1, h2); + assert_eq!(len_after_first, len_after_second); +} + +#[test] +fn store_dedup_shared_subtree() { + // Dos funciones con cuerpo idéntico: el subárbol del bloque y todos + // sus descendientes deben aparecer una sola vez en el almacén. + let a = parse::rust("fn alpha() -> i32 { 1 + 2 }").unwrap(); + let b = parse::rust("fn beta() -> i32 { 1 + 2 }").unwrap(); + + let mut store = MemStore::new(); + let h_a = store.put(&a); + let count_after_a = store.len(); + let h_b = store.put(&b); + let count_after_b = store.len(); + + assert_ne!(h_a, h_b, "los hashes raíz deben diferir (nombres distintos)"); + + // Buscar el bloque del cuerpo en ambas y verificar mismo hash. + let body_a = find_first_kind(&a, "block").unwrap(); + let body_b = find_first_kind(&b, "block").unwrap(); + assert_eq!(hash_node(body_a), hash_node(body_b)); + + // Crecimiento esperado al añadir b: solo los nodos que difieren entre + // las dos funciones (el `function_item` raíz, el identificador del + // nombre `beta`, posiblemente algún wrapper). En cualquier caso, + // estrictamente menos que duplicar el almacén. + assert!( + count_after_b < 2 * count_after_a, + "dedup falló: {count_after_b} >= 2 * {count_after_a}" + ); +} + +#[test] +fn store_subtree_resolvable_independently() { + // El hash de cualquier subárbol debe poder reconstruirse aunque + // hayamos pedido un árbol mayor que lo contiene. + let n = parse::rust("fn f() -> i32 { let x = 7; x * 2 }").unwrap(); + let mut store = MemStore::new(); + store.put(&n); + + let block = find_first_kind(&n, "block").unwrap(); + let block_hash = hash_node(block); + assert!(store.contains(&block_hash)); + let reconstructed_block = store.reconstruct(&block_hash).unwrap(); + assert_eq!(&reconstructed_block, block); +} + +#[test] +fn store_unknown_hash_is_none() { + let store = MemStore::new(); + let bogus = minga_core::ContentHash([0xAB; 32]); + assert!(store.get(&bogus).is_none()); + assert!(store.reconstruct(&bogus).is_none()); +} + +#[test] +fn store_multiple_files_share_common_constants() { + // Tres archivos con el literal "42" repetido: el nodo + // `integer_literal` con texto "42" debe almacenarse una sola vez. + let n1 = parse::rust("fn a() -> i32 { 42 }").unwrap(); + let n2 = parse::rust("fn b() -> i32 { 42 }").unwrap(); + let n3 = parse::rust("fn c() -> i32 { 42 }").unwrap(); + let mut store = MemStore::new(); + store.put(&n1); + let after_one = store.len(); + store.put(&n2); + store.put(&n3); + let after_three = store.len(); + // Cota laxa: 3 archivos no triplican el almacén; comparten ~todos los + // nodos del cuerpo (block, integer_literal "42"). + assert!(after_three < 3 * after_one); +} + +fn find_first_kind<'a>( + node: &'a minga_core::SemanticNode, + kind: &str, +) -> Option<&'a minga_core::SemanticNode> { + if node.kind == kind { + return Some(node); + } + for c in &node.children { + if let Some(f) = find_first_kind(c, kind) { + return Some(f); + } + } + None +} diff --git a/03_ukupacha/minga/minga-dht/Cargo.toml b/03_ukupacha/minga/minga-dht/Cargo.toml new file mode 100644 index 0000000..bb199ac --- /dev/null +++ b/03_ukupacha/minga/minga-dht/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "minga-dht" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Brahman — capa de discovery typed sobre el Kademlia compartido de card-net. Claves namespaced por kind (Code/Card/Persona/Service) sin colisión en una sola malla." + +[dependencies] +card-net = { workspace = true } +libp2p = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/03_ukupacha/minga/minga-dht/LEEME.md b/03_ukupacha/minga/minga-dht/LEEME.md new file mode 100644 index 0000000..d7892d2 --- /dev/null +++ b/03_ukupacha/minga/minga-dht/LEEME.md @@ -0,0 +1,9 @@ +# minga-dht + +> DHT (Kademlia adaptado) de [minga](../README.md). + +Lookup por `Address`. K-buckets, replicación configurable, refresh periódico. Sin masterless trust — los peers verifican lo que reciben contra el hash. + +## Deps + +- [`minga-core`](../minga-core/README.md), `tokio` diff --git a/03_ukupacha/minga/minga-dht/README.md b/03_ukupacha/minga/minga-dht/README.md new file mode 100644 index 0000000..4a11175 --- /dev/null +++ b/03_ukupacha/minga/minga-dht/README.md @@ -0,0 +1,9 @@ +# minga-dht + +> DHT (adapted Kademlia) of [minga](../README.md). + +Lookup by `Address`. K-buckets, configurable replication, periodic refresh. No masterless trust — peers verify what they receive against the hash. + +## Deps + +- [`minga-core`](../minga-core/README.md), `tokio` diff --git a/03_ukupacha/minga/minga-dht/src/key.rs b/03_ukupacha/minga/minga-dht/src/key.rs new file mode 100644 index 0000000..e7f917a --- /dev/null +++ b/03_ukupacha/minga/minga-dht/src/key.rs @@ -0,0 +1,8 @@ +//! Claves namespaced del DHT — **re-export** de la primitiva unificadora. +//! +//! `DhtKey`/`RecordKind` se mudaron a `card-net` (Capa 1 de Brahman) porque +//! son el namespace COMÚN que comparten minga, agora y card-discovery — no +//! pertenecen a un dominio concreto. Este módulo las re-exporta para no +//! romper los `use minga_dht::{DhtKey, RecordKind}` históricos. + +pub use card_net::key::*; diff --git a/03_ukupacha/minga/minga-dht/src/lib.rs b/03_ukupacha/minga/minga-dht/src/lib.rs new file mode 100644 index 0000000..f4e76c2 --- /dev/null +++ b/03_ukupacha/minga/minga-dht/src/lib.rs @@ -0,0 +1,72 @@ +//! `brahman-dht` — capa de discovery typed sobre el Kademlia compartido. +//! +//! `brahman-net` corre un único Kademlia para todo el ecosistema. Este +//! crate le pone arriba un esquema de claves namespaced ([`DhtKey`]): +//! `minga` publica bloques de código, `brahman-card-discovery` publica +//! Cards, `agora_app` publica Personas — todo sobre la misma malla sin +//! colisión, porque cada clave lleva un byte de `kind`. +//! +//! El modelo es de **provider records**: un nodo `announce`-a que provee +//! una clave; otros `find`-an quién la provee y abren un stream directo. + +#![forbid(unsafe_code)] + +pub mod key; + +pub use key::{DhtKey, RecordKind, DHT_KEY_LEN}; + +use card_net::BrahmanNet; +use libp2p::PeerId; +use std::sync::Arc; + +/// Discovery typed sobre `brahman-net`. +#[derive(Clone)] +pub struct Dht { + net: Arc, +} + +impl Dht { + /// Crea la capa DHT sobre un nodo `brahman-net` ya inicializado. + pub fn new(net: Arc) -> Self { + Self { net } + } + + /// Anuncia que este nodo provee `key`. El registro de provider se + /// renueva solo mientras el nodo siga vivo en la malla. + pub fn announce(&self, key: &DhtKey) { + self.net.start_providing(&key.to_bytes()); + } + + /// Retira el anuncio de `key`. + pub fn withdraw(&self, key: &DhtKey) { + self.net.stop_providing(&key.to_bytes()); + } + + /// Busca los peers que proveen `key`. + pub async fn find(&self, key: &DhtKey) -> Vec { + self.net.find_providers(&key.to_bytes()).await + } + + /// El nodo `brahman-net` subyacente (para abrir streams a un provider). + pub fn net(&self) -> &Arc { + &self.net + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn announce_find_withdraw_on_a_live_node() { + // Smoke: un nodo solo. `find` de una clave que nadie provee + // devuelve vacío; announce/withdraw no panickean. + let net = Arc::new(BrahmanNet::new().expect("nodo libp2p")); + let dht = Dht::new(net); + let key = DhtKey::card("modulo-inexistente"); + dht.announce(&key); + dht.withdraw(&key); + let found = dht.find(&DhtKey::card("nadie-lo-provee")).await; + assert!(found.is_empty()); + } +} diff --git a/03_ukupacha/minga/minga-explorer-llimphi/Cargo.toml b/03_ukupacha/minga/minga-explorer-llimphi/Cargo.toml new file mode 100644 index 0000000..9d7f16b --- /dev/null +++ b/03_ukupacha/minga/minga-explorer-llimphi/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "minga-explorer-llimphi" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Dashboard Llimphi del repo Minga: counts de nodos AST, atestaciones, claves MST, refresh por polling. Reemplazo del `minga-explorer` GPUI; lee sled directo (sin passphrase) y compone header + 3 stat cards + banner de error con widgets reusables de llimphi." + +[dependencies] +minga-store = { path = "../minga-store" } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-app-header = { workspace = true } +llimphi-widget-banner = { workspace = true } +llimphi-widget-stat-card = { workspace = true } +llimphi-widget-menubar = { workspace = true } +llimphi-widget-context-menu = { workspace = true } +llimphi-motion = { workspace = true } +app-bus = { workspace = true } +rimay-localize = { workspace = true } +wawa-config = { workspace = true } + +[[bin]] +name = "minga-explorer-llimphi" +path = "src/main.rs" diff --git a/03_ukupacha/minga/minga-explorer-llimphi/LEEME.md b/03_ukupacha/minga/minga-explorer-llimphi/LEEME.md new file mode 100644 index 0000000..7fe9e6f --- /dev/null +++ b/03_ukupacha/minga/minga-explorer-llimphi/LEEME.md @@ -0,0 +1,16 @@ +# minga-explorer-llimphi + +> UI: peers, content, tráfico de [minga](../README.md). + +Tres pestañas: peers (estado de cada conexión), content (lo que tenés localmente), traffic (bandwidth/req-rate por peer). Útil para diagnosticar la red. + +## Uso + +```sh +cargo run --release -p minga-explorer-llimphi +``` + +## Deps + +- Todos los `minga-*` +- [`llimphi-ui`](../../../02_ruway/llimphi/) diff --git a/03_ukupacha/minga/minga-explorer-llimphi/README.md b/03_ukupacha/minga/minga-explorer-llimphi/README.md new file mode 100644 index 0000000..6df4d9c --- /dev/null +++ b/03_ukupacha/minga/minga-explorer-llimphi/README.md @@ -0,0 +1,16 @@ +# minga-explorer-llimphi + +> UI: peers, content, traffic of [minga](../README.md). + +Three tabs: peers (per-connection state), content (what you have locally), traffic (bandwidth/req-rate per peer). Useful for diagnosing the network. + +## Usage + +```sh +cargo run --release -p minga-explorer-llimphi +``` + +## Deps + +- All `minga-*` +- [`llimphi-ui`](../../../02_ruway/llimphi/) diff --git a/03_ukupacha/minga/minga-explorer-llimphi/src/main.rs b/03_ukupacha/minga/minga-explorer-llimphi/src/main.rs new file mode 100644 index 0000000..0fb056a --- /dev/null +++ b/03_ukupacha/minga/minga-explorer-llimphi/src/main.rs @@ -0,0 +1,653 @@ +//! `minga-explorer-llimphi` — dashboard Llimphi del repo Minga (VCS +//! semántico P2P). +//! +//! Polling cada 2s contra `MINGA_REPO` (env, default `./.minga`), abre +//! el `PersistentRepo` (sled, sin passphrase porque los counts son +//! lectura pública) y muestra: +//! - Cantidad de nodos AST almacenados. +//! - Cantidad de atestaciones firmadas. +//! - Cantidad de claves del MST (Merkle Search Tree). +//! +//! No requiere keypair descifrado — eso queda para el CLI +//! (`minga status`) cuando hace falta el DID. El explorer foco es +//! observabilidad rápida. +//! +//! Stack visual: llimphi-theme + llimphi-widget-app-header + +//! llimphi-widget-banner + llimphi-widget-stat-card. Mismo patrón que +//! `nakui-explorer-llimphi`. +//! +//! Uso: +//! ```sh +//! cargo run -p minga-explorer-llimphi +//! # con repo custom: +//! MINGA_REPO=/path/to/.minga cargo run -p minga-explorer-llimphi +//! ``` + +#![forbid(unsafe_code)] + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use app_bus::{AppMenu, Menu, MenuItem}; +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, AlignItems, Dimension, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View}; +use llimphi_motion::{animate, motion, Tween}; +use llimphi_widget_app_header::{app_header, AppHeaderPalette}; +use llimphi_widget_banner::{banner_view, BannerKind}; +use llimphi_widget_context_menu::{ + context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec, +}; +use llimphi_widget_menubar::{ + menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec, + DEFAULT_HEIGHT as MENU_H, +}; +use llimphi_widget_stat_card::{stat_card_view, StatCardPalette}; +use minga_store::PersistentRepo; + +const REFRESH_INTERVAL: Duration = Duration::from_secs(2); +const REPO_DIRNAME: &str = "repo"; + +/// Cuántos items recientes mostrar por sección. Los stores no tienen +/// orden cronológico (sled ordena lexicográfico por hash); los +/// "recent" acá son simplemente los primeros del iter — sirve como +/// sample, no como log temporal. +const RECENT_LIMIT: usize = 5; + +#[derive(Clone, Default, Debug)] +struct RepoSnapshot { + nodes: usize, + attestations: usize, + mst_keys: usize, + recent_nodes: Vec<(String, String)>, + recent_attestations: Vec<(String, String)>, + recent_mst_keys: Vec, +} + +struct Model { + theme: Theme, + repo_path: PathBuf, + snapshot: Option, + error: Option, + last_load_ms: u64, + /// Mantenemos vivo el watcher para que su thread no muera. No se + /// usa después de crearlo (consume su sí mismo cuando se dropea). + _wawa_watcher: Option, + /// Barra de menú principal: índice del menú raíz abierto (`None` + /// cerrado). + menu_open: Option, + /// Fila activa dentro del dropdown abierto (`usize::MAX` = ninguna). + menu_active: usize, + /// Animación de aparición del dropdown. + menu_anim: Tween, + /// Menú contextual sobre el dashboard: `(x, y)` ancla en ventana. + /// `None` cerrado. El explorer es de sólo lectura — el contextual + /// sólo ofrece acciones de observación (refrescar / tema). + context_menu: Option<(f32, f32)>, +} + +#[derive(Clone)] +enum Msg { + /// Tick del scheduler: corre `load_snapshot` y dispatcha el + /// resultado como `Refresh`. + Tick, + /// Resultado de un refresh: snapshot exitoso o mensaje de error, + /// junto al tiempo que tardó el load. + Refresh { + result: Result, + elapsed_ms: u64, + }, + /// El bus de wawa-config cambió: re-aplicar theme/accent/idioma. + WawaChanged(wawa_config::WawaConfig), + /// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar). + MenuOpen(Option), + /// Comando elegido en el menú principal — se traduce al `Msg` real. + MenuCommand(String), + /// Navega la fila activa del dropdown (+1/-1). + MenuNav(i32), + /// Ejecuta el comando de la fila activa (Enter). + MenuActivate, + /// No-op: sólo fuerza re-render durante la animación del dropdown. + MenuTick, + /// Cierra cualquier menú abierto (click-fuera / Esc). + CloseMenus, + /// Right-click en la raíz → abre el menú contextual anclado en + /// `(x, y)` de ventana. + ContextMenuOpen(f32, f32), + /// Cicla el tema claro/oscuro localmente (override del de wawa hasta + /// el próximo cambio del bus). + CycleTheme, +} + +struct Explorer; + +impl App for Explorer { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "Minga — Repo" + } + + fn initial_size() -> (u32, u32) { + (800, 560) + } + + fn init(handle: &Handle) -> Model { + let repo_path = std::env::var("MINGA_REPO") + .ok() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".minga")); + + // Primer refresh inmediato + ticks periódicos. El `Tick` dispara + // el load en un thread aparte (vía `Handle::spawn` desde update); + // así el sled no bloquea el hilo de UI. + handle.dispatch(Msg::Tick); + handle.spawn_periodic(REFRESH_INTERVAL, || Msg::Tick); + + // Cargar config wawa una vez y aplicarla; suscribirse a cambios. + let initial_cfg = wawa_config::WawaConfig::load(); + let theme = theme_from_wawa(&initial_cfg); + apply_lang_from_wawa(&initial_cfg); + + let handle_clone = handle.clone(); + let watcher = wawa_config::ConfigWatcher::spawn(move |cfg| { + handle_clone.dispatch(Msg::WawaChanged(cfg)); + }) + .ok(); + + Model { + theme, + repo_path, + snapshot: None, + error: None, + last_load_ms: 0, + _wawa_watcher: watcher, + menu_open: None, + menu_active: usize::MAX, + menu_anim: Tween::idle(1.0), + context_menu: None, + } + } + + fn on_key(model: &Model, event: &KeyEvent) -> Option { + if event.state != KeyState::Pressed { + return None; + } + if let Some(mi) = model.menu_open { + let n = app_menu().menus.len().max(1); + return match &event.key { + Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus), + Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))), + Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))), + Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)), + Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)), + Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate), + _ => None, + }; + } + None + } + + fn update(model: Model, msg: Msg, handle: &Handle) -> Model { + let mut m = model; + match msg { + Msg::Tick => { + let path = m.repo_path.clone(); + handle.spawn(move || { + let started = std::time::Instant::now(); + let result = load_snapshot(&path); + let elapsed_ms = started.elapsed().as_millis() as u64; + Msg::Refresh { result, elapsed_ms } + }); + } + Msg::Refresh { result, elapsed_ms } => { + match result { + Ok(snap) => { + m.snapshot = Some(snap); + m.error = None; + } + Err(e) => { + m.error = Some(rimay_localize::t_args( + "minga-error-read", + &[ + ("path", m.repo_path.display().to_string().into()), + ("err", e.to_string().into()), + ], + )); + } + } + m.last_load_ms = elapsed_ms; + } + Msg::WawaChanged(cfg) => { + m.theme = theme_from_wawa(&cfg); + apply_lang_from_wawa(&cfg); + } + Msg::MenuOpen(which) => { + m.menu_open = which; + // Abrir un menú raíz cierra cualquier contextual. + m.context_menu = None; + m.menu_active = usize::MAX; + if which.is_some() { + m.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic); + animate(handle, motion::FAST, || Msg::MenuTick); + } + } + Msg::MenuNav(dir) => { + if let Some(mi) = m.menu_open { + let menu = app_menu(); + m.menu_active = menubar_nav(&menu, mi, m.menu_active, dir); + } + } + Msg::MenuActivate => { + if let Some(mi) = m.menu_open { + let menu = app_menu(); + if let Some(cmd) = menubar_command_at(&menu, mi, m.menu_active) { + m.menu_open = None; + return handle_menu_command(m, &cmd, handle); + } + } + } + Msg::MenuTick => {} + Msg::CloseMenus => { + m.menu_open = None; + m.menu_active = usize::MAX; + m.context_menu = None; + } + Msg::MenuCommand(cmd) => { + m.menu_open = None; + m.menu_active = usize::MAX; + return handle_menu_command(m, &cmd, handle); + } + Msg::ContextMenuOpen(x, y) => { + m.menu_open = None; + m.context_menu = Some((x, y)); + } + Msg::CycleTheme => { + m.theme = Theme::next_after(m.theme.name); + } + } + m + } + + fn view(model: &Model) -> View { + let theme = &model.theme; + let menu = app_menu(); + let menubar = menubar_view(&menubar_spec(&menu, model, theme)); + let header_palette = AppHeaderPalette::from_theme(theme); + let stat_palette = StatCardPalette::from_theme(theme); + + // Acentos por kind del dashboard: nodos azul, atestaciones + // verde, MST purple. Señales semánticas del dominio Minga. + let accent_nodes = Color::from_rgba8(0x88, 0xc0, 0xd0, 0xff); + let accent_attestations = Color::from_rgba8(0xa3, 0xbe, 0x8c, 0xff); + let accent_mst = Color::from_rgba8(0xb4, 0x8e, 0xad, 0xff); + + let header_text = match &model.snapshot { + Some(_) => rimay_localize::t_args( + "minga-header-loaded", + &[ + ("path", model.repo_path.display().to_string().into()), + ("ms", model.last_load_ms.to_string().into()), + ], + ), + None => rimay_localize::t_args( + "minga-header-searching", + &[("path", model.repo_path.display().to_string().into())], + ), + }; + + let header = app_header::(header_text, vec![], &header_palette); + + let mut body_children: Vec> = Vec::new(); + + if let Some(ref e) = model.error { + body_children.push(banner_view::(BannerKind::Error, e.clone())); + } + + match &model.snapshot { + None => { + body_children.push(empty_message(theme)); + } + Some(snap) => { + let node_items: Vec = snap + .recent_nodes + .iter() + .map(|(h, k)| format!("{h} {k}")) + .collect(); + let attestation_items: Vec = snap + .recent_attestations + .iter() + .map(|(h, did)| format!("{h} ← {did}")) + .collect(); + let mst_items: Vec = snap.recent_mst_keys.clone(); + + body_children.push(stat_card_view::( + &rimay_localize::t("minga-card-nodes-title"), + &snap.nodes.to_string(), + &rimay_localize::t("minga-card-nodes-desc"), + accent_nodes, + &node_items, + &stat_palette, + )); + body_children.push(stat_card_view::( + &rimay_localize::t("minga-card-attestations-title"), + &snap.attestations.to_string(), + &rimay_localize::t("minga-card-attestations-desc"), + accent_attestations, + &attestation_items, + &stat_palette, + )); + body_children.push(stat_card_view::( + &rimay_localize::t("minga-card-mst-title"), + &snap.mst_keys.to_string(), + &rimay_localize::t("minga-card-mst-desc"), + accent_mst, + &mst_items, + &stat_palette, + )); + } + } + + let body = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + padding: Rect { + left: length(16.0_f32), + right: length(16.0_f32), + top: length(12.0_f32), + bottom: length(16.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(8.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(body_children); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + // Right-click en la raíz (origen 0,0 ⇒ local == ventana) abre el + // menú contextual de observación. + .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y))) + .children(vec![menubar, header, body]) + } + + fn view_overlay(model: &Model) -> Option> { + // El menú contextual tiene prioridad si está abierto. + if let Some((x, y)) = model.context_menu { + let viewport = viewport_of(model); + // Acciones reales del explorer: refrescar el snapshot y ciclar + // el tema. El explorer es de sólo lectura — no inventamos + // edición. + let items = vec![ + ContextMenuItem::action(&rimay_localize::t("minga-menu-refresh")), + ContextMenuItem::action(&rimay_localize::t("minga-menu-theme")), + ]; + let on_pick: Arc Msg + Send + Sync> = + Arc::new(move |i: usize| match i { + 0 => Msg::Tick, + _ => Msg::CycleTheme, + }); + return Some(context_menu_view(ContextMenuSpec { + anchor: (x, y), + viewport, + header: Some(rimay_localize::t("minga-menu-context-title")), + items, + active: usize::MAX, + on_pick, + on_dismiss: Msg::CloseMenus, + palette: ContextMenuPalette::from_theme(&model.theme), + })); + } + // Si no, el dropdown del menú principal. + let menu = app_menu(); + menubar_overlay_animated( + &menubar_spec(&menu, model, &model.theme), + model.menu_active, + model.menu_anim.value(), + ) + } +} + +/// Viewport para clampear overlays: el explorer no trackea el tamaño de +/// ventana, así que usamos `initial_size()`. +fn viewport_of(_model: &Model) -> (f32, f32) { + let (w, h) = Explorer::initial_size(); + (w as f32, h as f32) +} + +/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`. +fn menubar_spec<'a>( + menu: &'a AppMenu, + model: &Model, + theme: &'a Theme, +) -> MenuBarSpec<'a, Msg> { + MenuBarSpec { + menu, + open: model.menu_open, + theme, + viewport: viewport_of(model), + height: MENU_H, + on_open: Arc::new(Msg::MenuOpen), + on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())), + } +} + +/// El menú principal del explorer. Archivo / Ver / Ayuda — sólo comandos +/// que mapean a acciones reales (refrescar, tema). Sin "Editar": el +/// explorer no tiene campos de texto editables. +fn app_menu() -> AppMenu { + AppMenu::new() + .menu( + Menu::new(&rimay_localize::t("minga-menu-file")) + .item( + MenuItem::new(&rimay_localize::t("minga-menu-refresh"), "file.refresh") + .shortcut("Ctrl+R"), + ) + .item( + MenuItem::new(&rimay_localize::t("minga-menu-quit"), "file.quit") + .shortcut("Ctrl+Q") + .separated(), + ), + ) + .menu( + Menu::new(&rimay_localize::t("minga-menu-view")) + .item(MenuItem::new(&rimay_localize::t("minga-menu-theme"), "view.theme")), + ) + .menu( + Menu::new(&rimay_localize::t("minga-menu-help")) + .item(MenuItem::new(&rimay_localize::t("minga-menu-about"), "help.about")), + ) +} + +/// Traduce un command id del menú principal al `Msg`/efecto real. +fn handle_menu_command(model: Model, cmd: &str, handle: &Handle) -> Model { + match cmd { + "file.refresh" => { + handle.dispatch(Msg::Tick); + model + } + "file.quit" => std::process::exit(0), + "view.theme" => { + handle.dispatch(Msg::CycleTheme); + model + } + // "help.about" y desconocidos: no-op (sin diálogo todavía). + _ => model, + } +} + +fn empty_message(theme: &Theme) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(24.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned( + rimay_localize::t("minga-empty"), + 13.0, + theme.fg_muted, + Alignment::Start, + ) +} + +/// Lee el repo sled `/repo` y devuelve los 3 counts. +/// Falla si: el dir no existe, sled rebota al abrir, o cualquier +/// store falla a `len()`. Ningún error es fatal — la UI muestra el +/// banner y mantiene el último snapshot bueno. +fn load_snapshot(repo_path: &std::path::Path) -> Result { + let inner = repo_path.join(REPO_DIRNAME); + if !inner.exists() { + return Err(format!( + "directorio del repo sled no existe: {}", + inner.display() + )); + } + let repo = PersistentRepo::open(&inner).map_err(|e| format!("open: {e}"))?; + + let nodes = repo.nodes.len(); + let attestations = repo.attestations.len(); + let mst_keys = repo.mst.len(); + + let recent_nodes: Vec<(String, String)> = repo + .nodes + .iter() + .filter_map(|r| r.ok()) + .take(RECENT_LIMIT) + .map(|(hash, stored)| (short_hash(&hash.to_string()), stored.kind)) + .collect(); + + let recent_attestations: Vec<(String, String)> = repo + .attestations + .iter() + .filter_map(|r| r.ok()) + .take(RECENT_LIMIT) + .map(|att| { + ( + short_hash(&att.content.to_string()), + short_hash(&att.author.to_string()), + ) + }) + .collect(); + + let recent_mst_keys: Vec = repo + .mst + .iter() + .filter_map(|r| r.ok()) + .take(RECENT_LIMIT) + .map(|h| short_hash(&h.to_string())) + .collect(); + + Ok(RepoSnapshot { + nodes, + attestations, + mst_keys, + recent_nodes, + recent_attestations, + recent_mst_keys, + }) +} + +/// Trunca un hex string a sus primeros 12 chars. Convención cross-app +/// para mostrar hashes/dids/contenthash compactos sin perder +/// distintividad práctica (12 hex = 48 bits, colisión improbable +/// dentro de un repo single-machine). +fn short_hash(s: &str) -> String { + s.chars().take(12).collect() +} + +/// Construye un `Theme` a partir de la config wawa: matchea el variant +/// canónico contra `Theme::by_name`, aplica el accent si está definido. +/// Cualquier campo no reconocido cae al default dark sin romper. +fn theme_from_wawa(cfg: &wawa_config::WawaConfig) -> Theme { + let mut t = wawa_config::canonical_theme_name(&cfg.theme_variant) + .and_then(Theme::by_name) + .unwrap_or_else(Theme::dark); + if let Some([r, g, b]) = wawa_config::accent_rgb(&cfg.accent) { + let c = Color::from_rgba8(r, g, b, 0xff); + t.accent = c; + t.border_focus = c; + } + t +} + +/// Aplica el `lang` de wawa a `rimay_localize`. Errores (locale +/// desconocido) se ignoran — la traducción cae a la cadena default +/// silenciosamente, no vale tumbar la UI por eso. +fn apply_lang_from_wawa(cfg: &wawa_config::WawaConfig) { + let _ = rimay_localize::set_locale(&cfg.lang); +} + +fn main() { + rimay_localize::init(); + llimphi_ui::run::(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_snapshot_errors_on_missing_dir() { + let p = std::env::temp_dir().join(format!( + "minga-explorer-llimphi-missing-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let err = load_snapshot(&p).unwrap_err(); + assert!( + err.contains("no existe"), + "msg debe explicar el missing: {err}" + ); + } + + #[test] + fn snapshot_default_is_zeros_and_empty_lists() { + let s = RepoSnapshot::default(); + assert_eq!(s.nodes, 0); + assert_eq!(s.attestations, 0); + assert_eq!(s.mst_keys, 0); + assert!(s.recent_nodes.is_empty()); + assert!(s.recent_attestations.is_empty()); + assert!(s.recent_mst_keys.is_empty()); + } + + #[test] + fn short_hash_takes_first_12_chars() { + let s = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd"; + assert_eq!(short_hash(s), "a1b2c3d4e5f6"); + assert_eq!(short_hash(s).len(), 12); + } + + #[test] + fn short_hash_handles_empty_or_shorter() { + assert_eq!(short_hash(""), ""); + assert_eq!(short_hash("abc"), "abc"); + } +} diff --git a/03_ukupacha/minga/minga-p2p/Cargo.toml b/03_ukupacha/minga/minga-p2p/Cargo.toml new file mode 100644 index 0000000..7e9f64c --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "minga-p2p" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Minga P2P: protocolo de sincronización entre repositorios. Lógica pura; el transporte (libp2p) se monta encima." + +[dependencies] +minga-core = { path = "../minga-core" } +minga-store = { path = "../minga-store" } +minga-dht = { path = "../minga-dht" } +card-net = { workspace = true } +serde = { workspace = true } +postcard = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +futures = { workspace = true } +libp2p = { workspace = true } +libp2p-stream = { workspace = true } +rand = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +tempfile = { workspace = true } diff --git a/03_ukupacha/minga/minga-p2p/LEEME.md b/03_ukupacha/minga/minga-p2p/LEEME.md new file mode 100644 index 0000000..4697b26 --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/LEEME.md @@ -0,0 +1,10 @@ +# minga-p2p + +> Capa P2P de [minga](../README.md). + +Transport TCP/QUIC con NAT-traversal (UPnP + hole punching). Construye sobre la primitiva del DHT. + +## Deps + +- [`minga-core`](../minga-core/README.md), [`minga-dht`](../minga-dht/README.md) +- `quinn` (QUIC), `tokio` diff --git a/03_ukupacha/minga/minga-p2p/README.md b/03_ukupacha/minga/minga-p2p/README.md new file mode 100644 index 0000000..6ea3757 --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/README.md @@ -0,0 +1,10 @@ +# minga-p2p + +> P2P layer of [minga](../README.md). + +TCP/QUIC transport with NAT-traversal (UPnP + hole punching). Built atop the DHT primitive. + +## Deps + +- [`minga-core`](../minga-core/README.md), [`minga-dht`](../minga-dht/README.md) +- `quinn` (QUIC), `tokio` diff --git a/03_ukupacha/minga/minga-p2p/src/async_driver.rs b/03_ukupacha/minga/minga-p2p/src/async_driver.rs new file mode 100644 index 0000000..6e90895 --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/src/async_driver.rs @@ -0,0 +1,100 @@ +//! Driver de sincronización sobre I/O asíncrona. +//! +//! Bridge entre la `SyncSession` puramente lógica y cualquier +//! transporte que implemente `AsyncRead + AsyncWrite`. Encuadre +//! length-prefixed: cada `Message` se serializa con postcard y se +//! envía precedido de un `u32 LE` con su longitud en bytes. +//! +//! La estructura del bucle es: +//! 1. Drenar todos los `Message`s pendientes a la salida. +//! 2. Si la sesión declara `is_done`, salir. +//! 3. Bloquear esperando un `Message` entrante; alimentarlo a la +//! sesión y volver al paso 1. +//! +//! Esto funciona porque cada paso del state machine emite los +//! mensajes que necesita inmediatamente — nunca quedan colgados +//! mensajes por un `Message` futuro. La única espera real ocurre en +//! el paso 3, cuando estamos esperando que el peer responda. + +use std::collections::VecDeque; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +use crate::message::Message; +use crate::session::SyncSession; + +/// Cota dura sobre el tamaño de un frame, para evitar que un peer +/// malicioso (o un bug) cause asignaciones desbocadas. 16 MB es de +/// sobra para mensajes de sync — un `AttestPush` de cien mil +/// atestaciones cabe en ~13 MB. +const MAX_FRAME_SIZE: u32 = 16 * 1024 * 1024; + +#[derive(Debug, thiserror::Error)] +pub enum AsyncSyncError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + + #[error("decode postcard: {0}")] + Decode(#[from] postcard::Error), + + #[error("frame demasiado grande: {0} bytes")] + FrameTooLarge(u32), + + #[error("la sesión cerró sin alcanzar `is_done`")] + UnexpectedClose, +} + +/// Ejecuta una sesión de sincronización completa sobre una stream +/// duplex. Devuelve la `SyncSession` resultante (con el `Mst`, +/// `MemStore` y `AttestationStore` ya mergeados con el peer). +pub async fn run_sync_async( + mut session: SyncSession, + mut stream: S, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let mut outbound: VecDeque = session.start().into(); + + loop { + while let Some(msg) = outbound.pop_front() { + send_frame(&mut stream, &msg).await?; + } + + if session.is_done() { + return Ok(session); + } + + let msg = recv_frame(&mut stream).await?; + outbound.extend(session.handle(msg)); + } +} + +async fn send_frame(stream: &mut S, msg: &Message) -> Result<(), AsyncSyncError> +where + S: AsyncWrite + Unpin, +{ + let bytes = msg.encode(); + let len = bytes.len() as u32; + if len > MAX_FRAME_SIZE { + return Err(AsyncSyncError::FrameTooLarge(len)); + } + stream.write_all(&len.to_le_bytes()).await?; + stream.write_all(&bytes).await?; + stream.flush().await?; + Ok(()) +} + +async fn recv_frame(stream: &mut S) -> Result +where + S: AsyncRead + Unpin, +{ + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await?; + let len = u32::from_le_bytes(len_buf); + if len > MAX_FRAME_SIZE { + return Err(AsyncSyncError::FrameTooLarge(len)); + } + let mut buf = vec![0u8; len as usize]; + stream.read_exact(&mut buf).await?; + Ok(Message::decode(&buf)?) +} diff --git a/03_ukupacha/minga/minga-p2p/src/harness.rs b/03_ukupacha/minga/minga-p2p/src/harness.rs new file mode 100644 index 0000000..9ddf107 --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/src/harness.rs @@ -0,0 +1,95 @@ +//! Harness in-memory determinístico para correr dos `SyncSession`s +//! una contra la otra y verificar invariantes del protocolo. + +use std::collections::VecDeque; + +use crate::message::Message; +use crate::session::SyncSession; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct SyncStats { + pub challenges: usize, + pub hellos: usize, + pub probe_reqs: usize, + pub probe_ress: usize, + pub fetches: usize, + pub delivers: usize, + pub attest_pushes: usize, + pub retract_pushes: usize, + pub root_declarations: usize, + pub dones: usize, +} + +impl SyncStats { + fn record(&mut self, m: &Message) { + match m { + Message::Challenge { .. } => self.challenges += 1, + Message::Hello { .. } => self.hellos += 1, + Message::ProbeReq { .. } => self.probe_reqs += 1, + Message::ProbeRes { .. } => self.probe_ress += 1, + Message::Fetch { .. } => self.fetches += 1, + Message::Deliver { .. } => self.delivers += 1, + Message::AttestPush { .. } => self.attest_pushes += 1, + Message::RetractPush { .. } => self.retract_pushes += 1, + Message::RootDeclaration { .. } => self.root_declarations += 1, + Message::Done => self.dones += 1, + } + } + + pub fn total(&self) -> usize { + self.challenges + + self.hellos + + self.probe_reqs + + self.probe_ress + + self.fetches + + self.delivers + + self.attest_pushes + + self.retract_pushes + + self.root_declarations + + self.dones + } +} + +/// Ejecuta la sincronización entre dos sesiones hasta convergencia. +/// +/// Pánico si la conversación termina sin que ambas partes alcancen +/// `is_done()` — eso sería un deadlock del protocolo y una regresión. +pub fn run_sync(a: &mut SyncSession, b: &mut SyncSession) -> SyncStats { + let mut from_a: VecDeque = VecDeque::new(); + let mut from_b: VecDeque = VecDeque::new(); + let mut stats = SyncStats::default(); + + from_a.extend(a.start()); + from_b.extend(b.start()); + + loop { + let mut progress = false; + + if let Some(msg) = from_a.pop_front() { + stats.record(&msg); + for out in b.handle(msg) { + from_b.push_back(out); + } + progress = true; + } + + if let Some(msg) = from_b.pop_front() { + stats.record(&msg); + for out in a.handle(msg) { + from_a.push_back(out); + } + progress = true; + } + + if !progress { + break; + } + } + + assert!( + a.is_done() && b.is_done(), + "deadlock: sync terminó sin que ambos peers cerraran" + ); + + stats +} diff --git a/03_ukupacha/minga/minga-p2p/src/lib.rs b/03_ukupacha/minga/minga-p2p/src/lib.rs new file mode 100644 index 0000000..92a55ad --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/src/lib.rs @@ -0,0 +1,26 @@ +//! minga-p2p: protocolo de sincronización entre repositorios Minga. +//! +//! Este crate define el **protocolo** y la **máquina de estados** de la +//! sincronización P2P, sin acoplarse a un transporte concreto. Un peer +//! manipula una `SyncSession` (puramente lógica) que consume mensajes +//! entrantes y produce mensajes salientes; el transporte real —libp2p, +//! HTTP, in-memory, lo que sea— se reduce a serializar/deserializar y +//! mover bytes. +//! +//! Este orden refleja el principio bottom-up del proyecto: validamos la +//! convergencia del protocolo con un `harness` in-memory determinístico +//! antes de invertir en async runtime + libp2p. + +pub mod async_driver; +pub mod harness; +pub mod message; +pub mod network; +pub mod peer; +pub mod session; + +pub use async_driver::{run_sync_async, AsyncSyncError}; +pub use harness::{run_sync, SyncStats}; +pub use message::Message; +pub use network::{DiscoveredPeer, LibP2pNode, NodeError, SYNC_PROTOCOL}; +pub use peer::{MingaPeer, PeerOpenError, PeerSyncError}; +pub use session::SyncSession; diff --git a/03_ukupacha/minga/minga-p2p/src/message.rs b/03_ukupacha/minga/minga-p2p/src/message.rs new file mode 100644 index 0000000..b6660e5 --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/src/message.rs @@ -0,0 +1,116 @@ +//! Mensajes del protocolo de sincronización (versión recursiva sobre +//! la estructura del MST). +//! +//! El protocolo es simétrico — ambos peers ejecutan el mismo rol y +//! emiten los mismos mensajes — y consta de ocho tipos: +//! +//! 1. `Hello { root_subtree_hash }` anuncia el hash Merkle del MST raíz +//! del emisor. Si ambos hashes coinciden, los dos repos son idénticos +//! y la sincronización termina sin un solo byte adicional. +//! +//! 2. `ProbeReq { subtree_hash }` solicita la **estructura** (level + +//! keys + child_hashes) de un subárbol previamente anunciado por el +//! otro peer. Es lo que permite descender el árbol del peer paso a +//! paso, podando ramas idénticas por igualdad de hash. +//! +//! 3. `ProbeRes { subtree_hash, probe }` responde con el `NodeProbe`, +//! o `None` si el subárbol era el vacío. Cada subárbol que el peer +//! no reconoce dispara un `ProbeReq` recursivo; cuando el peer ya +//! tiene un subárbol con el mismo hash, la rama se poda. +//! +//! 4. `Fetch { hash }` y `Deliver { hash, stored }` mueven los nodos +//! propiamente dichos. El receptor del `Deliver` **verifica +//! criptográficamente** que `hash_stored(stored) == hash` antes de +//! insertar — un peer malicioso no puede colar un `StoredNode` +//! distinto bajo un hash anunciado. +//! +//! 5. `Done` cierra el lado del emisor: ya recibió el `Hello` del otro, +//! no tiene probes ni fetches pendientes. Cuando ambos `Done`s han +//! cruzado, la sesión termina con ambos repos convergentes. + +use minga_core::{ + Attestation, ContentHash, Did, NodeProbe, Retraction, RootDecl, Signature, StoredNode, +}; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum Message { + /// Reto de session-handshake: 32 bytes aleatorios. Cada peer envía + /// uno al inicio. El otro lado lo incrustará en el payload del + /// `Hello` que firme con su llave privada — así un `Hello` + /// capturado en una sesión no puede replayearse en otra (que + /// tendrá un nonce distinto). + Challenge { + nonce: [u8; 32], + }, + + /// Saludo autenticado anti-replay: el emisor presenta su DID, el + /// hash del subárbol raíz de su MST, y una firma sobre el payload + /// `(peer_did || root_subtree_hash || nonce_recibido_del_peer)`. + /// El receptor reconstruye el payload con su PROPIO nonce (el que + /// envió en su Challenge) y verifica con la llave pública del + /// peer. Sin Challenge previo no hay Hello válido posible. + Hello { + peer_did: Did, + root_subtree_hash: ContentHash, + signature: Signature, + }, + ProbeReq { + subtree_hash: ContentHash, + }, + ProbeRes { + subtree_hash: ContentHash, + probe: Option, + }, + Fetch { + hash: ContentHash, + }, + Deliver { + hash: ContentHash, + stored: StoredNode, + }, + /// Empuje de atestaciones: el emisor entrega al peer las pruebas + /// criptográficas de autoría que conoce. Cada `Attestation` es + /// auto-verificable (firma + autor + contenido), así que el + /// receptor puede validar y mezclar sin confiar en la palabra del + /// remitente. Se envían tras el `Hello` autenticado para que el + /// peer verifique la identidad del remitente antes de procesarlas. + AttestPush { + attestations: Vec, + }, + /// Empuje de retracciones: contraparte negativa de `AttestPush`. + /// Cada `Retraction` es auto-verificable (firma sobre + /// `RETRACTION_DOMAIN ++ content_hash`), así que el receptor las + /// valida igual que las atestaciones — sin necesidad de confiar + /// en el remitente más allá de su firma. + RetractPush { + retractions: Vec, + }, + /// Declaración de raíces conocidas por el emisor: para cada raíz, + /// el α-hash, el struct-hash del CAS y el dialect declarado. El + /// receptor **re-verifica** llamando a `verify_root_alpha` tras + /// reconstruir el `SemanticNode` desde su store local — es lo que + /// cierra el loop de seguridad α↔struct frente a un peer que + /// pudiera anunciar un α-hash que no corresponde al contenido. Una + /// declaración cuyo struct_hash no está aún en el store (todavía + /// no se entregó por `Deliver`) o cuya α-verificación falla se + /// cuenta como rechazada y no entra al `roots` tree del receptor. + RootDeclaration { + decls: Vec, + }, + Done, +} + +impl Message { + /// Codifica el mensaje a bytes vía postcard. Diseñado para + /// transferir sobre cualquier transporte que mueva `Vec`. + /// Postcard es compacto, sin overhead de schema runtime. + pub fn encode(&self) -> Vec { + postcard::to_allocvec(self).expect("postcard encoding cannot fail for our types") + } + + /// Decodifica bytes a un `Message`. `Err` si los bytes son + /// malformados o no representan un `Message` válido. + pub fn decode(bytes: &[u8]) -> Result { + postcard::from_bytes(bytes) + } +} diff --git a/03_ukupacha/minga/minga-p2p/src/network.rs b/03_ukupacha/minga/minga-p2p/src/network.rs new file mode 100644 index 0000000..c140c02 --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/src/network.rs @@ -0,0 +1,22 @@ +//! Re-export del nodo de la red Brahman especializado para Minga. +//! +//! Antes este módulo contenía el swarm libp2p completo. Ahora vive en +//! `brahman-net` (capa P2P compartida con el resto de la familia +//! brahman: `/brahman/handshake/1.0.0`, futuros sub-protocolos). Este +//! módulo se reduce a: +//! +//! - Re-exportar `BrahmanNet` bajo el alias histórico `LibP2pNode` +//! para zero churn en `MingaPeer`. +//! - Declarar la const `SYNC_PROTOCOL` específica de Minga +//! (`/minga/sync/1.0.0`). +//! +//! Cualquier consumer que necesite armar un nodo P2P puede importar +//! `card_net::BrahmanNet` directo y registrar sus propios protocolos +//! sin pasar por minga. + +pub use card_net::{BrahmanNet as LibP2pNode, DiscoveredPeer, NodeError}; + +use libp2p::StreamProtocol; + +/// Sub-protocolo de sync Minga sobre la malla brahman-net. +pub const SYNC_PROTOCOL: StreamProtocol = StreamProtocol::new("/minga/sync/1.0.0"); diff --git a/03_ukupacha/minga/minga-p2p/src/peer.rs b/03_ukupacha/minga/minga-p2p/src/peer.rs new file mode 100644 index 0000000..db5322d --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/src/peer.rs @@ -0,0 +1,458 @@ +//! `MingaPeer`: API de alto nivel para un nodo Minga "always-on". +//! +//! Envuelve `LibP2pNode` con estado compartido (`Mst` + `MemStore` + +//! `AttestationStore` + `Keypair`) protegido por un `Mutex` async, y +//! expone: +//! - `run_passive_accept()`: lanza un bucle que acepta streams de +//! sync continuamente, procesa cada uno en una task paralela, y +//! mergea el resultado al estado compartido. +//! - `sync_with(peer_id)`: inicia un sync activo con un peer conocido. +//! - `snapshot()`: instantánea del estado actual. +//! +//! Modelo de concurrencia: cada sync entrante toma un *clone* del +//! estado, ejecuta la sesión sobre la copia, y al terminar mergea las +//! novedades al estado compartido. Múltiples syncs pueden correr en +//! paralelo; el merge final adquiere el lock brevemente. Eventualmente +//! consistente: un sync que empezó antes que un merge terminado puede +//! no ver esas novedades, pero el siguiente sync sí. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use futures::StreamExt; +use libp2p::{Multiaddr, PeerId, Stream}; +use tokio::sync::Mutex; +use tokio_util::compat::FuturesAsyncReadCompatExt; + +use minga_core::{ + alpha::hash_alpha_with, parse::Dialect, AttestationStore, ContentHash, Keypair, MemStore, Mst, + NodeStore, RetractionStore, SemanticNode, +}; +use minga_dht::DhtKey; +use minga_store::{PersistentRepo, StoreError}; + +use crate::async_driver::{run_sync_async, AsyncSyncError}; +use crate::network::{DiscoveredPeer, LibP2pNode, NodeError, SYNC_PROTOCOL}; +use crate::session::SyncSession; + +#[derive(Debug, thiserror::Error)] +pub enum PeerSyncError { + #[error("open stream: {0}")] + OpenStream(#[from] libp2p_stream::OpenStreamError), + + #[error("sync: {0}")] + AsyncSync(#[from] AsyncSyncError), +} + +#[derive(Debug, thiserror::Error)] +pub enum PeerOpenError { + #[error("network: {0}")] + Network(#[from] NodeError), + + #[error("store: {0}")] + Store(#[from] StoreError), +} + +struct PeerState { + mst: Mst, + store: MemStore, + attestations: AttestationStore, + retractions: RetractionStore, + /// Tabla local α-hash → (struct-hash, dialect). Se replica al + /// disco si hay backing persistente (`repo.roots`); también se + /// empuja al peer remoto durante sync vía `RootDeclaration`. + roots: HashMap, + keypair: Keypair, + /// Backing persistente opcional. Si está presente, todo cambio + /// de estado escribe a disco vía write-through. + persistent: Option>, +} + +pub struct MingaPeer { + /// El nodo libp2p envuelto en `Arc` para que otros consumidores + /// (por ejemplo `agora_net_brahman::AgoraNet`) puedan compartir + /// el mismo `BrahmanNet` — una sola identidad libp2p, una sola + /// tabla Kademlia, dos protocolos de stream. Ver + /// [`MingaPeer::brahman_net`] y [`MingaPeer::open_with_node`]. + node: Arc, + state: Arc>, +} + +impl MingaPeer { + pub fn new( + keypair: Keypair, + mst: Mst, + store: MemStore, + attestations: AttestationStore, + ) -> Result { + let node = Arc::new(LibP2pNode::new()?); + let state = Arc::new(Mutex::new(PeerState { + mst, + store, + attestations, + retractions: RetractionStore::new(), + roots: HashMap::new(), + keypair, + persistent: None, + })); + Ok(Self { node, state }) + } + + /// Abre o crea un peer persistente sobre `path`, construyendo un + /// `LibP2pNode` propio (efímero). Atajo para procesos que sólo + /// corren minga; si querés compartir el nodo libp2p con ágora u + /// otro consumidor, usá [`MingaPeer::open_with_node`]. + pub fn open(keypair: Keypair, path: impl AsRef) -> Result { + let node = Arc::new(LibP2pNode::new()?); + Self::open_with_node(keypair, path, node) + } + + /// Acceso al nodo libp2p subyacente — útil para que otro consumidor + /// (típicamente `agora_net_brahman::AgoraNet`) abra sus propios + /// sub-protocolos de stream sobre la misma malla brahman-net. Es + /// el lado "lectura" de la convergencia; el lado "construcción" es + /// [`MingaPeer::open_with_node`]. + pub fn brahman_net(&self) -> Arc { + Arc::clone(&self.node) + } + + /// Como [`MingaPeer::open`], pero adopta un `LibP2pNode` ya + /// existente envuelto en `Arc`. Sirve para compartir un solo nodo + /// libp2p entre minga y otros consumidores — un solo `PeerId`, una + /// sola Kademlia, varios protocolos de stream coexistiendo. Esta + /// es la convergencia que el README de ágora promete: *"agora + /// corre sobre la red de pares de minga cuando ambos están + /// activos"*. + pub fn open_with_node( + keypair: Keypair, + path: impl AsRef, + node: Arc, + ) -> Result { + let repo = Arc::new(PersistentRepo::open(path)?); + + // Cargar MST desde disco. + let mut mst = Mst::new(); + for r in repo.mst.iter() { + mst.insert(r?); + } + + // Cargar nodos desde disco. + let mut store = MemStore::new(); + for r in repo.nodes.iter() { + let (h, node) = r?; + store.put_chunked(h, node); + } + + // Cargar atestaciones desde disco. + let mut attestations = AttestationStore::new(); + for r in repo.attestations.iter() { + let att = r?; + // `add` re-verifica criptográficamente. Lo persistido ya + // estaba verificado, pero re-validar es cheap insurance. + let _ = attestations.add(att); + } + + // Cargar retracciones desde disco. + let mut retractions = RetractionStore::new(); + for r in repo.retractions.iter() { + let r = r?; + let _ = retractions.add(r); + } + + // Cargar raíces desde disco. `from_byte` puede devolver None + // si el dialect persistido es de una versión futura — se + // descarta esa entrada para no propagarla al wire (no + // sabríamos verificarla del otro lado tampoco). + let mut roots: HashMap = HashMap::new(); + for r in repo.roots.iter() { + let (alpha, struct_hash, dialect) = r?; + if let Some(d) = dialect { + roots.insert(alpha, (struct_hash, d)); + } + } + + let state = Arc::new(Mutex::new(PeerState { + mst, + store, + attestations, + retractions, + roots, + keypair, + persistent: Some(repo), + })); + Ok(Self { node, state }) + } + + pub fn peer_id(&self) -> PeerId { + self.node.peer_id + } + + pub async fn listen(&self, addr: Multiaddr) -> Multiaddr { + self.node.listen(addr).await + } + + pub fn dial(&self, addr: Multiaddr) { + self.node.dial(addr); + } + + /// Añade un peer al routing table de Kademlia (bootstrap). + pub fn add_dht_peer(&self, peer: PeerId, addr: Multiaddr) { + self.node.add_dht_peer(peer, addr); + } + + /// Consulta DHT por los peers más cercanos al `target`. + pub async fn find_closest_peers(&self, target: PeerId) -> Vec { + self.node.find_closest_peers(target).await + } + + /// Anuncia en el DHT que este peer provee el contenido `hash`. + /// El record viaja con un byte de namespace (`RecordKind::Code`) que + /// separa el keyspace de minga del de cards/personas sobre la misma + /// malla Kademlia compartida (`brahman-net`). + pub fn announce_provider(&self, hash: ContentHash) { + let key = DhtKey::for_hash(minga_dht::RecordKind::Code, hash.0); + self.node.start_providing(&key.to_bytes()); + } + + /// Anuncia en el DHT todas las raíces locales del peer (los + /// α-hashes registrados en `roots`). Útil al arrancar un `listen`: + /// el peer queda descubrible para cualquier otro que haga + /// `find_providers(α)` sobre la malla Kademlia compartida. Devuelve + /// la cantidad de raíces anunciadas — 0 si el repo está vacío o si + /// nunca se ingirió con `ingest_with_dialect`. + pub async fn announce_all_roots(&self) -> usize { + let alphas: Vec = { + let s = self.state.lock().await; + s.roots.keys().copied().collect() + }; + let n = alphas.len(); + for alpha in alphas { + let key = DhtKey::for_hash(minga_dht::RecordKind::Code, alpha.0); + self.node.start_providing(&key.to_bytes()); + } + n + } + + /// Consulta el DHT por peers que han anunciado proveer este + /// contenido. La clave usa el mismo namespace que [`announce_provider`]. + pub async fn find_providers(&self, hash: ContentHash) -> Vec { + let key = DhtKey::for_hash(minga_dht::RecordKind::Code, hash.0); + self.node.find_providers(&key.to_bytes()).await + } + + /// Lanza el bucle de aceptación pasiva. Devuelve un `JoinHandle` + /// que el caller puede mantener vivo (o ignorar — la task se + /// aborta al cerrar el runtime). + /// + /// Cada stream entrante dispara un sync en una task aislada que + /// trabaja sobre un clone del estado y mergea al final. + pub fn run_passive_accept(&self) -> tokio::task::JoinHandle<()> { + let mut control = self.node.control.clone(); + let state = Arc::clone(&self.state); + tokio::spawn(async move { + let mut incoming = control + .accept(SYNC_PROTOCOL) + .expect("only one accept handle per protocol"); + while let Some((_peer, stream)) = incoming.next().await { + let state = Arc::clone(&state); + tokio::spawn(handle_incoming(stream, state)); + } + }) + } + + /// Inicia un sync activo con un peer del que ya tenemos conexión + /// (vía `dial` previo). Toma un snapshot del estado, corre la + /// sesión, y mergea novedades al volver. + pub async fn sync_with(&self, peer_id: PeerId) -> Result<(), PeerSyncError> { + let mut control = self.node.control.clone(); + let stream = control.open_stream(peer_id, SYNC_PROTOCOL).await?; + let session = self.snapshot_session().await; + let result = run_sync_async(session, stream.compat()).await?; + self.merge_back(result).await; + Ok(()) + } + + async fn snapshot_session(&self) -> SyncSession { + let s = self.state.lock().await; + SyncSession::with_roots( + s.mst.clone(), + s.store.clone(), + s.attestations.clone(), + s.retractions.clone(), + s.roots.clone(), + s.keypair.clone(), + ) + } + + async fn merge_back(&self, mut session: SyncSession) { + // Verifica α↔struct de las declaraciones recibidas ANTES de + // mover la sesión: si el caller no llamó `take_verified_root_decls` + // explícitamente, las raíces no pasarían a `verified_root_decls` + // y se perderían en silencio. + let _verified = session.take_verified_root_decls(); + let (new_mst, new_store, new_atts, new_rets, new_roots) = + session.into_parts_with_roots(); + let mut s = self.state.lock().await; + merge_into_state(&mut s, new_mst, new_store, new_atts, new_rets, new_roots); + } + + /// Instantánea del estado actual (mst + store + attestations). + pub async fn snapshot(&self) -> (Mst, MemStore, AttestationStore) { + let s = self.state.lock().await; + (s.mst.clone(), s.store.clone(), s.attestations.clone()) + } + + /// Inserta un árbol directamente en el estado del peer (sin sync). + /// Si el peer está respaldado por disco, también lo persiste. + /// Anuncia automáticamente al peer como proveedor del contenido en + /// el DHT — de esa forma cualquier otro peer puede descubrirlo + /// preguntando "¿quién tiene este hash?". + /// Devuelve el `ContentHash` raíz del árbol. + pub async fn ingest(&self, node: &SemanticNode) -> ContentHash { + let mut s = self.state.lock().await; + let h = s.store.put(node); + s.mst.insert(h); + if let Some(repo) = &s.persistent { + let _ = repo.nodes.put(node); + let _ = repo.mst.insert(h); + } + drop(s); + + // Anunciamos como proveedores en el DHT con la clave typed + // (kind = Code) — comparte malla con cards/personas sin colisión. + let key = DhtKey::for_hash(minga_dht::RecordKind::Code, h.0); + self.node.start_providing(&key.to_bytes()); + + h + } + + /// Variante de [`ingest`] que conoce el `dialect` del archivo y por + /// tanto registra la raíz por su **α-hash** (estable bajo + /// renombrado de variables ligadas), no por su hash estructural. + /// Devuelve `(alpha_hash, struct_hash)`. Si el peer es persistente, + /// también actualiza el tree `roots` y los timestamps. + pub async fn ingest_with_dialect( + &self, + node: &SemanticNode, + dialect: Dialect, + ) -> (ContentHash, ContentHash) { + let alpha = hash_alpha_with(dialect, node); + let mut s = self.state.lock().await; + let struct_hash = s.store.put(node); + s.mst.insert(alpha); + s.roots.insert(alpha, (struct_hash, dialect)); + if let Some(repo) = &s.persistent { + let _ = repo.nodes.put(node); + let _ = repo.mst.insert(alpha); + let _ = repo.roots.put(alpha, struct_hash, dialect); + } + drop(s); + + let key = DhtKey::for_hash(minga_dht::RecordKind::Code, alpha.0); + self.node.start_providing(&key.to_bytes()); + + (alpha, struct_hash) + } + + /// Inserta una atestación en el peer. Si el peer es persistente, + /// también la escribe a disco. Falla si la firma no verifica. + pub async fn ingest_attestation( + &self, + att: minga_core::Attestation, + ) -> Result<(), minga_core::AttestationError> { + let mut s = self.state.lock().await; + s.attestations.add(att.clone())?; + if let Some(repo) = &s.persistent { + let _ = repo.attestations.add(att); + } + Ok(()) + } + + /// Fuerza un flush del backing persistente a disco. No hace nada + /// si el peer es solo en memoria. + pub async fn flush(&self) -> Result<(), StoreError> { + let s = self.state.lock().await; + if let Some(repo) = &s.persistent { + repo.flush()?; + } + Ok(()) + } +} + +async fn handle_incoming(stream: Stream, state: Arc>) { + let session = { + let s = state.lock().await; + SyncSession::with_roots( + s.mst.clone(), + s.store.clone(), + s.attestations.clone(), + s.retractions.clone(), + s.roots.clone(), + s.keypair.clone(), + ) + }; + if let Ok(mut result) = run_sync_async(session, stream.compat()).await { + let _verified = result.take_verified_root_decls(); + let (new_mst, new_store, new_atts, new_rets, new_roots) = + result.into_parts_with_roots(); + let mut s = state.lock().await; + merge_into_state(&mut s, new_mst, new_store, new_atts, new_rets, new_roots); + } + // Errores de sync se ignoran: cada sesión es independiente, una + // sesión rota no debería tumbar el peer entero. Una iteración + // futura puede contar errores para telemetría. +} + +fn merge_into_state( + state: &mut PeerState, + new_mst: Mst, + new_store: MemStore, + new_atts: AttestationStore, + new_rets: RetractionStore, + new_roots: HashMap, +) { + // Write-through: cada inserción en memoria también va al backing + // persistente si existe. Errores de IO se ignoran (best-effort); + // el estado en memoria sigue siendo la fuente de verdad inmediata + // y un siguiente sync re-popula lo que se haya perdido. + for h in new_mst.iter() { + state.mst.insert(*h); + if let Some(repo) = &state.persistent { + let _ = repo.mst.insert(*h); + } + } + for (h, node) in new_store.iter() { + state.store.put_chunked(*h, node.clone()); + if let Some(repo) = &state.persistent { + let _ = repo.nodes.put_chunked(*h, node); + } + } + for att in new_atts.all() { + if state.attestations.add(att.clone()).is_ok() { + // Solo persistimos las que pasaron verificación en memoria. + if let Some(repo) = &state.persistent { + let _ = repo.attestations.add(att.clone()); + } + } + } + for r in new_rets.all() { + if state.retractions.add(r.clone()).is_ok() { + if let Some(repo) = &state.persistent { + let _ = repo.retractions.add(r.clone()); + } + } + } + // Raíces ya verificadas (α↔struct↔dialect): la fuente local es + // autoritativa, así que sólo insertamos las que no conocemos + // todavía. La verificación criptográfica ya pasó en la sesión. + for (alpha, (struct_hash, dialect)) in new_roots { + if state.roots.contains_key(&alpha) { + continue; + } + state.roots.insert(alpha, (struct_hash, dialect)); + if let Some(repo) = &state.persistent { + let _ = repo.roots.put(alpha, struct_hash, dialect); + } + } +} diff --git a/03_ukupacha/minga/minga-p2p/src/session.rs b/03_ukupacha/minga/minga-p2p/src/session.rs new file mode 100644 index 0000000..2fa1dcc --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/src/session.rs @@ -0,0 +1,687 @@ +//! Máquina de estados de sincronización recursiva sobre la estructura +//! del MST, con verificación criptográfica de cada nodo entregado. +//! +//! La sesión es **pura**: no hace IO, no toca la red, no usa async. El +//! transporte la alimenta vía `handle(msg)` y consume sus salidas como +//! `Vec`. +//! +//! ## Algoritmo +//! +//! 1. Cada peer construye al inicio un `own_probes: HashMap` que indexa cada nodo interno de su MST por su hash +//! Merkle de subárbol. Es la tabla con la que respondemos +//! `ProbeReq`s en `O(1)`. +//! +//! 2. Cada peer envía `Hello` con el hash de su raíz. Si el peer +//! contrario reconoce ese hash en su propio `own_probes` (o coincide +//! con su propia raíz, o es la raíz vacía), no hay nada estructural +//! que descubrir — la rama está ya alineada. +//! +//! 3. Si el hash no se reconoce, el peer emite un `ProbeReq` para +//! pedirle al otro la estructura de ese subárbol. Cuando llega el +//! `ProbeRes`, el peer: +//! - Para cada **clave** del probe que no tiene en su MST, programa +//! un `Fetch` (la clave entrará al MST cuando llegue su `Deliver`). +//! - Para cada **child_hash** del probe que no aparece en +//! `own_probes`, recurre con un nuevo `ProbeReq`. Si el child_hash +//! ya está en `own_probes`, la rama se poda — toda esa subestructura +//! es idéntica a la nuestra. +//! +//! 4. Cuando un peer recibe un `Deliver`, verifica que el hash +//! anunciado coincida con el `hash_stored` real del nodo. Si no, +//! descarta. Si sí, inserta en el `MemStore` y, si el hash venía de +//! la raíz del MST del peer (no de un descendiente), también lo +//! inserta en su MST. +//! +//! 5. Cada `StoredNode` recibido contiene los hashes de sus hijos. Si +//! el receptor no los tiene, los pide vía `Fetch` (sync transitivo). +//! +//! 6. Un peer envía `Done` cuando: emitió y recibió `Hello`, no tiene +//! probes pendientes, ni fetches pendientes (raíz o hijo). La sesión +//! cierra cuando ambos `Done`s han cruzado. + +use minga_core::{ + alpha::verify_root_alpha, cas::ContentHash, empty_subtree_hash, hash_stored, parse::Dialect, + AttestationStore, Did, Keypair, MemStore, Mst, NodeProbe, NodeStore, RetractionStore, RootDecl, +}; +use rand::rngs::OsRng; +use rand::RngCore; +use std::collections::{HashMap, HashSet}; + +use crate::message::Message; + +/// Construye el payload firmado del `Hello` con orden fijo: +/// `verifier_nonce(32) || peer_did(32) || root_subtree_hash(32) = 96 bytes`. +/// El `verifier_nonce` es el nonce que emitió el peer que verificará +/// la firma; al firmar sobre él se vincula la firma a esta sesión. +/// Cualquier cambio al format es incompatible al protocolo. +pub(crate) fn hello_payload( + verifier_nonce: &[u8; 32], + did: &Did, + root: &ContentHash, +) -> [u8; 96] { + let mut p = [0u8; 96]; + p[..32].copy_from_slice(verifier_nonce); + p[32..64].copy_from_slice(&did.0); + p[64..].copy_from_slice(&root.0); + p +} + +pub struct SyncSession { + mst: Mst, + store: MemStore, + attestations: AttestationStore, + retractions: RetractionStore, + + /// Raíces locales (α-hash → (struct-hash, dialect)). Las empujamos + /// al peer como `RootDeclaration` para que pueda re-verificar el + /// binding α↔struct al recibirlas. + local_roots: HashMap, + + /// Llave del peer local: firma el `Hello` y queda asociada al + /// `Did` que el peer remoto verá. + keypair: Keypair, + + /// Identidad del peer remoto, capturada tras verificar la firma + /// de su `Hello`. + peer_did: Option, + + own_probes: HashMap, + own_root_subtree_hash: ContentHash, + + awaited_probes: HashSet, + seen_probes: HashSet, + awaiting_root: HashSet, + awaiting_child: HashSet, + + /// Declaraciones de raíces recibidas, **antes** de verificar. Se + /// drenan al finalizar la sesión (cuando el store ya recibió todo + /// lo que va a recibir) llamando a `take_verified_root_decls`. + pending_root_decls: Vec, + /// Declaraciones ya verificadas (α-hash recompone bajo el dialect + /// declarado tras reconstruir el `SemanticNode` del store local). + /// Listas para que el caller las persista en su `roots` tree. + verified_root_decls: HashMap, + + rejected_hellos: usize, + rejected_delivers: usize, + /// Contador de atestaciones rechazadas: firma rota, llegada antes + /// de autenticar al peer, o cualquier otra inconsistencia que el + /// `AttestationStore` rechace. + rejected_attests: usize, + /// Contador análogo para retracciones rechazadas. + rejected_retracts: usize, + /// Contador de declaraciones de raíz rechazadas: dialect byte + /// desconocido, struct_hash ausente en el store al finalizar, o + /// α-verificación falla (el α anunciado no recompone bajo el + /// dialect declarado). + rejected_root_decls: usize, + + /// Nonce aleatorio que **nosotros** emitimos en `Challenge`. La + /// firma del `Hello` del peer debe ser sobre este nonce. + self_nonce: [u8; 32], + /// Nonce que el peer publicó en su `Challenge` — sobre este + /// nonce firmamos nosotros nuestro `Hello`. + peer_nonce: Option<[u8; 32]>, + + sent_challenge: bool, + received_challenge: bool, + sent_hello: bool, + received_hello: bool, + sent_attestations: bool, + sent_retractions: bool, + sent_root_decls: bool, + sent_done: bool, + received_done: bool, +} + +impl SyncSession { + /// Constructor sin retracciones — el chasis lo usa cuando no hay + /// retracciones que sincronizar (o por compat con tests viejos). + pub fn new( + mst: Mst, + store: MemStore, + attestations: AttestationStore, + keypair: Keypair, + ) -> Self { + Self::with_retractions(mst, store, attestations, RetractionStore::new(), keypair) + } + + /// Constructor con retracciones, sin declaración explícita de + /// raíces. Se empuja un `RootDeclaration` vacío para que la sesión + /// igual avance al `Done` (el contador de sent_root_decls se marca). + pub fn with_retractions( + mst: Mst, + store: MemStore, + attestations: AttestationStore, + retractions: RetractionStore, + keypair: Keypair, + ) -> Self { + Self::with_roots( + mst, + store, + attestations, + retractions, + HashMap::new(), + keypair, + ) + } + + /// Constructor completo: además de retracciones, lleva el mapa de + /// raíces locales `(α-hash → (struct-hash, dialect))` que se + /// empujarán al peer como `RootDeclaration` tras el Hello. El peer + /// las re-verificará al final de la sesión. + pub fn with_roots( + mst: Mst, + store: MemStore, + attestations: AttestationStore, + retractions: RetractionStore, + local_roots: HashMap, + keypair: Keypair, + ) -> Self { + let own_probes = mst.build_probe_index(); + let own_root_subtree_hash = mst.root_hash(); + let mut self_nonce = [0u8; 32]; + OsRng.fill_bytes(&mut self_nonce); + Self { + mst, + store, + attestations, + retractions, + local_roots, + keypair, + peer_did: None, + own_probes, + own_root_subtree_hash, + awaited_probes: HashSet::new(), + seen_probes: HashSet::new(), + awaiting_root: HashSet::new(), + awaiting_child: HashSet::new(), + pending_root_decls: Vec::new(), + verified_root_decls: HashMap::new(), + rejected_hellos: 0, + rejected_delivers: 0, + rejected_attests: 0, + rejected_retracts: 0, + rejected_root_decls: 0, + self_nonce, + peer_nonce: None, + sent_challenge: false, + received_challenge: false, + sent_hello: false, + received_hello: false, + sent_attestations: false, + sent_retractions: false, + sent_root_decls: false, + sent_done: false, + received_done: false, + } + } + + /// Conveniencia para sesiones sin atestaciones previas. Equivalente + /// a `new(mst, store, AttestationStore::new(), keypair)`. + pub fn without_attestations(mst: Mst, store: MemStore, keypair: Keypair) -> Self { + Self::new(mst, store, AttestationStore::new(), keypair) + } + + /// Mensaje inicial: `Challenge` con un nonce aleatorio. El `Hello` + /// y las atestaciones llegarán como respuesta al `Challenge` del + /// otro peer (cuando lo recibamos, ya tendremos su nonce sobre el + /// que firmar nuestra identidad). + pub fn start(&mut self) -> Vec { + if self.sent_challenge { + return Vec::new(); + } + self.sent_challenge = true; + let mut out = vec![Message::Challenge { + nonce: self.self_nonce, + }]; + out.extend(self.maybe_done()); + out + } + + pub fn handle(&mut self, msg: Message) -> Vec { + let mut out = Vec::new(); + match msg { + Message::Challenge { nonce } => { + if self.received_challenge { + // Challenge duplicado: ignoramos. Un peer + // legítimo no debería enviar dos. + return out; + } + self.received_challenge = true; + self.peer_nonce = Some(nonce); + + // Ahora podemos firmar nuestro Hello sobre el nonce + // del peer — lo que ata la firma a esta sesión. + let payload = + hello_payload(&nonce, &self.keypair.did(), &self.own_root_subtree_hash); + let signature = self.keypair.sign(&payload); + self.sent_hello = true; + out.push(Message::Hello { + peer_did: self.keypair.did(), + root_subtree_hash: self.own_root_subtree_hash, + signature, + }); + + // Empuje de atestaciones: el peer ya nos verificará + // como remitente cuando reciba nuestro Hello. + let atts: Vec<_> = self.attestations.all().cloned().collect(); + if !atts.is_empty() { + out.push(Message::AttestPush { attestations: atts }); + } + self.sent_attestations = true; + + // Y de retracciones: análogo a AttestPush pero con + // las retracciones que conocemos. + let rets: Vec<_> = self.retractions.all().cloned().collect(); + if !rets.is_empty() { + out.push(Message::RetractPush { retractions: rets }); + } + self.sent_retractions = true; + + // Declaración de raíces: para cada raíz conocida + // localmente, anunciamos el binding α↔struct+dialect. + // El peer verificará al cerrar la sesión que el α que + // le anunciamos recompone bajo el dialect declarado. + let decls: Vec = self + .local_roots + .iter() + .map(|(alpha, (struct_hash, dialect))| { + RootDecl::new(*alpha, *struct_hash, *dialect) + }) + .collect(); + if !decls.is_empty() { + out.push(Message::RootDeclaration { decls }); + } + self.sent_root_decls = true; + } + + Message::Hello { + peer_did, + root_subtree_hash, + signature, + } => { + // ── Autenticación del peer + anti-replay ───────── + // La firma debe ser sobre nuestro `self_nonce` (que + // emitimos en nuestro Challenge), atándola a esta + // sesión. Un Hello capturado de otra sesión tendría + // un nonce distinto y la verificación fallaría. + let payload = hello_payload(&self.self_nonce, &peer_did, &root_subtree_hash); + if !peer_did.verify(&payload, &signature) { + self.rejected_hellos += 1; + return out; + } + self.peer_did = Some(peer_did); + self.received_hello = true; + if self.should_probe(&root_subtree_hash) { + self.awaited_probes.insert(root_subtree_hash); + out.push(Message::ProbeReq { + subtree_hash: root_subtree_hash, + }); + } + } + + Message::ProbeReq { subtree_hash } => { + let probe = self.own_probes.get(&subtree_hash).cloned(); + // Si el subárbol pedido era vacío (o desconocido para + // nosotros), respondemos con `None` — el peer lo + // tratará como un punto sin descendientes que descubrir. + out.push(Message::ProbeRes { + subtree_hash, + probe, + }); + } + + Message::ProbeRes { + subtree_hash, + probe, + } => { + self.awaited_probes.remove(&subtree_hash); + self.seen_probes.insert(subtree_hash); + if let Some(probe) = probe { + out.extend(self.process_probe(&probe)); + } + } + + Message::Fetch { hash } => { + if let Some(stored) = self.store.get(&hash).cloned() { + out.push(Message::Deliver { hash, stored }); + } + // Si no lo tenemos, callamos. El peer no debería estar + // pidiéndonos algo que no le hayamos anunciado. + } + + Message::Deliver { hash, stored } => { + // ── Verificación criptográfica ──────────────────── + // Recomputamos el hash del nodo entregado a partir de + // sus componentes. Si no coincide con el anunciado, + // alguien (peer malicioso o ruido en transporte) está + // intentando colar contenido distinto bajo un hash que + // no le corresponde. Descartamos silenciosamente y + // contamos para diagnóstico. + if hash_stored(&stored) != hash { + self.rejected_delivers += 1; + // No tocamos awaiting_*: la solicitud sigue + // pendiente y el peer (legítimo o no) puede + // reintentarla. + return out; + } + + let was_root = self.awaiting_root.remove(&hash); + self.awaiting_child.remove(&hash); + + // Antes de mover `stored`, descubrimos qué hijos + // faltan y los pedimos. + let mut new_fetches = Vec::new(); + for ch in &stored.children { + if !self.store.contains(ch) + && !self.awaiting_root.contains(ch) + && !self.awaiting_child.contains(ch) + { + self.awaiting_child.insert(*ch); + new_fetches.push(*ch); + } + } + + self.store.put_chunked(hash, stored); + if was_root { + self.mst.insert(hash); + } + + for h in new_fetches { + out.push(Message::Fetch { hash: h }); + } + } + + Message::AttestPush { attestations } => { + // Antes de procesar atestaciones del peer, exigimos + // haber autenticado su identidad. Un push antes del + // `Hello` es protocolo malformado o ataque — todas las + // atestaciones se cuentan como rechazadas. + if !self.received_hello { + self.rejected_attests += attestations.len(); + return out; + } + for att in attestations { + // `AttestationStore::add` re-verifica cada firma. + // Una sola atestación corrupta no contamina las + // demás del lote. + if self.attestations.add(att).is_err() { + self.rejected_attests += 1; + } + } + } + + Message::RetractPush { retractions } => { + // Mismo contrato que AttestPush: exigimos Hello previo. + if !self.received_hello { + self.rejected_retracts += retractions.len(); + return out; + } + for r in retractions { + if self.retractions.add(r).is_err() { + self.rejected_retracts += 1; + } + } + } + + Message::RootDeclaration { decls } => { + // Mismo contrato de autenticación que AttestPush / + // RetractPush: sin Hello verificado no procesamos. + if !self.received_hello { + self.rejected_root_decls += decls.len(); + return out; + } + // Las acumulamos crudas — la verificación α↔struct + // requiere que el struct_hash ya esté reconstruible + // desde el store local, lo que sólo está garantizado + // al cerrar la sesión. `take_verified_root_decls` + // drena este buffer y verifica entonces. + self.pending_root_decls.extend(decls); + } + + Message::Done => { + self.received_done = true; + } + } + out.extend(self.maybe_done()); + out + } + + fn process_probe(&mut self, probe: &NodeProbe) -> Vec { + let mut out = Vec::new(); + + // Cada clave del probe que no tenemos pasa a `awaiting_root` y + // generamos un Fetch. Si ya está en el store (sin estar aún en + // el MST), simplemente la promovemos al MST sin pedirla. + for k in &probe.keys { + if self.mst.contains(k) { + continue; + } + if self.store.contains(k) { + self.mst.insert(*k); + continue; + } + if self.awaiting_root.contains(k) { + continue; + } + self.awaiting_root.insert(*k); + out.push(Message::Fetch { hash: *k }); + } + + // Para cada subárbol hijo, decidimos si recurrir o podar: + // - el vacío se reconoce por hash sin red, + // - los que ya tenemos en `own_probes` (igualdad de hash = + // subestructura idéntica) se podan, + // - los ya vistos o solicitados no se duplican, + // - el resto dispara un `ProbeReq` recursivo. + for ch in &probe.child_hashes { + if self.should_probe(ch) { + self.awaited_probes.insert(*ch); + out.push(Message::ProbeReq { subtree_hash: *ch }); + } + } + + out + } + + /// Decide si vale la pena solicitar un probe sobre `h`. Cuatro + /// razones para NO pedirlo: + /// - es el subárbol vacío (lo conocemos por convención), + /// - coincide con nuestra propia raíz (igualdad estructural), + /// - aparece en `own_probes` (ya tenemos un subárbol idéntico), + /// - ya lo solicitamos o ya lo recibimos. + fn should_probe(&self, h: &ContentHash) -> bool { + if *h == empty_subtree_hash() { + return false; + } + if *h == self.own_root_subtree_hash { + return false; + } + if self.own_probes.contains_key(h) { + return false; + } + if self.awaited_probes.contains(h) || self.seen_probes.contains(h) { + return false; + } + true + } + + fn maybe_done(&mut self) -> Vec { + if self.sent_done { + return Vec::new(); + } + if !self.sent_challenge || !self.received_challenge { + return Vec::new(); + } + if !self.sent_hello || !self.received_hello { + return Vec::new(); + } + if !self.sent_attestations || !self.sent_retractions || !self.sent_root_decls { + return Vec::new(); + } + if !self.awaited_probes.is_empty() { + return Vec::new(); + } + if !self.awaiting_root.is_empty() || !self.awaiting_child.is_empty() { + return Vec::new(); + } + self.sent_done = true; + vec![Message::Done] + } + + /// Drena las declaraciones de raíz pendientes, verifica cada una + /// reconstruyendo el `SemanticNode` desde el store local y + /// llamando a [`verify_root_alpha`], y devuelve el mapa + /// `α-hash → (struct-hash, dialect)` de las que aprueban. Rechazos + /// (struct_hash ausente, dialect byte desconocido, α-hash no + /// recompone bajo el dialect declarado) se acumulan en el contador + /// `rejected_root_decls`. + /// + /// Idempotente para llamadas repetidas: la primera consume el + /// buffer pendiente y popula `verified_root_decls`; las siguientes + /// devuelven una copia del mapa ya verificado sin recontar. + pub fn take_verified_root_decls( + &mut self, + ) -> HashMap { + let pending = std::mem::take(&mut self.pending_root_decls); + for decl in pending { + let Some(dialect) = decl.dialect() else { + // Byte de dialect que esta versión no conoce — + // versión futura. La descartamos sin verificar. + self.rejected_root_decls += 1; + continue; + }; + // Si ya tenemos esta raíz localmente, no la + // sobre-escribimos — la fuente local es autoritativa. + if self.local_roots.contains_key(&decl.alpha) + || self.verified_root_decls.contains_key(&decl.alpha) + { + continue; + } + // El struct_hash tiene que estar en el store ya: el sync + // habrá entregado el nodo vía `Deliver` si correspondía. + let Some(node) = self.store.reconstruct(&decl.struct_hash) else { + self.rejected_root_decls += 1; + continue; + }; + // Re-verificación criptográfica del α: si el α anunciado + // no recompone bajo NINGÚN dialect conocido, el peer + // (malicioso o con bug) está mintiendo. Si recompone bajo + // un dialect distinto al declarado, también rechazamos — + // los profiles α producen hashes distintos por sus + // constantes de wire, así que el match cruzado es un + // intento de evadir la indexación por dialect. + match verify_root_alpha(&node, &decl.alpha) { + Some(d) if d == dialect => { + self.verified_root_decls + .insert(decl.alpha, (decl.struct_hash, dialect)); + } + _ => { + self.rejected_root_decls += 1; + } + } + } + self.verified_root_decls.clone() + } + + pub fn is_done(&self) -> bool { + self.sent_done && self.received_done + } + + pub fn rejected_delivers(&self) -> usize { + self.rejected_delivers + } + + pub fn rejected_hellos(&self) -> usize { + self.rejected_hellos + } + + pub fn rejected_attests(&self) -> usize { + self.rejected_attests + } + + pub fn rejected_retracts(&self) -> usize { + self.rejected_retracts + } + + pub fn rejected_root_decls(&self) -> usize { + self.rejected_root_decls + } + + /// `true` si la sesión ya verificó el `Hello` del peer remoto. + /// Útil para tests que necesitan saber cuándo es seguro inyectar + /// `AttestPush`/`RetractPush` (que requieren `received_hello`). + pub fn received_hello(&self) -> bool { + self.received_hello + } + + pub fn attestations(&self) -> &AttestationStore { + &self.attestations + } + + pub fn retractions(&self) -> &RetractionStore { + &self.retractions + } + + /// Identidad del peer remoto, capturada tras verificar su `Hello`. + /// `None` si todavía no llegó un `Hello` válido. + pub fn peer_did(&self) -> Option { + self.peer_did + } + + pub fn local_did(&self) -> Did { + self.keypair.did() + } + + /// Nonce aleatorio que esta sesión emitió en su `Challenge`. + /// Expuesto principalmente para tests y debugging — el nonce + /// viaja en claro por el wire y no es secreto. + pub fn self_nonce(&self) -> [u8; 32] { + self.self_nonce + } + + pub fn mst(&self) -> &Mst { + &self.mst + } + + pub fn store(&self) -> &MemStore { + &self.store + } + + pub fn into_parts(self) -> (Mst, MemStore, AttestationStore) { + (self.mst, self.store, self.attestations) + } + + /// Variante de [`into_parts`] que también devuelve las retracciones. + /// Pensada para callers que necesitan mezclar `RetractPush`es + /// recibidos en su estado persistente. + pub fn into_parts_with_retractions( + self, + ) -> (Mst, MemStore, AttestationStore, RetractionStore) { + (self.mst, self.store, self.attestations, self.retractions) + } + + /// Variante extendida que devuelve también el mapa de raíces + /// **ya verificadas** recibidas por wire. Para que las raíces + /// estén verificadas, llamar a `take_verified_root_decls` antes + /// de consumir la sesión; de lo contrario, el mapa estará vacío. + pub fn into_parts_with_roots( + self, + ) -> ( + Mst, + MemStore, + AttestationStore, + RetractionStore, + HashMap, + ) { + ( + self.mst, + self.store, + self.attestations, + self.retractions, + self.verified_root_decls, + ) + } +} diff --git a/03_ukupacha/minga/minga-p2p/tests/async_driver_invariants.rs b/03_ukupacha/minga/minga-p2p/tests/async_driver_invariants.rs new file mode 100644 index 0000000..f8df7ae --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/tests/async_driver_invariants.rs @@ -0,0 +1,161 @@ +//! Tests del `run_sync_async` sobre canales async in-memory. +//! +//! Equivalentes a los del harness síncrono pero ejecutados sobre +//! `tokio::io::duplex` — la misma lógica protocolar viajando sobre +//! bytes serializados con postcard, encuadrados con length-prefix, y +//! transportados por una pipa async. Si esto pasa, lo único que falta +//! para el sync sobre TCP/QUIC/libp2p es enchufar el transporte real. + +use minga_core::{parse, ContentHash, Keypair, MemStore, Mst, NodeStore}; +use minga_p2p::{run_sync_async, SyncSession}; + +fn kp(seed: u8) -> Keypair { + Keypair::from_seed(&[seed; 32]) +} + +fn build_repo(sources: &[&str]) -> (Mst, MemStore, Vec) { + let mut mst = Mst::new(); + let mut store = MemStore::new(); + let mut roots = Vec::new(); + for src in sources { + let n = parse::rust(src).unwrap(); + let h = store.put(&n); + mst.insert(h); + roots.push(h); + } + (mst, store, roots) +} + +#[tokio::test] +async fn async_sync_identical_repos() { + let sources = &["fn add(x: i32, y: i32) -> i32 { x + y }"]; + let (mst_a, store_a, _) = build_repo(sources); + let (mst_b, store_b, _) = build_repo(sources); + + let session_a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let session_b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + + let (a_stream, b_stream) = tokio::io::duplex(64 * 1024); + + let task_a = tokio::spawn(run_sync_async(session_a, a_stream)); + let task_b = tokio::spawn(run_sync_async(session_b, b_stream)); + + let a = task_a.await.unwrap().unwrap(); + let b = task_b.await.unwrap().unwrap(); + + assert_eq!(a.mst().root_hash(), b.mst().root_hash()); +} + +#[tokio::test] +async fn async_sync_one_empty_pulls_everything() { + let sources = &["fn complex(x: i32) -> i32 { let y = x * 2; y + 1 }"]; + let (mst_a, store_a, _) = build_repo(sources); + let (mst_b, store_b, _) = build_repo(&[]); + let store_a_size = store_a.len(); + + let session_a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let session_b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + + let (a_stream, b_stream) = tokio::io::duplex(64 * 1024); + + let task_a = tokio::spawn(run_sync_async(session_a, a_stream)); + let task_b = tokio::spawn(run_sync_async(session_b, b_stream)); + + let a = task_a.await.unwrap().unwrap(); + let b = task_b.await.unwrap().unwrap(); + + assert_eq!(a.mst().root_hash(), b.mst().root_hash()); + assert_eq!(a.store().len(), b.store().len()); + assert_eq!(b.store().len(), store_a_size); +} + +#[tokio::test] +async fn async_sync_disjoint_sets_merge() { + let only_a = &[ + "fn alpha() -> i32 { 1 }", + "fn beta(x: i32) -> i32 { x + 1 }", + ]; + let only_b = &[ + "fn gamma(y: i32) -> bool { y > 0 }", + "fn delta() -> &'static str { \"hello\" }", + ]; + + let (mst_a, store_a, _) = build_repo(only_a); + let (mst_b, store_b, _) = build_repo(only_b); + + let session_a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let session_b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + + let (a_stream, b_stream) = tokio::io::duplex(64 * 1024); + + let task_a = tokio::spawn(run_sync_async(session_a, a_stream)); + let task_b = tokio::spawn(run_sync_async(session_b, b_stream)); + + let a = task_a.await.unwrap().unwrap(); + let b = task_b.await.unwrap().unwrap(); + + assert_eq!(a.mst().root_hash(), b.mst().root_hash()); + assert_eq!(a.mst().len(), 4); +} + +#[tokio::test] +async fn async_sync_propagates_authenticated_identity() { + // Cada peer debe acabar conociendo el DID verificado del otro, + // exactamente como en el harness síncrono. + let kp_a = kp(10); + let kp_b = kp(20); + let did_a = kp_a.did(); + let did_b = kp_b.did(); + + let session_a = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp_a); + let session_b = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp_b); + + let (a_stream, b_stream) = tokio::io::duplex(64 * 1024); + + let task_a = tokio::spawn(run_sync_async(session_a, a_stream)); + let task_b = tokio::spawn(run_sync_async(session_b, b_stream)); + + let a = task_a.await.unwrap().unwrap(); + let b = task_b.await.unwrap().unwrap(); + + assert_eq!(a.peer_did(), Some(did_b)); + assert_eq!(b.peer_did(), Some(did_a)); +} + +#[tokio::test] +async fn async_sync_propagates_attestations() { + use minga_core::{Attestation, AttestationStore}; + + let kp_a = kp(30); + let kp_b = kp(40); + + let (mst_a, store_a, roots_a) = build_repo(&["fn from_a() -> i32 { 1 }"]); + let (mst_b, store_b, roots_b) = build_repo(&["fn from_b() -> i32 { 2 }"]); + + let mut atts_a = AttestationStore::new(); + atts_a + .add(Attestation::create(&kp_a, roots_a[0])) + .unwrap(); + + let mut atts_b = AttestationStore::new(); + atts_b + .add(Attestation::create(&kp_b, roots_b[0])) + .unwrap(); + + let session_a = SyncSession::new(mst_a, store_a, atts_a, kp_a.clone()); + let session_b = SyncSession::new(mst_b, store_b, atts_b, kp_b.clone()); + + let (a_stream, b_stream) = tokio::io::duplex(128 * 1024); + + let task_a = tokio::spawn(run_sync_async(session_a, a_stream)); + let task_b = tokio::spawn(run_sync_async(session_b, b_stream)); + + let a = task_a.await.unwrap().unwrap(); + let b = task_b.await.unwrap().unwrap(); + + // Los DIDs y atestaciones cruzaron correctamente sobre el wire. + assert_eq!(a.attestations().authors_of(&roots_a[0]), vec![kp_a.did()]); + assert_eq!(a.attestations().authors_of(&roots_b[0]), vec![kp_b.did()]); + assert_eq!(b.attestations().authors_of(&roots_a[0]), vec![kp_a.did()]); + assert_eq!(b.attestations().authors_of(&roots_b[0]), vec![kp_b.did()]); +} diff --git a/03_ukupacha/minga/minga-p2p/tests/kad_discovery.rs b/03_ukupacha/minga/minga-p2p/tests/kad_discovery.rs new file mode 100644 index 0000000..2689634 --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/tests/kad_discovery.rs @@ -0,0 +1,189 @@ +//! Tests de descubrimiento vía Kademlia DHT. + +use std::time::Duration; + +use minga_core::{parse, AttestationStore, Keypair, MemStore, Mst, NodeStore}; +use minga_p2p::{LibP2pNode, MingaPeer}; + +#[tokio::test] +async fn identify_auto_populates_kad_routing_table() { + // Sin `add_dht_peer` manual: solo dial. Identify intercambia + // direcciones automáticamente y poblamos Kad con ellas. Tras + // unos cientos de ms, A puede consultar B vía DHT. + let a = LibP2pNode::new().unwrap(); + let b = LibP2pNode::new().unwrap(); + + let addr_b = b.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + + a.dial(addr_b); + + // Margen para handshake Noise + Yamux + Identify. + tokio::time::sleep(Duration::from_millis(500)).await; + + let result = a.find_closest_peers(b.peer_id).await; + assert!( + result.iter().any(|p| p.peer_id == b.peer_id), + "tras Identify, B debe estar en el routing de A. Obtuvo: {:?}", + result.iter().map(|p| p.peer_id).collect::>() + ); +} + +#[tokio::test] +async fn kad_two_node_basic_discovery() { + // A escucha. B dializa, añade A al routing table de Kad. + // Tras el handshake Kad, B puede consultar el DHT y encontrar A. + let a = LibP2pNode::new().unwrap(); + let b = LibP2pNode::new().unwrap(); + + let addr_a = a.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + + b.add_dht_peer(a.peer_id, addr_a.clone()); + b.dial(addr_a.clone()); + + // Damos margen para handshake Noise+Yamux+Kad. + tokio::time::sleep(Duration::from_millis(300)).await; + + let result = b.find_closest_peers(a.peer_id).await; + assert!( + result.iter().any(|p| p.peer_id == a.peer_id), + "B debe encontrar A vía DHT, obtuvo {:?}", + result + ); +} + +#[tokio::test] +async fn kad_three_node_discovery_via_rendezvous() { + // Test canónico de descubrimiento DHT: + // - A es un peer "rendezvous" que pre-conoce a B y C (en una red + // real, A los aprendería de los handshakes Kad cuando B y C se + // conectan; aquí lo seedeamos explícitamente para no depender + // de timing de propagación). + // - B solo conoce a A. + // - B pregunta al DHT por C: la query va a A, A responde con C, + // B aprende la dirección de C sin haberle hablado nunca. + // + // Este es exactamente el patrón de IPFS, libp2p bootstrap nodes + // y cualquier P2P descentralizado real. + + let a = LibP2pNode::new().unwrap(); // rendezvous + let b = LibP2pNode::new().unwrap(); + let c = LibP2pNode::new().unwrap(); + + let addr_a = a.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + let addr_b = b.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + let addr_c = c.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + + // A (el rendezvous) tiene a B y C en su routing table. + a.add_dht_peer(b.peer_id, addr_b); + a.add_dht_peer(c.peer_id, addr_c); + + // B solo conoce a A. + b.add_dht_peer(a.peer_id, addr_a.clone()); + b.dial(addr_a.clone()); + + // Margen para que la conexión Kad B↔A se establezca. + tokio::time::sleep(Duration::from_millis(300)).await; + + // B pregunta al DHT por C. Su routing table solo tiene A; la + // query va a A; A responde con C de su table. B descubre. + let result = b.find_closest_peers(c.peer_id).await; + assert!( + result.iter().any(|p| p.peer_id == c.peer_id), + "B debe descubrir C vía A; obtuvo: {:?}", + result.iter().map(|p| p.peer_id).collect::>() + ); + + // Y la dirección de C debe haber viajado en el resultado, así + // que B podría dialarlo directamente sin pasar por A. + let c_entry = result.iter().find(|p| p.peer_id == c.peer_id).unwrap(); + assert!(!c_entry.addrs.is_empty(), "C debe venir con address resoluble"); +} + +#[tokio::test] +async fn kad_discovery_then_sync() { + // Cierre del bucle: B descubre C vía DHT a través de A, y luego + // sincroniza directamente con C. Discovery + transport + sync + // protocolar autenticado, todo end-to-end sobre red real. + + fn singleton(seed: u8, src: &str) -> MingaPeer { + let mut mst = Mst::new(); + let mut store = MemStore::new(); + let h = store.put(&parse::rust(src).unwrap()); + mst.insert(h); + MingaPeer::new( + Keypair::from_seed(&[seed; 32]), + mst, + store, + AttestationStore::new(), + ) + .unwrap() + } + + // A: rendezvous puro, solo Kad (no MingaPeer, no necesita estado). + let a = LibP2pNode::new().unwrap(); + let addr_a = a.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + + // C: tiene una función que B querrá. Pasivo para aceptar el sync. + let c = singleton(3, "fn from_c(x: i32) -> i32 { x + 100 }"); + let addr_c = c.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + let _accept_c = c.run_passive_accept(); + + // A pre-conoce a C en su routing table (rendezvous comportándose + // como tal). + a.add_dht_peer(c.peer_id(), addr_c); + + // B: tiene su propia función. Solo conoce A. + let b = singleton(2, "fn from_b() -> i32 { 0 }"); + b.add_dht_peer(a.peer_id, addr_a.clone()); + b.dial(addr_a.clone()); + + tokio::time::sleep(Duration::from_millis(300)).await; + + // B descubre a C vía DHT. + let discovered = b.find_closest_peers(c.peer_id()).await; + let c_entry = discovered + .iter() + .find(|p| p.peer_id == c.peer_id()) + .unwrap_or_else(|| { + panic!( + "B no descubrió C; encontró: {:?}", + discovered.iter().map(|p| p.peer_id).collect::>() + ) + }); + + // B usa la dirección descubierta para dial directo y sync. + let addr_c_via_dht = c_entry.addrs[0].clone(); + b.dial(addr_c_via_dht); + + // Reintentamos sync hasta que la conexión esté arriba. + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if b.sync_with(c.peer_id()).await.is_ok() { + break; + } + if std::time::Instant::now() >= deadline { + panic!("sync no completó en 5s"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Tras el sync, B y C tienen el mismo MST (unión). El merge de + // C sucede en su task de accept (paralela a B); esperamos a que + // ese merge se vea reflejado en su state. + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + let (mst_b, _, _) = b.snapshot().await; + let (mst_c, _, _) = c.snapshot().await; + if mst_b.root_hash() == mst_c.root_hash() && mst_b.len() == 2 { + break; + } + if std::time::Instant::now() >= deadline { + panic!( + "no convergencia tras 2s: |B|={}, |C|={}", + mst_b.len(), + mst_c.len() + ); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } +} diff --git a/03_ukupacha/minga/minga-p2p/tests/kad_providers.rs b/03_ukupacha/minga/minga-p2p/tests/kad_providers.rs new file mode 100644 index 0000000..b4ef0b6 --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/tests/kad_providers.rs @@ -0,0 +1,139 @@ +//! Tests de Provider Records vía Kademlia DHT. +//! +//! Discovery a nivel de **contenido**: en lugar de "¿quién está +//! cerca?", la pregunta es "¿quién tiene el hash X?". Cuando un peer +//! ingresa contenido, se anuncia como provider; otros peers consultan +//! el DHT para encontrar a quién dial directamente. + +use std::time::Duration; + +use minga_core::{parse, AttestationStore, ContentHash, Keypair, MemStore, Mst}; +use minga_p2p::{LibP2pNode, MingaPeer}; + +fn kp(seed: u8) -> Keypair { + Keypair::from_seed(&[seed; 32]) +} + +#[tokio::test] +async fn provider_announce_and_lookup_two_nodes() { + let a = LibP2pNode::new().unwrap(); + let b = LibP2pNode::new().unwrap(); + + let addr_b = b.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + + // A conoce a B y dializa para establecer conexión Kad. + a.add_dht_peer(b.peer_id, addr_b.clone()); + a.dial(addr_b); + + tokio::time::sleep(Duration::from_millis(300)).await; + + // A anuncia que tiene `content`. + let content = ContentHash([0x42; 32]); + a.start_providing(&content.0); + + // Margen para que el ADD_PROVIDER se replique a B. + tokio::time::sleep(Duration::from_millis(500)).await; + + // B consulta — debe encontrar A. + let providers = b.find_providers(&content.0).await; + assert!( + providers.iter().any(|p| *p == a.peer_id), + "B debe descubrir a A como provider, obtuvo: {:?}", + providers + ); +} + +#[tokio::test] +async fn provider_lookup_returns_empty_for_unknown_content() { + let a = LibP2pNode::new().unwrap(); + let b = LibP2pNode::new().unwrap(); + + let addr_b = b.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + a.add_dht_peer(b.peer_id, addr_b.clone()); + a.dial(addr_b); + tokio::time::sleep(Duration::from_millis(300)).await; + + // Nadie ha anunciado este hash. + let unknown = ContentHash([0xFF; 32]); + let providers = b.find_providers(&unknown.0).await; + assert!(providers.is_empty()); +} + +#[tokio::test] +async fn minga_peer_ingest_auto_announces_provider() { + // El test de integración del flujo "fase de salida al mundo real": + // un peer hace ingest de un archivo y, sin acción adicional, otro + // peer puede descubrirlo vía DHT como provider. + + let a_kp = kp(1); + let b_kp = kp(2); + + let a = MingaPeer::new(a_kp, Mst::new(), MemStore::new(), AttestationStore::new()).unwrap(); + let b = MingaPeer::new(b_kp, Mst::new(), MemStore::new(), AttestationStore::new()).unwrap(); + + let addr_a = a.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + let _addr_b = b.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + + // Conectar B a A vía Kad (rendezvous bidireccional). + a.add_dht_peer(b.peer_id(), _addr_b); + b.add_dht_peer(a.peer_id(), addr_a.clone()); + b.dial(addr_a); + + tokio::time::sleep(Duration::from_millis(300)).await; + + // A ingresa una función. Esto debe anunciarla automáticamente. + let n = parse::rust("fn discover_me() -> i32 { 7 }").unwrap(); + let h = a.ingest(&n).await; + + // Margen para la replicación del provider record. + tokio::time::sleep(Duration::from_millis(500)).await; + + // B busca quién tiene `h` y debe encontrar A. + let providers = b.find_providers(h).await; + assert!( + providers.iter().any(|p| *p == a.peer_id()), + "B debe descubrir a A como provider del contenido recién ingerido. Obtuvo: {:?}", + providers, + ); +} + +#[tokio::test] +async fn minga_peer_announce_all_roots_publishes_each_alpha() { + // Tras ingerir múltiples raíces con dialect, `announce_all_roots` + // republica todos los α-hashes en el DHT. Útil al arrancar un + // `listen` sobre un repo existente: las raíces vuelven a ser + // descubribles sin re-ingerir cada archivo. + use minga_core::parse::Dialect; + + let a_kp = kp(11); + let b_kp = kp(12); + + let a = MingaPeer::new(a_kp, Mst::new(), MemStore::new(), AttestationStore::new()).unwrap(); + let b = MingaPeer::new(b_kp, Mst::new(), MemStore::new(), AttestationStore::new()).unwrap(); + + let addr_a = a.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + let addr_b = b.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + + a.add_dht_peer(b.peer_id(), addr_b); + b.add_dht_peer(a.peer_id(), addr_a.clone()); + b.dial(addr_a); + tokio::time::sleep(Duration::from_millis(300)).await; + + // A ingresa dos raíces distintas con dialect. + let n1 = parse::rust("fn alpha_one() -> i32 { 1 }").unwrap(); + let (alpha1, _) = a.ingest_with_dialect(&n1, Dialect::Rust).await; + let n2 = parse::rust("fn alpha_two() -> i32 { 2 }").unwrap(); + let (alpha2, _) = a.ingest_with_dialect(&n2, Dialect::Rust).await; + + // Re-anuncia (idempotente). Devuelve 2. + let announced = a.announce_all_roots().await; + assert_eq!(announced, 2); + + tokio::time::sleep(Duration::from_millis(500)).await; + + // B busca cada α-hash y debe encontrar A. + let p1 = b.find_providers(alpha1).await; + let p2 = b.find_providers(alpha2).await; + assert!(p1.iter().any(|p| *p == a.peer_id())); + assert!(p2.iter().any(|p| *p == a.peer_id())); +} diff --git a/03_ukupacha/minga/minga-p2p/tests/libp2p_integration.rs b/03_ukupacha/minga/minga-p2p/tests/libp2p_integration.rs new file mode 100644 index 0000000..8bfa35a --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/tests/libp2p_integration.rs @@ -0,0 +1,161 @@ +//! Test de integración real con libp2p. +//! +//! Dos `LibP2pNode`s independientes en localhost: +//! - cada uno con su propia identidad libp2p, +//! - conectados por TCP (con cifrado Noise + multiplexado Yamux), +//! - intercambiando una sesión completa de sync vía bidirectional +//! streams sobre el protocolo `/minga/sync/1.0.0`. +//! +//! Lo único que el wire añade respecto al harness in-memory es el +//! transporte. La lógica del protocolo y el state machine son los +//! mismos — eso es exactamente lo que queríamos demostrar. + +use std::time::Duration; + +use futures::StreamExt; +use minga_core::{parse, ContentHash, Keypair, MemStore, Mst, NodeStore}; +use minga_p2p::{run_sync_async, LibP2pNode, SyncSession, SYNC_PROTOCOL}; +use tokio_util::compat::FuturesAsyncReadCompatExt; + +fn kp(seed: u8) -> Keypair { + Keypair::from_seed(&[seed; 32]) +} + +fn build_repo(sources: &[&str]) -> (Mst, MemStore, Vec) { + let mut mst = Mst::new(); + let mut store = MemStore::new(); + let mut roots = Vec::new(); + for src in sources { + let n = parse::rust(src).unwrap(); + let h = store.put(&n); + mst.insert(h); + roots.push(h); + } + (mst, store, roots) +} + +#[tokio::test] +async fn libp2p_sync_two_peers_over_tcp() { + let node_a = LibP2pNode::new().unwrap(); + let node_b = LibP2pNode::new().unwrap(); + let peer_b = node_b.peer_id; + + // Solo B necesita escuchar; A inicia el dial. + let addr_b = node_b + .listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()) + .await; + + // B acepta streams del protocolo Minga en una tarea. + let only_b_sources = &["fn from_b(x: i32) -> i32 { x + 1 }"]; + let (mst_b, store_b, _) = build_repo(only_b_sources); + let session_b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + + let mut control_b = node_b.control.clone(); + let task_b = tokio::spawn(async move { + let mut incoming = control_b.accept(SYNC_PROTOCOL).unwrap(); + let (_peer, stream) = incoming.next().await.expect("incoming stream"); + run_sync_async(session_b, stream.compat()).await + }); + + // A dializa B y abre stream. Reintenta hasta que la conexión esté + // arriba (puede tardar unos ms el handshake Noise+Yamux). + node_a.dial(addr_b); + let mut control_a = node_a.control.clone(); + let stream_a = { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match control_a.open_stream(peer_b, SYNC_PROTOCOL).await { + Ok(s) => break s, + Err(_) if std::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(e) => panic!("no se pudo abrir stream tras 5s: {e:?}"), + } + } + }; + + let only_a_sources = &["fn from_a() -> i32 { 0 }"]; + let (mst_a, store_a, _) = build_repo(only_a_sources); + let session_a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + + let task_a = tokio::spawn(async move { run_sync_async(session_a, stream_a.compat()).await }); + + let result_a = task_a.await.expect("task A").expect("sync A"); + let result_b = task_b.await.expect("task B").expect("sync B"); + + // Convergencia tras viajar sobre TCP real. + assert_eq!(result_a.mst().root_hash(), result_b.mst().root_hash()); + assert_eq!(result_a.mst().len(), 2); + assert_eq!(result_b.mst().len(), 2); + + // Cada peer terminó con la identidad libp2p del otro autenticada. + // (Las identidades libp2p no son las mismas que los DIDs Minga — + // las primeras autentican el canal, los segundos firman contenido.) + assert!(result_a.peer_did().is_some()); + assert!(result_b.peer_did().is_some()); +} + +#[tokio::test] +async fn libp2p_sync_with_attestations() { + use minga_core::{Attestation, AttestationStore}; + + let node_a = LibP2pNode::new().unwrap(); + let node_b = LibP2pNode::new().unwrap(); + let peer_b = node_b.peer_id; + + let addr_b = node_b + .listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()) + .await; + + let kp_a = kp(10); + let kp_b = kp(20); + + let (mst_a, store_a, roots_a) = build_repo(&["fn signed_by_a() -> i32 { 1 }"]); + let (mst_b, store_b, roots_b) = build_repo(&["fn signed_by_b() -> i32 { 2 }"]); + + let mut atts_a = AttestationStore::new(); + atts_a.add(Attestation::create(&kp_a, roots_a[0])).unwrap(); + + let mut atts_b = AttestationStore::new(); + atts_b.add(Attestation::create(&kp_b, roots_b[0])).unwrap(); + + let session_a = SyncSession::new(mst_a, store_a, atts_a, kp_a.clone()); + let session_b = SyncSession::new(mst_b, store_b, atts_b, kp_b.clone()); + + let mut control_b = node_b.control.clone(); + let task_b = tokio::spawn(async move { + let mut incoming = control_b.accept(SYNC_PROTOCOL).unwrap(); + let (_peer, stream) = incoming.next().await.expect("incoming stream"); + run_sync_async(session_b, stream.compat()).await + }); + + node_a.dial(addr_b); + let mut control_a = node_a.control.clone(); + let stream_a = { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match control_a.open_stream(peer_b, SYNC_PROTOCOL).await { + Ok(s) => break s, + Err(_) if std::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(e) => panic!("no se pudo abrir stream: {e:?}"), + } + } + }; + + let task_a = tokio::spawn(async move { run_sync_async(session_a, stream_a.compat()).await }); + + let result_a = task_a.await.unwrap().unwrap(); + let result_b = task_b.await.unwrap().unwrap(); + + // Atestaciones cruzaron criptográficamente verificadas. + assert_eq!( + result_a.attestations().authors_of(&roots_b[0]), + vec![kp_b.did()] + ); + assert_eq!( + result_b.attestations().authors_of(&roots_a[0]), + vec![kp_a.did()] + ); +} diff --git a/03_ukupacha/minga/minga-p2p/tests/passive_listener.rs b/03_ukupacha/minga/minga-p2p/tests/passive_listener.rs new file mode 100644 index 0000000..53fbdc9 --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/tests/passive_listener.rs @@ -0,0 +1,128 @@ +//! Tests del passive listener. +//! +//! Un peer "always-on" que acepta sincronizaciones continuamente: +//! cada peer entrante mergea sus contribuciones al estado compartido. +//! El test demuestra que dos peers consecutivos (B luego C) se +//! sincronizan independientemente con A, y A acaba con la unión de +//! ambos estados. + +use std::time::Duration; + +use minga_core::{parse, AttestationStore, Keypair, MemStore, Mst, NodeStore}; +use minga_p2p::MingaPeer; + +fn kp(seed: u8) -> Keypair { + Keypair::from_seed(&[seed; 32]) +} + +fn singleton_repo(src: &str) -> (Mst, MemStore, minga_core::ContentHash) { + let mut mst = Mst::new(); + let mut store = MemStore::new(); + let h = store.put(&parse::rust(src).unwrap()); + mst.insert(h); + (mst, store, h) +} + +async fn sync_with_retry(peer: &MingaPeer, target: libp2p::PeerId) { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if peer.sync_with(target).await.is_ok() { + return; + } + if std::time::Instant::now() >= deadline { + panic!("sync no completó en 5s"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +#[tokio::test] +async fn passive_listener_serves_two_consecutive_peers() { + // ── Peer A: vacío, escucha pasivamente ───────────────────────── + let a = MingaPeer::new( + kp(1), + Mst::new(), + MemStore::new(), + AttestationStore::new(), + ) + .unwrap(); + let addr_a = a.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + let _accept = a.run_passive_accept(); + + // ── Peer B: tiene función X. Sincroniza con A ───────────────── + let (mst_b, store_b, h_x) = singleton_repo("fn x() -> i32 { 1 }"); + let b = MingaPeer::new(kp(2), mst_b, store_b, AttestationStore::new()).unwrap(); + + b.dial(addr_a.clone()); + sync_with_retry(&b, a.peer_id()).await; + + // A debe haber absorbido X. + let (mst_a_mid, _, _) = a.snapshot().await; + assert!(mst_a_mid.contains(&h_x), "A no aprendió X de B"); + + // ── Peer C: tiene función Y. Sincroniza con A ───────────────── + let (mst_c, store_c, h_y) = singleton_repo("fn y(z: i32) -> i32 { z * 2 }"); + let c = MingaPeer::new(kp(3), mst_c, store_c, AttestationStore::new()).unwrap(); + + c.dial(addr_a.clone()); + sync_with_retry(&c, a.peer_id()).await; + + // ── Verificación: A acumuló X (de B) e Y (de C) ────────────── + let (mst_a_final, _, _) = a.snapshot().await; + assert!(mst_a_final.contains(&h_x), "A perdió X"); + assert!(mst_a_final.contains(&h_y), "A no aprendió Y"); + assert_eq!(mst_a_final.len(), 2); + + // C también tiene ambas: la suya y X que recibió de A durante el sync. + let (mst_c_final, _, _) = c.snapshot().await; + assert!(mst_c_final.contains(&h_x), "C no recibió X transitivamente"); + assert!(mst_c_final.contains(&h_y)); + assert_eq!(mst_c_final.len(), 2); +} + +#[tokio::test] +async fn passive_listener_propagates_attestations() { + use minga_core::Attestation; + + let kp_a = kp(10); + let kp_b = kp(20); + let kp_c = kp(30); + + // A pasivo, sin contenido. + let a = MingaPeer::new( + kp_a.clone(), + Mst::new(), + MemStore::new(), + AttestationStore::new(), + ) + .unwrap(); + let addr_a = a.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + let _accept = a.run_passive_accept(); + + // B con contenido firmado por kp_b. + let (mst_b, store_b, h_b) = singleton_repo("fn from_b() -> i32 { 1 }"); + let mut atts_b = AttestationStore::new(); + atts_b.add(Attestation::create(&kp_b, h_b)).unwrap(); + let b = MingaPeer::new(kp_b.clone(), mst_b, store_b, atts_b).unwrap(); + b.dial(addr_a.clone()); + sync_with_retry(&b, a.peer_id()).await; + + // C con contenido firmado por kp_c. Sincroniza con A: aprende + // tanto el contenido de B como su atestación. + let (mst_c, store_c, h_c) = singleton_repo("fn from_c() -> i32 { 2 }"); + let mut atts_c = AttestationStore::new(); + atts_c.add(Attestation::create(&kp_c, h_c)).unwrap(); + let c = MingaPeer::new(kp_c.clone(), mst_c, store_c, atts_c).unwrap(); + c.dial(addr_a.clone()); + sync_with_retry(&c, a.peer_id()).await; + + // C ahora ve la atestación de B sobre h_b — sin haber hablado + // nunca con B directamente. La transitividad funciona. + let (_, _, atts_c_final) = c.snapshot().await; + let authors_b = atts_c_final.authors_of(&h_b); + assert_eq!(authors_b, vec![kp_b.did()]); + + // Y C tiene su propia atestación intacta. + let authors_c = atts_c_final.authors_of(&h_c); + assert_eq!(authors_c, vec![kp_c.did()]); +} diff --git a/03_ukupacha/minga/minga-p2p/tests/persistent_peer.rs b/03_ukupacha/minga/minga-p2p/tests/persistent_peer.rs new file mode 100644 index 0000000..c1e7c04 --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/tests/persistent_peer.rs @@ -0,0 +1,239 @@ +//! Tests del `MingaPeer` con backing persistente. +//! +//! Verifica que: +//! - Abrir un path nuevo crea un repo vacío. +//! - Datos ingresados a un peer abierto se persisten a disco. +//! - Tras cerrar y reabrir el mismo path, el estado completo se +//! recupera (MST con mismo `root_hash`, store con todos los nodos +//! reconstruibles, atestaciones intactas y verificables). +//! - El sync sobre red poblando un peer persistente sobrevive +//! reinicio. + +use std::time::Duration; + +use minga_core::{parse, Attestation, AttestationStore, Keypair, MemStore, Mst, NodeStore}; +use minga_p2p::{MingaPeer, SyncSession}; +use tempfile::TempDir; + +fn kp(seed: u8) -> Keypair { + Keypair::from_seed(&[seed; 32]) +} + +#[tokio::test] +async fn open_creates_empty_repo_at_new_path() { + let dir = TempDir::new().unwrap(); + let peer = MingaPeer::open(kp(1), dir.path()).unwrap(); + let (mst, store, atts) = peer.snapshot().await; + assert!(mst.is_empty()); + assert!(store.is_empty()); + assert!(atts.is_empty()); +} + +#[tokio::test] +async fn ingest_persists_across_restart() { + let dir = TempDir::new().unwrap(); + let kp_a = kp(1); + + let n = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap(); + let h_expected = minga_core::hash_node(&n); + + // Sesión 1: abrir, ingerir, flush, drop. + { + let peer = MingaPeer::open(kp_a.clone(), dir.path()).unwrap(); + let h = peer.ingest(&n).await; + assert_eq!(h, h_expected); + peer.flush().await.unwrap(); + } + + // Sesión 2: reabrir, verificar que todo está intacto. + { + let peer = MingaPeer::open(kp_a, dir.path()).unwrap(); + let (mst, store, _) = peer.snapshot().await; + assert_eq!(mst.len(), 1); + assert!(mst.contains(&h_expected)); + assert!(store.contains(&h_expected)); + + // Reconstrucción exacta del árbol original. + let reconstructed = store.reconstruct(&h_expected).unwrap(); + assert_eq!(reconstructed, n); + } +} + +#[tokio::test] +async fn ingest_attestation_persists_across_restart() { + let dir = TempDir::new().unwrap(); + let kp_owner = kp(1); + let kp_signer = kp(2); + + let n = parse::rust("fn signed_function() -> i32 { 42 }").unwrap(); + let h = minga_core::hash_node(&n); + + { + let peer = MingaPeer::open(kp_owner.clone(), dir.path()).unwrap(); + peer.ingest(&n).await; + let att = Attestation::create(&kp_signer, h); + peer.ingest_attestation(att).await.unwrap(); + peer.flush().await.unwrap(); + } + + { + let peer = MingaPeer::open(kp_owner, dir.path()).unwrap(); + let (_, _, atts) = peer.snapshot().await; + let authors = atts.authors_of(&h); + assert_eq!(authors, vec![kp_signer.did()]); + + // La firma sigue verificando tras viajar disco→memoria. + let stored_atts = atts.get(&h); + assert_eq!(stored_atts.len(), 1); + assert!(stored_atts[0].verify()); + } +} + +#[tokio::test] +async fn ingest_multiple_authors_for_same_content_persist() { + let dir = TempDir::new().unwrap(); + let kp_owner = kp(1); + let alice = kp(10); + let bob = kp(20); + let carol = kp(30); + + let n = parse::rust("fn shared() -> i32 { 0 }").unwrap(); + let h = minga_core::hash_node(&n); + + { + let peer = MingaPeer::open(kp_owner.clone(), dir.path()).unwrap(); + peer.ingest(&n).await; + peer.ingest_attestation(Attestation::create(&alice, h)) + .await + .unwrap(); + peer.ingest_attestation(Attestation::create(&bob, h)) + .await + .unwrap(); + peer.ingest_attestation(Attestation::create(&carol, h)) + .await + .unwrap(); + peer.flush().await.unwrap(); + } + + { + let peer = MingaPeer::open(kp_owner, dir.path()).unwrap(); + let (_, _, atts) = peer.snapshot().await; + let mut authors = atts.authors_of(&h); + authors.sort_by_key(|d| d.0); + assert_eq!(authors.len(), 3); + let mut expected = vec![alice.did(), bob.did(), carol.did()]; + expected.sort_by_key(|d| d.0); + assert_eq!(authors, expected); + } +} + +#[tokio::test] +async fn root_hash_stable_across_restart() { + // El `root_hash` del MST es función pura del set de claves. Tras + // reabrir desde disco, debe ser idéntico. + let dir = TempDir::new().unwrap(); + let kp_a = kp(1); + + let target_root_hash; + { + let peer = MingaPeer::open(kp_a.clone(), dir.path()).unwrap(); + for src in &[ + "fn one() -> i32 { 1 }", + "fn two() -> i32 { 2 }", + "fn three(x: i32) -> i32 { x * x }", + ] { + peer.ingest(&parse::rust(src).unwrap()).await; + } + target_root_hash = peer.snapshot().await.0.root_hash(); + peer.flush().await.unwrap(); + } + + { + let peer = MingaPeer::open(kp_a, dir.path()).unwrap(); + let (mst, _, _) = peer.snapshot().await; + assert_eq!(mst.root_hash(), target_root_hash); + assert_eq!(mst.len(), 3); + } +} + +#[tokio::test] +async fn sync_into_persistent_peer_survives_restart() { + // Caso end-to-end: peer A pasivo y persistente. B sincroniza con + // A. A persiste lo que recibió. Cerramos A. Reabrimos. El estado + // sincronizado sigue ahí. + let dir = TempDir::new().unwrap(); + let kp_a = kp(1); + + let n = parse::rust("fn from_b(z: i32) -> i32 { z + 7 }").unwrap(); + let h_b = minga_core::hash_node(&n); + + // ── Sesión 1: A persistente acepta sync de B ───────────────── + { + let a = MingaPeer::open(kp_a.clone(), dir.path()).unwrap(); + let addr_a = a.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + let accept = a.run_passive_accept(); + + // B en memoria, le sincroniza su contenido. + let mut store_b = MemStore::new(); + let mut mst_b = Mst::new(); + let h = store_b.put(&n); + mst_b.insert(h); + let b = MingaPeer::new(kp(2), mst_b, store_b, AttestationStore::new()).unwrap(); + b.dial(addr_a); + + // Reintentar sync hasta éxito. + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if b.sync_with(a.peer_id()).await.is_ok() { + break; + } + if std::time::Instant::now() >= deadline { + panic!("sync no completó en 5s"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Esperar a que A's accept handler haya mergeado. + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + let (mst_a, _, _) = a.snapshot().await; + if mst_a.contains(&h_b) { + break; + } + if std::time::Instant::now() >= deadline { + panic!("merge en A no se vio en 2s"); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + + a.flush().await.unwrap(); + + // Cleanup explícito: abort la accept task y espera a que + // termine para liberar el lock de sled. + accept.abort(); + let _ = accept.await; + } + + // Pequeño margen para que tasks spawneadas terminen y los Arc + // se liberen. + tokio::time::sleep(Duration::from_millis(200)).await; + + // ── Sesión 2: reabrir A, verificar contenido sincronizado ──── + { + let a = MingaPeer::open(kp_a, dir.path()).unwrap(); + let (mst_a, store_a, _) = a.snapshot().await; + assert!( + mst_a.contains(&h_b), + "el contenido de B no sobrevivió al reinicio" + ); + assert!(store_a.contains(&h_b)); + + // Reconstruimos: lo que B firmó sigue ahí íntegro. + let reconstructed = store_a.reconstruct(&h_b).unwrap(); + assert_eq!(reconstructed, n); + } +} + +// Helper: silencia un warning si SyncSession se importa pero no se usa. +#[allow(dead_code)] +fn _session_marker(_: SyncSession) {} diff --git a/03_ukupacha/minga/minga-p2p/tests/sync_invariants.rs b/03_ukupacha/minga/minga-p2p/tests/sync_invariants.rs new file mode 100644 index 0000000..4563bc8 --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/tests/sync_invariants.rs @@ -0,0 +1,1072 @@ +//! Invariantes del protocolo de sincronización recursivo. +//! +//! Tres familias de tests: +//! - **Convergencia funcional**: tras `run_sync`, ambos peers tienen +//! el mismo `root_hash`, `MemStore` equivalente, y reconstruyen los +//! árboles bit a bit. +//! - **Eficiencia estructural**: el short-circuit por hash de subárbol +//! reduce probes y delivers cuando los repos comparten ramas. +//! - **Seguridad**: el receptor verifica `hash_stored(stored) == hash` +//! y rechaza nodos manipulados. + +use minga_core::{ + cas::hash_components, hash_node, hash_stored, parse, ContentHash, Keypair, MemStore, Mst, + NodeStore, Signature, StoredNode, +}; +use minga_p2p::{run_sync, Message, SyncSession}; + +fn kp(seed: u8) -> Keypair { + Keypair::from_seed(&[seed; 32]) +} + +/// Helper que replica la construcción del payload firmado del `Hello` +/// dentro del protocolo Minga. Usado por los tests que inyectan +/// mensajes manualmente. +fn hello_payload(nonce: &[u8; 32], did: &minga_core::Did, root: &ContentHash) -> [u8; 96] { + let mut p = [0u8; 96]; + p[..32].copy_from_slice(nonce); + p[32..64].copy_from_slice(&did.0); + p[64..96].copy_from_slice(&root.0); + p +} + +fn build_repo(sources: &[&str]) -> (Mst, MemStore, Vec) { + let mut mst = Mst::new(); + let mut store = MemStore::new(); + let mut roots = Vec::new(); + for src in sources { + let n = parse::rust(src).unwrap(); + let h = store.put(&n); + mst.insert(h); + roots.push(h); + } + (mst, store, roots) +} + +// ─── Convergencia funcional ──────────────────────────────────────── + +#[test] +fn sync_identical_is_noop() { + let sources = &[ + "fn add(x: i32, y: i32) -> i32 { x + y }", + "fn neg(x: i32) -> i32 { -x }", + ]; + let (mst_a, store_a, _) = build_repo(sources); + let (mst_b, store_b, _) = build_repo(sources); + + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + let stats = run_sync(&mut a, &mut b); + + // Mismas raíces de MST: el short-circuit en Hello evita cualquier + // probe o transferencia. Solo cruzan los 2 Hellos y los 2 Dones. + assert_eq!(stats.hellos, 2); + assert_eq!(stats.probe_reqs, 0); + assert_eq!(stats.probe_ress, 0); + assert_eq!(stats.fetches, 0); + assert_eq!(stats.delivers, 0); + assert_eq!(stats.dones, 2); + + assert_eq!(a.mst().root_hash(), b.mst().root_hash()); +} + +#[test] +fn sync_one_empty_pulls_everything() { + let sources = &["fn f(x: i32) -> i32 { x * 2 }"]; + let (mst_a, store_a, _) = build_repo(sources); + let (mst_b, store_b, _) = build_repo(&[]); + + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + run_sync(&mut a, &mut b); + + assert_eq!(a.mst().root_hash(), b.mst().root_hash()); + assert_eq!(a.store().len(), b.store().len()); + + for h in a.mst().iter() { + assert!(b.store().contains(h)); + let a_tree = a.store().reconstruct(h).unwrap(); + let b_tree = b.store().reconstruct(h).unwrap(); + assert_eq!(a_tree, b_tree); + } +} + +#[test] +fn sync_disjoint_sets_merge() { + let only_a = &[ + "fn alpha() -> i32 { 1 }", + "fn beta(x: i32) -> i32 { x + 1 }", + ]; + let only_b = &[ + "fn gamma(y: i32) -> bool { y > 0 }", + "fn delta() -> &'static str { \"hello\" }", + ]; + + let (mst_a, store_a, _) = build_repo(only_a); + let (mst_b, store_b, _) = build_repo(only_b); + + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + run_sync(&mut a, &mut b); + + assert_eq!(a.mst().root_hash(), b.mst().root_hash()); + assert_eq!(a.mst().len(), 4); + assert_eq!(b.mst().len(), 4); +} + +#[test] +fn sync_partial_overlap_converges() { + let common = &[ + "fn shared_one() -> i32 { 42 }", + "fn shared_two(n: i32) -> i32 { n + 1 }", + ]; + let extra_a = &["fn only_in_a() -> bool { true }"]; + let extra_b = &["fn only_in_b(s: &str) -> usize { s.len() }"]; + + let mut sources_a: Vec<&str> = common.to_vec(); + sources_a.extend_from_slice(extra_a); + let mut sources_b: Vec<&str> = common.to_vec(); + sources_b.extend_from_slice(extra_b); + + let (mst_a, store_a, _) = build_repo(&sources_a); + let (mst_b, store_b, _) = build_repo(&sources_b); + + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + run_sync(&mut a, &mut b); + + assert_eq!(a.mst().root_hash(), b.mst().root_hash()); + assert_eq!(a.mst().len(), 4); +} + +#[test] +fn sync_transitive_children_pulled() { + let big_src = r#" + fn complicated(x: i32, y: i32) -> i32 { + let a = x + y; + let b = a * 2; + match b { + n if n > 100 => n - 50, + n if n < 0 => -n, + _ => b, + } + } + "#; + let (mst_a, store_a, roots) = build_repo(&[big_src]); + let store_a_size = store_a.len(); + let root_hash = roots[0]; + + let (mst_b, store_b, _) = build_repo(&[]); + + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + run_sync(&mut a, &mut b); + + assert!(b.store().contains(&root_hash)); + assert_eq!(b.store().len(), store_a_size); + + let a_tree = a.store().reconstruct(&root_hash).unwrap(); + let b_tree = b.store().reconstruct(&root_hash).unwrap(); + assert_eq!(a_tree, b_tree); +} + +#[test] +fn sync_idempotent_after_convergence() { + let sources = &["fn p() -> i32 { 1 }", "fn q(x: i32) -> i32 { x + 1 }"]; + let (mst_a, store_a, _) = build_repo(sources); + let (mst_b, store_b, _) = build_repo(&["fn r(y: i32) -> i32 { y - 1 }"]); + + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + run_sync(&mut a, &mut b); + + let (mst_a, store_a, _) = a.into_parts(); + let (mst_b, store_b, _) = b.into_parts(); + let mut a2 = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let mut b2 = SyncSession::without_attestations(mst_b, store_b, kp(2)); + let stats = run_sync(&mut a2, &mut b2); + + // Tras converger, la segunda corrida es 2 Hellos + 2 Dones, nada + // estructural ni transferencias. + assert_eq!(stats.probe_reqs, 0); + assert_eq!(stats.probe_ress, 0); + assert_eq!(stats.fetches, 0); + assert_eq!(stats.delivers, 0); + assert_eq!(stats.hellos, 2); + assert_eq!(stats.dones, 2); +} + +#[test] +fn sync_both_empty_terminates() { + let mut a = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(1)); + let mut b = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(2)); + let stats = run_sync(&mut a, &mut b); + assert_eq!(stats.hellos, 2); + assert_eq!(stats.probe_reqs, 0); + assert_eq!(stats.dones, 2); + assert!(a.mst().is_empty()); + assert!(b.mst().is_empty()); +} + +#[test] +fn sync_three_way_via_pairwise_runs() { + let sources_a = &["fn a1() -> i32 { 1 }", "fn shared() -> i32 { 0 }"]; + let sources_b = &["fn b1(x: i32) -> i32 { x }", "fn shared() -> i32 { 0 }"]; + let sources_c = &["fn c1() -> bool { true }"]; + + let (mst_a, store_a, _) = build_repo(sources_a); + let (mst_b, store_b, _) = build_repo(sources_b); + let (mst_c, store_c, _) = build_repo(sources_c); + + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + run_sync(&mut a, &mut b); + let (mst_a, store_a, _) = a.into_parts(); + let (mst_b, store_b, _) = b.into_parts(); + + let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + let mut c = SyncSession::without_attestations(mst_c, store_c, kp(3)); + run_sync(&mut b, &mut c); + let (mst_b, _, _) = b.into_parts(); + let (mst_c, store_c, _) = c.into_parts(); + + let mut c = SyncSession::without_attestations(mst_c, store_c, kp(3)); + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + run_sync(&mut c, &mut a); + let (mst_c, _, _) = c.into_parts(); + let (mst_a, _, _) = a.into_parts(); + + assert_eq!(mst_a.root_hash(), mst_b.root_hash()); + assert_eq!(mst_b.root_hash(), mst_c.root_hash()); + assert_eq!(mst_a.len(), 4); +} + +// ─── Eficiencia estructural ──────────────────────────────────────── + +#[test] +fn sync_subtree_short_circuit_skips_shared_branches() { + // Construimos dos repos que comparten muchos nodos pero difieren en + // uno. El short-circuit por hash de subárbol debería podar las + // ramas compartidas: el número de probes y delivers debe estar + // dominado por la divergencia, no por el tamaño total. + let common: Vec = (0..50) + .map(|i| format!("fn shared_{}() -> i32 {{ {} }}", i, i)) + .collect(); + let common_refs: Vec<&str> = common.iter().map(|s| s.as_str()).collect(); + + let extra_a = "fn only_a() -> bool { true }".to_string(); + let mut sources_a: Vec<&str> = common_refs.clone(); + sources_a.push(&extra_a); + + let extra_b = "fn only_b() -> bool { false }".to_string(); + let mut sources_b: Vec<&str> = common_refs.clone(); + sources_b.push(&extra_b); + + let (mst_a, store_a, _) = build_repo(&sources_a); + let (mst_b, store_b, _) = build_repo(&sources_b); + let store_a_size = store_a.len(); + + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2)); + let stats = run_sync(&mut a, &mut b); + + assert_eq!(a.mst().root_hash(), b.mst().root_hash()); + + // Cota de eficiencia: cada peer debe pedir como máximo lo que + // realmente le falta. En este escenario, cada peer ignora una sola + // función nueva (~docena de StoredNodes). Si el short-circuit + // estuviera roto, transferiríamos cerca del store entero (~varios + // cientos). La cota es laxa pero detectaría esa regresión. + assert!( + stats.delivers < store_a_size / 2, + "demasiados delivers ({}); esperaba << {}", + stats.delivers, + store_a_size, + ); +} + +// ─── Seguridad: verificación criptográfica ───────────────────────── + +#[test] +fn cas_hash_node_equals_hash_stored() { + // El invariante fundacional para verificación: hashear el árbol + // como `SemanticNode` y como `StoredNode` produce idéntico hash. + // Sin esto, el receptor no podría confiar en lo que recibe. + let node = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap(); + let direct = hash_node(&node); + + let mut store = MemStore::new(); + let via_store = store.put(&node); + assert_eq!(direct, via_store); + + let stored = store.get(&direct).unwrap(); + let recomputed = hash_stored(stored); + assert_eq!(direct, recomputed); +} + +#[test] +fn sync_rejects_tampered_deliver() { + // Construimos un mensaje Deliver donde `hash` y `stored` no son + // consistentes — simulando un peer malicioso o un bit flip en el + // transporte. La sesión debe rechazarlo y no contaminar su estado. + let (mst_a, store_a, _) = build_repo(&[]); + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + let initial_store_size = a.store().len(); + let initial_mst_size = a.mst().len(); + + // Forjamos un StoredNode con identidad falsa: anunciamos un hash + // arbitrario pero adjuntamos contenido distinto. + let fake_stored = StoredNode { + kind: "function_item".to_string(), + field_name: None, + leaf_text: None, + children: Vec::new(), + }; + // El hash real de fake_stored es x; anunciamos como otra cosa. + let real_hash = hash_components("function_item", None, None, &[]); + let bogus_hash = ContentHash([0xAB; 32]); + assert_ne!(real_hash, bogus_hash); + + // Inyectamos como si viniera del peer (sesión recibe Hello primero + // para que received_hello sea true; luego le metemos el Deliver + // tóxico). El Hello se firma con la llave del peer simulado. + let peer_kp = kp(99); + let peer_root = minga_core::empty_subtree_hash(); + let peer_sig = peer_kp.sign(peer_root.as_bytes()); + a.handle(Message::Hello { + peer_did: peer_kp.did(), + root_subtree_hash: peer_root, + signature: peer_sig, + }); + let _ = a.handle(Message::Deliver { + hash: bogus_hash, + stored: fake_stored, + }); + + // El store y MST no deben cambiar; el contador de rechazos sí. + assert_eq!(a.store().len(), initial_store_size); + assert_eq!(a.mst().len(), initial_mst_size); + assert_eq!(a.rejected_delivers(), 1); + assert!(!a.store().contains(&bogus_hash)); +} + +#[test] +fn sync_accepts_well_formed_deliver() { + // Contraprueba del anterior: un Deliver con hash válido sí se + // acepta. Verifica que el rechazo es selectivo, no global. + let (mst_a, store_a, _) = build_repo(&[]); + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + + let stored = StoredNode { + kind: "integer_literal".to_string(), + field_name: None, + leaf_text: Some(b"42".to_vec()), + children: Vec::new(), + }; + let real_hash = hash_stored(&stored); + + let peer_kp = kp(99); + let peer_root = minga_core::empty_subtree_hash(); + let peer_sig = peer_kp.sign(peer_root.as_bytes()); + a.handle(Message::Hello { + peer_did: peer_kp.did(), + root_subtree_hash: peer_root, + signature: peer_sig, + }); + a.handle(Message::Deliver { + hash: real_hash, + stored, + }); + + // No estaba en awaiting_root (no llegó por probe), así que no + // entra al MST — pero sí al store. + assert!(a.store().contains(&real_hash)); + assert_eq!(a.rejected_delivers(), 0); +} + +// ─── Identidad y autenticación ───────────────────────────────────── + +#[test] +fn sync_captures_peer_did_after_valid_hello() { + // Tras un sync exitoso, cada sesión conoce el DID del otro peer + // — la primera afirmación criptográficamente verificable de la + // identidad del interlocutor. + let sources = &["fn f() -> i32 { 1 }"]; + let (mst_a, store_a, _) = build_repo(sources); + let (mst_b, store_b, _) = build_repo(sources); + + let kp_a = kp(10); + let kp_b = kp(20); + let did_a = kp_a.did(); + let did_b = kp_b.did(); + + let mut a = SyncSession::without_attestations(mst_a, store_a, kp_a); + let mut b = SyncSession::without_attestations(mst_b, store_b, kp_b); + + assert_eq!(a.peer_did(), None); + assert_eq!(b.peer_did(), None); + + run_sync(&mut a, &mut b); + + // Cada peer ahora tiene la identidad verificada del otro. + assert_eq!(a.peer_did(), Some(did_b)); + assert_eq!(b.peer_did(), Some(did_a)); + assert_eq!(a.local_did(), did_a); + assert_eq!(b.local_did(), did_b); +} + +#[test] +fn sync_rejects_hello_with_tampered_signature() { + // Un atacante que captura un Hello legítimo pero modifica un byte + // de la firma debe ser rechazado. La sesión no marca + // received_hello, no procesa el root, no emite ProbeReq — el + // contador de rechazos se incrementa en su lugar. + let (mst_a, store_a, _) = build_repo(&[]); + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + + let attacker = kp(2); + let root = minga_core::empty_subtree_hash(); + let mut sig = attacker.sign(root.as_bytes()); + sig.0[5] ^= 0xFF; + + let out = a.handle(Message::Hello { + peer_did: attacker.did(), + root_subtree_hash: root, + signature: sig, + }); + + assert!(out.is_empty(), "Hello con firma rota no debe producir respuesta"); + assert_eq!(a.rejected_hellos(), 1); + assert_eq!(a.peer_did(), None); +} + +#[test] +fn sync_rejects_hello_with_swapped_did() { + // Otro vector: la firma es válida bajo el DID original, pero el + // atacante reemplaza el campo `peer_did` por uno distinto. La + // verificación falla porque la firma no fue producida por la + // llave privada correspondiente al DID anunciado. + let (mst_a, store_a, _) = build_repo(&[]); + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + + let real_signer = kp(50); + let imposter = kp(51); + let root = minga_core::empty_subtree_hash(); + let sig = real_signer.sign(root.as_bytes()); + + a.handle(Message::Hello { + peer_did: imposter.did(), // dice ser imposter pero la firma es de real_signer + root_subtree_hash: root, + signature: sig, + }); + + assert_eq!(a.rejected_hellos(), 1); + assert_eq!(a.peer_did(), None); +} + +#[test] +fn sync_rejects_hello_signed_over_different_root() { + // El atacante firma un root diferente al que anuncia. La firma es + // válida sobre `wrong_root`, pero el mensaje dice `claimed_root`. + let (mst_a, store_a, _) = build_repo(&[]); + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + + let signer = kp(60); + let claimed_root = ContentHash([0xAA; 32]); + let wrong_root = ContentHash([0xBB; 32]); + let sig_over_wrong = signer.sign(wrong_root.as_bytes()); + + a.handle(Message::Hello { + peer_did: signer.did(), + root_subtree_hash: claimed_root, + signature: sig_over_wrong, + }); + + assert_eq!(a.rejected_hellos(), 1); + assert_eq!(a.peer_did(), None); +} + +#[test] +fn sync_rejects_replay_of_hello_from_different_session() { + // El test del bloque CRÍTICO: anti-replay anti-replay. + // + // Sesión 1: el peer "alice" responde a un Challenge de A1 + // firmando un Hello con el nonce de A1. + // + // Sesión 2: la misma A vuelve a abrir sesión (A2). A2 genera un + // nonce nuevo. Un atacante intenta replicar el Hello capturado de + // la sesión 1. Como el nonce es distinto, la firma no verifica. + let alice = kp(50); + let alice_root = ContentHash([0xAA; 32]); + + // Sesión 1. + let mut a1 = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(1)); + let nonce_a1 = a1.self_nonce(); + + // Alice firma su Hello sobre el nonce que A1 emitió. + let payload_1 = hello_payload(&nonce_a1, &alice.did(), &alice_root); + let sig_1 = alice.sign(&payload_1); + let captured_hello = Message::Hello { + peer_did: alice.did(), + root_subtree_hash: alice_root, + signature: sig_1, + }; + + // En sesión 1, el Hello se acepta limpiamente. + a1.handle(captured_hello.clone()); + assert_eq!(a1.peer_did(), Some(alice.did())); + assert_eq!(a1.rejected_hellos(), 0); + + // Sesión 2: A2 con nonce nuevo. El atacante replica `captured_hello`. + let mut a2 = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(2)); + assert_ne!(a2.self_nonce(), nonce_a1, "los nonces son distintos por sesión"); + + a2.handle(captured_hello); + + // Replay rechazado: la firma estaba sobre nonce_a1, A2 verifica + // contra su propio nonce, mismatch criptográfico. + assert_eq!(a2.rejected_hellos(), 1); + assert_eq!(a2.peer_did(), None); +} + +#[test] +fn sync_proceeds_after_valid_hello_following_rejection() { + // Si llega un Hello inválido seguido de uno válido, la sesión se + // recupera: acepta el válido y captura ese DID. No hay + // "envenenamiento" persistente del estado. + let (mst_a, store_a, _) = build_repo(&[]); + let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1)); + + let bad_signer = kp(70); + let mut bad_sig = bad_signer.sign(b"otro mensaje"); + bad_sig.0[0] ^= 0xFF; + let root = minga_core::empty_subtree_hash(); + a.handle(Message::Hello { + peer_did: bad_signer.did(), + root_subtree_hash: root, + signature: bad_sig, + }); + assert_eq!(a.rejected_hellos(), 1); + assert_eq!(a.peer_did(), None); + + let good_signer = kp(71); + let nonce = a.self_nonce(); + let good_payload = hello_payload(&nonce, &good_signer.did(), &root); + let good_sig = good_signer.sign(&good_payload); + a.handle(Message::Hello { + peer_did: good_signer.did(), + root_subtree_hash: root, + signature: good_sig, + }); + assert_eq!(a.rejected_hellos(), 1); + assert_eq!(a.peer_did(), Some(good_signer.did())); +} + +// Aux: dejamos `Signature` importado para que el bloque arriba siga +// compilando en futuras refactorizaciones que lo necesiten. +#[allow(dead_code)] +fn _signature_marker(_: Signature) {} + +// ─── Propagación de atestaciones ─────────────────────────────────── + +use minga_core::{Attestation, AttestationStore, Did}; + +fn build_repo_with_attests( + sources: &[&str], + signers: &[&Keypair], +) -> (Mst, MemStore, AttestationStore, Vec) { + let mut mst = Mst::new(); + let mut store = MemStore::new(); + let mut attests = AttestationStore::new(); + let mut roots = Vec::new(); + for src in sources { + let n = parse::rust(src).unwrap(); + let h = store.put(&n); + mst.insert(h); + for kp in signers { + attests.add(Attestation::create(kp, h)).unwrap(); + } + roots.push(h); + } + (mst, store, attests, roots) +} + +#[test] +fn sync_propagates_attestations_for_owned_content() { + // Cada peer tiene su propio contenido y firma sus propias claves. + // Tras sync, ambos peers conocen ambas atestaciones. + let kp_a = kp(10); + let kp_b = kp(20); + + let (mst_a, store_a, atts_a, roots_a) = + build_repo_with_attests(&["fn from_a() -> i32 { 1 }"], &[&kp_a]); + let (mst_b, store_b, atts_b, roots_b) = + build_repo_with_attests(&["fn from_b() -> i32 { 2 }"], &[&kp_b]); + + let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a.clone()); + let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b.clone()); + run_sync(&mut a, &mut b); + + // A debe ahora conocer la atestación de B sobre roots_b[0], y + // viceversa. Ambas verificables criptográficamente. + let h_a = roots_a[0]; + let h_b = roots_b[0]; + + let a_authors_for_a: Vec = a.attestations().authors_of(&h_a); + let a_authors_for_b: Vec = a.attestations().authors_of(&h_b); + assert_eq!(a_authors_for_a, vec![kp_a.did()]); + assert_eq!(a_authors_for_b, vec![kp_b.did()]); + + let b_authors_for_a: Vec = b.attestations().authors_of(&h_a); + let b_authors_for_b: Vec = b.attestations().authors_of(&h_b); + assert_eq!(b_authors_for_a, vec![kp_a.did()]); + assert_eq!(b_authors_for_b, vec![kp_b.did()]); +} + +#[test] +fn sync_merges_multiple_authors_for_shared_content() { + // Ambos peers tienen el MISMO contenido (mismo hash) pero + // atestaciones de autores DISTINTOS. Tras sync, cada peer ve el + // conjunto completo de autores que han respaldado ese contenido. + let kp_a = kp(30); + let kp_b = kp(40); + let kp_c = kp(50); + let kp_d = kp(60); + + let src = "fn shared() -> i32 { 99 }"; + + // A tiene firmas de A y C sobre el contenido. + let (mst_a, store_a, atts_a, _) = build_repo_with_attests(&[src], &[&kp_a, &kp_c]); + // B tiene firmas de B y D sobre el MISMO contenido. + let (mst_b, store_b, atts_b, roots_b) = build_repo_with_attests(&[src], &[&kp_b, &kp_d]); + let h = roots_b[0]; + + let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a.clone()); + let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b.clone()); + run_sync(&mut a, &mut b); + + // Ambos peers ven los cuatro autores. + let mut a_authors = a.attestations().authors_of(&h); + let mut b_authors = b.attestations().authors_of(&h); + a_authors.sort_by_key(|d| d.0); + b_authors.sort_by_key(|d| d.0); + assert_eq!(a_authors, b_authors); + assert_eq!(a_authors.len(), 4); + assert!(a_authors.contains(&kp_a.did())); + assert!(a_authors.contains(&kp_b.did())); + assert!(a_authors.contains(&kp_c.did())); + assert!(a_authors.contains(&kp_d.did())); +} + +#[test] +fn sync_attestations_are_verified_at_receiver() { + // Inyectamos manualmente un AttestPush con una firma corrupta + // entre las legítimas. La sesión solo acepta las legítimas e + // incrementa rejected_attests. + let mut a = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(1)); + + // Hello válido del peer simulado, para que received_hello sea true. + let peer_kp = kp(80); + let peer_root = minga_core::empty_subtree_hash(); + let nonce = a.self_nonce(); + let peer_payload = hello_payload(&nonce, &peer_kp.did(), &peer_root); + let peer_sig = peer_kp.sign(&peer_payload); + a.handle(Message::Hello { + peer_did: peer_kp.did(), + root_subtree_hash: peer_root, + signature: peer_sig, + }); + + // Tres atestaciones: dos legítimas y una con firma rota. + let alice = kp(81); + let bob = kp(82); + let h1 = ContentHash([1u8; 32]); + let h2 = ContentHash([2u8; 32]); + let h3 = ContentHash([3u8; 32]); + + let valid1 = Attestation::create(&alice, h1); + let valid2 = Attestation::create(&bob, h2); + let mut tampered = Attestation::create(&alice, h3); + tampered.signature.0[10] ^= 0xFF; + + a.handle(Message::AttestPush { + attestations: vec![valid1.clone(), tampered, valid2.clone()], + }); + + // Las dos válidas se mergean; la corrupta se rechaza. + assert_eq!(a.attestations().len(), 2); + assert_eq!(a.rejected_attests(), 1); + assert_eq!(a.attestations().authors_of(&h1), vec![alice.did()]); + assert_eq!(a.attestations().authors_of(&h2), vec![bob.did()]); + assert!(a.attestations().get(&h3).is_empty()); +} + +#[test] +fn sync_attest_push_before_hello_is_rejected() { + // Una atestación que llega antes del Hello autenticado se descarta + // — no podemos confiar en lo que dice el remitente hasta saber + // quién es. + let mut a = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(1)); + + let alice = kp(90); + let h = ContentHash([7u8; 32]); + let att = Attestation::create(&alice, h); + + let out = a.handle(Message::AttestPush { + attestations: vec![att], + }); + assert!(out.is_empty()); + assert_eq!(a.rejected_attests(), 1); + assert_eq!(a.attestations().len(), 0); +} + +#[test] +fn sync_attestations_are_idempotent_across_runs() { + // Re-correr el sync no duplica atestaciones (gracias a la + // idempotencia de AttestationStore::add por (autor, contenido)). + let kp_a = kp(100); + let kp_b = kp(101); + + let (mst_a, store_a, atts_a, _) = + build_repo_with_attests(&["fn run_one() -> i32 { 1 }"], &[&kp_a]); + let (mst_b, store_b, atts_b, _) = + build_repo_with_attests(&["fn run_two() -> i32 { 2 }"], &[&kp_b]); + + let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a.clone()); + let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b.clone()); + run_sync(&mut a, &mut b); + let after_first_a = a.attestations().len(); + let after_first_b = b.attestations().len(); + assert_eq!(after_first_a, 2); + assert_eq!(after_first_b, 2); + + let (mst_a, store_a, atts_a) = a.into_parts(); + let (mst_b, store_b, atts_b) = b.into_parts(); + let mut a2 = SyncSession::new(mst_a, store_a, atts_a, kp_a); + let mut b2 = SyncSession::new(mst_b, store_b, atts_b, kp_b); + run_sync(&mut a2, &mut b2); + + assert_eq!(a2.attestations().len(), after_first_a); + assert_eq!(b2.attestations().len(), after_first_b); +} + +#[test] +fn sync_attestations_about_remote_content() { + // Caso interesante: A tiene una atestación sobre contenido que + // **NO** posee (lo recibió por gossip de un tercero). Tras sync + // con B, B aprende esa atestación aunque A nunca tuvo el contenido + // en su store. + let kp_a = kp(110); + let kp_third_party = kp(111); + + // A no tiene contenido propio pero sí una atestación de + // `kp_third_party` sobre un hash arbitrario. + let phantom_hash = ContentHash([0xCD; 32]); + let mut atts_a = AttestationStore::new(); + atts_a + .add(Attestation::create(&kp_third_party, phantom_hash)) + .unwrap(); + + let kp_b = kp(112); + let mut a = SyncSession::new(Mst::new(), MemStore::new(), atts_a, kp_a); + let mut b = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp_b); + run_sync(&mut a, &mut b); + + // B ahora conoce la atestación, aunque ni A ni B tienen el + // contenido en su store. + assert_eq!(b.attestations().len(), 1); + assert_eq!(b.attestations().authors_of(&phantom_hash), vec![kp_third_party.did()]); + assert!(!b.store().contains(&phantom_hash)); +} + +#[test] +fn sync_attest_push_count_in_stats() { + // Cuando ambos peers tienen atestaciones, el harness registra dos + // AttestPushes (uno por dirección). + let kp_a = kp(120); + let kp_b = kp(121); + let (mst_a, store_a, atts_a, _) = + build_repo_with_attests(&["fn ax() -> i32 { 0 }"], &[&kp_a]); + let (mst_b, store_b, atts_b, _) = + build_repo_with_attests(&["fn bx() -> i32 { 0 }"], &[&kp_b]); + + let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a); + let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b); + let stats = run_sync(&mut a, &mut b); + + assert_eq!(stats.attest_pushes, 2); +} + +// ─── Propagación de retracciones ──────────────────────────────────── + +use minga_core::{Retraction, RetractionStore}; + +#[test] +fn sync_propagates_retractions_for_owned_content() { + // A retira su propia atestación; tras sync, B conoce la retracción. + let kp_a = kp(30); + let kp_b = kp(40); + + let (mst_a, store_a, atts_a, roots_a) = + build_repo_with_attests(&["fn x() -> i32 { 9 }"], &[&kp_a]); + let mut rets_a = RetractionStore::new(); + rets_a.add(Retraction::create(&kp_a, roots_a[0])).unwrap(); + + let (mst_b, store_b, atts_b, _roots_b) = + build_repo_with_attests(&["fn y() -> i32 { 8 }"], &[&kp_b]); + let rets_b = RetractionStore::new(); + + let mut a = SyncSession::with_retractions(mst_a, store_a, atts_a, rets_a, kp_a.clone()); + let mut b = SyncSession::with_retractions(mst_b, store_b, atts_b, rets_b, kp_b.clone()); + let stats = run_sync(&mut a, &mut b); + + assert!(stats.retract_pushes >= 1, "debe haber al menos un RetractPush"); + // B ahora conoce la retracción que firmó A. + let b_retract_authors = b.retractions().authors_of(&roots_a[0]); + assert_eq!(b_retract_authors, vec![kp_a.did()]); + // Y A, que no recibió retracciones de B, tiene la suya sola. + assert_eq!(a.retractions().len(), 1); + assert_eq!(a.rejected_retracts(), 0); + assert_eq!(b.rejected_retracts(), 0); +} + +#[test] +fn forged_retraction_signature_is_rejected() { + // Una retracción con firma rota debe contarse como rechazada y NO + // entrar al store del peer receptor. + let kp_a = kp(50); + let kp_b = kp(60); + + let (mst_a, store_a, atts_a, _) = + build_repo_with_attests(&["fn a() -> i32 { 1 }"], &[&kp_a]); + let mut rets_a = RetractionStore::new(); + // Forjamos una retracción con firma inválida pasándola por dentro + // del wire de un push manual. + let bogus = Retraction { + content: ContentHash([7u8; 32]), + author: kp_a.did(), + signature: minga_core::Signature([0u8; 64]), + }; + // No la agregamos al RetractionStore (`add` rechazaría); la mandamos + // por wire manualmente. + let _ = &mut rets_a; // silencio + + let (mst_b, store_b, atts_b, _) = + build_repo_with_attests(&["fn b() -> i32 { 2 }"], &[&kp_b]); + + let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a.clone()); + let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b.clone()); + + // Avanzamos hasta que ambos hayan intercambiado Hello. + let m1 = a.start(); + let mut from_a: Vec = m1; + let mut from_b: Vec = b.start(); + while !from_a.is_empty() || !from_b.is_empty() { + let take_a = from_a; + from_a = Vec::new(); + for m in take_a { + from_b.extend(b.handle(m)); + } + let take_b = from_b; + from_b = Vec::new(); + for m in take_b { + from_a.extend(a.handle(m)); + } + if b.received_hello() && a.received_hello() { + break; + } + } + // Inyectamos el push forjado. + b.handle(Message::RetractPush { + retractions: vec![bogus], + }); + assert_eq!(b.rejected_retracts(), 1); + assert_eq!(b.retractions().len(), 0); +} + +// ─── Propagación de declaraciones de raíz (α-verification) ───────── + +use minga_core::{alpha::hash_alpha_with, parse::Dialect, RootDecl}; +use std::collections::HashMap; + +#[test] +fn sync_propagates_verified_root_declarations() { + // A tiene una raíz declarada con su α-hash y dialect. Tras sync, + // B la conoce y la α-verificó contra el contenido reconstruido. + // + // Nota: este test indexa el MST por struct_hash (la convención + // pre-α), separando dos preocupaciones — el sync del grafo CAS + // por un lado, y la declaración del binding α→struct por el otro. + // RootDeclaration es metadata acerca de raíces, no reemplaza al + // MST como índice de contenido. + let kp_a = kp(200); + let kp_b = kp(201); + + let src = "fn add(x: i32, y: i32) -> i32 { x + y }"; + let node = parse::rust(src).unwrap(); + let alpha = hash_alpha_with(Dialect::Rust, &node); + let mut store_a = MemStore::new(); + let struct_hash = store_a.put(&node); + let mut mst_a = Mst::new(); + mst_a.insert(struct_hash); + let mut roots_a = HashMap::new(); + roots_a.insert(alpha, (struct_hash, Dialect::Rust)); + + let mut a = SyncSession::with_roots( + mst_a, + store_a, + AttestationStore::new(), + RetractionStore::new(), + roots_a, + kp_a, + ); + let mut b = SyncSession::with_roots( + Mst::new(), + MemStore::new(), + AttestationStore::new(), + RetractionStore::new(), + HashMap::new(), + kp_b, + ); + let stats = run_sync(&mut a, &mut b); + + // Se emitió al menos una RootDeclaration (A → B). + assert!(stats.root_declarations >= 1); + + // El contenido cruzó normal por el sync del grafo. + assert!(b.store().contains(&struct_hash)); + + // B verificó y aceptó la declaración: cero rechazos. + let verified = b.take_verified_root_decls(); + assert_eq!(b.rejected_root_decls(), 0); + assert_eq!(verified.len(), 1); + let (got_struct, got_dialect) = verified[&alpha]; + assert_eq!(got_struct, struct_hash); + assert_eq!(got_dialect, Dialect::Rust); +} + +#[test] +fn sync_rejects_root_decl_with_forged_alpha() { + // A inyecta una RootDeclaration con α-hash que no corresponde + // al contenido. B debe rechazarla. + let kp_a = kp(210); + let kp_b = kp(211); + + let src = "fn forge() -> i32 { 0 }"; + let node = parse::rust(src).unwrap(); + let mut store_a = MemStore::new(); + let struct_hash = store_a.put(&node); + // α-hash falso (todo ceros) que no recompondrá bajo ningún dialect. + let fake_alpha = ContentHash([0u8; 32]); + let mut mst_a = Mst::new(); + mst_a.insert(fake_alpha); + + let mut a = SyncSession::with_roots( + mst_a, + store_a, + AttestationStore::new(), + RetractionStore::new(), + HashMap::new(), // No declaramos roots locales: inyectamos manual. + kp_a, + ); + let mut b = SyncSession::with_roots( + Mst::new(), + MemStore::new(), + AttestationStore::new(), + RetractionStore::new(), + HashMap::new(), + kp_b, + ); + + // Iniciamos sync hasta intercambiar Hello. + let mut from_a: Vec = a.start(); + let mut from_b: Vec = b.start(); + while !from_a.is_empty() || !from_b.is_empty() { + let take_a = from_a; + from_a = Vec::new(); + for m in take_a { + from_b.extend(b.handle(m)); + } + let take_b = from_b; + from_b = Vec::new(); + for m in take_b { + from_a.extend(a.handle(m)); + } + if b.received_hello() && a.received_hello() { + break; + } + } + // Para verificar α necesitamos que B tenga el struct_hash en su + // store. Inyectamos el Deliver primero. + let stored = StoredNode { + kind: "source_file".to_string(), + field_name: None, + leaf_text: None, + children: vec![], + }; + let stored_hash = hash_stored(&stored); + b.handle(Message::Deliver { + hash: stored_hash, + stored, + }); + // Ahora el RootDeclaration forjado: el α falso "apunta" al stored_hash. + b.handle(Message::RootDeclaration { + decls: vec![RootDecl::new(fake_alpha, stored_hash, Dialect::Rust)], + }); + + let verified = b.take_verified_root_decls(); + assert_eq!(verified.len(), 0); + assert_eq!(b.rejected_root_decls(), 1); +} + +#[test] +fn sync_rejects_root_decl_before_hello() { + // Igual que AttestPush/RetractPush: una RootDeclaration sin Hello + // previo se descarta entera. + let mut a = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(220)); + let decls = vec![ + RootDecl::new(ContentHash([1u8; 32]), ContentHash([2u8; 32]), Dialect::Rust), + RootDecl::new(ContentHash([3u8; 32]), ContentHash([4u8; 32]), Dialect::Python), + ]; + let out = a.handle(Message::RootDeclaration { decls }); + assert!(out.is_empty()); + assert_eq!(a.rejected_root_decls(), 2); +} + +#[test] +fn sync_rejects_root_decl_with_unknown_dialect_byte() { + // Un byte de dialect que esta versión no conoce (e.g., 99) no + // tumba la sesión — la declaración se cuenta como rechazada. + let kp_a = kp(230); + let mut a = SyncSession::with_roots( + Mst::new(), + MemStore::new(), + AttestationStore::new(), + RetractionStore::new(), + HashMap::new(), + kp_a, + ); + // Forjamos un Hello válido contra a para pasar el gate. + let peer = kp(231); + let nonce = a.self_nonce(); + let peer_root = minga_core::empty_subtree_hash(); + let payload = hello_payload(&nonce, &peer.did(), &peer_root); + let sig = peer.sign(&payload); + a.handle(Message::Hello { + peer_did: peer.did(), + root_subtree_hash: peer_root, + signature: sig, + }); + + let decl_unknown = RootDecl { + alpha: ContentHash([5u8; 32]), + struct_hash: ContentHash([6u8; 32]), + dialect_byte: 99, // No reconocido por esta versión. + }; + a.handle(Message::RootDeclaration { + decls: vec![decl_unknown], + }); + let verified = a.take_verified_root_decls(); + assert_eq!(verified.len(), 0); + assert_eq!(a.rejected_root_decls(), 1); +} diff --git a/03_ukupacha/minga/minga-p2p/tests/wire_roundtrips.rs b/03_ukupacha/minga/minga-p2p/tests/wire_roundtrips.rs new file mode 100644 index 0000000..a28e755 --- /dev/null +++ b/03_ukupacha/minga/minga-p2p/tests/wire_roundtrips.rs @@ -0,0 +1,128 @@ +//! Tests de roundtrip de serialización para `Message`. + +use minga_core::{Attestation, ContentHash, Keypair, NodeProbe, StoredNode}; +use minga_p2p::Message; + +fn roundtrip(msg: &Message) { + let bytes = msg.encode(); + let decoded = Message::decode(&bytes).unwrap(); + assert_eq!(msg, &decoded); +} + +fn kp(seed: u8) -> Keypair { + Keypair::from_seed(&[seed; 32]) +} + +#[test] +fn hello_roundtrip() { + let k = kp(1); + let root = ContentHash([42; 32]); + let sig = k.sign(root.as_bytes()); + let msg = Message::Hello { + peer_did: k.did(), + root_subtree_hash: root, + signature: sig, + }; + roundtrip(&msg); +} + +#[test] +fn probe_req_roundtrip() { + roundtrip(&Message::ProbeReq { + subtree_hash: ContentHash([5; 32]), + }); +} + +#[test] +fn probe_res_with_probe_roundtrip() { + let msg = Message::ProbeRes { + subtree_hash: ContentHash([7; 32]), + probe: Some(NodeProbe { + level: 2, + keys: vec![ContentHash([1; 32])], + child_hashes: vec![ContentHash([10; 32]), ContentHash([20; 32])], + }), + }; + roundtrip(&msg); +} + +#[test] +fn probe_res_empty_roundtrip() { + roundtrip(&Message::ProbeRes { + subtree_hash: ContentHash([7; 32]), + probe: None, + }); +} + +#[test] +fn fetch_roundtrip() { + roundtrip(&Message::Fetch { + hash: ContentHash([3; 32]), + }); +} + +#[test] +fn deliver_roundtrip() { + let stored = StoredNode { + kind: "function_item".to_string(), + field_name: Some("body".to_string()), + leaf_text: None, + children: vec![ContentHash([1; 32]), ContentHash([2; 32])], + }; + roundtrip(&Message::Deliver { + hash: ContentHash([99; 32]), + stored, + }); +} + +#[test] +fn attest_push_roundtrip() { + let alice = kp(10); + let bob = kp(20); + let attestations = vec![ + Attestation::create(&alice, ContentHash([1; 32])), + Attestation::create(&bob, ContentHash([2; 32])), + ]; + roundtrip(&Message::AttestPush { attestations }); +} + +#[test] +fn done_roundtrip() { + roundtrip(&Message::Done); +} + +#[test] +fn malformed_bytes_decode_to_error() { + let bogus = vec![0xFFu8; 100]; + assert!(Message::decode(&bogus).is_err()); +} + +#[test] +fn empty_bytes_decode_to_error() { + assert!(Message::decode(&[]).is_err()); +} + +#[test] +fn message_decode_after_encode_preserves_signatures() { + // El roundtrip de un Hello debe preservar la firma de modo que la + // verificación criptográfica del receptor siga funcionando. + let k = kp(33); + let root = ContentHash([55; 32]); + let sig = k.sign(root.as_bytes()); + let original = Message::Hello { + peer_did: k.did(), + root_subtree_hash: root, + signature: sig, + }; + let bytes = original.encode(); + let decoded = Message::decode(&bytes).unwrap(); + let Message::Hello { + peer_did, + root_subtree_hash, + signature, + } = decoded + else { + panic!("variante incorrecta tras decode"); + }; + assert!(peer_did.verify(root_subtree_hash.as_bytes(), &signature)); +} diff --git a/03_ukupacha/minga/minga-store/Cargo.toml b/03_ukupacha/minga/minga-store/Cargo.toml new file mode 100644 index 0000000..20ff2b7 --- /dev/null +++ b/03_ukupacha/minga/minga-store/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "minga-store" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Almacenamiento persistente para Minga: stores con backing sled para nodos, atestaciones y MST." + +[dependencies] +minga-core = { path = "../minga-core" } +sled = { workspace = true } +postcard = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +blake3 = { workspace = true } diff --git a/03_ukupacha/minga/minga-store/LEEME.md b/03_ukupacha/minga/minga-store/LEEME.md new file mode 100644 index 0000000..4a77ea3 --- /dev/null +++ b/03_ukupacha/minga/minga-store/LEEME.md @@ -0,0 +1,9 @@ +# minga-store + +> Storage local de [minga](../README.md). + +Chunks content-addressed en `$XDG_DATA_HOME/minga/chunks/`. Verifica hash al leer. Eviction LRU configurable. Misma forma que [`wawa-fs`](../../wawa/wawa-fs/README.md) — interoperabilidad directa. + +## Deps + +- [`minga-core`](../minga-core/README.md), `blake3` diff --git a/03_ukupacha/minga/minga-store/README.md b/03_ukupacha/minga/minga-store/README.md new file mode 100644 index 0000000..f250c46 --- /dev/null +++ b/03_ukupacha/minga/minga-store/README.md @@ -0,0 +1,9 @@ +# minga-store + +> Local storage of [minga](../README.md). + +Content-addressed chunks at `$XDG_DATA_HOME/minga/chunks/`. Verifies hash on read. Configurable LRU eviction. Same shape as [`wawa-fs`](../../wawa/wawa-fs/README.md) — direct interop. + +## Deps + +- [`minga-core`](../minga-core/README.md), `blake3` diff --git a/03_ukupacha/minga/minga-store/src/alpha_paths_store.rs b/03_ukupacha/minga/minga-store/src/alpha_paths_store.rs new file mode 100644 index 0000000..6b010ce --- /dev/null +++ b/03_ukupacha/minga/minga-store/src/alpha_paths_store.rs @@ -0,0 +1,166 @@ +//! `SledAlphaPathsStore`: índice inverso α-hash → paths conocidos. +//! +//! El `SledPathHistoryStore` indexa por path (clave directa); para +//! consultar "qué paths existen para esta raíz" había que escanear todo +//! ese tree y reconstruir el reverso en RAM (lo que hacía `cmd_roots`). +//! Cuando el repo crece a millones de paths esa pasada se vuelve +//! prohibitiva. +//! +//! Layout: clave `[α (32 bytes)] [path bytes]`, valor `ts_secs` u64 +//! big-endian. Como la α va primero, una iteración con prefijo `α` da +//! todos los paths de esa raíz sin tocar el resto del tree. Los paths +//! quedan ordenados lexicográficamente — irrelevante para el caso de +//! uso pero estable. +//! +//! Este store es **local** al peer, igual que `path_history` y +//! `attestation_timestamps`: los paths no viajan por wire. + +use minga_core::ContentHash; +use sled::{Db, Tree}; + +use crate::error::StoreError; + +pub struct SledAlphaPathsStore { + tree: Tree, +} + +impl SledAlphaPathsStore { + pub fn open_tree(db: &Db, name: &str) -> Result { + Ok(Self { + tree: db.open_tree(name)?, + }) + } + + /// Registra (o actualiza con un timestamp más reciente) la + /// asociación α → path. Idempotente: re-registrar con ts más viejo + /// no pisa. + pub fn record(&self, alpha: ContentHash, path: &str, ts_secs: u64) -> Result<(), StoreError> { + let key = compose_key(&alpha, path); + if let Some(prev) = self.tree.get(&key)? { + if prev.len() == 8 { + let mut buf = [0u8; 8]; + buf.copy_from_slice(&prev); + if u64::from_be_bytes(buf) >= ts_secs { + return Ok(()); + } + } + } + self.tree.insert(key, ts_secs.to_be_bytes().to_vec())?; + Ok(()) + } + + /// Lista los paths registrados para `alpha`, junto con su ts. Vec + /// vacío si la raíz no tiene paths locales. + pub fn paths_for(&self, alpha: &ContentHash) -> Result, StoreError> { + let mut out = Vec::new(); + for entry in self.tree.scan_prefix(alpha.0) { + let (k, v) = entry?; + if k.len() < 32 || v.len() != 8 { + continue; + } + let path = String::from_utf8(k[32..].to_vec()) + .map_err(|_| StoreError::HashMismatch)?; + let mut ts = [0u8; 8]; + ts.copy_from_slice(&v); + out.push((path, u64::from_be_bytes(ts))); + } + Ok(out) + } + + /// Devuelve el path con timestamp más reciente para `alpha`, o + /// `None`. Conveniencia para callers que sólo quieren "el path + /// canónico" (típicamente, el último usado). + pub fn most_recent_path(&self, alpha: &ContentHash) -> Result, StoreError> { + let mut best: Option<(String, u64)> = None; + for (p, ts) in self.paths_for(alpha)? { + match &best { + Some((_, bts)) if *bts >= ts => {} + _ => best = Some((p, ts)), + } + } + Ok(best.map(|(p, _)| p)) + } + + pub fn len(&self) -> usize { + self.tree.len() + } + + pub fn is_empty(&self) -> bool { + self.tree.is_empty() + } + + pub fn flush(&self) -> Result<(), StoreError> { + self.tree.flush()?; + Ok(()) + } +} + +fn compose_key(alpha: &ContentHash, path: &str) -> Vec { + let p = path.as_bytes(); + let mut k = Vec::with_capacity(32 + p.len()); + k.extend_from_slice(&alpha.0); + k.extend_from_slice(p); + k +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn open_temp_db() -> (Db, TempDir) { + let dir = TempDir::new().unwrap(); + let cfg = sled::Config::default().path(dir.path()).temporary(true); + let db = cfg.open().unwrap(); + (db, dir) + } + + #[test] + fn record_and_query_single_alpha() { + let (db, _d) = open_temp_db(); + let s = SledAlphaPathsStore::open_tree(&db, "alpha_paths").unwrap(); + let a = ContentHash([7u8; 32]); + s.record(a, "src/main.rs", 100).unwrap(); + s.record(a, "lib/x.rs", 200).unwrap(); + let mut paths = s.paths_for(&a).unwrap(); + paths.sort(); + assert_eq!( + paths, + vec![("lib/x.rs".to_string(), 200), ("src/main.rs".to_string(), 100)] + ); + } + + #[test] + fn most_recent_returns_latest_ts() { + let (db, _d) = open_temp_db(); + let s = SledAlphaPathsStore::open_tree(&db, "alpha_paths").unwrap(); + let a = ContentHash([9u8; 32]); + s.record(a, "a", 10).unwrap(); + s.record(a, "b", 50).unwrap(); + s.record(a, "c", 20).unwrap(); + assert_eq!(s.most_recent_path(&a).unwrap(), Some("b".to_string())); + } + + #[test] + fn record_does_not_regress_timestamp() { + let (db, _d) = open_temp_db(); + let s = SledAlphaPathsStore::open_tree(&db, "alpha_paths").unwrap(); + let a = ContentHash([1u8; 32]); + s.record(a, "x", 100).unwrap(); + s.record(a, "x", 50).unwrap(); + let paths = s.paths_for(&a).unwrap(); + assert_eq!(paths, vec![("x".to_string(), 100)]); + } + + #[test] + fn scan_does_not_leak_across_alphas() { + let (db, _d) = open_temp_db(); + let s = SledAlphaPathsStore::open_tree(&db, "alpha_paths").unwrap(); + let a = ContentHash([1u8; 32]); + let b = ContentHash([2u8; 32]); + s.record(a, "p", 1).unwrap(); + s.record(b, "p", 1).unwrap(); + assert_eq!(s.paths_for(&a).unwrap().len(), 1); + assert_eq!(s.paths_for(&b).unwrap().len(), 1); + } +} diff --git a/03_ukupacha/minga/minga-store/src/attestation_store.rs b/03_ukupacha/minga/minga-store/src/attestation_store.rs new file mode 100644 index 0000000..0ae8e8f --- /dev/null +++ b/03_ukupacha/minga/minga-store/src/attestation_store.rs @@ -0,0 +1,83 @@ +//! Almacén persistente de atestaciones firmadas. +//! +//! Layout: una sola `sled::Tree` cuya clave es la concatenación +//! `content_hash || author_did` (64 bytes) y cuyo valor es la +//! `Attestation` serializada. Esto permite: +//! - Idempotencia natural: misma `(autor, contenido)` = misma clave. +//! - Listar todas las atestaciones de un contenido vía `scan_prefix` +//! con los primeros 32 bytes (el `ContentHash`). +//! +//! `add` re-verifica criptográficamente cada atestación antes de +//! persistirla — el contrato es idéntico al de `AttestationStore` en +//! memoria: jamás se almacenan firmas inválidas. + +use minga_core::{Attestation, AttestationError, ContentHash, Did}; +use sled::{Db, Tree}; + +use crate::error::StoreError; + +pub struct SledAttestationStore { + tree: Tree, +} + +impl SledAttestationStore { + pub fn open_tree(db: &Db, name: &str) -> Result { + Ok(Self { + tree: db.open_tree(name)?, + }) + } + + pub fn add(&self, att: Attestation) -> Result<(), StoreError> { + if !att.verify() { + return Err(StoreError::Attestation(AttestationError::InvalidSignature)); + } + let key = compose_key(&att.content, &att.author); + let bytes = postcard::to_allocvec(&att)?; + self.tree.insert(&key, bytes)?; + Ok(()) + } + + /// Devuelve todas las atestaciones para `content` (vacío si + /// ninguna). Orden no especificado. + pub fn get(&self, content: &ContentHash) -> Result, StoreError> { + let mut out = Vec::new(); + for kv in self.tree.scan_prefix(&content.0) { + let (_k, v) = kv?; + out.push(postcard::from_bytes(&v)?); + } + Ok(out) + } + + pub fn authors_of(&self, content: &ContentHash) -> Result, StoreError> { + Ok(self.get(content)?.into_iter().map(|a| a.author).collect()) + } + + pub fn len(&self) -> usize { + self.tree.len() + } + + pub fn is_empty(&self) -> bool { + self.tree.is_empty() + } + + pub fn flush(&self) -> Result<(), StoreError> { + self.tree.flush()?; + Ok(()) + } + + /// Itera todas las atestaciones persistidas. Cargando un peer al + /// arrancar, esto repuebla el `AttestationStore` en memoria. + pub fn iter(&self) -> impl Iterator> + '_ { + self.tree.iter().map(|kv| { + let (_k, v) = kv?; + Ok(postcard::from_bytes(&v)?) + }) + } +} + +fn compose_key(content: &ContentHash, author: &Did) -> [u8; 64] { + let mut k = [0u8; 64]; + k[..32].copy_from_slice(&content.0); + k[32..].copy_from_slice(&author.0); + k +} diff --git a/03_ukupacha/minga/minga-store/src/error.rs b/03_ukupacha/minga/minga-store/src/error.rs new file mode 100644 index 0000000..bfcea0a --- /dev/null +++ b/03_ukupacha/minga/minga-store/src/error.rs @@ -0,0 +1,19 @@ +use minga_core::{AttestationError, RetractionError}; + +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("sled: {0}")] + Sled(#[from] sled::Error), + + #[error("postcard: {0}")] + Postcard(#[from] postcard::Error), + + #[error("attestation: {0}")] + Attestation(#[from] AttestationError), + + #[error("retraction: {0}")] + Retraction(#[from] RetractionError), + + #[error("hash inconsistente con el contenido del nodo")] + HashMismatch, +} diff --git a/03_ukupacha/minga/minga-store/src/keypair_file.rs b/03_ukupacha/minga/minga-store/src/keypair_file.rs new file mode 100644 index 0000000..434af7c --- /dev/null +++ b/03_ukupacha/minga/minga-store/src/keypair_file.rs @@ -0,0 +1,41 @@ +//! Persistencia en disco de keypairs cifrados. +//! +//! El cifrado en sí (AES-GCM + Argon2id) vive en `minga-core`, que es +//! pure logic. Aquí solo se monta la parte de IO: leer/escribir +//! bytes a un archivo. +//! +//! Layout del archivo: el blob crudo que produce +//! `Keypair::encrypt(passphrase)`. 85 bytes total. + +use std::fs; +use std::io; +use std::path::Path; + +use minga_core::{Keypair, KeypairCryptoError}; + +#[derive(Debug, thiserror::Error)] +pub enum KeypairFileError { + #[error("io: {0}")] + Io(#[from] io::Error), + + #[error("crypto: {0}")] + Crypto(#[from] KeypairCryptoError), +} + +/// Guarda un keypair cifrado con la passphrase en `path`. Si el +/// archivo ya existe, lo sobrescribe. +pub fn save>( + keypair: &Keypair, + path: P, + passphrase: &str, +) -> Result<(), KeypairFileError> { + let blob = keypair.encrypt(passphrase)?; + fs::write(path, blob)?; + Ok(()) +} + +/// Carga un keypair desde un archivo cifrado. +pub fn load>(path: P, passphrase: &str) -> Result { + let blob = fs::read(path)?; + Ok(Keypair::decrypt(&blob, passphrase)?) +} diff --git a/03_ukupacha/minga/minga-store/src/lib.rs b/03_ukupacha/minga/minga-store/src/lib.rs new file mode 100644 index 0000000..dee0a38 --- /dev/null +++ b/03_ukupacha/minga/minga-store/src/lib.rs @@ -0,0 +1,46 @@ +//! `minga-store`: backing persistente con `sled` para los stores de Minga. +//! +//! Tres stores paralelos a los de `minga-core`: +//! - [`SledNodeStore`]: hashes → `StoredNode`s, equivalente persistente +//! de `MemStore`. +//! - [`SledAttestationStore`]: pruebas criptográficas de autoría +//! indexadas por content hash. +//! - [`SledMstStore`]: conjunto de claves del MST. La estructura +//! probabilística del MST se reconstruye en memoria al cargar +//! ([`SledMstStore::to_in_memory`]) — solo persistimos las claves +//! porque el árbol es deterministicamente derivable de ellas. +//! +//! Una `PersistentRepo` agrupa los tres sobre una única `sled::Db` +//! (tres trees con namespaces separados). +//! +//! El núcleo (`minga-core`) sigue siendo agnóstico de IO: estos tipos +//! tienen APIs paralelas (devuelven `Result`, deserializan vía +//! postcard) y los protocolos de sync se quedan operando sobre los +//! tipos in-memory. La integración con `MingaPeer` (que hoy usa +//! `MemStore` concreto) llegará tras un trait genérico — esta +//! iteración se centra en que la capa de persistencia esté correcta +//! y testeada. + +pub mod alpha_paths_store; +pub mod attestation_store; +pub mod error; +pub mod keypair_file; +pub mod mst_store; +pub mod node_store; +pub mod path_history_store; +pub mod repo; +pub mod retraction_store; +pub mod roots_store; +pub mod timestamp_store; + +pub use alpha_paths_store::SledAlphaPathsStore; +pub use attestation_store::SledAttestationStore; +pub use error::StoreError; +pub use keypair_file::KeypairFileError; +pub use mst_store::SledMstStore; +pub use node_store::SledNodeStore; +pub use path_history_store::SledPathHistoryStore; +pub use repo::PersistentRepo; +pub use retraction_store::SledRetractionStore; +pub use roots_store::SledRootsStore; +pub use timestamp_store::SledTimestampStore; diff --git a/03_ukupacha/minga/minga-store/src/mst_store.rs b/03_ukupacha/minga/minga-store/src/mst_store.rs new file mode 100644 index 0000000..01f1f0b --- /dev/null +++ b/03_ukupacha/minga/minga-store/src/mst_store.rs @@ -0,0 +1,81 @@ +//! Persistencia del MST. +//! +//! Solo persistimos las **claves** (los `ContentHash`es del conjunto). +//! La estructura probabilística del MST (niveles, separadores, +//! árbol de Merkle) es derivable determinísticamente de las claves, +//! así que reconstruirla en memoria al cargar es trivial. +//! +//! Layout: una `sled::Tree` cuyas claves son los 32 bytes del hash y +//! cuyos valores son vacíos. Los hashes se ordenan automáticamente +//! por sled (orden lexicográfico = orden por bytes), lo que coincide +//! con el orden que `Mst::iter` produce. + +use minga_core::{ContentHash, Mst}; +use sled::{Db, Tree}; + +use crate::error::StoreError; + +pub struct SledMstStore { + tree: Tree, +} + +impl SledMstStore { + pub fn open_tree(db: &Db, name: &str) -> Result { + Ok(Self { + tree: db.open_tree(name)?, + }) + } + + pub fn insert(&self, h: ContentHash) -> Result { + let prev = self.tree.insert(h.0, &[])?; + Ok(prev.is_none()) + } + + /// Elimina una clave del MST. `Ok(true)` si existía, `Ok(false)` + /// si no. Los nodos del grafo CAS NO se eliminan: pueden seguir + /// referenciados desde otras raíces. + pub fn remove(&self, h: &ContentHash) -> Result { + Ok(self.tree.remove(h.0)?.is_some()) + } + + pub fn contains(&self, h: &ContentHash) -> Result { + Ok(self.tree.contains_key(h.0)?) + } + + pub fn len(&self) -> usize { + self.tree.len() + } + + pub fn is_empty(&self) -> bool { + self.tree.is_empty() + } + + /// Itera todas las claves del MST en orden ascendente por hash. + pub fn iter(&self) -> impl Iterator> + '_ { + self.tree.iter().map(|kv| { + let (k, _) = kv?; + if k.len() != 32 { + return Err(StoreError::HashMismatch); + } + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&k); + Ok(ContentHash(bytes)) + }) + } + + /// Reconstruye un `Mst` en memoria a partir de las claves + /// persistidas. Útil al arrancar un peer: cargamos las claves + /// del disco y rehacemos la estructura para operaciones rápidas. + pub fn to_in_memory(&self) -> Result { + let mut mst = Mst::new(); + for h in self.iter() { + mst.insert(h?); + } + Ok(mst) + } + + pub fn flush(&self) -> Result<(), StoreError> { + self.tree.flush()?; + Ok(()) + } +} diff --git a/03_ukupacha/minga/minga-store/src/node_store.rs b/03_ukupacha/minga/minga-store/src/node_store.rs new file mode 100644 index 0000000..9e2465f --- /dev/null +++ b/03_ukupacha/minga/minga-store/src/node_store.rs @@ -0,0 +1,171 @@ +//! Almacén persistente de `StoredNode`s indexados por `ContentHash`. +//! +//! Cada nodo se serializa con postcard y se inserta en una `sled::Tree` +//! cuya clave son los 32 bytes del hash. La operación `put` es +//! recursiva sobre los hijos (igual que `MemStore::put`): cada +//! subárbol se hashea y persiste exactamente una vez. + +use minga_core::{cas, hash_stored, ContentHash, SemanticNode, StoredNode}; +use sled::{Db, Tree}; + +use crate::error::StoreError; + +pub struct SledNodeStore { + tree: Tree, +} + +impl SledNodeStore { + pub fn open_tree(db: &Db, name: &str) -> Result { + Ok(Self { + tree: db.open_tree(name)?, + }) + } + + /// Inserta un árbol completo. Recursivamente desempaqueta hijos. + /// Devuelve el hash de la raíz. Idempotente: insertar el mismo + /// árbol dos veces no añade entradas nuevas. + pub fn put(&self, node: &SemanticNode) -> Result { + let mut child_hashes = Vec::with_capacity(node.children.len()); + for c in &node.children { + child_hashes.push(self.put(c)?); + } + let h = cas::hash_components( + &node.kind, + node.field_name.as_deref(), + node.leaf_text.as_deref(), + &child_hashes, + ); + if !self.tree.contains_key(h.0)? { + let stored = StoredNode { + kind: node.kind.clone(), + field_name: node.field_name.clone(), + leaf_text: node.leaf_text.clone(), + children: child_hashes, + }; + let bytes = postcard::to_allocvec(&stored)?; + self.tree.insert(h.0, bytes)?; + } + Ok(h) + } + + /// Inserta un nodo ya troceado por hash. Verifica que el hash + /// coincida con `hash_stored(stored)` antes de insertar — sin + /// esa verificación no podemos confiar en la integridad de lo + /// que viene del wire. + pub fn put_chunked( + &self, + hash: ContentHash, + stored: &StoredNode, + ) -> Result<(), StoreError> { + if hash_stored(stored) != hash { + return Err(StoreError::HashMismatch); + } + if !self.tree.contains_key(hash.0)? { + let bytes = postcard::to_allocvec(stored)?; + self.tree.insert(hash.0, bytes)?; + } + Ok(()) + } + + pub fn get(&self, h: &ContentHash) -> Result, StoreError> { + match self.tree.get(h.0)? { + Some(bytes) => Ok(Some(postcard::from_bytes(&bytes)?)), + None => Ok(None), + } + } + + pub fn contains(&self, h: &ContentHash) -> Result { + Ok(self.tree.contains_key(h.0)?) + } + + /// Reconstruye un `SemanticNode` resolviendo recursivamente todos + /// los hijos. `Ok(None)` si algún hash no está en el store + /// (almacén incompleto). + pub fn reconstruct(&self, h: &ContentHash) -> Result, StoreError> { + let stored = match self.get(h)? { + Some(s) => s, + None => return Ok(None), + }; + let mut children = Vec::with_capacity(stored.children.len()); + for ch in &stored.children { + match self.reconstruct(ch)? { + Some(n) => children.push(n), + None => return Ok(None), + } + } + Ok(Some(SemanticNode { + kind: stored.kind, + field_name: stored.field_name, + leaf_text: stored.leaf_text, + children, + })) + } + + pub fn len(&self) -> usize { + self.tree.len() + } + + pub fn is_empty(&self) -> bool { + self.tree.is_empty() + } + + pub fn flush(&self) -> Result<(), StoreError> { + self.tree.flush()?; + Ok(()) + } + + /// Elimina un nodo del store por su hash. **Cuidado**: los hijos no + /// se borran en cascada (otros nodos pueden referenciarlos). El + /// caller es responsable de la consistencia (típicamente: usar + /// mark-sweep sobre raíces vivas). + pub fn remove(&self, h: &ContentHash) -> Result { + Ok(self.tree.remove(h.0)?.is_some()) + } + + /// Itera sólo los hashes (sin deserializar el valor). Más liviano + /// que `iter` cuando sólo se necesitan las claves — útil para + /// mark-sweep del GC. + pub fn iter_hashes(&self) -> impl Iterator> + '_ { + self.tree.iter().map(|kv| { + let (k, _) = kv?; + if k.len() != 32 { + return Err(StoreError::HashMismatch); + } + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&k); + Ok(ContentHash(bytes)) + }) + } + + /// Lee sólo los hashes de los hijos de un nodo (sin reconstruir + /// `StoredNode` completo más allá del shape del header postcard). + /// Optimización del walk del mark-sweep: para visitar el subárbol + /// no necesitamos `kind`/`field_name`/`leaf_text`. + pub fn children_of(&self, h: &ContentHash) -> Result>, StoreError> { + match self.tree.get(h.0)? { + Some(bytes) => { + let stored: StoredNode = postcard::from_bytes(&bytes)?; + Ok(Some(stored.children)) + } + None => Ok(None), + } + } + + /// Itera todos los pares `(hash, stored_node)` persistidos. Sin + /// orden garantizado más allá del lexicográfico de sled. Usado al + /// arrancar para volcar el contenido a un `MemStore` en memoria. + pub fn iter( + &self, + ) -> impl Iterator> + '_ { + self.tree.iter().map(|kv| { + let (k, v) = kv?; + if k.len() != 32 { + return Err(StoreError::HashMismatch); + } + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&k); + let stored: StoredNode = postcard::from_bytes(&v)?; + Ok((ContentHash(bytes), stored)) + }) + } +} diff --git a/03_ukupacha/minga/minga-store/src/path_history_store.rs b/03_ukupacha/minga/minga-store/src/path_history_store.rs new file mode 100644 index 0000000..adc230a --- /dev/null +++ b/03_ukupacha/minga/minga-store/src/path_history_store.rs @@ -0,0 +1,136 @@ +//! `SledPathHistoryStore`: historial cronológico de raíces ingeridas +//! desde cada path local. +//! +//! Para implementar `minga blame` necesitamos saber, dado un archivo, +//! la secuencia de α-hashes que fueron sus raíces a lo largo del +//! tiempo. Las atestaciones no llevan esta info (las firmas son sobre +//! contenido, no sobre path) y la transmisión por wire es por hash, +//! no por nombre — así que esta info es estrictamente **local** al +//! peer, como [`SledTimestampStore`]. +//! +//! Layout: clave `path_bytes` (la cadena UTF-8 del path canonicalizado +//! por el caller), valor postcard-serializado `Vec<(ContentHash, u64)>` +//! en orden cronológico ascendente. Para cada ingesta sobre el mismo +//! path se re-serializa el vector entero — aceptable porque los +//! historiales son cortos (decenas de entradas como mucho). + +use minga_core::ContentHash; +use sled::{Db, Tree}; + +use crate::error::StoreError; + +pub struct SledPathHistoryStore { + tree: Tree, +} + +impl SledPathHistoryStore { + pub fn open_tree(db: &Db, name: &str) -> Result { + Ok(Self { + tree: db.open_tree(name)?, + }) + } + + /// Anexa un par `(alpha, ts_secs)` al historial de `path`. Si el + /// último α registrado en el historial es idéntico al recién + /// ingerido (ingesta del mismo contenido sin cambios), NO se + /// duplica — el historial sólo crece cuando el contenido cambia + /// realmente. Esto evita ruido en `minga blame` cuando `watch` + /// dispara múltiples eventos por una sola edición. + pub fn append( + &self, + path: &str, + alpha: ContentHash, + ts_secs: u64, + ) -> Result<(), StoreError> { + let mut history = self.history(path)?; + if let Some(last) = history.last() { + if last.0 == alpha { + return Ok(()); + } + } + history.push((alpha, ts_secs)); + let bytes = postcard::to_allocvec(&history)?; + self.tree.insert(path.as_bytes(), bytes)?; + Ok(()) + } + + /// Historial cronológico de `path`. Vec vacío si nunca se ingirió. + pub fn history(&self, path: &str) -> Result, StoreError> { + let Some(bytes) = self.tree.get(path.as_bytes())? else { + return Ok(Vec::new()); + }; + Ok(postcard::from_bytes(&bytes)?) + } + + /// Cuántos paths tienen historial registrado. + pub fn len(&self) -> usize { + self.tree.len() + } + + pub fn is_empty(&self) -> bool { + self.tree.is_empty() + } + + pub fn flush(&self) -> Result<(), StoreError> { + self.tree.flush()?; + Ok(()) + } + + /// Iterador sobre todos los paths con su historial completo. + pub fn iter( + &self, + ) -> impl Iterator), StoreError>> + '_ { + self.tree.iter().map(|kv| { + let (k, v) = kv?; + let path = String::from_utf8(k.to_vec()) + .map_err(|_| StoreError::HashMismatch)?; + let history: Vec<(ContentHash, u64)> = postcard::from_bytes(&v)?; + Ok((path, history)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use minga_core::ContentHash; + use tempfile::TempDir; + + fn open_temp_db() -> (sled::Db, TempDir) { + let dir = TempDir::new().expect("tempdir"); + let cfg = sled::Config::default().path(dir.path()).temporary(true); + let db = cfg.open().expect("sled open"); + (db, dir) + } + + #[test] + fn append_and_read_history() { + let (db, _dir) = open_temp_db(); + let s = SledPathHistoryStore::open_tree(&db, "path_history").unwrap(); + let a1 = ContentHash([1u8; 32]); + let a2 = ContentHash([2u8; 32]); + s.append("src/main.rs", a1, 100).unwrap(); + s.append("src/main.rs", a2, 200).unwrap(); + let h = s.history("src/main.rs").unwrap(); + assert_eq!(h, vec![(a1, 100), (a2, 200)]); + } + + #[test] + fn append_skips_duplicate_tail() { + // Re-ingerir el mismo α no debe duplicar la entrada. + let (db, _dir) = open_temp_db(); + let s = SledPathHistoryStore::open_tree(&db, "path_history").unwrap(); + let a = ContentHash([42u8; 32]); + s.append("x.rs", a, 1).unwrap(); + s.append("x.rs", a, 99).unwrap(); + let h = s.history("x.rs").unwrap(); + assert_eq!(h, vec![(a, 1)], "el segundo append debió ser no-op"); + } + + #[test] + fn history_empty_for_unknown_path() { + let (db, _dir) = open_temp_db(); + let s = SledPathHistoryStore::open_tree(&db, "path_history").unwrap(); + assert!(s.history("nope.rs").unwrap().is_empty()); + } +} diff --git a/03_ukupacha/minga/minga-store/src/repo.rs b/03_ukupacha/minga/minga-store/src/repo.rs new file mode 100644 index 0000000..a7ab5f0 --- /dev/null +++ b/03_ukupacha/minga/minga-store/src/repo.rs @@ -0,0 +1,86 @@ +//! `PersistentRepo`: agrupa los tres stores (nodos, atestaciones, MST) +//! sobre una única `sled::Db`. Cada store ocupa su propio tree +//! (namespace lógico) dentro del mismo directorio en disco. + +use std::path::Path; + +use sled::Db; + +use crate::{ + alpha_paths_store::SledAlphaPathsStore, attestation_store::SledAttestationStore, + error::StoreError, mst_store::SledMstStore, node_store::SledNodeStore, + path_history_store::SledPathHistoryStore, retraction_store::SledRetractionStore, + roots_store::SledRootsStore, timestamp_store::SledTimestampStore, +}; + +pub struct PersistentRepo { + db: Db, + pub nodes: SledNodeStore, + pub attestations: SledAttestationStore, + pub mst: SledMstStore, + /// α-hash → (struct-hash, dialect). Indirección de los archivos + /// ingeridos hacia el grafo CAS interno. + pub roots: SledRootsStore, + /// Timestamps locales de cuándo se observó cada atestación. No se + /// transmite por wire — es metadata propia del peer. + pub timestamps: SledTimestampStore, + /// Retracciones firmadas: el autor declara que ya no respalda un + /// contenido. Coexiste con la atestación original (que sigue como + /// prueba histórica). + pub retractions: SledRetractionStore, + /// Historial path → secuencia de α-hashes ingeridos. Local al peer + /// (los paths no se transmiten por wire). Alimenta `minga blame`. + pub paths: SledPathHistoryStore, + /// Índice inverso α → paths persistente. Lo poblan los mismos + /// callsites que llaman a `paths.append`. Evita reconstruirlo en RAM + /// cada vez que `cmd_roots` quiere mostrar el path canónico. + pub alpha_paths: SledAlphaPathsStore, +} + +impl PersistentRepo { + pub fn open>(path: P) -> Result { + let db = sled::open(path)?; + let nodes = SledNodeStore::open_tree(&db, "nodes")?; + let attestations = SledAttestationStore::open_tree(&db, "attestations")?; + let mst = SledMstStore::open_tree(&db, "mst")?; + let roots = SledRootsStore::open_tree(&db, "roots")?; + let timestamps = SledTimestampStore::open_tree(&db, "attestation_timestamps")?; + let retractions = SledRetractionStore::open_tree(&db, "retractions")?; + let paths = SledPathHistoryStore::open_tree(&db, "path_history")?; + let alpha_paths = SledAlphaPathsStore::open_tree(&db, "alpha_paths")?; + + // Migración perezosa: repos viejos no tienen `alpha_paths` + // poblado. Si está vacío y `path_history` ya tiene entradas, lo + // reconstruimos una sola vez a partir del historial — el costo + // se paga al primer `open` post-upgrade, después es O(1) por + // ingesta. + if alpha_paths.is_empty() && !paths.is_empty() { + for entry in paths.iter() { + let (path, history) = entry?; + for (alpha, ts) in history { + alpha_paths.record(alpha, &path, ts)?; + } + } + alpha_paths.flush()?; + } + + Ok(Self { + db, + nodes, + attestations, + mst, + roots, + timestamps, + retractions, + paths, + alpha_paths, + }) + } + + /// Flushea todos los trees a disco. Llamar en puntos de checkpoint + /// o antes de cerrar para garantizar durabilidad. + pub fn flush(&self) -> Result<(), StoreError> { + self.db.flush()?; + Ok(()) + } +} diff --git a/03_ukupacha/minga/minga-store/src/retraction_store.rs b/03_ukupacha/minga/minga-store/src/retraction_store.rs new file mode 100644 index 0000000..af7631e --- /dev/null +++ b/03_ukupacha/minga/minga-store/src/retraction_store.rs @@ -0,0 +1,79 @@ +//! Almacén persistente de retracciones firmadas. +//! +//! Espejo de [`SledAttestationStore`] para el caso negativo: un autor +//! que retira su respaldo a un contenido firma una +//! [`minga_core::Retraction`] y la persistimos aquí. Layout idéntico: +//! clave `content_hash || author_did` (64 bytes), valor postcard. +//! +//! `add` re-verifica criptográficamente: el store nunca contiene +//! firmas inválidas. + +use minga_core::{ContentHash, Did, Retraction, RetractionError}; +use sled::{Db, Tree}; + +use crate::error::StoreError; + +pub struct SledRetractionStore { + tree: Tree, +} + +impl SledRetractionStore { + pub fn open_tree(db: &Db, name: &str) -> Result { + Ok(Self { + tree: db.open_tree(name)?, + }) + } + + pub fn add(&self, r: Retraction) -> Result<(), StoreError> { + if !r.verify() { + return Err(StoreError::Retraction(RetractionError::InvalidSignature)); + } + let key = compose_key(&r.content, &r.author); + let bytes = postcard::to_allocvec(&r)?; + self.tree.insert(&key, bytes)?; + Ok(()) + } + + pub fn get(&self, content: &ContentHash) -> Result, StoreError> { + let mut out = Vec::new(); + for kv in self.tree.scan_prefix(&content.0) { + let (_k, v) = kv?; + out.push(postcard::from_bytes(&v)?); + } + Ok(out) + } + + /// `true` si existe una retracción firmada por `author` sobre + /// `content`. Útil para preguntar "¿este autor retiró su firma de + /// este contenido?". + pub fn contains(&self, content: &ContentHash, author: &Did) -> Result { + Ok(self.tree.contains_key(compose_key(content, author))?) + } + + pub fn len(&self) -> usize { + self.tree.len() + } + + pub fn is_empty(&self) -> bool { + self.tree.is_empty() + } + + pub fn flush(&self) -> Result<(), StoreError> { + self.tree.flush()?; + Ok(()) + } + + pub fn iter(&self) -> impl Iterator> + '_ { + self.tree.iter().map(|kv| { + let (_k, v) = kv?; + Ok(postcard::from_bytes(&v)?) + }) + } +} + +fn compose_key(content: &ContentHash, author: &Did) -> [u8; 64] { + let mut k = [0u8; 64]; + k[..32].copy_from_slice(&content.0); + k[32..].copy_from_slice(&author.0); + k +} diff --git a/03_ukupacha/minga/minga-store/src/roots_store.rs b/03_ukupacha/minga/minga-store/src/roots_store.rs new file mode 100644 index 0000000..f7d42ee --- /dev/null +++ b/03_ukupacha/minga/minga-store/src/roots_store.rs @@ -0,0 +1,105 @@ +//! `SledRootsStore`: indirección α-hash → struct-hash, con dialect. +//! +//! Las **raíces** del repo (archivos ingeridos completos) se identifican +//! por su **α-hash** — equivalente bajo renombrado de variables ligadas +//! (ver `minga_core::alpha`). El MST y las atestaciones se indexan por +//! ese hash. Pero el grafo interno (`SledNodeStore`) sigue siendo CAS +//! estructural: cada `StoredNode` se identifica por `cas::hash_node`. +//! +//! Este store guarda la indirección `α_hash → (struct_hash, dialect)`: +//! - `struct_hash` permite localizar la raíz dentro del `SledNodeStore`. +//! - `dialect` permite re-verificar el α-hash si el repo lo necesita +//! (sync entre peers, debugging, validación). +//! +//! Layout: clave `[α_hash;32]`, valor `[dialect_byte;1] || [struct_hash;32]`. + +use minga_core::{parse::Dialect, ContentHash}; +use sled::{Db, Tree}; + +use crate::error::StoreError; + +const VALUE_LEN: usize = 33; + +pub struct SledRootsStore { + tree: Tree, +} + +impl SledRootsStore { + pub fn open_tree(db: &Db, name: &str) -> Result { + Ok(Self { + tree: db.open_tree(name)?, + }) + } + + /// Registra la asociación `α_hash → (struct_hash, dialect)`. Idempotente. + pub fn put( + &self, + alpha: ContentHash, + struct_hash: ContentHash, + dialect: Dialect, + ) -> Result<(), StoreError> { + let mut val = [0u8; VALUE_LEN]; + val[0] = dialect.as_byte(); + val[1..].copy_from_slice(&struct_hash.0); + self.tree.insert(alpha.0, val.as_slice())?; + Ok(()) + } + + /// Resuelve el α-hash al struct-hash de la raíz (y dialect, si es + /// reconocido por esta versión del binario). `Ok(None)` si el α-hash + /// no es una raíz registrada. + pub fn get( + &self, + alpha: &ContentHash, + ) -> Result)>, StoreError> { + let Some(bytes) = self.tree.get(alpha.0)? else { + return Ok(None); + }; + if bytes.len() != VALUE_LEN { + return Err(StoreError::HashMismatch); + } + let dialect = Dialect::from_byte(bytes[0]); + let mut sh = [0u8; 32]; + sh.copy_from_slice(&bytes[1..]); + Ok(Some((ContentHash(sh), dialect))) + } + + pub fn contains(&self, alpha: &ContentHash) -> Result { + Ok(self.tree.contains_key(alpha.0)?) + } + + pub fn remove(&self, alpha: &ContentHash) -> Result { + Ok(self.tree.remove(alpha.0)?.is_some()) + } + + pub fn len(&self) -> usize { + self.tree.len() + } + + pub fn is_empty(&self) -> bool { + self.tree.is_empty() + } + + pub fn flush(&self) -> Result<(), StoreError> { + self.tree.flush()?; + Ok(()) + } + + pub fn iter( + &self, + ) -> impl Iterator), StoreError>> + '_ + { + self.tree.iter().map(|kv| { + let (k, v) = kv?; + if k.len() != 32 || v.len() != VALUE_LEN { + return Err(StoreError::HashMismatch); + } + let mut alpha = [0u8; 32]; + alpha.copy_from_slice(&k); + let dialect = Dialect::from_byte(v[0]); + let mut sh = [0u8; 32]; + sh.copy_from_slice(&v[1..]); + Ok((ContentHash(alpha), ContentHash(sh), dialect)) + }) + } +} diff --git a/03_ukupacha/minga/minga-store/src/timestamp_store.rs b/03_ukupacha/minga/minga-store/src/timestamp_store.rs new file mode 100644 index 0000000..4cb86a7 --- /dev/null +++ b/03_ukupacha/minga/minga-store/src/timestamp_store.rs @@ -0,0 +1,64 @@ +//! `SledTimestampStore`: timestamps locales de atestaciones. +//! +//! La estructura `Attestation` no lleva timestamp (la firma cubre sólo +//! el contenido). Sin embargo el repo local quiere ordenar "cuándo vi +//! por primera vez esta firma" para construir un `minga log`. Este +//! store separa esa responsabilidad: clave igual a la del +//! `SledAttestationStore` (`content_hash || author_did`, 64 bytes), +//! valor `u64` little-endian con segundos Unix. +//! +//! Es **local**: no se transmite por el wire. Si dos peers ven la misma +//! atestación tendrán timestamps distintos (cuando llegó a cada uno). +//! Aceptable porque `minga log` es una vista local del historial. + +use minga_core::{ContentHash, Did}; +use sled::{Db, Tree}; + +use crate::error::StoreError; + +pub struct SledTimestampStore { + tree: Tree, +} + +impl SledTimestampStore { + pub fn open_tree(db: &Db, name: &str) -> Result { + Ok(Self { + tree: db.open_tree(name)?, + }) + } + + pub fn put(&self, content: &ContentHash, author: &Did, ts_secs: u64) -> Result<(), StoreError> { + let key = compose_key(content, author); + // Idempotente: si ya existe, conservar el primero (el "cuándo lo vi"). + if self.tree.contains_key(&key)? { + return Ok(()); + } + self.tree.insert(&key, &ts_secs.to_le_bytes())?; + Ok(()) + } + + pub fn get(&self, content: &ContentHash, author: &Did) -> Result, StoreError> { + let key = compose_key(content, author); + let Some(bytes) = self.tree.get(&key)? else { + return Ok(None); + }; + if bytes.len() != 8 { + return Err(StoreError::HashMismatch); + } + let mut arr = [0u8; 8]; + arr.copy_from_slice(&bytes); + Ok(Some(u64::from_le_bytes(arr))) + } + + pub fn flush(&self) -> Result<(), StoreError> { + self.tree.flush()?; + Ok(()) + } +} + +fn compose_key(content: &ContentHash, author: &Did) -> [u8; 64] { + let mut k = [0u8; 64]; + k[..32].copy_from_slice(&content.0); + k[32..].copy_from_slice(&author.0); + k +} diff --git a/03_ukupacha/minga/minga-store/tests/alpha_paths_rebuild.rs b/03_ukupacha/minga/minga-store/tests/alpha_paths_rebuild.rs new file mode 100644 index 0000000..b67f0f4 --- /dev/null +++ b/03_ukupacha/minga/minga-store/tests/alpha_paths_rebuild.rs @@ -0,0 +1,45 @@ +//! Migración perezosa de `alpha_paths`: un repo cuyo `path_history` ya +//! tiene entradas pero cuyo `alpha_paths` está vacío (caso real al +//! actualizar un repo viejo) debe reconstruir el reverse-index en el +//! primer `open`. + +use minga_core::ContentHash; +use minga_store::{PersistentRepo, SledPathHistoryStore}; +use tempfile::TempDir; + +#[test] +fn open_rebuilds_alpha_paths_from_existing_history() { + let dir = TempDir::new().unwrap(); + let path = dir.path(); + let alpha1 = ContentHash([11u8; 32]); + let alpha2 = ContentHash([22u8; 32]); + + // Sesión 1: simulamos un repo viejo populando sólo `path_history` + // a nivel sled (sin pasar por `PersistentRepo::open`, que es + // justamente quien dispara la migración). + { + let db = sled::open(path).unwrap(); + let paths = SledPathHistoryStore::open_tree(&db, "path_history").unwrap(); + paths.append("src/a.rs", alpha1, 100).unwrap(); + paths.append("src/a.rs", alpha2, 200).unwrap(); + paths.append("src/b.rs", alpha1, 150).unwrap(); + paths.flush().unwrap(); + } + + // Sesión 2: open dispara la migración. + let repo = PersistentRepo::open(path).unwrap(); + assert!(!repo.alpha_paths.is_empty()); + + let p1 = repo.alpha_paths.paths_for(&alpha1).unwrap(); + let mut p1_names: Vec<_> = p1.iter().map(|(p, _)| p.clone()).collect(); + p1_names.sort(); + assert_eq!(p1_names, vec!["src/a.rs".to_string(), "src/b.rs".to_string()]); + + let p2 = repo.alpha_paths.paths_for(&alpha2).unwrap(); + assert_eq!(p2, vec![("src/a.rs".to_string(), 200)]); + + // Idempotencia: el segundo open no duplica entradas. + drop(repo); + let repo2 = PersistentRepo::open(path).unwrap(); + assert_eq!(repo2.alpha_paths.paths_for(&alpha1).unwrap().len(), 2); +} diff --git a/03_ukupacha/minga/minga-store/tests/attestation_store_persistence.rs b/03_ukupacha/minga/minga-store/tests/attestation_store_persistence.rs new file mode 100644 index 0000000..786333e --- /dev/null +++ b/03_ukupacha/minga/minga-store/tests/attestation_store_persistence.rs @@ -0,0 +1,113 @@ +//! Invariantes del `SledAttestationStore`. + +use minga_core::{Attestation, AttestationError, ContentHash, Keypair}; +use minga_store::{SledAttestationStore, StoreError}; +use tempfile::TempDir; + +fn open_store(path: &std::path::Path) -> (sled::Db, SledAttestationStore) { + let db = sled::open(path).unwrap(); + let store = SledAttestationStore::open_tree(&db, "atts").unwrap(); + (db, store) +} + +fn kp(seed: u8) -> Keypair { + Keypair::from_seed(&[seed; 32]) +} + +fn ch(seed: u8) -> ContentHash { + ContentHash([seed; 32]) +} + +#[test] +fn add_then_get_roundtrips() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + + let alice = kp(1); + let att = Attestation::create(&alice, ch(7)); + store.add(att.clone()).unwrap(); + + let retrieved = store.get(&ch(7)).unwrap(); + assert_eq!(retrieved, vec![att]); +} + +#[test] +fn invalid_signature_rejected() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + + let alice = kp(1); + let mut att = Attestation::create(&alice, ch(7)); + att.signature.0[10] ^= 0xFF; + + let r = store.add(att); + assert!(matches!( + r, + Err(StoreError::Attestation(AttestationError::InvalidSignature)) + )); + assert_eq!(store.len(), 0); +} + +#[test] +fn idempotent_per_author_and_content() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + + let alice = kp(1); + let att = Attestation::create(&alice, ch(5)); + store.add(att.clone()).unwrap(); + store.add(att.clone()).unwrap(); + store.add(att).unwrap(); + + assert_eq!(store.len(), 1); +} + +#[test] +fn multiple_authors_per_content() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + let alice = kp(1); + let bob = kp(2); + let carol = kp(3); + let h = ch(99); + + store.add(Attestation::create(&alice, h)).unwrap(); + store.add(Attestation::create(&bob, h)).unwrap(); + store.add(Attestation::create(&carol, h)).unwrap(); + + assert_eq!(store.len(), 3); + let authors = store.authors_of(&h).unwrap(); + assert_eq!(authors.len(), 3); + assert!(authors.contains(&alice.did())); + assert!(authors.contains(&bob.did())); + assert!(authors.contains(&carol.did())); +} + +#[test] +fn data_persists_across_reopen() { + let dir = TempDir::new().unwrap(); + let path = dir.path(); + let alice = kp(42); + let h = ch(11); + + { + let (db, store) = open_store(path); + store.add(Attestation::create(&alice, h)).unwrap(); + store.flush().unwrap(); + drop(store); + drop(db); + } + { + let (_db, store) = open_store(path); + let authors = store.authors_of(&h).unwrap(); + assert_eq!(authors, vec![alice.did()]); + } +} + +#[test] +fn unknown_content_returns_empty() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + let authors = store.authors_of(&ch(0)).unwrap(); + assert!(authors.is_empty()); +} diff --git a/03_ukupacha/minga/minga-store/tests/keypair_file_persistence.rs b/03_ukupacha/minga/minga-store/tests/keypair_file_persistence.rs new file mode 100644 index 0000000..ac9fb27 --- /dev/null +++ b/03_ukupacha/minga/minga-store/tests/keypair_file_persistence.rs @@ -0,0 +1,67 @@ +//! Tests de persistencia del keypair cifrado en disco. + +use minga_core::Keypair; +use minga_store::keypair_file; +use tempfile::TempDir; + +#[test] +fn save_then_load_preserves_identity() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("keypair"); + + let original = Keypair::from_seed(&[7; 32]); + keypair_file::save(&original, &path, "secreto42").unwrap(); + + let loaded = keypair_file::load(&path, "secreto42").unwrap(); + assert_eq!(loaded.did(), original.did()); + + let msg = b"el peer sigue siendo el mismo"; + let sig = loaded.sign(msg); + assert!(original.did().verify(msg, &sig)); +} + +#[test] +fn load_with_wrong_passphrase_errors() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("keypair"); + + let kp = Keypair::from_seed(&[3; 32]); + keypair_file::save(&kp, &path, "correcta").unwrap(); + + let r = keypair_file::load(&path, "incorrecta"); + assert!(r.is_err()); +} + +#[test] +fn load_missing_file_errors() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("no-existe"); + let r = keypair_file::load(&path, "x"); + assert!(r.is_err()); +} + +#[test] +fn save_overwrites_existing() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("keypair"); + + let first = Keypair::from_seed(&[1; 32]); + keypair_file::save(&first, &path, "pass").unwrap(); + + let second = Keypair::from_seed(&[2; 32]); + keypair_file::save(&second, &path, "pass").unwrap(); + + let loaded = keypair_file::load(&path, "pass").unwrap(); + assert_eq!(loaded.did(), second.did()); + assert_ne!(loaded.did(), first.did()); +} + +#[test] +fn file_size_is_compact() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("keypair"); + keypair_file::save(&Keypair::from_seed(&[5; 32]), &path, "p").unwrap(); + let size = std::fs::metadata(&path).unwrap().len(); + // 8 magic + 1 version + 16 salt + 12 nonce + 32 secret + 16 tag = 85. + assert_eq!(size, 85); +} diff --git a/03_ukupacha/minga/minga-store/tests/mst_store_persistence.rs b/03_ukupacha/minga/minga-store/tests/mst_store_persistence.rs new file mode 100644 index 0000000..d21ed34 --- /dev/null +++ b/03_ukupacha/minga/minga-store/tests/mst_store_persistence.rs @@ -0,0 +1,119 @@ +//! Invariantes del `SledMstStore`. La propiedad clave: el `Mst` +//! reconstruido desde disco produce el mismo `root_hash` que el `Mst` +//! que insertamos — la estructura es derivable solo de las claves. + +use minga_core::{ContentHash, Mst}; +use minga_store::SledMstStore; +use tempfile::TempDir; + +fn open_store(path: &std::path::Path) -> (sled::Db, SledMstStore) { + let db = sled::open(path).unwrap(); + let store = SledMstStore::open_tree(&db, "mst").unwrap(); + (db, store) +} + +fn ch(seed: u64) -> ContentHash { + let h = blake3::hash(&seed.to_le_bytes()); + ContentHash(*h.as_bytes()) +} + +#[test] +fn insert_and_contains() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + + let h = ch(1); + assert!(!store.contains(&h).unwrap()); + assert!(store.insert(h).unwrap()); + assert!(store.contains(&h).unwrap()); + + // Idempotencia: re-insertar devuelve false. + assert!(!store.insert(h).unwrap()); + assert_eq!(store.len(), 1); +} + +#[test] +fn iter_returns_sorted_keys() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + + let hashes: Vec = (0..32u64).map(ch).collect(); + for h in &hashes { + store.insert(*h).unwrap(); + } + + let collected: Vec = store.iter().map(|r| r.unwrap()).collect(); + let mut sorted = hashes.clone(); + sorted.sort(); + assert_eq!(collected, sorted); +} + +#[test] +fn root_hash_matches_in_memory_mst() { + // La propiedad fundacional: persistir solo las claves y reconstruir + // el árbol da exactamente el mismo `root_hash` que un `Mst` + // construido en memoria con las mismas claves. + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + + let mut in_memory = Mst::new(); + for i in 0..50u64 { + let h = ch(i); + store.insert(h).unwrap(); + in_memory.insert(h); + } + + let reconstructed = store.to_in_memory().unwrap(); + assert_eq!(reconstructed.root_hash(), in_memory.root_hash()); + assert_eq!(reconstructed.len(), in_memory.len()); +} + +#[test] +fn data_persists_across_reopen() { + let dir = TempDir::new().unwrap(); + let path = dir.path(); + let hashes: Vec = (0..20u64).map(ch).collect(); + + let target_root_hash; + { + let (db, store) = open_store(path); + for h in &hashes { + store.insert(*h).unwrap(); + } + target_root_hash = store.to_in_memory().unwrap().root_hash(); + store.flush().unwrap(); + drop(store); + drop(db); + } + { + let (_db, store) = open_store(path); + let reconstructed = store.to_in_memory().unwrap(); + assert_eq!(reconstructed.root_hash(), target_root_hash); + assert_eq!(reconstructed.len(), 20); + } +} + +#[test] +fn order_independent_persistence() { + // Insertar las mismas claves en orden distinto produce el mismo + // `root_hash`. Equivalencia con la garantía del MST in-memory. + let dir1 = TempDir::new().unwrap(); + let dir2 = TempDir::new().unwrap(); + + let hashes: Vec = (0..30u64).map(ch).collect(); + + let (_db1, s1) = open_store(dir1.path()); + for h in &hashes { + s1.insert(*h).unwrap(); + } + + let (_db2, s2) = open_store(dir2.path()); + for h in hashes.iter().rev() { + s2.insert(*h).unwrap(); + } + + assert_eq!( + s1.to_in_memory().unwrap().root_hash(), + s2.to_in_memory().unwrap().root_hash() + ); +} diff --git a/03_ukupacha/minga/minga-store/tests/node_store_persistence.rs b/03_ukupacha/minga/minga-store/tests/node_store_persistence.rs new file mode 100644 index 0000000..0a2e396 --- /dev/null +++ b/03_ukupacha/minga/minga-store/tests/node_store_persistence.rs @@ -0,0 +1,145 @@ +//! Invariantes del `SledNodeStore`. Cubre: +//! - Round-trip estructural (lo que entra sale igual). +//! - Hash consistente con `cas::hash_node`. +//! - Idempotencia. +//! - Persistencia tras cerrar y reabrir el DB. +//! - Rechazo de `put_chunked` con hash inconsistente. + +use minga_core::{cas::hash_components, hash_node, parse, ContentHash, StoredNode}; +use minga_store::{SledNodeStore, StoreError}; +use tempfile::TempDir; + +fn open_store(path: &std::path::Path) -> (sled::Db, SledNodeStore) { + let db = sled::open(path).unwrap(); + let store = SledNodeStore::open_tree(&db, "nodes").unwrap(); + (db, store) +} + +#[test] +fn round_trip_preserves_tree() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + let original = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap(); + let h = store.put(&original).unwrap(); + let reconstructed = store.reconstruct(&h).unwrap().unwrap(); + assert_eq!(original, reconstructed); +} + +#[test] +fn put_hash_matches_cas() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + let n = parse::rust("fn f() -> bool { true }").unwrap(); + let h_via_put = store.put(&n).unwrap(); + let h_via_cas = hash_node(&n); + assert_eq!(h_via_put, h_via_cas); +} + +#[test] +fn put_is_idempotent() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + let n = parse::rust("fn f() { 1 + 2 + 3 }").unwrap(); + let h1 = store.put(&n).unwrap(); + let len_after_first = store.len(); + let h2 = store.put(&n).unwrap(); + let len_after_second = store.len(); + assert_eq!(h1, h2); + assert_eq!(len_after_first, len_after_second); +} + +#[test] +fn data_persists_across_reopen() { + let dir = TempDir::new().unwrap(); + let path = dir.path(); + + let original = parse::rust("fn squared(n: i32) -> i32 { n * n }").unwrap(); + let h; + { + let (db, store) = open_store(path); + h = store.put(&original).unwrap(); + store.flush().unwrap(); + drop(store); + drop(db); + } + { + let (_db, store) = open_store(path); + let reconstructed = store.reconstruct(&h).unwrap().unwrap(); + assert_eq!(reconstructed, original); + } +} + +#[test] +fn shared_subtrees_dedup() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + + let a = parse::rust("fn alpha() -> i32 { 1 + 2 }").unwrap(); + let b = parse::rust("fn beta() -> i32 { 1 + 2 }").unwrap(); + + store.put(&a).unwrap(); + let count_after_a = store.len(); + store.put(&b).unwrap(); + let count_after_b = store.len(); + + // El cuerpo `{ 1 + 2 }` y subnodos son idénticos: comparten + // entrada en sled. Crecimiento estricto pero menor que duplicar. + assert!( + count_after_b < 2 * count_after_a, + "dedup falló: {} >= 2 * {}", + count_after_b, + count_after_a + ); +} + +#[test] +fn put_chunked_rejects_hash_mismatch() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + + let stored = StoredNode { + kind: "function_item".to_string(), + field_name: None, + leaf_text: None, + children: Vec::new(), + }; + let bogus_hash = ContentHash([0xAB; 32]); + + let result = store.put_chunked(bogus_hash, &stored); + assert!(matches!(result, Err(StoreError::HashMismatch))); + assert!(!store.contains(&bogus_hash).unwrap()); +} + +#[test] +fn put_chunked_accepts_correct_hash() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + + let stored = StoredNode { + kind: "integer_literal".to_string(), + field_name: None, + leaf_text: Some(b"42".to_vec()), + children: Vec::new(), + }; + let real_hash = hash_components( + &stored.kind, + stored.field_name.as_deref(), + stored.leaf_text.as_deref(), + &stored.children, + ); + + store.put_chunked(real_hash, &stored).unwrap(); + assert!(store.contains(&real_hash).unwrap()); + let retrieved = store.get(&real_hash).unwrap().unwrap(); + assert_eq!(retrieved, stored); +} + +#[test] +fn unknown_hash_returns_none() { + let dir = TempDir::new().unwrap(); + let (_db, store) = open_store(dir.path()); + let bogus = ContentHash([0xFE; 32]); + assert_eq!(store.get(&bogus).unwrap(), None); + assert_eq!(store.reconstruct(&bogus).unwrap(), None); + assert!(!store.contains(&bogus).unwrap()); +} diff --git a/03_ukupacha/minga/minga-store/tests/repo_integration.rs b/03_ukupacha/minga/minga-store/tests/repo_integration.rs new file mode 100644 index 0000000..e84b557 --- /dev/null +++ b/03_ukupacha/minga/minga-store/tests/repo_integration.rs @@ -0,0 +1,104 @@ +//! Test de integración del `PersistentRepo`: los tres stores conviven +//! en una misma `sled::Db`, escritos en una sesión y recuperados +//! intactos en la siguiente. + +use minga_core::{parse, Attestation, ContentHash, Keypair}; +use minga_store::PersistentRepo; +use tempfile::TempDir; + +#[test] +fn three_stores_persist_together_across_reopen() { + let dir = TempDir::new().unwrap(); + let path = dir.path(); + let alice = Keypair::from_seed(&[1; 32]); + + // ── Sesión 1: poblamos el repo ────────────────────────────────── + let function_hash; + let target_root_hash; + { + let repo = PersistentRepo::open(path).unwrap(); + let n = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap(); + function_hash = repo.nodes.put(&n).unwrap(); + repo.mst.insert(function_hash).unwrap(); + repo.attestations + .add(Attestation::create(&alice, function_hash)) + .unwrap(); + + target_root_hash = repo.mst.to_in_memory().unwrap().root_hash(); + repo.flush().unwrap(); + } + + // ── Sesión 2: reabrimos y verificamos integridad ──────────────── + { + let repo = PersistentRepo::open(path).unwrap(); + + // Nodo recuperable. + let stored = repo.nodes.get(&function_hash).unwrap().unwrap(); + assert_eq!(stored.kind, "source_file"); + + // Reconstrucción completa idéntica al original. + let reconstructed = repo.nodes.reconstruct(&function_hash).unwrap().unwrap(); + let original = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap(); + assert_eq!(reconstructed, original); + + // MST: misma raíz tras reconstruir. + assert_eq!( + repo.mst.to_in_memory().unwrap().root_hash(), + target_root_hash + ); + + // Atestación: sigue ahí, sigue verificable. + let authors = repo.attestations.authors_of(&function_hash).unwrap(); + assert_eq!(authors, vec![alice.did()]); + let atts = repo.attestations.get(&function_hash).unwrap(); + assert!(atts[0].verify()); + } +} + +#[test] +fn repo_supports_multiple_functions_and_authors() { + let dir = TempDir::new().unwrap(); + let repo = PersistentRepo::open(dir.path()).unwrap(); + + let alice = Keypair::from_seed(&[1; 32]); + let bob = Keypair::from_seed(&[2; 32]); + + let mut hashes: Vec = Vec::new(); + for src in &[ + "fn one() -> i32 { 1 }", + "fn two() -> i32 { 2 }", + "fn three(x: i32) -> i32 { x + 1 }", + ] { + let n = parse::rust(src).unwrap(); + let h = repo.nodes.put(&n).unwrap(); + repo.mst.insert(h).unwrap(); + hashes.push(h); + } + + // Alice firma las tres; Bob firma solo la primera. + for h in &hashes { + repo.attestations + .add(Attestation::create(&alice, *h)) + .unwrap(); + } + repo.attestations + .add(Attestation::create(&bob, hashes[0])) + .unwrap(); + + repo.flush().unwrap(); + + assert_eq!(repo.mst.len(), 3); + assert_eq!(repo.attestations.len(), 4); + + // La función firmada por ambos tiene dos autores. + let authors_first = repo.attestations.authors_of(&hashes[0]).unwrap(); + assert_eq!(authors_first.len(), 2); + assert!(authors_first.contains(&alice.did())); + assert!(authors_first.contains(&bob.did())); + + // Las otras dos solo tienen a Alice. + assert_eq!( + repo.attestations.authors_of(&hashes[1]).unwrap(), + vec![alice.did()] + ); +} diff --git a/03_ukupacha/minga/minga-vfs/Cargo.toml b/03_ukupacha/minga/minga-vfs/Cargo.toml new file mode 100644 index 0000000..cc9d195 --- /dev/null +++ b/03_ukupacha/minga/minga-vfs/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "minga-vfs" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Virtual File System de Minga: proyecta el repositorio direccionado por contenido como un filesystem FUSE de sólo lectura." + +[dependencies] +minga-core = { path = "../minga-core" } +minga-store = { path = "../minga-store" } +fuser = { workspace = true } +libc = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/03_ukupacha/minga/minga-vfs/LEEME.md b/03_ukupacha/minga/minga-vfs/LEEME.md new file mode 100644 index 0000000..0f88abf --- /dev/null +++ b/03_ukupacha/minga/minga-vfs/LEEME.md @@ -0,0 +1,9 @@ +# minga-vfs + +> VFS distribuido de [minga](../README.md). + +Path lookup → DHT → chunk fetch. Cache local + cooperative fetching: si un peer cercano ya bajó el chunk, se lo pedimos a él en vez de re-bajar. + +## Deps + +- [`minga-core`](../minga-core/README.md), [`minga-store`](../minga-store/README.md), [`minga-p2p`](../minga-p2p/README.md) diff --git a/03_ukupacha/minga/minga-vfs/README.md b/03_ukupacha/minga/minga-vfs/README.md new file mode 100644 index 0000000..88ebeaf --- /dev/null +++ b/03_ukupacha/minga/minga-vfs/README.md @@ -0,0 +1,9 @@ +# minga-vfs + +> Distributed VFS of [minga](../README.md). + +Path lookup → DHT → chunk fetch. Local cache + cooperative fetching: if a nearby peer already has the chunk, request it from them instead of re-downloading. + +## Deps + +- [`minga-core`](../minga-core/README.md), [`minga-store`](../minga-store/README.md), [`minga-p2p`](../minga-p2p/README.md) diff --git a/03_ukupacha/minga/minga-vfs/src/fs.rs b/03_ukupacha/minga/minga-vfs/src/fs.rs new file mode 100644 index 0000000..76a66f3 --- /dev/null +++ b/03_ukupacha/minga/minga-vfs/src/fs.rs @@ -0,0 +1,346 @@ +//! Adaptador a `fuser`: el único módulo del crate acoplado a FUSE. +//! +//! Traduce el contrato [`NodeSource`] a la `Filesystem` trait. El +//! filesystem es de sólo lectura y de estructura fija (ver el layout en +//! la documentación del crate). Los inodos estáticos (raíz, `README`, +//! `roots/`, `cas/`) tienen números reservados; los archivos por hash +//! reciben un inodo dinámico la primera vez que se nombran, estable a +//! partir de ahí. +//! +//! `fuser` 0.15 despacha las peticiones de forma secuencial en un único +//! hilo de sesión, así que los métodos toman `&mut self` y mutamos los +//! mapas internos sin necesidad de locks. + +use std::collections::HashMap; +use std::ffi::OsStr; +use std::time::{Duration, SystemTime}; + +use fuser::{ + FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyStatfs, + Request, +}; +use minga_core::ContentHash; + +use crate::render::{render_sexp, render_source}; +use crate::source::{reconstruct, NodeSource}; + +/// TTL de las respuestas cacheadas por el kernel. El contenido es +/// inmutable (direccionado por contenido), pero el *conjunto* de raíces +/// crece con cada ingest; 1 s es el compromiso habitual. +const TTL: Duration = Duration::from_secs(1); + +// Inodos estáticos. Los dinámicos arrancan en INO_DYNAMIC_BASE. +const INO_ROOT: u64 = 1; +const INO_README: u64 = 2; +const INO_ROOTS_DIR: u64 = 3; +const INO_CAS_DIR: u64 = 4; +const INO_DYNAMIC_BASE: u64 = 16; + +/// Contenido del archivo `/README` del propio montaje. +const README: &str = "\ +Minga VFS — proyección de sólo lectura de un repositorio Minga. + +Layout: + roots/ Código fuente reconstruido (format normalizado) de + cada archivo ingerido. `ls roots/` los lista todos. + cas/ S-expression del subárbol con ese hash. Este + directorio NO se lista (son demasiados nodos), pero + `cat cas/` resuelve cualquier hash conocido. + +El hash es un BLAKE3 de 64 hex en minúsculas sobre la ESTRUCTURA +semántica del código: whitespace y comentarios no cuentan. Por eso +`roots/` es una reconstrucción normalizada, no el archivo +original byte-a-byte. + +Filesystem de sólo lectura. Desmontar: fusermount -u . +"; + +/// Cuál de los dos directorios de hashes; determina el renderizado. +#[derive(Clone, Copy)] +enum Dir { + /// `roots/` — código fuente reconstruido. + Roots, + /// `cas/` — S-expression del árbol. + Cas, +} + +/// Implementación de `fuser::Filesystem` sobre un [`NodeSource`]. +pub struct MingaFs { + source: S, + /// Siguiente inodo dinámico libre. + next_ino: u64, + /// `(inodo_padre, nombre)` → inodo dinámico, para que un mismo hash + /// conserve su inodo entre llamadas. + name_to_ino: HashMap<(u64, String), u64>, + /// Inodo dinámico → contenido ya renderizado. Cachea el resultado + /// del primer `lookup`/`read` de cada archivo. + content: HashMap>, + /// Marca de tiempo uniforme para todos los atributos. + epoch: SystemTime, + uid: u32, + gid: u32, +} + +impl MingaFs { + /// Construye el filesystem sobre `source`. Los archivos virtuales + /// quedan a nombre del usuario y grupo del proceso, para que pueda + /// leerlos sin `allow_other`. + pub fn new(source: S) -> Self { + Self { + source, + next_ino: INO_DYNAMIC_BASE, + name_to_ino: HashMap::new(), + content: HashMap::new(), + epoch: SystemTime::now(), + // SAFETY: getuid/getgid son siempre seguras, sin efectos. + uid: unsafe { libc::getuid() }, + gid: unsafe { libc::getgid() }, + } + } + + /// Inodo dinámico para `(parent, name)`, asignándolo si es la + /// primera vez que se ve. + fn intern_ino(&mut self, parent: u64, name: &str) -> u64 { + if let Some(&ino) = self.name_to_ino.get(&(parent, name.to_string())) { + return ino; + } + let ino = self.next_ino; + self.next_ino += 1; + self.name_to_ino.insert((parent, name.to_string()), ino); + ino + } + + /// Resuelve un nombre bajo `roots/` o `cas/`: parsea el hash, + /// reconstruye el nodo, lo renderiza según `dir`, cachea el + /// contenido y devuelve `(inodo, tamaño)`. `None` si el nombre no + /// es un hash válido o el nodo no está en el store. + fn resolve(&mut self, dir: Dir, parent: u64, name: &str) -> Option<(u64, usize)> { + let hash = parse_hash(name)?; + let node = reconstruct(&self.source, &hash)?; + let rendered = match dir { + Dir::Roots => render_source(&node), + Dir::Cas => render_sexp(&node), + }; + let bytes = rendered.into_bytes(); + let size = bytes.len(); + let ino = self.intern_ino(parent, name); + self.content.insert(ino, bytes); + Some((ino, size)) + } + + fn dir_attr(&self, ino: u64) -> FileAttr { + FileAttr { + ino, + size: 0, + blocks: 0, + atime: self.epoch, + mtime: self.epoch, + ctime: self.epoch, + crtime: self.epoch, + kind: FileType::Directory, + perm: 0o555, + nlink: 2, + uid: self.uid, + gid: self.gid, + rdev: 0, + blksize: 512, + flags: 0, + } + } + + fn file_attr(&self, ino: u64, size: usize) -> FileAttr { + let size = size as u64; + FileAttr { + ino, + size, + blocks: size.div_ceil(512), + atime: self.epoch, + mtime: self.epoch, + ctime: self.epoch, + crtime: self.epoch, + kind: FileType::RegularFile, + perm: 0o444, + nlink: 1, + uid: self.uid, + gid: self.gid, + rdev: 0, + blksize: 512, + flags: 0, + } + } +} + +impl Filesystem for MingaFs { + fn lookup(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) { + let Some(name) = name.to_str() else { + reply.error(libc::ENOENT); + return; + }; + match parent { + INO_ROOT => match name { + "README" => reply.entry(&TTL, &self.file_attr(INO_README, README.len()), 0), + "roots" => reply.entry(&TTL, &self.dir_attr(INO_ROOTS_DIR), 0), + "cas" => reply.entry(&TTL, &self.dir_attr(INO_CAS_DIR), 0), + _ => reply.error(libc::ENOENT), + }, + INO_ROOTS_DIR | INO_CAS_DIR => { + let dir = if parent == INO_ROOTS_DIR { + Dir::Roots + } else { + Dir::Cas + }; + match self.resolve(dir, parent, name) { + Some((ino, size)) => reply.entry(&TTL, &self.file_attr(ino, size), 0), + None => reply.error(libc::ENOENT), + } + } + // Los archivos no tienen hijos. + _ => reply.error(libc::ENOENT), + } + } + + fn getattr(&mut self, _req: &Request<'_>, ino: u64, _fh: Option, reply: ReplyAttr) { + match ino { + INO_ROOT | INO_ROOTS_DIR | INO_CAS_DIR => reply.attr(&TTL, &self.dir_attr(ino)), + INO_README => reply.attr(&TTL, &self.file_attr(INO_README, README.len())), + _ => match self.content.get(&ino) { + Some(bytes) => { + let size = bytes.len(); + reply.attr(&TTL, &self.file_attr(ino, size)); + } + None => reply.error(libc::ENOENT), + }, + } + } + + fn read( + &mut self, + _req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + size: u32, + _flags: i32, + _lock_owner: Option, + reply: ReplyData, + ) { + let data: &[u8] = if ino == INO_README { + README.as_bytes() + } else { + match self.content.get(&ino) { + Some(bytes) => bytes.as_slice(), + None => { + reply.error(libc::ENOENT); + return; + } + } + }; + let start = (offset.max(0) as usize).min(data.len()); + let end = start.saturating_add(size as usize).min(data.len()); + reply.data(&data[start..end]); + } + + fn readdir( + &mut self, + _req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + mut reply: ReplyDirectory, + ) { + // Lista completa, incluidos `.` y `..`; el `offset` indica + // desde qué entrada reanudar. + let entries: Vec<(u64, FileType, String)> = match ino { + INO_ROOT => vec![ + (INO_ROOT, FileType::Directory, ".".into()), + (INO_ROOT, FileType::Directory, "..".into()), + (INO_README, FileType::RegularFile, "README".into()), + (INO_ROOTS_DIR, FileType::Directory, "roots".into()), + (INO_CAS_DIR, FileType::Directory, "cas".into()), + ], + INO_ROOTS_DIR => { + let mut v = vec![ + (INO_ROOTS_DIR, FileType::Directory, ".".into()), + (INO_ROOT, FileType::Directory, "..".into()), + ]; + for hash in self.source.roots() { + let name = hash.to_string(); + let child = self.intern_ino(INO_ROOTS_DIR, &name); + v.push((child, FileType::RegularFile, name)); + } + v + } + // `cas/` no se enumera: resuelve sólo por `lookup` directo. + INO_CAS_DIR => vec![ + (INO_CAS_DIR, FileType::Directory, ".".into()), + (INO_ROOT, FileType::Directory, "..".into()), + ], + _ => { + reply.error(libc::ENOTDIR); + return; + } + }; + + for (i, (e_ino, kind, name)) in entries.into_iter().enumerate().skip(offset as usize) { + // El offset del siguiente registro es `i + 1`. + if reply.add(e_ino, (i + 1) as i64, kind, name) { + break; // buffer del kernel lleno + } + } + reply.ok(); + } + + fn statfs(&mut self, _req: &Request<'_>, _ino: u64, reply: ReplyStatfs) { + // blocks, bfree, bavail, files, ffree, bsize, namelen, frsize. + reply.statfs(0, 0, 0, 0, 0, 512, 255, 512); + } +} + +/// Parsea un nombre de archivo como un `ContentHash`: exactamente 64 +/// dígitos hex en minúsculas (el format que produce `Display`). +fn parse_hash(name: &str) -> Option { + if name.len() != 64 { + return None; + } + let mut bytes = [0u8; 32]; + let raw = name.as_bytes(); + for (i, slot) in bytes.iter_mut().enumerate() { + let hi = hex_val(raw[2 * i])?; + let lo = hex_val(raw[2 * i + 1])?; + *slot = (hi << 4) | lo; + } + Some(ContentHash(bytes)) +} + +fn hex_val(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_hash_accepts_64_lowercase_hex() { + let h = parse_hash(&"ab".repeat(32)).expect("64 hex válidos"); + assert_eq!(h.0, [0xab; 32]); + } + + #[test] + fn parse_hash_rejects_bad_length_and_chars() { + assert!(parse_hash("abc").is_none()); + assert!(parse_hash(&"AB".repeat(32)).is_none(), "mayúsculas no"); + assert!(parse_hash(&"zz".repeat(32)).is_none(), "no-hex no"); + } + + #[test] + fn parse_hash_roundtrips_display() { + let original = ContentHash([0x3f; 32]); + let back = parse_hash(&original.to_string()).expect("roundtrip"); + assert_eq!(original, back); + } +} diff --git a/03_ukupacha/minga/minga-vfs/src/lib.rs b/03_ukupacha/minga/minga-vfs/src/lib.rs new file mode 100644 index 0000000..2006b12 --- /dev/null +++ b/03_ukupacha/minga/minga-vfs/src/lib.rs @@ -0,0 +1,81 @@ +//! `minga-vfs`: proyecta el repositorio de Minga —direccionado por +//! contenido semántico— como un filesystem FUSE de **sólo lectura**. +//! +//! Minga guarda código como un grafo de `StoredNode`s identificados por +//! `ContentHash`; los archivos ingeridos son las raíces del MST. Este +//! crate convierte ese grafo en algo que cualquier herramienta Unix +//! (`ls`, `cat`, `grep`, un editor) puede recorrer, sin exponer `sled` +//! ni la API del store. +//! +//! ## Layout del filesystem +//! +//! ```text +//! / +//! ├── README explicación del propio VFS +//! ├── roots/ un archivo por raíz del MST (cada archivo ingerido) +//! │ └── código fuente reconstruido, format normalizado +//! └── cas/ cualquier nodo del store, resuelto bajo demanda +//! └── S-expression del subárbol con ese hash +//! ``` +//! +//! `roots/` **se enumera** (`ls` lista todas las raíces). `cas/` no se +//! enumera —son potencialmente decenas de miles de nodos— pero +//! `cas/` resuelve cualquier hash conocido: ése es el "blob por +//! hash bajo demanda". El mismo hash bajo `roots/` y bajo `cas/` da dos +//! vistas del mismo nodo: fuente reconstruida vs. árbol literal. +//! +//! ## Arquitectura (separabilidad) +//! +//! - [`render`] — `SemanticNode` → texto. Lógica pura, sin IO ni FUSE; +//! reutilizable por un frontend web o TUI. +//! - [`source`] — el contrato [`NodeSource`] y sus backends (`sled` +//! vía [`RepoSource`], memoria vía [`MemSource`]). +//! - `fs` — el único módulo acoplado a `fuser`: traduce el contrato a +//! la `Filesystem` trait. + +mod fs; +pub mod render; +pub mod source; + +pub use fs::MingaFs; +pub use render::{render_sexp, render_source}; +pub use source::{reconstruct, MemSource, NodeSource, RepoSource}; + +use std::io; +use std::path::Path; + +use fuser::MountOption; + +/// Opciones de montaje comunes: sólo lectura, etiquetado como `minga` +/// para que aparezca legible en `mount` / `df`. +fn mount_options() -> Vec { + vec![ + MountOption::RO, + MountOption::FSName("minga".to_string()), + MountOption::Subtype("minga".to_string()), + ] +} + +/// Monta el VFS en `mountpoint` y **bloquea** hasta que se desmonte +/// (`fusermount -u `, `umount`, o una señal al proceso). +/// +/// El punto de montaje debe ser un directorio existente. El filesystem +/// es de sólo lectura: toda escritura falla con `EROFS`/`EACCES`. +pub fn mount(source: S, mountpoint: P) -> io::Result<()> +where + S: NodeSource, + P: AsRef, +{ + fuser::mount2(MingaFs::new(source), mountpoint, &mount_options()) +} + +/// Como [`mount`] pero spawnea un hilo de fondo y retorna de inmediato. +/// La sesión queda viva mientras el `BackgroundSession` no se dropee; +/// dropearlo desmonta el filesystem. +pub fn spawn_mount(source: S, mountpoint: P) -> io::Result +where + S: NodeSource + Send + 'static, + P: AsRef, +{ + fuser::spawn_mount2(MingaFs::new(source), mountpoint, &mount_options()) +} diff --git a/03_ukupacha/minga/minga-vfs/src/render.rs b/03_ukupacha/minga/minga-vfs/src/render.rs new file mode 100644 index 0000000..c243336 --- /dev/null +++ b/03_ukupacha/minga/minga-vfs/src/render.rs @@ -0,0 +1,427 @@ +//! Renderizado de un `SemanticNode` a texto legible. Lógica **pura**: +//! sin IO, sin FUSE, sin `sled`. El VFS la usa para materializar el +//! contenido de cada archivo virtual bajo demanda, pero es reutilizable +//! por cualquier frontend (web, TUI). +//! +//! Dos vistas complementarias: +//! +//! - [`render_source`]: reconstrucción **canónica** del código fuente. +//! El AST semántico descartó whitespace y comentarios al ingerir +//! (son `extra` en tree-sitter), así que esto NO recupera el archivo +//! original byte-a-byte: es una forma *normalizada*, re-indentada por +//! estructura. Para lenguajes con bloques por llaves (Rust/TS/JS/Go) +//! indenta por `{`/`}`; para Python (estructura por indentación) +//! detecta el root `module` y recurre por `function_definition`, +//! `if_statement`, `for_statement`, etc. con indent explícito. +//! +//! - [`render_sexp`]: el árbol como S-expression indentada. Vista +//! exacta y sin pérdida de lo que el store guarda de verdad. + +use minga_core::SemanticNode; + +/// Reconstruye el código fuente de un subárbol en forma canónica. +/// +/// Si el root es un `module` (Python tree-sitter), delega al renderer +/// indent-aware. Si no, usa el pretty-printer general por tokens con +/// llaves. +pub fn render_source(node: &SemanticNode) -> String { + if node.kind == "module" { + return render_python(node); + } + let mut tokens = Vec::new(); + collect_leaves(node, &mut tokens); + pretty_print(&tokens) +} + +// ─── Render Python (indent-aware) ────────────────────────────────── + +/// Reconstruye un archivo Python. El root es `module`; sus children son +/// statements de nivel superior. Para cada statement compuesto +/// (función, clase, if, for, while, with, try) recurre por el `block` +/// del cuerpo aumentando la indentación. +fn render_python(module: &SemanticNode) -> String { + let mut out = String::new(); + render_py_block_children(&module.children, 0, &mut out); + while out.ends_with([' ', '\t', '\n']) { + out.pop(); + } + out.push('\n'); + out +} + +fn render_py_block(block: &SemanticNode, indent: usize, out: &mut String) { + render_py_block_children(&block.children, indent, out); +} + +fn render_py_block_children(children: &[SemanticNode], indent: usize, out: &mut String) { + for child in children { + if is_py_compound(child) { + render_py_compound(child, indent, out); + } else if child.kind == ":" || child.kind == "comment" { + // tokens sueltos del header de un parent ya capturado; ignorar + // si llegan a este nivel (no debería pasar con root `module`). + continue; + } else { + let mut tokens = Vec::new(); + collect_leaves(child, &mut tokens); + if tokens.is_empty() { + continue; + } + push_indent(out, indent); + join_py_tokens(&tokens, out); + out.push('\n'); + } + } +} + +fn is_py_compound(node: &SemanticNode) -> bool { + matches!( + node.kind.as_str(), + "function_definition" + | "async_function_definition" + | "class_definition" + | "decorated_definition" + | "if_statement" + | "for_statement" + | "async_for_statement" + | "while_statement" + | "with_statement" + | "async_with_statement" + | "try_statement" + | "match_statement" + ) +} + +/// Renderiza un statement compuesto. Itera los children: los tokens +/// previos a un `block` forman el header (terminado en `:`); cada `block` +/// se renderiza con indent+1; las cláusulas anidadas (`elif_clause`, +/// `else_clause`, `except_clause`, `finally_clause`, `case_clause`) se +/// recursan como compounds en el mismo nivel de indent. +fn render_py_compound(node: &SemanticNode, indent: usize, out: &mut String) { + let mut header: Vec = Vec::new(); + let mut header_emitted = false; + for c in &node.children { + match c.kind.as_str() { + "block" => { + if !header_emitted { + flush_py_header(&mut header, indent, out); + header_emitted = true; + } + render_py_block(c, indent + 1, out); + } + "elif_clause" | "else_clause" | "except_clause" | "finally_clause" + | "case_clause" => { + if !header_emitted { + flush_py_header(&mut header, indent, out); + header_emitted = true; + } + render_py_compound(c, indent, out); + } + _ => collect_leaves(c, &mut header), + } + } + if !header_emitted { + // Compound sin cuerpo (raro en código real, pero posible). + flush_py_header(&mut header, indent, out); + } +} + +fn flush_py_header(header: &mut Vec, indent: usize, out: &mut String) { + if header.is_empty() { + return; + } + push_indent(out, indent); + join_py_tokens(header, out); + if !out.ends_with(':') { + out.push(':'); + } + out.push('\n'); + header.clear(); +} + +/// Junta tokens Python en una línea respetando las reglas de espacio +/// (compartidas con el renderer general). +fn join_py_tokens(tokens: &[String], out: &mut String) { + for (i, tok) in tokens.iter().enumerate() { + if i > 0 { + let prev = tokens[i - 1].as_str(); + if needs_space(tok, prev) { + out.push(' '); + } + } + out.push_str(tok); + } +} + +/// Recolecta el texto de los nodos hoja en orden de recorrido (DFS). +/// Sólo las hojas tienen `leaf_text`; los nodos internos se recurren. +fn collect_leaves(node: &SemanticNode, out: &mut Vec) { + match &node.leaf_text { + Some(bytes) => { + let text = String::from_utf8_lossy(bytes); + let trimmed = text.trim(); + if !trimmed.is_empty() { + out.push(trimmed.to_string()); + } + } + None => { + for child in &node.children { + collect_leaves(child, out); + } + } + } +} + +/// Tokens que no quieren un espacio a su izquierda (puntuación que se +/// pega al token anterior). Incluye `(` y `[`: en una vista normalizada +/// se pegan al identificador previo (`main()`, `v[0]`) — el caso de +/// llamada/indexado es el dominante. +fn no_space_before(t: &str) -> bool { + matches!( + t, + ")" | "]" | "," | ";" | "." | "::" | "?" | ":" | "!" | "(" | "[" + ) +} + +/// Tokens tras los cuales no va espacio (abren un grupo o son prefijos +/// que se pegan al token siguiente). +fn no_space_after(t: &str) -> bool { + matches!(t, "(" | "[" | "." | "::" | "!" | "#") +} + +/// ¿Hace falta un espacio entre `prev` y `cur` en una misma línea? +fn needs_space(cur: &str, prev: &str) -> bool { + !no_space_before(cur) && !no_space_after(prev) +} + +fn push_indent(out: &mut String, indent: usize) { + for _ in 0..indent { + out.push_str(" "); + } +} + +/// Re-imprime una secuencia de tokens con indentación por llaves. +fn pretty_print(tokens: &[String]) -> String { + let mut out = String::new(); + let mut indent: usize = 0; + // ¿Hay ya contenido en la línea en curso? + let mut line_open = false; + + for (i, tok) in tokens.iter().enumerate() { + let t = tok.as_str(); + let next = tokens.get(i + 1).map(String::as_str); + match t { + "{" => { + if line_open { + out.push(' '); + } + out.push('{'); + indent += 1; + out.push('\n'); + line_open = false; + } + "}" => { + indent = indent.saturating_sub(1); + if line_open { + out.push('\n'); + } + push_indent(&mut out, indent); + out.push('}'); + line_open = true; + // `} else`, `},`, `};`, `})`, `}.` se quedan en línea; + // cualquier otra cosa abre línea nueva. + if !matches!(next, Some("else") | Some(",") | Some(";") | Some(")") | Some(".")) { + out.push('\n'); + line_open = false; + } + } + ";" => { + out.push(';'); + out.push('\n'); + line_open = false; + } + _ => { + if !line_open { + push_indent(&mut out, indent); + line_open = true; + } else if let Some(prev) = tokens.get(i.wrapping_sub(1)).map(String::as_str) { + if i > 0 && needs_space(t, prev) { + out.push(' '); + } + } + out.push_str(t); + } + } + } + + // Final canónico: exactamente un newline. + while out.ends_with([' ', '\t', '\n']) { + out.pop(); + } + out.push('\n'); + out +} + +/// Renderiza el subárbol como S-expression indentada (2 espacios por +/// nivel). Cada nodo es `(kind ...)`; los nodos con `field_name` lo +/// prefijan como `field: (kind ...)`; las hojas llevan su texto entre +/// comillas. Es la representación literal de lo que hay en el store. +pub fn render_sexp(node: &SemanticNode) -> String { + let mut out = String::new(); + sexp_node(node, 0, &mut out); + out.push('\n'); + out +} + +fn sexp_node(node: &SemanticNode, depth: usize, out: &mut String) { + for _ in 0..depth { + out.push_str(" "); + } + // Convención tree-sitter: el nombre de campo va FUERA del paréntesis. + if let Some(field) = &node.field_name { + out.push_str(field); + out.push_str(": "); + } + out.push('('); + out.push_str(&node.kind); + + match &node.leaf_text { + Some(bytes) => { + out.push(' '); + out.push('"'); + out.push_str(&escape(&String::from_utf8_lossy(bytes))); + out.push('"'); + out.push(')'); + } + None if node.children.is_empty() => { + out.push(')'); + } + None => { + for child in &node.children { + out.push('\n'); + sexp_node(child, depth + 1, out); + } + out.push(')'); + } + } +} + +/// Escapa una cadena para que quepa entre comillas en la S-expression. +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use minga_core::ast::SemanticNode; + + fn leaf(kind: &str, text: &str) -> SemanticNode { + SemanticNode { + kind: kind.to_string(), + field_name: None, + leaf_text: Some(text.as_bytes().to_vec()), + children: Vec::new(), + } + } + + fn branch(kind: &str, children: Vec) -> SemanticNode { + SemanticNode { + kind: kind.to_string(), + field_name: None, + leaf_text: None, + children, + } + } + + #[test] + fn source_indents_on_braces() { + // tokens: fn main ( ) { let x = 1 ; } + let tree = branch( + "fn_item", + vec![ + leaf("fn", "fn"), + leaf("ident", "main"), + leaf("(", "("), + leaf(")", ")"), + leaf("{", "{"), + leaf("let", "let"), + leaf("ident", "x"), + leaf("=", "="), + leaf("int", "1"), + leaf(";", ";"), + leaf("}", "}"), + ], + ); + let out = render_source(&tree); + assert!(out.contains("fn main()"), "tokens pegados a paréntesis: {out:?}"); + assert!(out.contains(" let x = 1;"), "cuerpo indentado: {out:?}"); + assert!(out.ends_with("}\n"), "termina en una sola llave + newline: {out:?}"); + } + + #[test] + fn sexp_shows_kinds_fields_and_leaves() { + let mut id = leaf("identifier", "x"); + id.field_name = Some("name".to_string()); + let tree = branch("declaration", vec![id]); + let out = render_sexp(&tree); + assert!(out.contains("(declaration")); + assert!(out.contains("name: (identifier \"x\")")); + } + + #[test] + fn sexp_escapes_special_chars() { + let out = render_sexp(&leaf("string", "a\"b\nc")); + assert!(out.contains("\\\""), "comilla escapada: {out:?}"); + assert!(out.contains("\\n"), "newline escapado: {out:?}"); + } + + /// El render Python real opera sobre el árbol que produce + /// tree-sitter al parsear. Estos tests usan ese path end-to-end — + /// más realistas que árboles a mano y testean la cadena completa. + #[test] + fn python_function_indents_body() { + use minga_core::parse::Dialect; + let src = "def add(a, b):\n return a + b\n"; + let node = Dialect::Python.parse(src).expect("parse"); + let out = render_source(&node); + assert!(out.contains("def add"), "header presente: {out:?}"); + assert!(out.contains(":\n"), "header cerrado con `:`: {out:?}"); + assert!(out.contains(" return"), "cuerpo indentado: {out:?}"); + } + + #[test] + fn python_if_else_keeps_branches_at_same_level() { + use minga_core::parse::Dialect; + let src = "if x:\n a = 1\nelse:\n a = 2\n"; + let node = Dialect::Python.parse(src).expect("parse"); + let out = render_source(&node); + // ambos branches deben estar al nivel base (sin indent), y sus + // cuerpos un nivel adentro. + assert!(out.contains("\nelse:"), "else al nivel base: {out:?}"); + assert!(out.contains(" a = 1"), "rama if indentada: {out:?}"); + assert!(out.contains(" a = 2"), "rama else indentada: {out:?}"); + } + + #[test] + fn python_class_with_method() { + use minga_core::parse::Dialect; + let src = "class C:\n def m(self):\n return self.x\n"; + let node = Dialect::Python.parse(src).expect("parse"); + let out = render_source(&node); + assert!(out.contains("class C:"), "header de clase: {out:?}"); + assert!(out.contains(" def m"), "método indentado 4: {out:?}"); + assert!(out.contains(" return"), "cuerpo del método indentado 8: {out:?}"); + } +} diff --git a/03_ukupacha/minga/minga-vfs/src/source.rs b/03_ukupacha/minga/minga-vfs/src/source.rs new file mode 100644 index 0000000..bad49c7 --- /dev/null +++ b/03_ukupacha/minga/minga-vfs/src/source.rs @@ -0,0 +1,164 @@ +//! El contrato [`NodeSource`] —lo mínimo que el VFS necesita de un +//! repositorio Minga— y sus dos backends. Agnóstico de `fuser`. +//! +//! El VFS no quiere conocer `sled` ni la estructura interna del store: +//! sólo necesita (a) enumerar las raíces del MST y (b) resolver un nodo +//! por hash. Eso es [`NodeSource`]. [`RepoSource`] lo implementa sobre +//! el [`PersistentRepo`] en disco; [`MemSource`] sobre un `MemStore` en +//! RAM (tests, índices efímeros recién sincronizados). + +use minga_core::{ContentHash, SemanticNode, StoredNode}; +use minga_store::PersistentRepo; + +/// Lo que el VFS necesita de un repositorio para proyectarlo. +pub trait NodeSource { + /// Hashes raíz: el conjunto de claves del MST, un elemento por + /// archivo ingerido. Es lo que se lista bajo `roots/`. + fn roots(&self) -> Vec; + + /// Resuelve un único nodo (un eslabón del grafo) por su hash. + /// `None` si no está en el almacén. + fn get(&self, hash: &ContentHash) -> Option; +} + +/// Reconstruye el `SemanticNode` completo de un hash, resolviendo +/// recursivamente sus hijos contra `source`. +/// +/// Devuelve `None` si el almacén está incompleto: o el propio `hash` +/// falta, o lo hace algún descendiente (puede ocurrir en un repo a +/// medio sincronizar). +pub fn reconstruct(source: &S, hash: &ContentHash) -> Option +where + S: NodeSource + ?Sized, +{ + let stored = source.get(hash)?; + let mut children = Vec::with_capacity(stored.children.len()); + for child in &stored.children { + children.push(reconstruct(source, child)?); + } + Some(SemanticNode { + kind: stored.kind, + field_name: stored.field_name, + leaf_text: stored.leaf_text, + children, + }) +} + +/// [`NodeSource`] respaldado por un [`PersistentRepo`] de `minga-store` +/// (almacén `sled` en disco). Es la fuente que usa `minga mount`. +pub struct RepoSource { + repo: PersistentRepo, +} + +impl RepoSource { + /// Envuelve un repo ya abierto. La propiedad pasa al `RepoSource`: + /// el repo se cierra cuando éste se dropea. + pub fn new(repo: PersistentRepo) -> Self { + Self { repo } + } +} + +impl NodeSource for RepoSource { + fn roots(&self) -> Vec { + // Las claves del MST corruptas (si las hubiera) se descartan en + // silencio: un par de entradas ilegibles no deben tirar el `ls`. + // Esto devuelve **α-hashes**: la identidad estable de los + // archivos ingeridos, no su hash estructural. + self.repo.mst.iter().filter_map(Result::ok).collect() + } + + fn get(&self, hash: &ContentHash) -> Option { + // Primero intentamos resolver `hash` como α-hash de una raíz: + // si lo es, redirigimos al struct-hash que apunta al `StoredNode` + // real dentro del grafo CAS. Si no es raíz, asumimos que es un + // hash estructural y lo buscamos directo (esto cubre la + // navegación `cas/` de cualquier nodo interno). + if let Ok(Some((struct_hash, _dialect))) = self.repo.roots.get(hash) { + return self.repo.nodes.get(&struct_hash).ok().flatten(); + } + self.repo.nodes.get(hash).ok().flatten() + } +} + +/// [`NodeSource`] en memoria: un `MemStore` más un conjunto explícito +/// de raíces. Para tests y para montar índices que viven sólo en RAM. +#[derive(Default)] +pub struct MemSource { + store: minga_core::MemStore, + roots: Vec, +} + +impl MemSource { + pub fn new() -> Self { + Self::default() + } + + /// Inserta un árbol como raíz (un "archivo") y devuelve su hash. + /// Idempotente: ingerir dos veces el mismo árbol no lo duplica. + pub fn add_root(&mut self, node: &SemanticNode) -> ContentHash { + use minga_core::NodeStore; + let hash = self.store.put(node); + if !self.roots.contains(&hash) { + self.roots.push(hash); + } + hash + } +} + +impl NodeSource for MemSource { + fn roots(&self) -> Vec { + self.roots.clone() + } + + fn get(&self, hash: &ContentHash) -> Option { + use minga_core::NodeStore; + self.store.get(hash).cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use minga_core::ast::SemanticNode; + + fn leaf(kind: &str, text: &str) -> SemanticNode { + SemanticNode { + kind: kind.to_string(), + field_name: None, + leaf_text: Some(text.as_bytes().to_vec()), + children: Vec::new(), + } + } + + #[test] + fn mem_source_reconstructs_what_it_stored() { + let tree = SemanticNode { + kind: "root".to_string(), + field_name: None, + leaf_text: None, + children: vec![leaf("a", "1"), leaf("b", "2")], + }; + let mut src = MemSource::new(); + let hash = src.add_root(&tree); + + assert_eq!(src.roots(), vec![hash]); + let back = reconstruct(&src, &hash).expect("debe reconstruir"); + assert_eq!(back, tree); + } + + #[test] + fn add_root_is_idempotent() { + let tree = leaf("only", "x"); + let mut src = MemSource::new(); + let h1 = src.add_root(&tree); + let h2 = src.add_root(&tree); + assert_eq!(h1, h2); + assert_eq!(src.roots().len(), 1); + } + + #[test] + fn unknown_hash_reconstructs_to_none() { + let src = MemSource::new(); + assert!(reconstruct(&src, &ContentHash([0u8; 32])).is_none()); + } +} diff --git a/03_ukupacha/minga/minga-vfs/tests/projection.rs b/03_ukupacha/minga/minga-vfs/tests/projection.rs new file mode 100644 index 0000000..c41decc --- /dev/null +++ b/03_ukupacha/minga/minga-vfs/tests/projection.rs @@ -0,0 +1,77 @@ +//! Cobertura de la capa agnóstica del VFS (render + source) sobre +//! código real parseado con tree-sitter. No monta FUSE: verifica que +//! la proyección hash → contenido es correcta de punta a punta. + +use minga_core::parse; +use minga_vfs::render::{render_sexp, render_source}; +use minga_vfs::source::{reconstruct, MemSource, NodeSource}; + +#[test] +fn ingest_rust_then_reconstruct_is_lossless_at_the_ast_level() { + let original = parse::rust("fn add(a: i32, b: i32) -> i32 { a + b }").unwrap(); + + let mut src = MemSource::new(); + let hash = src.add_root(&original); + + // El árbol reconstruido desde el store debe ser idéntico bit a bit + // al que se ingirió: el direccionamiento por contenido lo garantiza. + let back = reconstruct(&src, &hash).expect("el hash recién insertado resuelve"); + assert_eq!(back, original); +} + +#[test] +fn roots_lists_every_ingested_file() { + let mut src = MemSource::new(); + let a = src.add_root(&parse::rust("fn a() {}").unwrap()); + let b = src.add_root(&parse::python("def b():\n pass\n").unwrap()); + + let roots = src.roots(); + assert_eq!(roots.len(), 2); + assert!(roots.contains(&a)); + assert!(roots.contains(&b)); +} + +#[test] +fn source_view_recovers_rust_keywords_and_structure() { + let node = parse::rust("fn main() { let x = 1; }").unwrap(); + let mut src = MemSource::new(); + let hash = src.add_root(&node); + + let rebuilt = reconstruct(&src, &hash).unwrap(); + let text = render_source(&rebuilt); + + for token in ["fn", "main", "let", "x", "1"] { + assert!(text.contains(token), "falta `{token}` en:\n{text}"); + } + // El cuerpo entre llaves debe quedar indentado en su propia línea. + assert!(text.contains("\n "), "cuerpo sin indentar:\n{text}"); +} + +#[test] +fn sexp_view_exposes_tree_sitter_node_kinds() { + let node = parse::rust("fn main() {}").unwrap(); + let mut src = MemSource::new(); + let hash = src.add_root(&node); + + let rebuilt = reconstruct(&src, &hash).unwrap(); + let sexp = render_sexp(&rebuilt); + + assert!(sexp.contains("(source_file"), "raíz del árbol:\n{sexp}"); + assert!(sexp.contains("function_item"), "el ítem función:\n{sexp}"); +} + +#[test] +fn deduplicated_subtrees_share_one_node() { + // Dos archivos con la misma función `helper` deben compartir el + // subárbol en el store: ingerir el segundo no lo vuelve a guardar. + let mut src = MemSource::new(); + let one = parse::rust("fn helper() { 42 }").unwrap(); + let two = parse::rust("fn helper() { 42 }").unwrap(); + + let h1 = src.add_root(&one); + let h2 = src.add_root(&two); + + // Estructura idéntica ⇒ mismo hash ⇒ una sola raíz. + assert_eq!(h1, h2); + assert_eq!(src.roots().len(), 1); +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c41b0d5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7873 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.12.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "app-bus" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "directories", + "serde", + "toml 0.8.23", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitmaps" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec 0.7.6", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.12.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "card-core" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "toml 0.8.23", + "ulid", +] + +[[package]] +name = "card-discovery" +version = "0.1.0" +dependencies = [ + "card-core", + "cards", + "libp2p", + "minga-dht", + "serde_json", + "tempfile", + "tokio", + "ulid", +] + +[[package]] +name = "card-net" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "blake3", + "futures", + "libp2p", + "libp2p-allow-block-list", + "libp2p-stream", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "cards" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "anyhow", + "card-core", + "chasqui-card", + "nahual-meta-schema", + "nickel-lang", + "serde", + "serde_json", + "thiserror 2.0.18", + "ulid", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chasqui-card" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "card-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "ulid", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "codespan" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583f52b0658b321b25fd6b209b6c76cf058f433071297de64e5980c3d9aad937" +dependencies = [ + "codespan-reporting 0.13.1", + "serde", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width 0.1.14", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.2.2", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-cache-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18" +dependencies = [ + "bytemuck", + "thiserror 1.0.69", +] + +[[package]] +name = "fontique" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64763d1f274c8383333851435b6cdf071c31cfcdb39fd5860d20943205a007a7" +dependencies = [ + "bytemuck", + "fontconfig-cache-parser", + "hashbrown 0.15.5", + "icu_locid", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "peniko", + "read-fonts 0.29.3", + "roxmltree", + "smallvec", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "fuser" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369" +dependencies = [ + "libc", + "log", + "memchr", + "nix 0.29.0", + "page_size", + "smallvec", + "zerocopy", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-bounded" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91f328e7fb845fc832912fb6a34f40cf6d1888c92f974d1893a54e97b5ff542e" +dependencies = [ + "futures-timer", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot 0.12.5", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.12.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.12.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.4", + "ring", + "socket2 0.5.10", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot 0.12.5", + "rand 0.9.4", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.4", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap 0.8.2", + "tinystr 0.8.3", + "writeable 0.6.3", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap 0.7.5", + "tinystr 0.7.6", + "writeable 0.5.5", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable 0.6.3", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "if-watch" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2" +dependencies = [ + "async-io", + "core-foundation", + "fnv", + "futures", + "if-addrs", + "ipnet", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "rtnetlink", + "system-configuration", + "tokio", + "windows 0.62.2", +] + +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.9.4", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "imbl-sized-chunks" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d" +dependencies = [ + "bitmaps", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.4", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json_scanner" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0a2dc336065c75719cffd3c6c929e0ec4ed85b92b8248a7bbd999acb0e419c" +dependencies = [ + "memchr", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.12.1", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec 0.7.6", + "euclid", + "smallvec", +] + +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libp2p" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce71348bf5838e46449ae240631117b487073d5f347c06d434caddcb91dceb5a" +dependencies = [ + "bytes", + "either", + "futures", + "futures-timer", + "getrandom 0.2.17", + "libp2p-allow-block-list", + "libp2p-autonat", + "libp2p-connection-limits", + "libp2p-core", + "libp2p-dcutr", + "libp2p-dns", + "libp2p-identify", + "libp2p-identity", + "libp2p-kad", + "libp2p-mdns", + "libp2p-metrics", + "libp2p-noise", + "libp2p-quic", + "libp2p-relay", + "libp2p-swarm", + "libp2p-tcp", + "libp2p-upnp", + "libp2p-yamux", + "multiaddr", + "pin-project", + "rw-stream-sink", + "thiserror 2.0.18", +] + +[[package]] +name = "libp2p-allow-block-list" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16ccf824ee859ca83df301e1c0205270206223fd4b1f2e512a693e1912a8f4a" +dependencies = [ + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", +] + +[[package]] +name = "libp2p-autonat" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab5e25c49a7d48dac83d95d8f3bac0a290d8a5df717012f6e34ce9886396c0b" +dependencies = [ + "async-trait", + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-request-response", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.6", + "rand_core 0.6.4", + "thiserror 2.0.18", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-connection-limits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18b8b607cf3bfa2f8c57db9c7d8569a315d5cc0a282e6bfd5ebfc0a9840b2a0" +dependencies = [ + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", +] + +[[package]] +name = "libp2p-core" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249128cd37a2199aff30a7675dffa51caf073b51aa612d2f544b19932b9aebca" +dependencies = [ + "either", + "fnv", + "futures", + "futures-timer", + "libp2p-identity", + "multiaddr", + "multihash", + "multistream-select", + "parking_lot 0.12.5", + "pin-project", + "quick-protobuf", + "rand 0.8.6", + "rw-stream-sink", + "thiserror 2.0.18", + "tracing", + "unsigned-varint 0.8.0", + "web-time", +] + +[[package]] +name = "libp2p-dcutr" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4107305e12158af3e66960b6181789c547394c9c9a8696f721521602bfc73a" +dependencies = [ + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "hashlink", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "thiserror 2.0.18", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-dns" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b770c1c8476736ca98c578cba4b505104ff8e842c2876b528925f9766379f9a" +dependencies = [ + "async-trait", + "futures", + "hickory-resolver", + "libp2p-core", + "libp2p-identity", + "parking_lot 0.12.5", + "smallvec", + "tracing", +] + +[[package]] +name = "libp2p-identify" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab792a8b68fdef443a62155b01970c81c3aadab5e659621b063ef252a8e65e8" +dependencies = [ + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "libp2p-identity" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9525f3831544f7ae497bde79adf114ef127b0fbbb97edbbf692a80408636421c" +dependencies = [ + "bs58", + "ed25519-dalek", + "hkdf", + "multihash", + "prost", + "rand 0.8.6", + "sha2", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "libp2p-kad" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d3fd632a5872ec804d37e7413ceea20588f69d027a0fa3c46f82574f4dee60" +dependencies = [ + "asynchronous-codec", + "bytes", + "either", + "fnv", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.6", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tracing", + "uint", + "web-time", +] + +[[package]] +name = "libp2p-mdns" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66872d0f1ffcded2788683f76931be1c52e27f343edb93bc6d0bcd8887be443" +dependencies = [ + "futures", + "hickory-proto", + "if-watch", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.6", + "smallvec", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-metrics" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805a555148522cb3414493a5153451910cb1a146c53ffbf4385708349baf62b7" +dependencies = [ + "futures", + "libp2p-core", + "libp2p-dcutr", + "libp2p-identify", + "libp2p-identity", + "libp2p-kad", + "libp2p-relay", + "libp2p-swarm", + "pin-project", + "prometheus-client", + "web-time", +] + +[[package]] +name = "libp2p-noise" +version = "0.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc73eacbe6462a0eb92a6527cac6e63f02026e5407f8831bde8293f19217bfbf" +dependencies = [ + "asynchronous-codec", + "bytes", + "futures", + "libp2p-core", + "libp2p-identity", + "multiaddr", + "multihash", + "quick-protobuf", + "rand 0.8.6", + "snow", + "static_assertions", + "thiserror 2.0.18", + "tracing", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "libp2p-quic" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc448b2de9f4745784e3751fe8bc6c473d01b8317edd5ababcb0dec803d843f" +dependencies = [ + "futures", + "futures-timer", + "if-watch", + "libp2p-core", + "libp2p-identity", + "libp2p-tls", + "quinn", + "rand 0.8.6", + "ring", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-relay" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9b0392ed623243ad298326b9f806d51191829ac7585cc825c54c6c67b04d9" +dependencies = [ + "asynchronous-codec", + "bytes", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.6", + "static_assertions", + "thiserror 2.0.18", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-request-response" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f1cca83488b90102abac7b67d5c36fc65bc02ed47620228af7ed002e6a1478" +dependencies = [ + "async-trait", + "futures", + "futures-bounded", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.6", + "smallvec", + "tracing", +] + +[[package]] +name = "libp2p-stream" +version = "0.4.0-alpha" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6bd8025c80205ec2810cfb28b02f362ab48a01bee32c50ab5f12761e033464" +dependencies = [ + "futures", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.6", + "tracing", +] + +[[package]] +name = "libp2p-swarm" +version = "0.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce88c6c4bf746c8482480345ea3edfd08301f49e026889d1cbccfa1808a9ed9e" +dependencies = [ + "either", + "fnv", + "futures", + "futures-timer", + "hashlink", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm-derive", + "multistream-select", + "rand 0.8.6", + "smallvec", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-swarm-derive" +version = "0.35.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd297cf53f0cb3dee4d2620bb319ae47ef27c702684309f682bdb7e55a18ae9c" +dependencies = [ + "heck 0.5.0", + "quote", + "syn", +] + +[[package]] +name = "libp2p-tcp" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb6585b9309699f58704ec9ab0bb102eca7a3777170fa91a8678d73ca9cafa93" +dependencies = [ + "futures", + "futures-timer", + "if-watch", + "libc", + "libp2p-core", + "socket2 0.6.4", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-tls" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ff65a82e35375cbc31ebb99cacbbf28cb6c4fefe26bf13756ddcf708d40080" +dependencies = [ + "futures", + "futures-rustls", + "libp2p-core", + "libp2p-identity", + "rcgen", + "ring", + "rustls", + "rustls-webpki", + "thiserror 2.0.18", + "x509-parser", + "yasna", +] + +[[package]] +name = "libp2p-upnp" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4757e65fe69399c1a243bbb90ec1ae5a2114b907467bf09f3575e899815bb8d3" +dependencies = [ + "futures", + "futures-timer", + "igd-next", + "libp2p-core", + "libp2p-swarm", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-yamux" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f15df094914eb4af272acf9adaa9e287baa269943f32ea348ba29cfb9bfc60d8" +dependencies = [ + "either", + "futures", + "libp2p-core", + "thiserror 2.0.18", + "tracing", + "yamux 0.12.1", + "yamux 0.13.10", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.12.1", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "llimphi-compositor" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-layout", + "llimphi-text", + "vello", + "wgpu", +] + +[[package]] +name = "llimphi-hal" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "pollster", + "raw-window-handle", + "wgpu", + "winit", +] + +[[package]] +name = "llimphi-layout" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "taffy", +] + +[[package]] +name = "llimphi-motion" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-raster" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-hal", + "pollster", + "vello", +] + +[[package]] +name = "llimphi-text" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "parley", + "vello", +] + +[[package]] +name = "llimphi-theme" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-raster", +] + +[[package]] +name = "llimphi-ui" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-compositor", + "llimphi-hal", + "llimphi-layout", + "llimphi-raster", + "llimphi-text", + "pollster", +] + +[[package]] +name = "llimphi-widget-app-header" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-banner" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-button" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-card" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-context-menu" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-menubar" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-button", + "llimphi-widget-context-menu", +] + +[[package]] +name = "llimphi-widget-panel" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-stat-card" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-card", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "malachite" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de8195e0d0bccfa3e54997e8e7c6c67859b08512067801b5a63dd0b7a174e87" +dependencies = [ + "malachite-base", + "malachite-float", + "malachite-nz", + "malachite-q", +] + +[[package]] +name = "malachite-base" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8b6f86fdbb1eb9955946be91775239dfcb0acdb1a51bb07d5fc9b8c854f5ccd" +dependencies = [ + "hashbrown 0.16.1", + "itertools", + "libm", + "ryu", +] + +[[package]] +name = "malachite-float" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d5021773c1552820b10ce7410817fadc1dfcef907b4f9a29af5346d756fd28" +dependencies = [ + "itertools", + "malachite-base", + "malachite-nz", + "malachite-q", + "serde", +] + +[[package]] +name = "malachite-nz" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0197a2f5cfee19d59178e282985c6ca79a9233e26a2adcf40acb693896aa09f6" +dependencies = [ + "itertools", + "libm", + "malachite-base", + "serde", + "wide", +] + +[[package]] +name = "malachite-q" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2add95162aede090c48f0ee51bea7d328847ce3180aa44588111f846cc116b" +dependencies = [ + "itertools", + "malachite-base", + "malachite-nz", + "serde", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.12.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minga-cli" +version = "0.1.0" +dependencies = [ + "axum", + "clap", + "futures", + "http-body-util", + "libp2p", + "minga-core", + "minga-p2p", + "minga-store", + "minga-vfs", + "notify", + "postcard", + "rpassword", + "serde", + "serde_json", + "similar", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tower", + "zstd", +] + +[[package]] +name = "minga-core" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "argon2", + "blake3", + "ed25519-dalek", + "postcard", + "rand 0.8.6", + "serde", + "serde-big-array", + "thiserror 2.0.18", + "tree-sitter", + "tree-sitter-go", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-typescript", +] + +[[package]] +name = "minga-dht" +version = "0.1.0" +dependencies = [ + "card-net", + "libp2p", + "tokio", +] + +[[package]] +name = "minga-explorer-llimphi" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-motion", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-app-header", + "llimphi-widget-banner", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "llimphi-widget-stat-card", + "minga-store", + "rimay-localize", + "wawa-config", +] + +[[package]] +name = "minga-p2p" +version = "0.1.0" +dependencies = [ + "card-net", + "futures", + "libp2p", + "libp2p-stream", + "minga-core", + "minga-dht", + "minga-store", + "postcard", + "rand 0.8.6", + "serde", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + +[[package]] +name = "minga-store" +version = "0.1.0" +dependencies = [ + "blake3", + "minga-core", + "postcard", + "serde", + "sled", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "minga-vfs" +version = "0.1.0" +dependencies = [ + "fuser", + "libc", + "minga-core", + "minga-store", + "tempfile", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot 0.12.5", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multiaddr" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6351f60b488e04c1d21bc69e56b89cb3f5e8f5d22557d6e8031bdfd79b6961" +dependencies = [ + "arrayref", + "byteorder", + "data-encoding", + "libp2p-identity", + "multibase", + "multihash", + "percent-encoding", + "serde", + "static_assertions", + "unsigned-varint 0.8.0", + "url", +] + +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "unsigned-varint 0.8.0", +] + +[[package]] +name = "multistream-select" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0df8e5eec2298a62b326ee4f0d7fe1a6b90a09dfcf9df37b38f947a8c42f19" +dependencies = [ + "bytes", + "futures", + "log", + "pin-project", + "smallvec", + "unsigned-varint 0.7.2", +] + +[[package]] +name = "naga" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" +dependencies = [ + "arrayvec 0.7.6", + "bit-set", + "bitflags 2.12.1", + "cfg_aliases", + "codespan-reporting 0.11.1", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "strum", + "termcolor", + "thiserror 2.0.18", + "unicode-xid", +] + +[[package]] +name = "nahual-meta-schema" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.12.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags 2.12.1", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nickel-lang" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5242161492409bbb82716f52e7af9c7d4ce8423d3b31d648fa818518c4d82295" +dependencies = [ + "codespan-reporting 0.13.1", + "indexmap", + "malachite", + "nickel-lang-core", + "nickel-lang-vector", + "serde", + "serde_json", + "serde_yaml", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "nickel-lang-core" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692d8a2ba34c633bc37e704dc94f4ca33edaa8fbf6d08efdcadb81db333ccdb6" +dependencies = [ + "base64", + "bumpalo", + "codespan", + "codespan-reporting 0.13.1", + "colorchoice", + "indexmap", + "indoc", + "json_scanner", + "lalrpop", + "lalrpop-util", + "logos", + "malachite", + "malachite-q", + "md-5", + "nickel-lang-parser", + "nickel-lang-vector", + "once_cell", + "ouroboros", + "paste", + "pretty", + "regex", + "saphyr-parser", + "serde", + "serde_json", + "serde_yaml", + "sha-1", + "sha2", + "simple-counter", + "smallvec", + "strip-ansi-escapes", + "strsim", + "toml 0.9.12+spec-1.1.0", + "toml_edit 0.24.1+spec-1.1.0", + "typed-arena", + "unicode-segmentation", +] + +[[package]] +name = "nickel-lang-parser" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7aaf73e60b66ef4fffc969b0e4e419a15a029525f9b53f2f5cc0ca41bbe17ff" +dependencies = [ + "bumpalo", + "codespan", + "codespan-reporting 0.13.1", + "indexmap", + "lalrpop", + "lalrpop-util", + "logos", + "malachite", + "nickel-lang-vector", + "ouroboros", + "pretty", + "regex", + "saphyr-parser", + "serde", + "serde_json", + "simple-counter", + "toml_edit 0.24.1+spec-1.1.0", + "typed-arena", +] + +[[package]] +name = "nickel-lang-vector" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f243832286908d8873add24a905d6732ffabd6cfb2bf74cb18d667e892e279" +dependencies = [ + "imbl-sized-chunks", + "serde", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.12.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.12.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.12.1", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.12.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parley" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28dadbe655332fd7d996794ec8d0c376695f6ca47bc75aa01e0967c7f28e42a" +dependencies = [ + "fontique", + "hashbrown 0.15.5", + "peniko", + "skrifa 0.31.3", + "swash", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "peniko" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b44f9ddd2f480176b34278eb653ec1c8062f3b143a4e16eeff5ffac3334e288" +dependencies = [ + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec 0.5.2", + "typed-arena", + "unicode-width 0.2.2", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "prometheus-client" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf41c1a7c32ed72abe5082fb19505b969095c12da9f5732a4bc9878757fd087c" +dependencies = [ + "dtoa", + "itoa", + "parking_lot 0.12.5", + "prometheus-client-derive-encode", +] + +[[package]] +name = "prometheus-client-derive-encode" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quick-protobuf" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6da84cc204722a989e01ba2f6e1e276e190f22263d0cb6ce8526fcdb0d2e1f" +dependencies = [ + "byteorder", +] + +[[package]] +name = "quick-protobuf-codec" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15a0580ab32b169745d7a39db2ba969226ca16738931be152a3209b409de2474" +dependencies = [ + "asynchronous-codec", + "bytes", + "quick-protobuf", + "thiserror 1.0.69", + "unsigned-varint 0.8.0", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "futures-io", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls", + "socket2 0.6.4", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.4", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "read-fonts" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ea612a55c08586a1d15134be8a776186c440c312ebda3b9e8efbfe4255b7f4" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rimay-localize" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "fluent-bundle", + "once_cell", + "parking_lot 0.12.5", + "sys-locale", + "thiserror 2.0.18", + "tracing", + "unic-langid", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtnetlink" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "nix 0.30.1", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rw-stream-sink" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c9026ff5d2f23da5e45bbc283f156383001bfb09c4e44256d02c1a685fe9a1" +dependencies = [ + "futures", + "pin-project", + "static_assertions", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7caad094bd561859bcd467734a720c3c1f5d1f338995351fefe2190c45efed" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "saphyr-parser" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb771b59f6b1985d1406325ec28f97cfb14256abcec4fdfb37b36a1766d6af7" +dependencies = [ + "arraydeque", + "hashlink", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.2.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "simple-counter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb57743b52ea059937169c0061d70298fe2df1d2c988b44caae79dd979d9b49" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbeb4ca4399663735553a09dd17ce7e49a0a0203f03b706b39628c4d913a8607" +dependencies = [ + "bytemuck", + "read-fonts 0.29.3", +] + +[[package]] +name = "skrifa" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576e60c7de4bb6a803a0312f9bef17e78cf1e8d25a80e1ade76770d7a0237955" +dependencies = [ + "bytemuck", + "read-fonts 0.33.1", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sled" +version = "0.34.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935" +dependencies = [ + "crc32fast", + "crossbeam-epoch", + "crossbeam-utils", + "fs2", + "fxhash", + "libc", + "log", + "parking_lot 0.11.2", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.12.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "snow" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "rand_core 0.6.4", + "ring", + "rustc_version", + "sha2", + "subtle", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot 0.12.5", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +dependencies = [ + "skrifa 0.40.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.12.1", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec 0.7.6", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec 0.7.6", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio 1.2.1", + "parking_lot 0.12.5", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.24.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01f2eadbbc6b377a847be05f60791ef1058d9f696ecb51d2c07fe911d8569d8e" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uint" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "serde", + "web-time", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr 0.8.3", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr 0.8.3", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "unsigned-varint" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6889a77d49f1f013504cec6bf97a2c730394adedaeb1deb5ea08949a50541105" + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vello" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa3f8a53870a2ee699ce05b738a3f9974c92c35ed4874de86052ac68d214811c" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko", + "png", + "skrifa 0.35.0", + "static_assertions", + "thiserror 2.0.18", + "vello_encoding", + "vello_shaders", + "wgpu", +] + +[[package]] +name = "vello_encoding" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c69b0fe94b0ac7e47619c504ee2c377355174f5c46353c46d03fa5f7e435922b" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko", + "skrifa 0.35.0", + "smallvec", +] + +[[package]] +name = "vello_shaders" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ebea426bb2f95b7610bca09178b03d809ede1d3c500a9acf6eca43e8f200be" +dependencies = [ + "bytemuck", + "naga", + "thiserror 2.0.18", + "vello_encoding", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.12.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wawa-config" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "directories", + "notify", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.12.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.12.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +dependencies = [ + "arrayvec 0.7.6", + "bitflags 2.12.1", + "cfg_aliases", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +dependencies = [ + "arrayvec 0.7.6", + "bit-vec", + "bitflags 2.12.1", + "cfg_aliases", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "24.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +dependencies = [ + "android_system_properties", + "arrayvec 0.7.6", + "ash", + "bit-set", + "bitflags 2.12.1", + "block", + "bytemuck", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "ordered-float", + "parking_lot 0.12.5", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +dependencies = [ + "bitflags 2.12.1", + "js-sys", + "log", + "web-sys", +] + +[[package]] +name = "wide" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7714cd0430a663154667c74da5d09325c2387695bee18b3f7f72825aa3693a" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.12.1", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.12.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.12.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yamux" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed0164ae619f2dc144909a9f082187ebb5893693d8c0196e8085283ccd4b776" +dependencies = [ + "futures", + "log", + "nohash-hasher", + "parking_lot 0.12.5", + "pin-project", + "rand 0.8.6", + "static_assertions", +] + +[[package]] +name = "yamux" +version = "0.13.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1991f6690292030e31b0144d73f5e8368936c58e45e7068254f7138b23b00672" +dependencies = [ + "futures", + "log", + "nohash-hasher", + "parking_lot 0.12.5", + "pin-project", + "rand 0.9.4", + "static_assertions", + "web-time", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1ed5ff5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,443 @@ +# Cargo.toml raíz STANDALONE de minga — front-door sobre Llimphi. +# Solo el código de minga; Llimphi y lo fundacional por git-dep del monorepo gioser.git. +[workspace] +resolver = "2" +members = [ + "03_ukupacha/minga/card-discovery", + "03_ukupacha/minga/minga-cli", + "03_ukupacha/minga/minga-core", + "03_ukupacha/minga/minga-dht", + "03_ukupacha/minga/minga-explorer-llimphi", + "03_ukupacha/minga/minga-p2p", + "03_ukupacha/minga/minga-store", + "03_ukupacha/minga/minga-vfs", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.80" +license = "MIT" +authors = ["Sergio "] +publish = false +repository = "https://gitea.gioser.net/sergio/minga" + +[workspace.dependencies] + +# === Registro de apps / menú global === +app-bus = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# === Serialización === +serde = { version = "1", features = ["derive"] } +serde_json = "1" +lsp-types = "0.97" +serde-big-array = "0.5" +postcard = { version = "1", features = ["use-std"] } +toml = "0.8" +ron = "0.8" +bincode = "1" +base64 = "0.22" + +# === Errores === +thiserror = "2" # bump uniforme; arje (era 1) puede requerir ajustes menores +anyhow = "1" + +# === Async === +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["compat"] } +async-trait = "0.1" +futures = "0.3" + +# === Observabilidad === +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +# === Linux primitives (arje) === +nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] } +libc = "0.2" + +# === IDs / Hash / Crypto === +ulid = { version = "1", features = ["serde"] } +uuid = { version = "1", features = ["v4", "rng-getrandom"] } +sha2 = "0.10" +blake3 = "1.5" +ed25519-dalek = "2" +aes-gcm = "0.10" +chacha20poly1305 = "0.10" +argon2 = "0.5" +rand = "0.8" + +# === WASM (arje) === +# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para +# que el ABI WASM del host sea idéntico en Linux y en bare-metal. +wasmi = "1.0" +wat = "1" + +# === Storage / DB === +sled = "0.34" +rusqlite = { version = "0.31", features = ["bundled", "blob"] } + +# === Ingesta de documentos (iniy-ingest: PDF / EPUB) === +pdf-extract = "0.7" +epub = "2.1" + +# === Bulk import Wikipedia (iniy-wiki dump) === +bzip2 = "0.4" + +# === Compresión (minga multi-bundle) === +zstd = "0.13" + +# === HTTP server (iniy-server) === +axum = "0.7" +tower = "0.5" + +# === ANN sobre embeddings (iniy nli --ann) === +instant-distance = "0.6" + +# === P2P (minga) === +libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] } +libp2p-stream = "=0.4.0-alpha" +libp2p-allow-block-list = "0.6" + +# === SSH (ssh, sandokan RemoteEngine, matilda) === +russh = "0.54" + +# === Math determinista cross-platform (dominium) === +libm = "0.2" + +# === SMF (takiy-midi) === +# midly: parser/emitter SMF tipo 0/1, no_std-friendly, sin allocs en hot path. +midly = "0.5" + +# === Code parsing (minga) === +arboard = "3" +ropey = "1.6" +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +tree-sitter-python = "0.23" +tree-sitter-typescript = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-go = "0.23" + +# === FS notify === +notify = "6.1" + +# === Grafos (iniy, nakui-core ya lo usa directo en 0.6) === +petgraph = "0.6" + +# === Image decoding (nahual-image-viewer-llimphi) === +# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless). +# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app +# los pide específicamente. +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } + +# === FUSE (minga-vfs) === +# default-features = false: prescinde de pkg-config/libfuse-dev en build. +# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime). +fuser = { version = "0.15", default-features = false } + +# === CLI / auth (minga) === +clap = { version = "4", features = ["derive"] } +rpassword = "7" + +# === PAM (auth-core) === +pam = "0.8" + +# === D-Bus (arje compat) === +zbus = { version = "4", default-features = false, features = ["tokio"] } + +# === Tests === +tempfile = "3" + +# === Llimphi (motor gráfico soberano) === +# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux. +# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24. +# vello 0.5 = rasterizador vectorial sobre wgpu 24. +# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos). +# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone). +wgpu = "24" +winit = "0.30" +raw-window-handle = "0.6" +pollster = "0.4" +vello = "0.5" +taffy = "0.9" +# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break). +parley = "0.4" +# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps. +llimphi-ui = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Paleta semántica compartida por las apps y los widgets. +llimphi-theme = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Tweens y helpers de animación sobre el bucle Elm. +llimphi-motion = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps. +llimphi-icons = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Widgets reusables sobre llimphi-ui — uno por crate. +llimphi-widget-app-header = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-banner = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-card = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-clipboard = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-context-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-edit-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-menubar = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-list = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-grid = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-slider = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-scroll = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-splitter = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-stat-card = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-tabs = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-command-palette = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-diff-viewer = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-fif = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-file-picker = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-bookmarks = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-mini-map = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-shuma-term = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-symbol-outline = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-plugin-host = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-theme-switcher = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-text-area = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-text-editor-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-text-editor = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-text-editor-lsp = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-text-input = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-tiled = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-nodegraph = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-tree = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-navigator = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Sello vectorial wawa (rombo + W implícita + Merkle Core). +llimphi-widget-wawa-mark = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Widgets de elegancia transversal (tooltip, spinner, progress, toast, +# modal, empty, status-bar, shortcuts-help, splash). +llimphi-widget-tooltip = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-spinner = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-progress = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-toast = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-modal = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-empty = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-status-bar = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-shortcuts-help = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-timeline = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-splash = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Controles de formulario y signaling (switch, segmented, breadcrumb, +# badge, avatar, skeleton, field). +llimphi-widget-switch = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-segmented = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-dock-rail = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-breadcrumb = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-badge = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-avatar = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-skeleton = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-field = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Firma visual transversal (gradient sutil + hairline accent). +llimphi-widget-panel = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-panes = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-workspace = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Abstracción Selector — host (paths) + wawa (khipus). +llimphi-module-selector = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# === Filesystem helpers === +directories = "5" + +# === Diff line-based (llimphi-module-diff-viewer) === +# `similar` es la crate de facto: implementa Myers + Patience + LCS, +# expone `TextDiff` con ChangeTag por línea (Equal/Insert/Delete), +# zero deps fuera de std. La 2.x es estable hace años. +similar = "2" + +# === Fuzzy matching (shuma-history) === +# nucleo-matcher = mismo matcher que helix-editor: rápido, Unicode-correct, +# bonus por prefijos, ranking estable. La versión 0.3 expone el API simple +# que necesitamos (Matcher + Pattern + score). +nucleo-matcher = "0.3" + +# === Transporte autenticado (shuma-link) === +# snow = framework Noise pure-rust. Lo usamos en modo Noise_XK (cliente +# conoce la pubkey del servidor, server descubre la del cliente y la +# valida contra una allowlist). ChaCha20-Poly1305 + X25519 + BLAKE2s. +# La versión 0.9 viene pinneada por libp2p, así nos alineamos. +snow = "0.9" +hex = "0.4" + +# === PTY + emulador de terminal (shuma-exec, módulos REPL) === +# portable-pty aloja un PTY cross-platform; lo usamos para los +# comandos TUI tipo vim/htop/less que necesitan un terminal de verdad. +# vt100 parsea la secuencia de bytes que el PTY emite (ANSI + cursor +# movement + erase + screen state) y mantiene un buffer de pantalla +# renderizable como grid. +portable-pty = "0.9" +vt100 = "0.16" + +# === WASM web (gioser) === +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = "0.3" +glam = "0.30" + +# === Markdown (pluma) === +pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] } + +# === Archivos comprimidos (nahual archive viewer) === +# Sólo listamos el directorio central (nombres/tamaños); no descomprimimos, +# por eso default-features=false alcanza para ZIP. Para tar.gz sí +# descomprimimos en streaming con flate2 (ya declarado arriba), saltando +# los datos de cada entrada — sólo leemos headers. +zip = { version = "2.4", default-features = false } +tar = { version = "0.4", default-features = false } + +# === Fuentes (nahual font viewer) === +# Parseo de TTF/OTF/TTC y extracción de contornos de glifo a paths. +ttf-parser = "0.25" + +# ============================================================ +# Intra-workspace deps de nahual (referenciadas por workspace = true) +# ============================================================ +nahual-text-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-image-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-thumb-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-gallery-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-video-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-card-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-audio-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-tree-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-hex-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-table-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-markdown-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-archive-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-font-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-map-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-geo-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-viewer-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-file-explorer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# ============================================================ +# Intra-workspace deps de pineal (módulo de gráficos) +# ============================================================ +pineal-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-render = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-cartesian = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-stream = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-mesh = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-financial = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-polar = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-heatmap = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-treemap = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-flow = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-phosphor = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-export = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-hexbin = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-contour = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-bars = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# ============================================================ +# Intra-workspace deps de iniy (laboratorio semántico de creencias) +# ============================================================ +iniy-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-ingest = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-extract = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-nli = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-nli-llm = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-graph = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-store = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# === auto: declarados por crates internos faltantes === +cosmos-coords = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cosmos-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cosmos-ephemeris = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cosmos-time = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cosmos-wcs = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# === auto: externas de eternal === +celestial-eop-data = { version = "0.1"} +approx = "0.5" +byteorder = "1.5" +cc = "1.0" +chrono = "0.4" +crc32fast = "1.4" +criterion = "0.5" +csv = "1.4" +flate2 = "1.0" +glob = "0.3" +indicatif = "0.18" +lz4_flex = "0.11" +memmap2 = "0.9" +mockito = "1.0" +ndarray = "0.15" +num-traits = "0.2" +once_cell = "1.19" +parking_lot = "0.12" +png = "0.18" +proptest = "1.4" +quick-xml = "0.31" +rayon = "1.8" +regex = "1.11" +reqwest = "0.12" +tiff = "0.11" +wide = "0.7" +wiremock = "0.6" + +# === i18n (rimay-localize) === +fluent-bundle = "0.15" +unic-langid = { version = "0.9", features = ["macros"] } +sys-locale = "0.3" + +# === Servo (puriy-engine) === +# Crates publicados de Servo embebibles individualmente. html5ever/markup5ever +# ya entran via ammonia→surrealdb→nakui, así que alineamos versión para no +# duplicar el árbol. markup5ever_rcdom es el DOM Rc-based simple (suficiente +# para Fase 2: parsear y renderizar, sin scripting). cssparser es el tokenizer +# CSS de Stylo, sirve para inline styles. ureq = HTTP síncrono minimalista, +# evita pull de tokio en el engine. +html5ever = "0.39" +markup5ever = "0.39" +markup5ever_rcdom = "0.39" +cssparser = "0.35" +url = "2" +ureq = { version = "2", default-features = false, features = ["tls"] } + +# === takiy-synth (SoundFont MIDI) === +# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador +# feo de takiy-synth por muestras reales (FluidR3, GeneralUser GS, etc). +rustysynth = "1.3" + +# === takiy-playback (audio device output) === +# cpal = backend de audio cross-platform (ALSA/PulseAudio/Pipewire en +# Linux, WASAPI en Windows, CoreAudio en macOS). Lo usamos sólo para +# abrir el device default y empujar muestras f32 — nada de mezclado +# ni efectos en el callback. +cpal = "0.15" + +# === media-source-wav (decoder PCM en disco) === +# hound = lector/escritor WAV puro-Rust, sin deps nativas. Soporta PCM +# entero (8/16/24/32) y float (32). Suficiente para abrir samples y +# stems de prueba sin meter ffmpeg/symphonia. +hound = "3.5" + +# === media-source-{mp3,flac,vorbis} (decoders vía symphonia) === +# symphonia es una colección de decoders puro-Rust mantenida. `mp3` cubre +# media-source-mp3; `flac` (decoder + demuxer FLAC nativo) cubre +# media-source-flac (lossless); `vorbis` + `ogg` (codec + demuxer Ogg) +# cubren media-source-vorbis (lossy clásico, libre de patentes). Sin aac: +# ese tier patentado entra por shared/foreign-av. +symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] } + +# === media-source-opus (decoder Opus NATIVO puro-Rust) === +# Opus es el formato de audio nativo de gioser (par del video AV1). ogg +# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus +# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video. +ogg = "0.9" +opus-wave = "3" + +# === media-source-webm (demux nativo Matroska/WebM) === +# matroska-demuxer es un demuxer puro-Rust de MKV/WebM (EBML). Saca los +# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1 +# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo. +matroska-demuxer = "0.7" +# === git-deps al monorepo (agregados por la extracción) === +card-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +card-net = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cards = { git = "https://gitea.gioser.net/sergio/gioser.git" } +rimay-localize = { git = "https://gitea.gioser.net/sergio/gioser.git" } +wawa-config = { git = "https://gitea.gioser.net/sergio/gioser.git" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ede9631 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sergio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..212d4ff --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# minga + +> Sovereign P2P sharing with a content-addressed FUSE mount, in Rust. + +`minga` (Quechua: *collective work*) shares content peer-to-peer: multi-bundle packs, content-addressed (BLAKE3) storage, and a FUSE filesystem so shared content mounts as real files. Discovery and transport ride the `card`/`chasqui` networking layer (relay, dcutr, autonat — NAT-traversal included). + +## How dependencies work +Front-door repo: only `minga-*` crates here. The `card` identity primitives, shared leaves and UI are git-dependencies of the [`gioser`](https://gitea.gioser.net/sergio/gioser) monorepo (source of truth). + +## License +MIT. Part of the [gioser](https://gitea.gioser.net/sergio/gioser) suite.