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:
2026-06-04 12:17:58 +00:00
commit 85795c42cb
111 changed files with 25926 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
*.pdb
+130
View File
@@ -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.
+37
View File
@@ -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.
+39
View File
@@ -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.
+26
View File
@@ -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.
+221
View File
@@ -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 ~23× sobre postcard de código fuente real (probado con repo del workspace gioser). Subir a 19+ exprime un 2030 % extra a costa de 510× 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());
}
}
+35
View File
@@ -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"
+17
View File
@@ -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`
+17
View File
@@ -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`
+455
View File
@@ -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
+109
View File
@@ -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,
}
+26
View File
@@ -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;
+713
View File
@@ -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)
}
+291
View File
@@ -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");
}
+27
View File
@@ -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 }
+9
View File
@@ -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`
+9
View File
@@ -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 &params.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 &params.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 &params.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 &params.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 &params.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 &params.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 &params.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 &params.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,
}
}
+52
View File
@@ -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())
}
}
+95
View File
@@ -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())
}
}
+27
View File
@@ -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};
+457
View File
@@ -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();
}
}
}
}
+523
View File
@@ -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)
}
}
+144
View File
@@ -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
}
+16
View File
@@ -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 }
+9
View File
@@ -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`
+9
View File
@@ -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`
+8
View File
@@ -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::*;
+72
View File
@@ -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");
}
}
+26
View File
@@ -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 }
+10
View File
@@ -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`
+10
View File
@@ -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
}
+26
View File
@@ -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;
+116
View File
@@ -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");
+458
View File
@@ -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);
}
}
}
+687
View File
@@ -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));
}
+18
View File
@@ -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 }
+9
View File
@@ -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`
+9
View File
@@ -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)?)
}
+46
View File
@@ -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());
}
}
+86
View File
@@ -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()]
);
}
+16
View File
@@ -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