chore: monorepo inicial con arje + minga + yahweh absorbidos
Workspace en 4 ejes (core/modules/apps/shared):
- core/: 24 crates de arje (Init systemd-compatible: ente-card, ente-zero,
ente-kernel, ente-bus, ente-cas, ente-soma, ente-wasm, ente-snapshot,
ente-brain, ente-echo, ente-policy-provider, + 12 crates *-compat)
- modules/semantic_dht/: 5 crates de minga (minga-core con AST/CAS/MST,
minga-p2p con libp2p Kad, minga-store, minga-vfs, minga-cli)
- modules/ui_engine/: 11 crates de yahweh (libs/{core,theme,bus,providers},
widgets/{tree,splitter,tabs,tiled,container_core,text_input})
- apps/: 5 crates de yahweh (file_explorer, database_explorer, text_viewer,
image_viewer, yahweh-shell)
- shared_wit/protocol.wit: handshake/lifecycle inicial
Cargo.toml unificado: thiserror bumped a 2 (transparente para arje), tokio
"full", paths intra-workspace de yahweh redirigidos a su nueva ubicación.
cargo check --workspace: 0 errores, 17 warnings (dead code preexistente).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "minga-store"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Almacenamiento persistente para Minga: stores con backing sled para nodos, atestaciones y MST."
|
||||
|
||||
[dependencies]
|
||||
minga-core = { path = "../minga-core" }
|
||||
sled = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
@@ -0,0 +1,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,16 @@
|
||||
use minga_core::AttestationError;
|
||||
|
||||
#[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("hash inconsistente con el contenido del nodo")]
|
||||
HashMismatch,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Persistencia en disco de keypairs cifrados.
|
||||
//!
|
||||
//! El cifrado en sí (AES-GCM + Argon2id) vive en `minga-core`, que es
|
||||
//! pure logic. Aquí solo se monta la parte de IO: leer/escribir
|
||||
//! bytes a un archivo.
|
||||
//!
|
||||
//! Layout del archivo: el blob crudo que produce
|
||||
//! `Keypair::encrypt(passphrase)`. 85 bytes total.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use minga_core::{Keypair, KeypairCryptoError};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum KeypairFileError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("crypto: {0}")]
|
||||
Crypto(#[from] KeypairCryptoError),
|
||||
}
|
||||
|
||||
/// Guarda un keypair cifrado con la passphrase en `path`. Si el
|
||||
/// archivo ya existe, lo sobrescribe.
|
||||
pub fn save<P: AsRef<Path>>(
|
||||
keypair: &Keypair,
|
||||
path: P,
|
||||
passphrase: &str,
|
||||
) -> Result<(), KeypairFileError> {
|
||||
let blob = keypair.encrypt(passphrase)?;
|
||||
fs::write(path, blob)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Carga un keypair desde un archivo cifrado.
|
||||
pub fn load<P: AsRef<Path>>(path: P, passphrase: &str) -> Result<Keypair, KeypairFileError> {
|
||||
let blob = fs::read(path)?;
|
||||
Ok(Keypair::decrypt(&blob, passphrase)?)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! `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 attestation_store;
|
||||
pub mod error;
|
||||
pub mod keypair_file;
|
||||
pub mod mst_store;
|
||||
pub mod node_store;
|
||||
pub mod repo;
|
||||
|
||||
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 repo::PersistentRepo;
|
||||
@@ -0,0 +1,74 @@
|
||||
//! 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())
|
||||
}
|
||||
|
||||
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,134 @@
|
||||
//! 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(())
|
||||
}
|
||||
|
||||
/// 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,41 @@
|
||||
//! `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::{
|
||||
attestation_store::SledAttestationStore, error::StoreError, mst_store::SledMstStore,
|
||||
node_store::SledNodeStore,
|
||||
};
|
||||
|
||||
pub struct PersistentRepo {
|
||||
db: Db,
|
||||
pub nodes: SledNodeStore,
|
||||
pub attestations: SledAttestationStore,
|
||||
pub mst: SledMstStore,
|
||||
}
|
||||
|
||||
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")?;
|
||||
Ok(Self {
|
||||
db,
|
||||
nodes,
|
||||
attestations,
|
||||
mst,
|
||||
})
|
||||
}
|
||||
|
||||
/// Flushea los tres 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,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()]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user