feat: minga standalone — compartición P2P soberana con montaje FUSE content-addressed (front-door, git-dep al monorepo)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
@@ -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<LibP2pNode>) 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<Attestation>} propaga autoría tras Hello autenticado; cada firma Ed25519 verificada
|
||||
6. RetractPush{Vec<Retraction>} tombstones firmados con RETRACTION_DOMAIN (anti-replay)
|
||||
7. RootDeclaration{Vec<RootDecl{α,struct_hash,dialect}>} 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.
|
||||
@@ -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.
|
||||
@@ -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<LibP2pNode>` 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.
|
||||
@@ -0,0 +1,26 @@
|
||||
<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
|
||||
|
||||
# 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.
|
||||
@@ -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/<hash>` 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 <DID>`.
|
||||
- `minga show <hash> [--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<PathBuf, ContentHash>` 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 <target>` 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 <alpha_hex>
|
||||
|
||||
# Watch (autoingest + autoremove)
|
||||
minga -r ./.minga watch ./src
|
||||
|
||||
# Sync por DHT (necesita peer bootstrap)
|
||||
minga -r ./.minga sync <alpha_hex> # busca providers
|
||||
minga -r ./.minga sync <multiaddr> # 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<Dialect>` 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 <hash>`). 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 <other>`** — 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<bool>`, 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<Retraction> }`. 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 <path>`** — 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 <hash>` 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 <hash>` (que requería conocer el hash). | hecho |
|
||||
| H | **`minga history <path>`** — 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 <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> <out>`** + **`minga bundle import <archivo>`** — 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<LibP2pNode>` (compartido).
|
||||
- Nuevos constructores:
|
||||
- **`MingaPeer::open_with_node(keypair, path, node: Arc<LibP2pNode>)`** — adopta un nodo libp2p ya existente en lugar de crear uno propio. El `open` clásico ahora delega a esto.
|
||||
- **`MingaPeer::brahman_net() -> Arc<LibP2pNode>`** — 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 <addr>`** — 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<StoredNode>` (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 <ts>`** — 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=<u64>` (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 <valor>`** — middleware axum que exige `Authorization: Bearer <valor>`; 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<LogEntry>` 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.*
|
||||
@@ -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 }
|
||||
@@ -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<Dht>,
|
||||
}
|
||||
|
||||
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<PeerId> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Card>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<CardIndex> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,17 @@
|
||||
# minga-cli
|
||||
|
||||
> CLI de [minga](../README.md).
|
||||
|
||||
Comandos: `minga peer add/list`, `minga put/get`, `minga ls <vfs-path>`, `minga share <local-path>`.
|
||||
|
||||
## Uso
|
||||
|
||||
```sh
|
||||
cargo run --release -p minga-cli -- peer list
|
||||
cargo run --release -p minga-cli -- put /local/file
|
||||
```
|
||||
|
||||
## Deps
|
||||
|
||||
- Todos los `minga-*`
|
||||
- `clap`
|
||||
@@ -0,0 +1,17 @@
|
||||
# minga-cli
|
||||
|
||||
> CLI of [minga](../README.md).
|
||||
|
||||
Commands: `minga peer add/list`, `minga put/get`, `minga ls <vfs-path>`, `minga share <local-path>`.
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
cargo run --release -p minga-cli -- peer list
|
||||
cargo run --release -p minga-cli -- put /local/file
|
||||
```
|
||||
|
||||
## Deps
|
||||
|
||||
- All `minga-*`
|
||||
- `clap`
|
||||
@@ -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<StoredNode>,
|
||||
pub attestations: Vec<Attestation>,
|
||||
pub retractions: Vec<Retraction>,
|
||||
}
|
||||
|
||||
/// 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<BundleV1>,
|
||||
}
|
||||
|
||||
/// 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<ContentHash>,
|
||||
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<BundleImportStats>,
|
||||
}
|
||||
|
||||
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> <out>`: 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<BundleExportStats, CliError> {
|
||||
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<BundleExportAllStats, CliError> {
|
||||
let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
|
||||
|
||||
let mut items: Vec<BundleV1> = Vec::new();
|
||||
let mut skipped: Vec<ContentHash> = 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<BundleV1, CliError> {
|
||||
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<ContentHash> = HashSet::new();
|
||||
let mut nodes: Vec<StoredNode> = Vec::new();
|
||||
let mut frontier: Vec<ContentHash> = 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 <archivo>`: 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<BundleImportStats, CliError> {
|
||||
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<BundleImportAllStats, CliError> {
|
||||
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<u8> = 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<BundleImportStats, CliError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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/<peer_id>`")]
|
||||
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,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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/<id>`).
|
||||
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 <punto>`.
|
||||
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<PathBuf>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
|
||||
/// 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 <token>` 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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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 <other> ≡ minga diff <hash> <other>.
|
||||
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<u64, CliError> {
|
||||
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<String, CliError> {
|
||||
let pass = rpassword::prompt_password("Passphrase: ")
|
||||
.map_err(CliError::Io)?;
|
||||
Ok(pass)
|
||||
}
|
||||
|
||||
fn prompt_passphrase_with_confirm() -> Result<String, CliError> {
|
||||
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)
|
||||
}
|
||||
@@ -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=<file>` — 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<PathBuf>,
|
||||
passphrase: Arc<String>,
|
||||
}
|
||||
|
||||
/// 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 <token>`; 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<String>) -> 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<String>, 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<AppState>) -> Result<Json<serde_json::Value>, 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<AppState>) -> Result<Json<serde_json::Value>, 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<u8>,
|
||||
}
|
||||
|
||||
async fn get_show(
|
||||
State(s): State<AppState>,
|
||||
AxumPath(alpha): AxumPath<String>,
|
||||
Query(q): Query<ShowQuery>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<u64>,
|
||||
}
|
||||
|
||||
async fn get_signers(
|
||||
State(s): State<AppState>,
|
||||
AxumPath(alpha): AxumPath<String>,
|
||||
Query(q): Query<SignersQuery>,
|
||||
) -> Result<Json<serde_json::Value>, 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<AppState>,
|
||||
AxumPath(_alpha_unused): AxumPath<String>,
|
||||
Query(q): Query<HistoryQuery>,
|
||||
) -> Result<Json<serde_json::Value>, 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<CliError> 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))
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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::<Vec<_>>()
|
||||
);
|
||||
// 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}"
|
||||
);
|
||||
}
|
||||
@@ -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=<future> 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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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<String>) {
|
||||
if let Some(t) = &node.leaf_text {
|
||||
if let Ok(s) = std::str::from_utf8(t) {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> = Vec::new();
|
||||
feed(&mut h, node, &mut scope);
|
||||
ContentHash(*h.finalize().as_bytes())
|
||||
}
|
||||
|
||||
fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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);
|
||||
}
|
||||
@@ -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<String> = Vec::new();
|
||||
feed(&mut h, node, &mut scope);
|
||||
ContentHash(*h.finalize().as_bytes())
|
||||
}
|
||||
|
||||
fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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);
|
||||
}
|
||||
@@ -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<Dialect> {
|
||||
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
|
||||
}
|
||||
@@ -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<String> = Vec::new();
|
||||
feed(&mut h, node, &mut scope);
|
||||
ContentHash(*h.finalize().as_bytes())
|
||||
}
|
||||
|
||||
fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
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<String> = 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<String>) {
|
||||
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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
// 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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> = Vec::new();
|
||||
feed(&mut h, node, &mut scope);
|
||||
ContentHash(*h.finalize().as_bytes())
|
||||
}
|
||||
|
||||
fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = 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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub leaf_text: Option<Vec<u8>>,
|
||||
pub children: Vec<SemanticNode>,
|
||||
}
|
||||
|
||||
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<String>) -> 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 }
|
||||
}
|
||||
}
|
||||
@@ -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<ContentHash, Vec<Attestation>>,
|
||||
}
|
||||
|
||||
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<Did> {
|
||||
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<Item = &Attestation> + '_ {
|
||||
self.by_content.values().flat_map(|v| v.iter())
|
||||
}
|
||||
}
|
||||
@@ -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<ContentHash> = 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());
|
||||
}
|
||||
@@ -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<Vec<u8>, 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<Self, KeypairCryptoError> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<ContentHash>,
|
||||
pub child_hashes: Vec<ContentHash>,
|
||||
}
|
||||
|
||||
/// 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<ContentHash> = 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<NodeData>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct NodeData {
|
||||
level: u32,
|
||||
keys: Vec<ContentHash>,
|
||||
children: Vec<Subtree>,
|
||||
}
|
||||
|
||||
/// 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<ContentHash, NodeProbe> {
|
||||
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<ContentHash>,
|
||||
pub only_in_other: Vec<ContentHash>,
|
||||
}
|
||||
|
||||
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::<usize>(),
|
||||
}
|
||||
}
|
||||
|
||||
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<Subtree> = Vec::with_capacity(i + 1);
|
||||
let mut right_children: Vec<Subtree> = 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<ContentHash, NodeProbe>) {
|
||||
if let Subtree::Node(n) = t {
|
||||
let child_hashes: Vec<ContentHash> = 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<ContentHash>,
|
||||
only_in_2: &mut Vec<ContentHash>,
|
||||
) {
|
||||
// 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<ContentHash>) {
|
||||
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<ContentHash>,
|
||||
only_b: &mut Vec<ContentHash>,
|
||||
) {
|
||||
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<Self::Item> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<SemanticNode, ParseError> {
|
||||
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<Dialect> {
|
||||
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<Dialect> {
|
||||
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<Dialect> {
|
||||
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<Dialect> = 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<SemanticNode, ParseError> {
|
||||
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<SemanticNode, ParseError> {
|
||||
parse_with(tree_sitter_rust::LANGUAGE.into(), source)
|
||||
}
|
||||
|
||||
pub fn python(source: &str) -> Result<SemanticNode, ParseError> {
|
||||
parse_with(tree_sitter_python::LANGUAGE.into(), source)
|
||||
}
|
||||
|
||||
pub fn typescript(source: &str) -> Result<SemanticNode, ParseError> {
|
||||
parse_with(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), source)
|
||||
}
|
||||
|
||||
pub fn javascript(source: &str) -> Result<SemanticNode, ParseError> {
|
||||
parse_with(tree_sitter_javascript::LANGUAGE.into(), source)
|
||||
}
|
||||
|
||||
pub fn go(source: &str) -> Result<SemanticNode, ParseError> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ContentHash, Vec<Retraction>>,
|
||||
}
|
||||
|
||||
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<Did> {
|
||||
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<Item = &Retraction> + '_ {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
Dialect::from_byte(self.dialect_byte)
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub leaf_text: Option<Vec<u8>>,
|
||||
pub children: Vec<ContentHash>,
|
||||
}
|
||||
|
||||
/// 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<SemanticNode>;
|
||||
|
||||
/// 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<dyn Iterator<Item = (&ContentHash, &StoredNode)> + '_>;
|
||||
|
||||
fn len(&self) -> usize;
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct MemStore {
|
||||
map: HashMap<ContentHash, StoredNode>,
|
||||
}
|
||||
|
||||
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<dyn Iterator<Item = (&ContentHash, &StoredNode)> + '_> {
|
||||
Box::new(self.map.iter())
|
||||
}
|
||||
|
||||
fn reconstruct(&self, h: &ContentHash) -> Option<SemanticNode> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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>) -> i32 { let mut s = 0; for x in v { s += x } s }")
|
||||
.unwrap();
|
||||
let b = parse::rust("fn f(v: Vec<i32>) -> 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>) -> i32 { match v { Some(x) => x, _ => 0 } }").unwrap();
|
||||
let b =
|
||||
parse::rust("fn f(v: Result<i32, ()>) -> 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>) -> i32 { match v { Some(x) => x + 1, None => 0 } }")
|
||||
.unwrap();
|
||||
let b =
|
||||
parse::rust("fn f(v: Option<i32>) -> 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>) -> i32 { match v { None => 0, Some(z) => z } }").unwrap();
|
||||
let b =
|
||||
parse::rust("fn f(v: Option<i32>) -> 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>) -> i32 { if let Some(x) = v { x + 1 } else { 0 } }",
|
||||
)
|
||||
.unwrap();
|
||||
let b = parse::rust(
|
||||
"fn f(v: Option<i32>) -> 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>) -> i32 { if let Some(x) = v { x } else { 0 } }",
|
||||
)
|
||||
.unwrap();
|
||||
let b = parse::rust(
|
||||
"fn f(v: Option<i32>) -> 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>) -> 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>) -> 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>) -> i32 { let Some(x) = v else { return 0 }; x + 1 }",
|
||||
)
|
||||
.unwrap();
|
||||
let b = parse::rust(
|
||||
"fn f(v: Option<i32>) -> 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<i32>, b: Option<i32>) -> i32 { if let Some(x) = a && let Some(y) = b { x + y } else { 0 } }",
|
||||
)
|
||||
.unwrap();
|
||||
let c = parse::rust(
|
||||
"fn f(a: Option<i32>, b: Option<i32>) -> 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>) -> i32 { if let Some(x) = v { x + 1 } else { 0 } }",
|
||||
)
|
||||
.unwrap();
|
||||
let minus = parse::rust(
|
||||
"fn f(v: Option<i32>) -> i32 { if let Some(x) = v { x - 1 } else { 0 } }",
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(hash_node_alpha(&plus), hash_node_alpha(&minus));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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<ContentHash> = (0..32u64).map(ch).collect();
|
||||
for h in &hashes {
|
||||
m.insert(*h);
|
||||
}
|
||||
let collected: Vec<ContentHash> = 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<ContentHash> = (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<ContentHash> = m_natural.iter().copied().collect();
|
||||
let s_reverse: Vec<ContentHash> = m_reverse.iter().copied().collect();
|
||||
let s_shuffled: Vec<ContentHash> = 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<ContentHash> = (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)));
|
||||
}
|
||||
@@ -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<T: serde::Serialize + for<'a> 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<Attestation, _> = postcard::from_bytes(&bogus);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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::*;
|
||||
@@ -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<BrahmanNet>,
|
||||
}
|
||||
|
||||
impl Dht {
|
||||
/// Crea la capa DHT sobre un nodo `brahman-net` ya inicializado.
|
||||
pub fn new(net: Arc<BrahmanNet>) -> 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<PeerId> {
|
||||
self.net.find_providers(&key.to_bytes()).await
|
||||
}
|
||||
|
||||
/// El nodo `brahman-net` subyacente (para abrir streams a un provider).
|
||||
pub fn net(&self) -> &Arc<BrahmanNet> {
|
||||
&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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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/)
|
||||
@@ -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/)
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
struct Model {
|
||||
theme: Theme,
|
||||
repo_path: PathBuf,
|
||||
snapshot: Option<RepoSnapshot>,
|
||||
error: Option<String>,
|
||||
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<wawa_config::ConfigWatcher>,
|
||||
/// Barra de menú principal: índice del menú raíz abierto (`None`
|
||||
/// cerrado).
|
||||
menu_open: Option<usize>,
|
||||
/// Fila activa dentro del dropdown abierto (`usize::MAX` = ninguna).
|
||||
menu_active: usize,
|
||||
/// Animación de aparición del dropdown.
|
||||
menu_anim: Tween<f32>,
|
||||
/// 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<RepoSnapshot, String>,
|
||||
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<usize>),
|
||||
/// 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<Msg>) -> 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<Msg> {
|
||||
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<Msg>) -> 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<Msg> {
|
||||
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::<Msg>(header_text, vec![], &header_palette);
|
||||
|
||||
let mut body_children: Vec<View<Msg>> = Vec::new();
|
||||
|
||||
if let Some(ref e) = model.error {
|
||||
body_children.push(banner_view::<Msg>(BannerKind::Error, e.clone()));
|
||||
}
|
||||
|
||||
match &model.snapshot {
|
||||
None => {
|
||||
body_children.push(empty_message(theme));
|
||||
}
|
||||
Some(snap) => {
|
||||
let node_items: Vec<String> = snap
|
||||
.recent_nodes
|
||||
.iter()
|
||||
.map(|(h, k)| format!("{h} {k}"))
|
||||
.collect();
|
||||
let attestation_items: Vec<String> = snap
|
||||
.recent_attestations
|
||||
.iter()
|
||||
.map(|(h, did)| format!("{h} ← {did}"))
|
||||
.collect();
|
||||
let mst_items: Vec<String> = snap.recent_mst_keys.clone();
|
||||
|
||||
body_children.push(stat_card_view::<Msg>(
|
||||
&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::<Msg>(
|
||||
&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::<Msg>(
|
||||
&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<View<Msg>> {
|
||||
// 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<dyn Fn(usize) -> 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<Msg>) -> 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<Msg> {
|
||||
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_path>/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<RepoSnapshot, String> {
|
||||
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<String> = 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::<Explorer>();
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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<S>(
|
||||
mut session: SyncSession,
|
||||
mut stream: S,
|
||||
) -> Result<SyncSession, AsyncSyncError>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let mut outbound: VecDeque<Message> = 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<S>(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<S>(stream: &mut S) -> Result<Message, AsyncSyncError>
|
||||
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)?)
|
||||
}
|
||||
@@ -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<Message> = VecDeque::new();
|
||||
let mut from_b: VecDeque<Message> = 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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<NodeProbe>,
|
||||
},
|
||||
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<Attestation>,
|
||||
},
|
||||
/// 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<Retraction>,
|
||||
},
|
||||
/// 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<RootDecl>,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// Codifica el mensaje a bytes vía postcard. Diseñado para
|
||||
/// transferir sobre cualquier transporte que mueva `Vec<u8>`.
|
||||
/// Postcard es compacto, sin overhead de schema runtime.
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
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<Self, postcard::Error> {
|
||||
postcard::from_bytes(bytes)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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<ContentHash, (ContentHash, Dialect)>,
|
||||
keypair: Keypair,
|
||||
/// Backing persistente opcional. Si está presente, todo cambio
|
||||
/// de estado escribe a disco vía write-through.
|
||||
persistent: Option<Arc<PersistentRepo>>,
|
||||
}
|
||||
|
||||
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<LibP2pNode>,
|
||||
state: Arc<Mutex<PeerState>>,
|
||||
}
|
||||
|
||||
impl MingaPeer {
|
||||
pub fn new(
|
||||
keypair: Keypair,
|
||||
mst: Mst,
|
||||
store: MemStore,
|
||||
attestations: AttestationStore,
|
||||
) -> Result<Self, NodeError> {
|
||||
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<Path>) -> Result<Self, PeerOpenError> {
|
||||
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<LibP2pNode> {
|
||||
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<Path>,
|
||||
node: Arc<LibP2pNode>,
|
||||
) -> Result<Self, PeerOpenError> {
|
||||
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<ContentHash, (ContentHash, Dialect)> = 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<DiscoveredPeer> {
|
||||
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<ContentHash> = {
|
||||
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<PeerId> {
|
||||
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<Mutex<PeerState>>) {
|
||||
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<ContentHash, (ContentHash, Dialect)>,
|
||||
) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Message>`.
|
||||
//!
|
||||
//! ## Algoritmo
|
||||
//!
|
||||
//! 1. Cada peer construye al inicio un `own_probes: HashMap<ContentHash,
|
||||
//! NodeProbe>` 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<ContentHash, (ContentHash, Dialect)>,
|
||||
|
||||
/// 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<Did>,
|
||||
|
||||
own_probes: HashMap<ContentHash, NodeProbe>,
|
||||
own_root_subtree_hash: ContentHash,
|
||||
|
||||
awaited_probes: HashSet<ContentHash>,
|
||||
seen_probes: HashSet<ContentHash>,
|
||||
awaiting_root: HashSet<ContentHash>,
|
||||
awaiting_child: HashSet<ContentHash>,
|
||||
|
||||
/// 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<RootDecl>,
|
||||
/// 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<ContentHash, (ContentHash, Dialect)>,
|
||||
|
||||
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<ContentHash, (ContentHash, Dialect)>,
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<RootDecl> = 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<Message> {
|
||||
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<Message> {
|
||||
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<ContentHash, (ContentHash, Dialect)> {
|
||||
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<Did> {
|
||||
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<ContentHash, (ContentHash, Dialect)>,
|
||||
) {
|
||||
(
|
||||
self.mst,
|
||||
self.store,
|
||||
self.attestations,
|
||||
self.retractions,
|
||||
self.verified_root_decls,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<ContentHash>) {
|
||||
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()]);
|
||||
}
|
||||
@@ -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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[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::<Vec<_>>()
|
||||
);
|
||||
|
||||
// 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::<Vec<_>>()
|
||||
)
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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<ContentHash>) {
|
||||
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()]
|
||||
);
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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) {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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<Self, StoreError> {
|
||||
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<Vec<(String, u64)>, 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<Option<String>, 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<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Self, StoreError> {
|
||||
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<Vec<Attestation>, 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<Vec<Did>, 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<Item = Result<Attestation, StoreError>> + '_ {
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<P: AsRef<Path>>(
|
||||
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<P: AsRef<Path>>(path: P, passphrase: &str) -> Result<Keypair, KeypairFileError> {
|
||||
let blob = fs::read(path)?;
|
||||
Ok(Keypair::decrypt(&blob, passphrase)?)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Self, StoreError> {
|
||||
Ok(Self {
|
||||
tree: db.open_tree(name)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert(&self, h: ContentHash) -> Result<bool, StoreError> {
|
||||
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<bool, StoreError> {
|
||||
Ok(self.tree.remove(h.0)?.is_some())
|
||||
}
|
||||
|
||||
pub fn contains(&self, h: &ContentHash) -> Result<bool, StoreError> {
|
||||
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<Item = Result<ContentHash, StoreError>> + '_ {
|
||||
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<Mst, StoreError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<Self, StoreError> {
|
||||
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<ContentHash, StoreError> {
|
||||
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<Option<StoredNode>, 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<bool, StoreError> {
|
||||
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<Option<SemanticNode>, 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<bool, StoreError> {
|
||||
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<Item = Result<ContentHash, StoreError>> + '_ {
|
||||
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<Option<Vec<ContentHash>>, 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<Item = Result<(ContentHash, StoredNode), StoreError>> + '_ {
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<Self, StoreError> {
|
||||
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<Vec<(ContentHash, u64)>, 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<Item = Result<(String, Vec<(ContentHash, u64)>), 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());
|
||||
}
|
||||
}
|
||||
@@ -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<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<Self, StoreError> {
|
||||
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<Vec<Retraction>, 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<bool, StoreError> {
|
||||
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<Item = Result<Retraction, StoreError>> + '_ {
|
||||
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
|
||||
}
|
||||
@@ -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<Self, StoreError> {
|
||||
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<Option<(ContentHash, Option<Dialect>)>, 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<bool, StoreError> {
|
||||
Ok(self.tree.contains_key(alpha.0)?)
|
||||
}
|
||||
|
||||
pub fn remove(&self, alpha: &ContentHash) -> Result<bool, StoreError> {
|
||||
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<Item = Result<(ContentHash, ContentHash, Option<Dialect>), 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<Self, StoreError> {
|
||||
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<Option<u64>, 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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<ContentHash> = (0..32u64).map(ch).collect();
|
||||
for h in &hashes {
|
||||
store.insert(*h).unwrap();
|
||||
}
|
||||
|
||||
let collected: Vec<ContentHash> = 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<ContentHash> = (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<ContentHash> = (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()
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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<ContentHash> = 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()]
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user