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,26 @@
|
||||
[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."
|
||||
|
||||
[[bin]]
|
||||
name = "minga"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
minga-core = { path = "../minga-core" }
|
||||
minga-p2p = { path = "../minga-p2p" }
|
||||
minga-store = { path = "../minga-store" }
|
||||
clap = { workspace = true }
|
||||
rpassword = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
libp2p = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Implementaciones de los subcomandos. Funciones puras que retornan
|
||||
//! datos estructurados — el binario las llama y formatea la salida.
|
||||
//!
|
||||
//! Layout en disco bajo `repo_path/`:
|
||||
//! - `keypair` — la `Keypair` del peer cifrada con passphrase.
|
||||
//! - `repo/` — directorio sled con `nodes`, `attestations`, `mst`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use libp2p::{multiaddr::Protocol, Multiaddr, PeerId};
|
||||
use minga_core::{parse, Attestation, ContentHash, Did, Keypair};
|
||||
use minga_p2p::MingaPeer;
|
||||
use minga_store::{keypair_file, PersistentRepo};
|
||||
|
||||
use crate::error::CliError;
|
||||
|
||||
pub const KEYPAIR_FILENAME: &str = "keypair";
|
||||
pub const REPO_DIRNAME: &str = "repo";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RepoStatus {
|
||||
pub did: Did,
|
||||
pub mst_len: usize,
|
||||
pub nodes_len: usize,
|
||||
pub attestations_len: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IngestResult {
|
||||
pub hash: ContentHash,
|
||||
pub did: Did,
|
||||
}
|
||||
|
||||
/// `minga init`: genera un keypair fresco, crea el repo persistente,
|
||||
/// y guarda el keypair cifrado.
|
||||
pub fn cmd_init(repo_path: &Path, passphrase: &str) -> Result<Did, CliError> {
|
||||
if repo_path.exists() {
|
||||
// Si el directorio existe pero está vacío, lo aceptamos.
|
||||
// Si tiene cualquier cosa, abortamos para no pisar un repo.
|
||||
let mut entries = fs::read_dir(repo_path)?;
|
||||
if entries.next().is_some() {
|
||||
return Err(CliError::AlreadyExists(repo_path.to_path_buf()));
|
||||
}
|
||||
} else {
|
||||
fs::create_dir_all(repo_path)?;
|
||||
}
|
||||
|
||||
let keypair = Keypair::generate();
|
||||
keypair_file::save(&keypair, repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
|
||||
// Crear el repo sled vacío. Se cierra al final del scope; el
|
||||
// siguiente comando lo reabre.
|
||||
let _ = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
|
||||
|
||||
Ok(keypair.did())
|
||||
}
|
||||
|
||||
/// `minga status`: descifra el keypair, abre el repo, devuelve
|
||||
/// estadísticas básicas.
|
||||
pub fn cmd_status(repo_path: &Path, passphrase: &str) -> Result<RepoStatus, CliError> {
|
||||
let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
|
||||
|
||||
Ok(RepoStatus {
|
||||
did: keypair.did(),
|
||||
mst_len: repo.mst.len(),
|
||||
nodes_len: repo.nodes.len(),
|
||||
attestations_len: repo.attestations.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `minga ingest <file>`: parsea un archivo Rust con tree-sitter,
|
||||
/// inserta el AST en el store, lo añade al MST, y crea una atestación
|
||||
/// firmada por el dueño del keypair (auto-firma de autoría).
|
||||
pub fn cmd_ingest(
|
||||
repo_path: &Path,
|
||||
passphrase: &str,
|
||||
file: &Path,
|
||||
) -> Result<IngestResult, CliError> {
|
||||
let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
|
||||
|
||||
let source = fs::read_to_string(file)?;
|
||||
let node = parse::rust(&source)?;
|
||||
let hash = repo.nodes.put(&node)?;
|
||||
repo.mst.insert(hash)?;
|
||||
repo.attestations
|
||||
.add(Attestation::create(&keypair, hash))?;
|
||||
repo.flush()?;
|
||||
|
||||
Ok(IngestResult {
|
||||
hash,
|
||||
did: keypair.did(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `minga listen <addr>`: arranca el peer, escucha en `addr`, y
|
||||
/// acepta sincronizaciones entrantes hasta que el proceso se cierre.
|
||||
pub async fn cmd_listen(
|
||||
repo_path: &Path,
|
||||
passphrase: &str,
|
||||
addr: &str,
|
||||
) -> Result<Multiaddr, CliError> {
|
||||
let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
let did = keypair.did();
|
||||
let peer = MingaPeer::open(keypair, repo_path.join(REPO_DIRNAME))?;
|
||||
let multi: Multiaddr = addr
|
||||
.parse()
|
||||
.map_err(|e: libp2p::multiaddr::Error| CliError::Multiaddr(e.to_string()))?;
|
||||
let actual = peer.listen(multi).await;
|
||||
let _accept = peer.run_passive_accept();
|
||||
|
||||
// Bloqueamos para siempre mientras la task de accept procesa
|
||||
// sincronizaciones. El usuario cierra con Ctrl+C.
|
||||
println!("Escuchando en: {}", actual);
|
||||
println!("DID Minga: {}", did);
|
||||
println!("PeerID libp2p: {}", peer.peer_id());
|
||||
futures::future::pending::<()>().await;
|
||||
|
||||
Ok(actual)
|
||||
}
|
||||
|
||||
/// `minga sync <multiaddr>`: dializa al peer y ejecuta una
|
||||
/// sincronización completa con él.
|
||||
pub async fn cmd_sync(
|
||||
repo_path: &Path,
|
||||
passphrase: &str,
|
||||
target: &str,
|
||||
) -> Result<(), CliError> {
|
||||
let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
let peer = MingaPeer::open(keypair, repo_path.join(REPO_DIRNAME))?;
|
||||
|
||||
let multi: Multiaddr = target
|
||||
.parse()
|
||||
.map_err(|e: libp2p::multiaddr::Error| CliError::Multiaddr(e.to_string()))?;
|
||||
let peer_id = extract_peer_id(&multi).ok_or(CliError::NoPeerIdInMultiaddr)?;
|
||||
|
||||
peer.dial(multi);
|
||||
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if peer.sync_with(peer_id).await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(CliError::SyncTimeout);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_peer_id(addr: &Multiaddr) -> Option<PeerId> {
|
||||
addr.iter().find_map(|p| match p {
|
||||
Protocol::P2p(peer_id) => Some(peer_id),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `minga watch <dir>`: vigila un directorio, re-parsea y re-ingesta
|
||||
/// cualquier archivo `.rs` que se cree o modifique. Convierte Minga en
|
||||
/// un VCS de fondo: el usuario escribe en su editor habitual y el
|
||||
/// código queda versionado y firmado en el repo automáticamente.
|
||||
pub async fn cmd_watch(
|
||||
repo_path: &Path,
|
||||
passphrase: &str,
|
||||
watch_dir: &Path,
|
||||
) -> Result<(), CliError> {
|
||||
let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
|
||||
|
||||
// Pasada inicial: ingerimos todos los .rs ya presentes para que
|
||||
// el repo arranque sincronizado con el contenido actual del
|
||||
// directorio (no solo con cambios futuros).
|
||||
initial_scan(&repo, &keypair, watch_dir);
|
||||
|
||||
// Canal entre el callback síncrono de notify y el bucle async.
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut watcher = notify::recommended_watcher(
|
||||
move |res: Result<notify::Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
},
|
||||
)?;
|
||||
notify::Watcher::watch(&mut watcher, watch_dir, notify::RecursiveMode::Recursive)?;
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
if !is_relevant_event(&event) {
|
||||
continue;
|
||||
}
|
||||
for path in &event.paths {
|
||||
if is_rs_file(path) {
|
||||
match ingest_into_repo(&repo, &keypair, path) {
|
||||
Ok(hash) => {
|
||||
eprintln!("ingerido: {} → {}", path.display(), hash);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("warning: {} no se pudo ingerir: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initial_scan(repo: &PersistentRepo, keypair: &Keypair, dir: &Path) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if is_rs_file(&p) {
|
||||
let _ = ingest_into_repo(repo, keypair, &p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest_into_repo(
|
||||
repo: &PersistentRepo,
|
||||
keypair: &Keypair,
|
||||
file: &Path,
|
||||
) -> Result<ContentHash, CliError> {
|
||||
let source = fs::read_to_string(file)?;
|
||||
let node = parse::rust(&source)?;
|
||||
let hash = repo.nodes.put(&node)?;
|
||||
repo.mst.insert(hash)?;
|
||||
repo.attestations
|
||||
.add(Attestation::create(keypair, hash))?;
|
||||
repo.flush()?;
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
fn is_rs_file(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()) == Some("rs") && path.is_file()
|
||||
}
|
||||
|
||||
fn is_relevant_event(event: ¬ify::Event) -> bool {
|
||||
matches!(
|
||||
event.kind,
|
||||
notify::EventKind::Create(_) | notify::EventKind::Modify(_)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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),
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! `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 commands;
|
||||
pub mod error;
|
||||
|
||||
pub use commands::{
|
||||
cmd_ingest, cmd_init, cmd_listen, cmd_status, cmd_sync, cmd_watch, IngestResult, RepoStatus,
|
||||
};
|
||||
pub use error::CliError;
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Binario `minga`: argument parsing y formateo de salida.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use minga_cli::{
|
||||
cmd_ingest, cmd_init, cmd_listen, cmd_status, cmd_sync, cmd_watch, CliError,
|
||||
};
|
||||
|
||||
#[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,
|
||||
},
|
||||
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
|
||||
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!("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());
|
||||
println!("Hash: {}", r.hash);
|
||||
println!("Firmado por: {}", r.did);
|
||||
}
|
||||
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))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_passphrase() -> Result<String, CliError> {
|
||||
let pass = rpassword::prompt_password("Passphrase: ")
|
||||
.map_err(CliError::Io)?;
|
||||
Ok(pass)
|
||||
}
|
||||
|
||||
fn prompt_passphrase_with_confirm() -> Result<String, CliError> {
|
||||
let pass = rpassword::prompt_password("Passphrase nueva: ")
|
||||
.map_err(CliError::Io)?;
|
||||
let conf = rpassword::prompt_password("Confirma: ")
|
||||
.map_err(CliError::Io)?;
|
||||
if pass != conf {
|
||||
return Err(CliError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"passphrases no coinciden",
|
||||
)));
|
||||
}
|
||||
Ok(pass)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! 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.hash, r2.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);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Tests del file watcher: el "puente humano" que convierte Minga en
|
||||
//! un VCS de fondo — el usuario edita archivos y Minga los versiona
|
||||
//! sin acción explícita.
|
||||
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
use minga_cli::{cmd_init, cmd_status, cmd_watch};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Espera hasta que el `cmd_status` reporte `expected` claves en MST,
|
||||
/// o hasta `timeout`. Devuelve `true` si se alcanzó la cuenta.
|
||||
async fn wait_until_mst_size(
|
||||
repo: &std::path::Path,
|
||||
pass: &str,
|
||||
expected: usize,
|
||||
timeout: Duration,
|
||||
) -> bool {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
loop {
|
||||
if let Ok(s) = cmd_status(repo, pass) {
|
||||
if s.mst_len >= expected {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watcher_initial_scan_picks_up_existing_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let repo = dir.path().join("repo");
|
||||
let watch = dir.path().join("src");
|
||||
fs::create_dir(&watch).unwrap();
|
||||
cmd_init(&repo, "p").unwrap();
|
||||
|
||||
// Escribimos archivos ANTES de arrancar el watcher.
|
||||
fs::write(watch.join("a.rs"), "fn a() -> i32 { 1 }").unwrap();
|
||||
fs::write(watch.join("b.rs"), "fn b() -> i32 { 2 }").unwrap();
|
||||
|
||||
// Arrancamos el watcher en una task. La pasada inicial debería
|
||||
// ingerir ambos.
|
||||
let repo_clone = repo.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = cmd_watch(&repo_clone, "p", &watch).await;
|
||||
});
|
||||
|
||||
// Damos margen para la pasada inicial. cmd_watch tiene el repo
|
||||
// abierto, pero cmd_status no puede mientras tanto (sled lock).
|
||||
// Solución: cancelamos el watcher antes de medir.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let s = cmd_status(&repo, "p").unwrap();
|
||||
assert_eq!(s.mst_len, 2, "esperaba 2 funciones del initial scan");
|
||||
assert_eq!(s.attestations_len, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watcher_ingests_new_file_after_creation() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let repo = dir.path().join("repo");
|
||||
let watch = dir.path().join("src");
|
||||
fs::create_dir(&watch).unwrap();
|
||||
cmd_init(&repo, "p").unwrap();
|
||||
|
||||
// Watcher arranca con directorio vacío.
|
||||
let repo_clone = repo.clone();
|
||||
let watch_clone = watch.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = cmd_watch(&repo_clone, "p", &watch_clone).await;
|
||||
});
|
||||
|
||||
// Margen para que el watcher se inicialice y registre con notify.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
// Creamos un archivo. notify debería emitir un evento y el
|
||||
// watcher debería ingerirlo.
|
||||
fs::write(watch.join("new.rs"), "fn new() -> i32 { 42 }").unwrap();
|
||||
|
||||
// Esperamos a que el evento se procese.
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
|
||||
// Detenemos el watcher para liberar el lock de sled antes de
|
||||
// hacer cmd_status.
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
// Polling con timeout — algunos sistemas de archivos tienen
|
||||
// latencia de eventos.
|
||||
assert!(
|
||||
wait_until_mst_size(&repo, "p", 1, Duration::from_secs(3)).await,
|
||||
"el watcher no ingirió el archivo creado",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watcher_ignores_non_rs_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let repo = dir.path().join("repo");
|
||||
let watch = dir.path().join("src");
|
||||
fs::create_dir(&watch).unwrap();
|
||||
cmd_init(&repo, "p").unwrap();
|
||||
|
||||
// Pre-poblamos con un .rs y varios archivos no-Rust.
|
||||
fs::write(watch.join("real.rs"), "fn real() -> i32 { 0 }").unwrap();
|
||||
fs::write(watch.join("readme.md"), "# proyecto").unwrap();
|
||||
fs::write(watch.join("data.json"), "{}").unwrap();
|
||||
|
||||
let repo_clone = repo.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = cmd_watch(&repo_clone, "p", &watch).await;
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let s = cmd_status(&repo, "p").unwrap();
|
||||
assert_eq!(s.mst_len, 1, "solo el .rs debe haberse ingerido");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[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 }
|
||||
blake3 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde-big-array = { workspace = true }
|
||||
aes-gcm = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
blake3 = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
@@ -0,0 +1,515 @@
|
||||
//! 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`.
|
||||
//!
|
||||
//! **Pendiente:** `if let`, `while let`, `let-else`, let-chains, `or_pattern`
|
||||
//! con bindings (Rust requiere mismas variables en cada rama).
|
||||
|
||||
use crate::ast::SemanticNode;
|
||||
use crate::cas::ContentHash;
|
||||
use blake3::Hasher;
|
||||
|
||||
const TAG_NO_LEAF: u8 = 0;
|
||||
const TAG_LEAF: u8 = 1;
|
||||
const TAG_BINDER: u8 = 2;
|
||||
const TAG_REF_BOUND: u8 = 3;
|
||||
const TAG_REF_FREE: u8 = 4;
|
||||
|
||||
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),
|
||||
"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),
|
||||
}
|
||||
}
|
||||
|
||||
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 emit_identifier_ref(h: &mut Hasher, node: &SemanticNode, scope: &Vec<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]);
|
||||
}
|
||||
|
||||
fn emit_binder_body(h: &mut Hasher) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
h.update(&[TAG_BINDER]);
|
||||
h.update(&[0u8; 8]);
|
||||
}
|
||||
|
||||
fn emit_binder_node(h: &mut Hasher, node: &SemanticNode) {
|
||||
write_kind_and_field(h, node);
|
||||
emit_binder_body(h);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_callable(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = Vec::new();
|
||||
for c in &node.children {
|
||||
if c.field_name.as_deref() == Some("parameters") {
|
||||
collect_callable_binders(c, &mut binders);
|
||||
}
|
||||
}
|
||||
|
||||
let scope_before = scope.len();
|
||||
scope.extend(binders);
|
||||
|
||||
h.update(&(node.children.len() as u64).to_le_bytes());
|
||||
for c in &node.children {
|
||||
if c.field_name.as_deref() == Some("parameters") {
|
||||
feed_callable_params(h, c);
|
||||
} else {
|
||||
feed(h, c, scope);
|
||||
}
|
||||
}
|
||||
|
||||
scope.truncate(scope_before);
|
||||
}
|
||||
|
||||
fn feed_block(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let scope_before = scope.len();
|
||||
h.update(&(node.children.len() as u64).to_le_bytes());
|
||||
for c in &node.children {
|
||||
if c.kind == "let_declaration" {
|
||||
feed_let(h, c, scope);
|
||||
for cc in &c.children {
|
||||
if cc.field_name.as_deref() == Some("pattern") {
|
||||
collect_pattern_binders(cc, scope);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
feed(h, c, scope);
|
||||
}
|
||||
}
|
||||
scope.truncate(scope_before);
|
||||
}
|
||||
|
||||
fn feed_let(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
|
||||
write_kind_and_field(h, node);
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
h.update(&(node.children.len() as u64).to_le_bytes());
|
||||
for c in &node.children {
|
||||
if c.field_name.as_deref() == Some("pattern") {
|
||||
feed_pattern(h, c);
|
||||
} else {
|
||||
feed(h, c, scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_for(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = Vec::new();
|
||||
for c in &node.children {
|
||||
if c.field_name.as_deref() == Some("pattern") {
|
||||
collect_pattern_binders(c, &mut binders);
|
||||
}
|
||||
}
|
||||
|
||||
h.update(&(node.children.len() as u64).to_le_bytes());
|
||||
for c in &node.children {
|
||||
match c.field_name.as_deref() {
|
||||
Some("pattern") => feed_pattern(h, c),
|
||||
Some("body") => {
|
||||
let scope_before = scope.len();
|
||||
scope.extend(binders.iter().cloned());
|
||||
feed(h, c, scope);
|
||||
scope.truncate(scope_before);
|
||||
}
|
||||
_ => feed(h, c, scope),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_match_arm(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
|
||||
let mut binders: Vec<String> = Vec::new();
|
||||
for c in &node.children {
|
||||
if c.field_name.as_deref() == Some("pattern") {
|
||||
collect_match_pattern_binders(c, &mut binders);
|
||||
}
|
||||
}
|
||||
|
||||
let scope_before = scope.len();
|
||||
scope.extend(binders);
|
||||
|
||||
h.update(&(node.children.len() as u64).to_le_bytes());
|
||||
for c in &node.children {
|
||||
if c.field_name.as_deref() == Some("pattern") {
|
||||
if c.kind == "match_pattern" {
|
||||
feed_match_pattern_split(h, c, scope);
|
||||
} else {
|
||||
feed_pattern(h, c);
|
||||
}
|
||||
} else {
|
||||
feed(h, c, scope);
|
||||
}
|
||||
}
|
||||
|
||||
scope.truncate(scope_before);
|
||||
}
|
||||
|
||||
fn feed_match_pattern_split(h: &mut Hasher, mp: &SemanticNode, scope: &mut Vec<String>) {
|
||||
write_kind_and_field(h, mp);
|
||||
emit_leaf_marker(h, mp);
|
||||
h.update(&(mp.children.len() as u64).to_le_bytes());
|
||||
for c in &mp.children {
|
||||
if c.field_name.as_deref() == Some("condition") {
|
||||
feed(h, c, scope);
|
||||
} else {
|
||||
feed_pattern(h, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_match_pattern_binders(p: &SemanticNode, out: &mut Vec<String>) {
|
||||
if p.kind == "match_pattern" {
|
||||
for c in &p.children {
|
||||
if c.field_name.as_deref() != Some("condition") {
|
||||
collect_pattern_binders(c, out);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
collect_pattern_binders(p, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_callable_params(h: &mut Hasher, params: &SemanticNode) {
|
||||
write_kind_and_field(h, params);
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
h.update(&(params.children.len() as u64).to_le_bytes());
|
||||
for c in ¶ms.children {
|
||||
match c.kind.as_str() {
|
||||
"parameter" => feed_parameter(h, c),
|
||||
_ => feed_pattern(h, c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_parameter(h: &mut Hasher, node: &SemanticNode) {
|
||||
write_kind_and_field(h, node);
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
h.update(&(node.children.len() as u64).to_le_bytes());
|
||||
for c in &node.children {
|
||||
if c.field_name.as_deref() == Some("pattern") {
|
||||
feed_pattern(h, c);
|
||||
} else {
|
||||
feed_as_literal(h, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pattern-aware emitter. Within a pattern, identifiers split into two
|
||||
/// roles: binders (introduce a new local) and constructors (variant or
|
||||
/// path references). The disambiguation rule mirrors Rust's: a `pattern`
|
||||
/// field forces binder; otherwise lowercase initial = binder, uppercase =
|
||||
/// constructor.
|
||||
fn feed_pattern(h: &mut Hasher, node: &SemanticNode) {
|
||||
write_kind_and_field(h, node);
|
||||
match node.kind.as_str() {
|
||||
"identifier" => {
|
||||
if is_binder_identifier(node) {
|
||||
emit_binder_body(h);
|
||||
} else {
|
||||
emit_leaf_marker(h, node);
|
||||
h.update(&[0u8; 8]);
|
||||
}
|
||||
}
|
||||
"tuple_pattern" | "ref_pattern" | "reference_pattern" | "mut_pattern" | "slice_pattern" => {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
h.update(&(node.children.len() as u64).to_le_bytes());
|
||||
for c in &node.children {
|
||||
feed_pattern(h, c);
|
||||
}
|
||||
}
|
||||
"tuple_struct_pattern" => {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
h.update(&(node.children.len() as u64).to_le_bytes());
|
||||
for c in &node.children {
|
||||
if c.field_name.as_deref() == Some("type") {
|
||||
feed_as_literal(h, c);
|
||||
} else {
|
||||
feed_pattern(h, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
"struct_pattern" => {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
h.update(&(node.children.len() as u64).to_le_bytes());
|
||||
for c in &node.children {
|
||||
if c.field_name.as_deref() == Some("type") {
|
||||
feed_as_literal(h, c);
|
||||
} else if c.kind == "field_pattern" {
|
||||
feed_field_pattern(h, c);
|
||||
} else {
|
||||
feed_as_literal(h, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
"captured_pattern" => {
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
h.update(&(node.children.len() as u64).to_le_bytes());
|
||||
let mut named_binder = false;
|
||||
for c in &node.children {
|
||||
if !named_binder && c.kind == "identifier" {
|
||||
emit_binder_node(h, c);
|
||||
named_binder = true;
|
||||
} else {
|
||||
feed_pattern(h, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => feed_as_literal(h, node),
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_field_pattern(h: &mut Hasher, fp: &SemanticNode) {
|
||||
write_kind_and_field(h, fp);
|
||||
let has_pattern = fp
|
||||
.children
|
||||
.iter()
|
||||
.any(|c| c.field_name.as_deref() == Some("pattern"));
|
||||
h.update(&[TAG_NO_LEAF]);
|
||||
h.update(&(fp.children.len() as u64).to_le_bytes());
|
||||
for c in &fp.children {
|
||||
if has_pattern {
|
||||
if c.field_name.as_deref() == Some("pattern") {
|
||||
feed_pattern(h, c);
|
||||
} else {
|
||||
feed_as_literal(h, c);
|
||||
}
|
||||
} else if matches!(
|
||||
c.kind.as_str(),
|
||||
"identifier" | "shorthand_field_identifier" | "field_identifier"
|
||||
) {
|
||||
emit_binder_node(h, c);
|
||||
} else {
|
||||
feed_as_literal(h, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_as_literal(h: &mut Hasher, node: &SemanticNode) {
|
||||
write_kind_and_field(h, node);
|
||||
emit_leaf_marker(h, node);
|
||||
h.update(&(node.children.len() as u64).to_le_bytes());
|
||||
for c in &node.children {
|
||||
feed_as_literal(h, c);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_callable_binders(params: &SemanticNode, out: &mut Vec<String>) {
|
||||
for c in ¶ms.children {
|
||||
match c.kind.as_str() {
|
||||
"parameter" => {
|
||||
for cc in &c.children {
|
||||
if cc.field_name.as_deref() == Some("pattern") {
|
||||
collect_pattern_binders(cc, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => collect_pattern_binders(c, out),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_pattern_binders(p: &SemanticNode, out: &mut Vec<String>) {
|
||||
match p.kind.as_str() {
|
||||
"identifier" => {
|
||||
if is_binder_identifier(p) {
|
||||
push_identifier_name(p, out);
|
||||
}
|
||||
}
|
||||
"tuple_pattern" | "ref_pattern" | "reference_pattern" | "mut_pattern" | "slice_pattern" => {
|
||||
for c in &p.children {
|
||||
collect_pattern_binders(c, out);
|
||||
}
|
||||
}
|
||||
"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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Determina si un `identifier` en posición de patrón se interpreta como
|
||||
/// binder. Reglas:
|
||||
/// - 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,
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,52 @@
|
||||
use tree_sitter::Node;
|
||||
|
||||
/// Nodo de AST normalizado: descarta posiciones, whitespace y trivia
|
||||
/// (comentarios marcados como `extra` en la gramática). Dos fragmentos de
|
||||
/// código semánticamente equivalentes producen árboles idénticos.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SemanticNode {
|
||||
pub kind: String,
|
||||
pub field_name: Option<String>,
|
||||
pub leaf_text: Option<Vec<u8>>,
|
||||
pub children: Vec<SemanticNode>,
|
||||
}
|
||||
|
||||
impl SemanticNode {
|
||||
pub fn from_tree_sitter(node: Node<'_>, source: &[u8]) -> Self {
|
||||
Self::build(node, source, None)
|
||||
}
|
||||
|
||||
fn build(node: Node<'_>, source: &[u8], field_name: Option<String>) -> Self {
|
||||
let kind = node.kind().to_string();
|
||||
let mut children = Vec::new();
|
||||
|
||||
// Incluimos todos los hijos no-`extra`: nombrados (rules de la
|
||||
// gramática) y anónimos (tokens literales como operadores y
|
||||
// separadores). Lo único que descartamos son `extras` —
|
||||
// comentarios y whitespace en gramáticas tree-sitter — que es
|
||||
// exactamente la invariancia que queremos: dos formas con el
|
||||
// mismo contenido y estructura producen el mismo árbol.
|
||||
let mut cursor = node.walk();
|
||||
if cursor.goto_first_child() {
|
||||
loop {
|
||||
let child = cursor.node();
|
||||
if !child.is_extra() {
|
||||
let field = cursor.field_name().map(|s| s.to_string());
|
||||
children.push(Self::build(child, source, field));
|
||||
}
|
||||
if !cursor.goto_next_sibling() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let leaf_text = if children.is_empty() {
|
||||
let range = node.byte_range();
|
||||
Some(source[range].to_vec())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
SemanticNode { kind, field_name, leaf_text, children }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Atestaciones firmadas: la sustancia material de la atribución
|
||||
//! irrefutable. Una `Attestation` es una firma criptográfica sobre un
|
||||
//! `ContentHash` que vincula a su autor (un `Did`) con un fragmento
|
||||
//! concreto de contenido del repositorio.
|
||||
//!
|
||||
//! Modelo: cada hash del MST puede tener cero o más atestaciones,
|
||||
//! provenientes de autores distintos. La existencia de una atestación
|
||||
//! válida prueba que el dueño de cierta clave privada **vio y firmó
|
||||
//! exactamente ese hash** — no puede negarlo después sin admitir que
|
||||
//! filtró su llave. Es el equivalente a un commit firmado en Git pero
|
||||
//! a granularidad arbitraria: una función, un módulo, o un estado del
|
||||
//! repositorio entero.
|
||||
//!
|
||||
//! `AttestationStore` solo acepta atestaciones criptográficamente
|
||||
//! válidas: el `add` rechaza cualquier intento de inyectar firmas
|
||||
//! falsificadas. Esto convierte al store en una fuente confiable de
|
||||
//! la pregunta "¿quién ha respaldado este contenido?".
|
||||
|
||||
use crate::cas::ContentHash;
|
||||
use crate::identity::{Did, Keypair, Signature};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Attestation {
|
||||
pub content: ContentHash,
|
||||
pub author: Did,
|
||||
pub signature: Signature,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AttestationError {
|
||||
InvalidSignature,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AttestationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidSignature => write!(f, "firma de la atestación no verifica"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AttestationError {}
|
||||
|
||||
impl Attestation {
|
||||
/// Crea una atestación firmando el `ContentHash` con la `Keypair`
|
||||
/// del autor. El `Did` queda registrado a partir de la `Keypair`
|
||||
/// — no se acepta un `Did` arbitrario, lo que descarta de raíz
|
||||
/// las atestaciones donde alguien dice ser otro.
|
||||
pub fn create(keypair: &Keypair, content: ContentHash) -> Self {
|
||||
Self {
|
||||
content,
|
||||
author: keypair.did(),
|
||||
signature: keypair.sign(&content.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifica que `signature` es una firma válida sobre `content`
|
||||
/// hecha con la llave privada del `author`. Cualquier modificación
|
||||
/// de cualquiera de los tres campos invalida la atestación.
|
||||
pub fn verify(&self) -> bool {
|
||||
self.author.verify(&self.content.0, &self.signature)
|
||||
}
|
||||
}
|
||||
|
||||
/// Registro de atestaciones por `ContentHash`.
|
||||
///
|
||||
/// Idempotente por `(author, content)`: insertar dos veces la misma
|
||||
/// atestación no la duplica. Pero un mismo `ContentHash` puede tener
|
||||
/// atestaciones de **autores distintos** — es la base de los "filtros
|
||||
/// de convergencia" del spec, donde el peso de un cambio se mide por
|
||||
/// cuántas identidades reputadas lo respaldan.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct AttestationStore {
|
||||
by_content: HashMap<ContentHash, Vec<Attestation>>,
|
||||
}
|
||||
|
||||
impl AttestationStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Inserta una atestación. Devuelve `Err(InvalidSignature)` si la
|
||||
/// firma no verifica — el store NUNCA almacena firmas rotas, así
|
||||
/// que cualquier consulta posterior puede confiar en lo que lee.
|
||||
pub fn add(&mut self, att: Attestation) -> Result<(), AttestationError> {
|
||||
if !att.verify() {
|
||||
return Err(AttestationError::InvalidSignature);
|
||||
}
|
||||
let entry = self.by_content.entry(att.content).or_default();
|
||||
if !entry.iter().any(|a| a.author == att.author) {
|
||||
entry.push(att);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get(&self, content: &ContentHash) -> &[Attestation] {
|
||||
self.by_content
|
||||
.get(content)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Conjunto de DIDs que han atestado este contenido. Cada autor
|
||||
/// aparece como máximo una vez (deduplicación por `add`).
|
||||
pub fn authors_of(&self, content: &ContentHash) -> Vec<Did> {
|
||||
self.by_content
|
||||
.get(content)
|
||||
.map(|v| v.iter().map(|a| a.author).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_content.values().map(Vec::len).sum()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_content.values().all(Vec::is_empty)
|
||||
}
|
||||
|
||||
/// Itera todas las atestaciones del store (orden no especificado).
|
||||
/// Usado por el protocolo de sync para enumerar lo que tenemos y
|
||||
/// empujarlo al peer.
|
||||
pub fn all(&self) -> impl Iterator<Item = &Attestation> + '_ {
|
||||
self.by_content.values().flat_map(|v| v.iter())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use crate::ast::SemanticNode;
|
||||
use blake3::Hasher;
|
||||
|
||||
/// Hash de 32 bytes que identifica unívocamente un `SemanticNode` por su
|
||||
/// estructura lógica. Dos nodos con misma estructura → mismo hash, sin
|
||||
/// importar formato, 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 formato 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("formato inválido: faltan magic / versión / longitud")]
|
||||
InvalidFormat,
|
||||
|
||||
#[error("passphrase incorrecta o cifrado manipulado")]
|
||||
DecryptFailed,
|
||||
|
||||
#[error("argon2: {0}")]
|
||||
Argon2(String),
|
||||
}
|
||||
|
||||
/// Decentralized Identifier: 32 bytes de la clave pública Ed25519.
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct Did(pub [u8; SECRET_KEY_LENGTH]);
|
||||
|
||||
impl Did {
|
||||
pub fn as_bytes(&self) -> &[u8; SECRET_KEY_LENGTH] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Verifica que `sig` sea una firma válida sobre `msg` producida
|
||||
/// con la llave privada correspondiente a este DID. Devuelve
|
||||
/// `false` ante cualquier irregularidad: bytes de DID que no son
|
||||
/// un punto válido en la curva, firma malformada, mensaje que no
|
||||
/// coincide.
|
||||
pub fn verify(&self, msg: &[u8], sig: &Signature) -> bool {
|
||||
let Ok(vk) = VerifyingKey::from_bytes(&self.0) else {
|
||||
return false;
|
||||
};
|
||||
let ed_sig = Ed25519Sig::from_bytes(&sig.0);
|
||||
vk.verify(msg, &ed_sig).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Did {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "did:key:")?;
|
||||
for b in &self.0 {
|
||||
write!(f, "{:02x}", b)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Signature(
|
||||
#[serde(with = "serde_big_array::BigArray")] pub [u8; SIGNATURE_LENGTH],
|
||||
);
|
||||
|
||||
impl Signature {
|
||||
pub fn as_bytes(&self) -> &[u8; SIGNATURE_LENGTH] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Llave criptográfica completa: priva (para firmar) + pública (para
|
||||
/// que otros verifiquen). Por convención llamamos `Did` al lado público
|
||||
/// expuesto al mundo, pero el `Keypair` mantiene ambos lados juntos.
|
||||
#[derive(Clone)]
|
||||
pub struct Keypair {
|
||||
signing: SigningKey,
|
||||
}
|
||||
|
||||
impl Keypair {
|
||||
/// Genera un nuevo `Keypair` usando aleatoriedad del sistema
|
||||
/// operativo (`/dev/urandom` en Unix, `BCryptGenRandom` en
|
||||
/// Windows). Para producción.
|
||||
pub fn generate() -> Self {
|
||||
let mut seed = [0u8; SECRET_KEY_LENGTH];
|
||||
OsRng.fill_bytes(&mut seed);
|
||||
Self::from_seed(&seed)
|
||||
}
|
||||
|
||||
/// Reconstruye un `Keypair` desde una semilla de 32 bytes. Misma
|
||||
/// semilla → mismo `Keypair` (mismo `Did`, mismas firmas). Útil
|
||||
/// para tests reproducibles y para escenarios donde la semilla
|
||||
/// proviene de otra fuente determinista (HKDF, BIP39, etc.).
|
||||
pub fn from_seed(seed: &[u8; SECRET_KEY_LENGTH]) -> Self {
|
||||
Self {
|
||||
signing: SigningKey::from_bytes(seed),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn did(&self) -> Did {
|
||||
Did(self.signing.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
pub fn sign(&self, msg: &[u8]) -> Signature {
|
||||
Signature(self.signing.sign(msg).to_bytes())
|
||||
}
|
||||
|
||||
/// Cifra la parte privada del keypair con una passphrase humana.
|
||||
/// Esquema:
|
||||
///
|
||||
/// 1. Genera un salt aleatorio de 16 bytes y un nonce de 12 bytes.
|
||||
/// 2. Deriva una clave AES-256 desde la passphrase vía Argon2id
|
||||
/// (parámetros por defecto OWASP).
|
||||
/// 3. Cifra los 32 bytes de la clave secreta con AES-256-GCM
|
||||
/// (autenticado: integrity built-in).
|
||||
/// 4. Compone el blob:
|
||||
/// `MAGIC(8) || VERSION(1) || SALT(16) || NONCE(12) || CIPHERTEXT+TAG(48)`.
|
||||
///
|
||||
/// Total: 85 bytes. La passphrase nunca se almacena; quien no la
|
||||
/// conozca no puede recuperar la identidad.
|
||||
pub fn encrypt(&self, passphrase: &str) -> Result<Vec<u8>, KeypairCryptoError> {
|
||||
let mut salt = [0u8; ARGON2_SALT_LEN];
|
||||
let mut nonce_bytes = [0u8; AES_NONCE_LEN];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
|
||||
let aes_key = derive_aes_key(passphrase, &salt)?;
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&aes_key)
|
||||
.map_err(|_| KeypairCryptoError::DecryptFailed)?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let secret_bytes = self.signing.to_bytes();
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, secret_bytes.as_ref())
|
||||
.map_err(|_| KeypairCryptoError::DecryptFailed)?;
|
||||
|
||||
let mut out = Vec::with_capacity(KEYPAIR_HEADER_LEN + ciphertext.len());
|
||||
out.extend_from_slice(KEYPAIR_MAGIC);
|
||||
out.push(KEYPAIR_VERSION);
|
||||
out.extend_from_slice(&salt);
|
||||
out.extend_from_slice(&nonce_bytes);
|
||||
out.extend_from_slice(&ciphertext);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Descifra un keypair cifrado con `encrypt`. Falla con
|
||||
/// `DecryptFailed` si la passphrase es incorrecta **o** si los
|
||||
/// bytes han sido manipulados (AES-GCM detecta ambas vías).
|
||||
pub fn decrypt(bytes: &[u8], passphrase: &str) -> Result<Self, KeypairCryptoError> {
|
||||
if bytes.len() < KEYPAIR_HEADER_LEN {
|
||||
return Err(KeypairCryptoError::InvalidFormat);
|
||||
}
|
||||
if &bytes[..8] != KEYPAIR_MAGIC {
|
||||
return Err(KeypairCryptoError::InvalidFormat);
|
||||
}
|
||||
if bytes[8] != KEYPAIR_VERSION {
|
||||
return Err(KeypairCryptoError::InvalidFormat);
|
||||
}
|
||||
|
||||
let salt = &bytes[9..9 + ARGON2_SALT_LEN];
|
||||
let nonce_bytes = &bytes[9 + ARGON2_SALT_LEN..KEYPAIR_HEADER_LEN];
|
||||
let ciphertext = &bytes[KEYPAIR_HEADER_LEN..];
|
||||
|
||||
let aes_key = derive_aes_key(passphrase, salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&aes_key)
|
||||
.map_err(|_| KeypairCryptoError::DecryptFailed)?;
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| KeypairCryptoError::DecryptFailed)?;
|
||||
|
||||
if plaintext.len() != SECRET_KEY_LENGTH {
|
||||
return Err(KeypairCryptoError::InvalidFormat);
|
||||
}
|
||||
let mut seed = [0u8; SECRET_KEY_LENGTH];
|
||||
seed.copy_from_slice(&plaintext);
|
||||
Ok(Self::from_seed(&seed))
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_aes_key(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], KeypairCryptoError> {
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
|
||||
.map_err(|e| KeypairCryptoError::Argon2(e.to_string()))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Keypair {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// Nunca exponemos la parte privada en debug. Solo el DID.
|
||||
write!(f, "Keypair {{ did: {} }}", self.did())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! 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 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 store::{hash_stored, MemStore, NodeStore, StoredNode};
|
||||
@@ -0,0 +1,457 @@
|
||||
//! Merkle Search Tree (MST).
|
||||
//!
|
||||
//! Estructura B-árbol probabilística sobre hashes, en la que el "nivel" de
|
||||
//! cada clave se deriva determinísticamente de su propio hash (cantidad de
|
||||
//! nibbles cero al inicio). Eso da dos propiedades clave:
|
||||
//!
|
||||
//! * **Independencia del orden de inserción.** El conjunto `{a, b, c}`
|
||||
//! siempre produce el mismo árbol y el mismo `root_hash`, sin importar
|
||||
//! en qué orden se insertaron las claves.
|
||||
//! * **Comparación logarítmica.** Dos repositorios pueden saber si tienen
|
||||
//! el mismo conjunto de hashes con un único byte (`root_hash`); y, si
|
||||
//! difieren, descender solo por las ramas con hashes distintos.
|
||||
//!
|
||||
//! Esta implementación es completa para insert/contains/iter y produce un
|
||||
//! `root_hash` Merkle correcto. La operación de `diff` mínima (delta de
|
||||
//! sincronización P2P) se construirá encima cuando exista `minga-p2p`.
|
||||
|
||||
use crate::cas::ContentHash;
|
||||
use blake3::Hasher;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Resumen estructural de un nodo interno del MST: nivel al que viven
|
||||
/// sus claves, las claves a ese nivel, y el hash de cada uno de sus
|
||||
/// hijos (subárboles). Esto es lo que un peer transmite cuando otro le
|
||||
/// pregunta por la forma de un subárbol durante una sincronización
|
||||
/// recursiva: bandwidth proporcional a la divergencia, no al tamaño.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct NodeProbe {
|
||||
pub level: u32,
|
||||
pub keys: Vec<ContentHash>,
|
||||
pub child_hashes: Vec<ContentHash>,
|
||||
}
|
||||
|
||||
/// Hash canónico del subárbol vacío (el "neutro" del MST). Cualquier
|
||||
/// peer puede computarlo localmente sin tocar la red, lo que permite
|
||||
/// reconocer ramas vacías en el otro lado sin pedir un probe.
|
||||
pub fn empty_subtree_hash() -> ContentHash {
|
||||
static H: OnceLock<ContentHash> = OnceLock::new();
|
||||
*H.get_or_init(|| {
|
||||
let mut h = Hasher::new();
|
||||
h.update(b"E");
|
||||
ContentHash(*h.finalize().as_bytes())
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Mst {
|
||||
root: Subtree,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
enum Subtree {
|
||||
#[default]
|
||||
Empty,
|
||||
Node(Box<NodeData>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct NodeData {
|
||||
level: u32,
|
||||
keys: Vec<ContentHash>,
|
||||
children: Vec<Subtree>,
|
||||
}
|
||||
|
||||
/// Nivel determinístico de un hash: número de nibbles (4 bits) cero al
|
||||
/// inicio. Distribución geométrica con base 16, lo que da árbol balanceado
|
||||
/// en expectativa con profundidad logarítmica.
|
||||
fn level_of(h: &ContentHash) -> u32 {
|
||||
let mut count = 0u32;
|
||||
for &b in &h.0 {
|
||||
if b == 0 {
|
||||
count += 2;
|
||||
} else if b < 0x10 {
|
||||
count += 1;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
impl Mst {
|
||||
pub fn new() -> Self {
|
||||
Self { root: Subtree::Empty }
|
||||
}
|
||||
|
||||
/// Inserta `h`. Devuelve `true` si era una clave nueva.
|
||||
pub fn insert(&mut self, h: ContentHash) -> bool {
|
||||
let l = level_of(&h);
|
||||
let root = std::mem::take(&mut self.root);
|
||||
let (new_root, inserted) = insert_in(root, h, l);
|
||||
self.root = new_root;
|
||||
inserted
|
||||
}
|
||||
|
||||
pub fn contains(&self, h: &ContentHash) -> bool {
|
||||
contains_in(&self.root, h)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
len_of(&self.root)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
matches!(self.root, Subtree::Empty)
|
||||
}
|
||||
|
||||
/// Recorrido in-order: claves emitidas en orden ascendente por hash.
|
||||
pub fn iter(&self) -> Iter<'_> {
|
||||
let mut it = Iter { stack: Vec::new() };
|
||||
it.descend_left(&self.root);
|
||||
it
|
||||
}
|
||||
|
||||
/// Hash Merkle del árbol completo. Dos MSTs con el mismo conjunto de
|
||||
/// claves tienen el mismo `root_hash`, sin importar orden de inserción.
|
||||
pub fn root_hash(&self) -> ContentHash {
|
||||
subtree_hash(&self.root)
|
||||
}
|
||||
|
||||
/// Construye un índice `subtree_hash -> NodeProbe` cubriendo cada
|
||||
/// nodo interno del árbol. Sirve a un peer como tabla de respuestas
|
||||
/// instantáneas a `ProbeReq`s del otro lado: dado un hash que el
|
||||
/// peer recibió de nosotros (en un Hello o un ProbeRes previo),
|
||||
/// podemos reconstituir su `NodeProbe` en `O(1)`.
|
||||
pub fn build_probe_index(&self) -> HashMap<ContentHash, NodeProbe> {
|
||||
let mut idx = HashMap::new();
|
||||
index_subtree(&self.root, &mut idx);
|
||||
idx
|
||||
}
|
||||
|
||||
/// Diferencia simétrica entre `self` y `other`. Devuelve las claves
|
||||
/// que están en `self` pero no en `other`, y viceversa.
|
||||
///
|
||||
/// Aprovecha la estructura Merkle: cualquier subárbol cuya raíz
|
||||
/// hashee igual entre ambos lados se descarta sin descender. Cuando
|
||||
/// dos nodos comparten nivel y separadores, recurrimos en paralelo
|
||||
/// sobre sus hijos — cada par idéntico se poda por hash. Cuando la
|
||||
/// estructura diverge (niveles distintos o separadores distintos en
|
||||
/// el mismo nivel), enumeramos las claves de ambos y hacemos merge
|
||||
/// ordenado.
|
||||
///
|
||||
/// El resultado siempre viene ordenado por hash ascendente, lo que
|
||||
/// permite a un peer P2P hacer streaming de los bloques que faltan
|
||||
/// en orden estable y deduplicar mientras los recibe.
|
||||
pub fn diff(&self, other: &Mst) -> MstDiff {
|
||||
let mut d = MstDiff::default();
|
||||
diff_subtrees(&self.root, &other.root, &mut d.only_in_self, &mut d.only_in_other);
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
/// Resultado de comparar dos MSTs. `is_empty()` ⇔ ambos representan el
|
||||
/// mismo conjunto.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct MstDiff {
|
||||
pub only_in_self: Vec<ContentHash>,
|
||||
pub only_in_other: Vec<ContentHash>,
|
||||
}
|
||||
|
||||
impl MstDiff {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.only_in_self.is_empty() && self.only_in_other.is_empty()
|
||||
}
|
||||
|
||||
pub fn total(&self) -> usize {
|
||||
self.only_in_self.len() + self.only_in_other.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_in(t: &Subtree, h: &ContentHash) -> bool {
|
||||
match t {
|
||||
Subtree::Empty => false,
|
||||
Subtree::Node(n) => match n.keys.binary_search(h) {
|
||||
Ok(_) => true,
|
||||
Err(i) => contains_in(&n.children[i], h),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn len_of(t: &Subtree) -> usize {
|
||||
match t {
|
||||
Subtree::Empty => 0,
|
||||
Subtree::Node(n) => n.keys.len() + n.children.iter().map(len_of).sum::<usize>(),
|
||||
}
|
||||
}
|
||||
|
||||
fn subtree_hash(t: &Subtree) -> ContentHash {
|
||||
let mut h = Hasher::new();
|
||||
match t {
|
||||
Subtree::Empty => {
|
||||
h.update(b"E");
|
||||
}
|
||||
Subtree::Node(n) => {
|
||||
h.update(b"N");
|
||||
h.update(&n.level.to_le_bytes());
|
||||
h.update(&(n.keys.len() as u64).to_le_bytes());
|
||||
for k in &n.keys {
|
||||
h.update(&k.0);
|
||||
}
|
||||
for c in &n.children {
|
||||
h.update(&subtree_hash(c).0);
|
||||
}
|
||||
}
|
||||
}
|
||||
ContentHash(*h.finalize().as_bytes())
|
||||
}
|
||||
|
||||
/// Inserta `h` (de nivel `l`) en el subárbol `t`. Devuelve el nuevo
|
||||
/// subárbol y si fue una inserción real (no duplicado).
|
||||
fn insert_in(t: Subtree, h: ContentHash, l: u32) -> (Subtree, bool) {
|
||||
match t {
|
||||
Subtree::Empty => {
|
||||
let node = NodeData {
|
||||
level: l,
|
||||
keys: vec![h],
|
||||
children: vec![Subtree::Empty, Subtree::Empty],
|
||||
};
|
||||
(Subtree::Node(Box::new(node)), true)
|
||||
}
|
||||
Subtree::Node(boxed) => {
|
||||
let n = *boxed;
|
||||
if l > n.level {
|
||||
// Nueva clave de nivel mayor: parte el árbol actual y la
|
||||
// promueve a nueva raíz.
|
||||
let (left, right) = split_at(Subtree::Node(Box::new(n)), &h);
|
||||
let new_root = NodeData {
|
||||
level: l,
|
||||
keys: vec![h],
|
||||
children: vec![left, right],
|
||||
};
|
||||
(Subtree::Node(Box::new(new_root)), true)
|
||||
} else if l == n.level {
|
||||
match n.keys.binary_search(&h) {
|
||||
Ok(_) => (Subtree::Node(Box::new(n)), false),
|
||||
Err(i) => {
|
||||
let NodeData { level, mut keys, mut children } = n;
|
||||
let middle = std::mem::replace(&mut children[i], Subtree::Empty);
|
||||
let (left, right) = split_at(middle, &h);
|
||||
keys.insert(i, h);
|
||||
children[i] = left;
|
||||
children.insert(i + 1, right);
|
||||
(
|
||||
Subtree::Node(Box::new(NodeData { level, keys, children })),
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// l < n.level: la clave nueva pertenece a un subárbol bajo
|
||||
// el separador correspondiente.
|
||||
let i = match n.keys.binary_search(&h) {
|
||||
Ok(_) => unreachable!(
|
||||
"colisión: clave de nivel inferior coincide con separador de nivel superior"
|
||||
),
|
||||
Err(i) => i,
|
||||
};
|
||||
let NodeData { level, keys, mut children } = n;
|
||||
let child = std::mem::replace(&mut children[i], Subtree::Empty);
|
||||
let (new_child, inserted) = insert_in(child, h, l);
|
||||
children[i] = new_child;
|
||||
(
|
||||
Subtree::Node(Box::new(NodeData { level, keys, children })),
|
||||
inserted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parte `t` en (claves < pivot, claves > pivot). Pre-condición: el nivel
|
||||
/// de cada subárbol involucrado es estrictamente menor que el del pivot
|
||||
/// (que vive arriba). El pivot mismo no aparece en el resultado.
|
||||
fn split_at(t: Subtree, pivot: &ContentHash) -> (Subtree, Subtree) {
|
||||
match t {
|
||||
Subtree::Empty => (Subtree::Empty, Subtree::Empty),
|
||||
Subtree::Node(boxed) => {
|
||||
let n = *boxed;
|
||||
let i = match n.keys.binary_search(pivot) {
|
||||
Ok(_) => unreachable!("pivot coincide con clave de nivel inferior"),
|
||||
Err(i) => i,
|
||||
};
|
||||
let NodeData { level, keys, children } = n;
|
||||
|
||||
let mut left_keys = keys.clone();
|
||||
left_keys.truncate(i);
|
||||
let mut right_keys = keys;
|
||||
right_keys.drain(..i);
|
||||
|
||||
let mut left_children: Vec<Subtree> = Vec::with_capacity(i + 1);
|
||||
let mut right_children: Vec<Subtree> = Vec::with_capacity(level as usize + 1);
|
||||
|
||||
let mut iter = children.into_iter();
|
||||
for _ in 0..i {
|
||||
left_children.push(iter.next().expect("invariante: children > i"));
|
||||
}
|
||||
let middle = iter.next().expect("invariante: existe children[i]");
|
||||
let (l_mid, r_mid) = split_at(middle, pivot);
|
||||
left_children.push(l_mid);
|
||||
right_children.push(r_mid);
|
||||
for c in iter {
|
||||
right_children.push(c);
|
||||
}
|
||||
|
||||
let left = if left_keys.is_empty() {
|
||||
left_children.pop().unwrap_or(Subtree::Empty)
|
||||
} else {
|
||||
Subtree::Node(Box::new(NodeData {
|
||||
level,
|
||||
keys: left_keys,
|
||||
children: left_children,
|
||||
}))
|
||||
};
|
||||
let right = if right_keys.is_empty() {
|
||||
right_children.pop().unwrap_or(Subtree::Empty)
|
||||
} else {
|
||||
Subtree::Node(Box::new(NodeData {
|
||||
level,
|
||||
keys: right_keys,
|
||||
children: right_children,
|
||||
}))
|
||||
};
|
||||
(left, right)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn index_subtree(t: &Subtree, idx: &mut HashMap<ContentHash, NodeProbe>) {
|
||||
if let Subtree::Node(n) = t {
|
||||
let child_hashes: Vec<ContentHash> = n.children.iter().map(subtree_hash).collect();
|
||||
let probe = NodeProbe {
|
||||
level: n.level,
|
||||
keys: n.keys.clone(),
|
||||
child_hashes,
|
||||
};
|
||||
idx.insert(subtree_hash(t), probe);
|
||||
for c in &n.children {
|
||||
index_subtree(c, idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_subtrees(
|
||||
t1: &Subtree,
|
||||
t2: &Subtree,
|
||||
only_in_1: &mut Vec<ContentHash>,
|
||||
only_in_2: &mut Vec<ContentHash>,
|
||||
) {
|
||||
// Short-circuit por hash Merkle: si los dos subárboles colapsan al
|
||||
// mismo hash de 32 bytes, representan el mismo conjunto. Una sola
|
||||
// comparación poda toda la rama. Aplicado recursivamente, en árboles
|
||||
// mayormente iguales el coste es proporcional a la divergencia, no al
|
||||
// tamaño total.
|
||||
if subtree_hash(t1) == subtree_hash(t2) {
|
||||
return;
|
||||
}
|
||||
match (t1, t2) {
|
||||
(Subtree::Empty, _) => collect_all(t2, only_in_2),
|
||||
(_, Subtree::Empty) => collect_all(t1, only_in_1),
|
||||
(Subtree::Node(n1), Subtree::Node(n2)) => {
|
||||
if n1.level == n2.level && n1.keys == n2.keys {
|
||||
// Mismo nivel y mismos separadores: los hijos se alinean
|
||||
// posicionalmente. Recurrimos en paralelo — cada par
|
||||
// idéntico se podará en su llamada por el hash de Merkle.
|
||||
for (c1, c2) in n1.children.iter().zip(n2.children.iter()) {
|
||||
diff_subtrees(c1, c2, only_in_1, only_in_2);
|
||||
}
|
||||
} else {
|
||||
// Estructura divergente. Enumeramos ambos lados ordenados
|
||||
// y hacemos merge. Correcto pero sin más poda Merkle: una
|
||||
// futura iteración con `split_at` por cada separador del
|
||||
// nivel mayor recuperaría la poda en el caso desalineado.
|
||||
let mut k1 = Vec::with_capacity(len_of(t1));
|
||||
let mut k2 = Vec::with_capacity(len_of(t2));
|
||||
collect_all(t1, &mut k1);
|
||||
collect_all(t2, &mut k2);
|
||||
merge_diff_sorted(&k1, &k2, only_in_1, only_in_2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_all(t: &Subtree, out: &mut Vec<ContentHash>) {
|
||||
if let Subtree::Node(n) = t {
|
||||
for i in 0..n.keys.len() {
|
||||
collect_all(&n.children[i], out);
|
||||
out.push(n.keys[i]);
|
||||
}
|
||||
collect_all(&n.children[n.keys.len()], out);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_diff_sorted(
|
||||
a: &[ContentHash],
|
||||
b: &[ContentHash],
|
||||
only_a: &mut Vec<ContentHash>,
|
||||
only_b: &mut Vec<ContentHash>,
|
||||
) {
|
||||
let mut i = 0;
|
||||
let mut j = 0;
|
||||
while i < a.len() && j < b.len() {
|
||||
match a[i].cmp(&b[j]) {
|
||||
std::cmp::Ordering::Less => {
|
||||
only_a.push(a[i]);
|
||||
i += 1;
|
||||
}
|
||||
std::cmp::Ordering::Greater => {
|
||||
only_b.push(b[j]);
|
||||
j += 1;
|
||||
}
|
||||
std::cmp::Ordering::Equal => {
|
||||
i += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
only_a.extend_from_slice(&a[i..]);
|
||||
only_b.extend_from_slice(&b[j..]);
|
||||
}
|
||||
|
||||
pub struct Iter<'a> {
|
||||
/// Cada frame es (nodo, próximo índice de clave a emitir). Cuando se
|
||||
/// pushea un frame, ya descendimos por su hijo izquierdo (children[0]).
|
||||
stack: Vec<(&'a NodeData, usize)>,
|
||||
}
|
||||
|
||||
impl<'a> Iter<'a> {
|
||||
fn descend_left(&mut self, t: &'a Subtree) {
|
||||
let mut cur = t;
|
||||
while let Subtree::Node(n) = cur {
|
||||
self.stack.push((n.as_ref(), 0));
|
||||
cur = &n.children[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for Iter<'a> {
|
||||
type Item = &'a ContentHash;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
let (node, ki) = {
|
||||
let top = self.stack.last()?;
|
||||
(top.0, top.1)
|
||||
};
|
||||
if ki < node.keys.len() {
|
||||
self.stack.last_mut().unwrap().1 = ki + 1;
|
||||
self.descend_left(&node.children[ki + 1]);
|
||||
return Some(&node.keys[ki]);
|
||||
} else {
|
||||
self.stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Adaptadores de parsing por dialecto. Hoy: Rust vía tree-sitter-rust.
|
||||
//!
|
||||
//! `parse::rust` produce un `SemanticNode` normalizado a partir de una
|
||||
//! cadena de código fuente. El error es opaco a propósito: el caller no
|
||||
//! necesita distinguir "gramática inválida" de "fallo del parser".
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
pub fn rust(source: &str) -> Result<SemanticNode, ParseError> {
|
||||
let lang: Language = tree_sitter_rust::LANGUAGE.into();
|
||||
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()))
|
||||
}
|
||||
@@ -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 formato 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,252 @@
|
||||
//! 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));
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Invariantes de las atestaciones firmadas y del `AttestationStore`.
|
||||
//!
|
||||
//! La tesis del módulo: una atestación válida es una **prueba**
|
||||
//! criptográfica de autoría, no una declaración. El store nunca
|
||||
//! almacena pruebas falsas — cualquier intento de inyectar una firma
|
||||
//! corrupta se rechaza al ingresar, no al consultar.
|
||||
|
||||
use minga_core::{Attestation, AttestationError, AttestationStore, ContentHash, Keypair};
|
||||
|
||||
fn kp(seed: u8) -> Keypair {
|
||||
Keypair::from_seed(&[seed; 32])
|
||||
}
|
||||
|
||||
fn ch(seed: u8) -> ContentHash {
|
||||
ContentHash([seed; 32])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_then_verify_succeeds() {
|
||||
let alice = kp(1);
|
||||
let att = Attestation::create(&alice, ch(7));
|
||||
assert!(att.verify());
|
||||
assert_eq!(att.author, alice.did());
|
||||
assert_eq!(att.content, ch(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifying_content_invalidates() {
|
||||
let alice = kp(1);
|
||||
let mut att = Attestation::create(&alice, ch(7));
|
||||
att.content = ch(8);
|
||||
assert!(!att.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifying_signature_invalidates() {
|
||||
let alice = kp(1);
|
||||
let mut att = Attestation::create(&alice, ch(7));
|
||||
att.signature.0[0] ^= 0xFF;
|
||||
assert!(!att.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifying_author_invalidates() {
|
||||
let alice = kp(1);
|
||||
let bob = kp(2);
|
||||
let mut att = Attestation::create(&alice, ch(7));
|
||||
att.author = bob.did();
|
||||
assert!(!att.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_accepts_valid_attestation() {
|
||||
let alice = kp(1);
|
||||
let att = Attestation::create(&alice, ch(5));
|
||||
let mut store = AttestationStore::new();
|
||||
assert!(store.add(att.clone()).is_ok());
|
||||
assert_eq!(store.len(), 1);
|
||||
assert_eq!(store.get(&ch(5)), &[att][..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_rejects_invalid_signature() {
|
||||
let alice = kp(1);
|
||||
let mut att = Attestation::create(&alice, ch(5));
|
||||
att.signature.0[10] ^= 1;
|
||||
let mut store = AttestationStore::new();
|
||||
assert_eq!(store.add(att), Err(AttestationError::InvalidSignature));
|
||||
assert_eq!(store.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_rejects_swapped_content() {
|
||||
// Atestación creada para `ch(1)`, modificada para reclamar `ch(2)`.
|
||||
// La firma sigue siendo válida sobre `ch(1)` pero ahora el content
|
||||
// dice `ch(2)` — no verifica.
|
||||
let alice = kp(1);
|
||||
let mut att = Attestation::create(&alice, ch(1));
|
||||
att.content = ch(2);
|
||||
let mut store = AttestationStore::new();
|
||||
assert!(store.add(att).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_is_idempotent_for_same_author_content() {
|
||||
let alice = kp(1);
|
||||
let att = Attestation::create(&alice, ch(5));
|
||||
let mut store = AttestationStore::new();
|
||||
store.add(att.clone()).unwrap();
|
||||
store.add(att.clone()).unwrap();
|
||||
store.add(att).unwrap();
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_keeps_multiple_authors_per_content() {
|
||||
let alice = kp(1);
|
||||
let bob = kp(2);
|
||||
let carol = kp(3);
|
||||
let h = ch(99);
|
||||
let mut store = AttestationStore::new();
|
||||
store.add(Attestation::create(&alice, h)).unwrap();
|
||||
store.add(Attestation::create(&bob, h)).unwrap();
|
||||
store.add(Attestation::create(&carol, h)).unwrap();
|
||||
assert_eq!(store.len(), 3);
|
||||
assert_eq!(store.get(&h).len(), 3);
|
||||
|
||||
let authors = store.authors_of(&h);
|
||||
assert_eq!(authors.len(), 3);
|
||||
assert!(authors.contains(&alice.did()));
|
||||
assert!(authors.contains(&bob.did()));
|
||||
assert!(authors.contains(&carol.did()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authors_of_for_unknown_content_is_empty() {
|
||||
let store = AttestationStore::new();
|
||||
assert!(store.authors_of(&ch(0)).is_empty());
|
||||
assert_eq!(store.get(&ch(0)).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_authors_distinct_signatures_same_content() {
|
||||
// Firmar el mismo `ContentHash` con dos llaves distintas produce
|
||||
// firmas distintas (Ed25519 es determinista por llave, así que la
|
||||
// diferencia viene de la llave, no de un nonce aleatorio).
|
||||
let alice = kp(1);
|
||||
let bob = kp(2);
|
||||
let h = ch(50);
|
||||
let a1 = Attestation::create(&alice, h);
|
||||
let a2 = Attestation::create(&bob, h);
|
||||
assert_ne!(a1.signature, a2.signature);
|
||||
assert_ne!(a1.author, a2.author);
|
||||
assert!(a1.verify());
|
||||
assert!(a2.verify());
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Invariantes del direccionamiento por contenido semántico.
|
||||
//!
|
||||
//! Estos tests definen la *tesis matemática* del núcleo: qué cambios deben
|
||||
//! preservar el hash y qué cambios deben romperlo. Si alguno falla, la
|
||||
//! garantía fundacional de Minga está rota.
|
||||
|
||||
use minga_core::{cas::hash_node, parse};
|
||||
|
||||
#[test]
|
||||
fn whitespace_invariant() {
|
||||
let a = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap();
|
||||
let b = parse::rust("fn add(x:i32,y:i32)->i32{x+y}").unwrap();
|
||||
let c = parse::rust("fn add( x : i32 , y : i32 )\n -> i32\n{\n x + y\n}").unwrap();
|
||||
assert_eq!(hash_node(&a), hash_node(&b));
|
||||
assert_eq!(hash_node(&a), hash_node(&c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_invariant() {
|
||||
let a = parse::rust("fn f() { 1 + 2 }").unwrap();
|
||||
let b = parse::rust("fn f() { /* comentario */ 1 + 2 // cola\n }").unwrap();
|
||||
let c = parse::rust("// arriba\nfn f() {\n // dentro\n 1 + 2\n}\n").unwrap();
|
||||
assert_eq!(hash_node(&a), hash_node(&b));
|
||||
assert_eq!(hash_node(&a), hash_node(&c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_change_breaks_hash() {
|
||||
let a = parse::rust("fn f() { 1 + 2 }").unwrap();
|
||||
let b = parse::rust("fn f() { 1 + 3 }").unwrap();
|
||||
assert_ne!(hash_node(&a), hash_node(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_breaks_hash_for_now() {
|
||||
// Capa base: renombrar identificadores cambia el hash. La identidad
|
||||
// por intención (alpha-equivalencia: mismo cuerpo módulo nombres
|
||||
// ligados) es una capa superior que se construirá encima.
|
||||
let a = parse::rust("fn add(x: i32) -> i32 { x }").unwrap();
|
||||
let b = parse::rust("fn add(y: i32) -> i32 { y }").unwrap();
|
||||
assert_ne!(hash_node(&a), hash_node(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_change_breaks_hash() {
|
||||
let a = parse::rust("fn f(x: i32) -> i32 { x }").unwrap();
|
||||
let b = parse::rust("fn f(x: i64) -> i64 { x }").unwrap();
|
||||
assert_ne!(hash_node(&a), hash_node(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_matters() {
|
||||
// Reordenar dos funciones top-level cambia el hash del archivo entero
|
||||
// (el árbol del source_file tiene hijos ordenados). El hash de cada
|
||||
// función individual debe permanecer estable.
|
||||
let file_a = parse::rust("fn a() {} fn b() {}").unwrap();
|
||||
let file_b = parse::rust("fn b() {} fn a() {}").unwrap();
|
||||
assert_ne!(hash_node(&file_a), hash_node(&file_b));
|
||||
|
||||
// Pero las funciones individuales (segundo nivel) sí coinciden cruzadas:
|
||||
let fa = &file_a.children[0]; // fn a
|
||||
let fb_in_b = &file_b.children[1]; // fn a en file_b
|
||||
assert_eq!(hash_node(fa), hash_node(fb_in_b));
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Invariantes de la identidad criptográfica: roundtrip de firma,
|
||||
//! determinismo desde semilla, detección de manipulaciones.
|
||||
|
||||
use minga_core::{Did, Keypair, KeypairCryptoError, Signature};
|
||||
|
||||
fn kp(seed: u8) -> Keypair {
|
||||
Keypair::from_seed(&[seed; 32])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keypair_from_seed_is_deterministic() {
|
||||
let a = kp(7);
|
||||
let b = kp(7);
|
||||
assert_eq!(a.did(), b.did());
|
||||
let msg = b"hola minga";
|
||||
assert_eq!(a.sign(msg), b.sign(msg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_seeds_produce_distinct_dids() {
|
||||
let a = kp(1);
|
||||
let b = kp(2);
|
||||
assert_ne!(a.did(), b.did());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_produces_unique_dids() {
|
||||
// Dos `generate()` consecutivos deben dar DIDs distintos con
|
||||
// probabilidad abrumadora (chance de colisión ≈ 2^-256).
|
||||
let a = Keypair::generate();
|
||||
let b = Keypair::generate();
|
||||
assert_ne!(a.did(), b.did());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_verify_roundtrip() {
|
||||
let k = kp(42);
|
||||
let msg = b"mensaje arbitrario de longitud variable, con UTF-8: cafe \xc3\xa9";
|
||||
let sig = k.sign(msg);
|
||||
assert!(k.did().verify(msg, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_fails_with_wrong_did() {
|
||||
let signer = kp(10);
|
||||
let msg = b"contenido";
|
||||
let sig = signer.sign(msg);
|
||||
let imposter = kp(11).did();
|
||||
assert!(!imposter.verify(msg, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_fails_with_tampered_message() {
|
||||
let k = kp(99);
|
||||
let sig = k.sign(b"mensaje original");
|
||||
assert!(!k.did().verify(b"mensaje modificado", &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_fails_with_tampered_signature() {
|
||||
let k = kp(99);
|
||||
let mut sig = k.sign(b"x");
|
||||
sig.0[0] ^= 0xFF;
|
||||
assert!(!k.did().verify(b"x", &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_handles_invalid_did_bytes() {
|
||||
// Did con bytes que no forman un punto válido en la curva debería
|
||||
// fallar verificación silenciosamente (sin pánico).
|
||||
let bogus_did = Did([0xFF; 32]);
|
||||
let sig = Signature([0u8; 64]);
|
||||
assert!(!bogus_did.verify(b"anything", &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_display_uses_did_key_prefix() {
|
||||
let did = kp(0).did();
|
||||
let s = format!("{}", did);
|
||||
assert!(s.starts_with("did:key:"));
|
||||
assert_eq!(s.len(), "did:key:".len() + 64); // 32 bytes en hex = 64 chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_roundtrip_preserves_identity() {
|
||||
let original = kp(7);
|
||||
let blob = original.encrypt("contraseña-correcta").unwrap();
|
||||
let restored = Keypair::decrypt(&blob, "contraseña-correcta").unwrap();
|
||||
|
||||
// El DID se preserva: misma identidad pública.
|
||||
assert_eq!(original.did(), restored.did());
|
||||
|
||||
// Y la capacidad de firmar — un mensaje firmado por uno verifica
|
||||
// contra el DID del otro (porque son la misma llave).
|
||||
let msg = b"prueba post-cifrado";
|
||||
let sig_original = original.sign(msg);
|
||||
let sig_restored = restored.sign(msg);
|
||||
assert_eq!(sig_original, sig_restored);
|
||||
assert!(restored.did().verify(msg, &sig_original));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_with_wrong_passphrase_fails() {
|
||||
let kp = kp(11);
|
||||
let blob = kp.encrypt("correcta").unwrap();
|
||||
let r = Keypair::decrypt(&blob, "incorrecta");
|
||||
assert!(matches!(r, Err(KeypairCryptoError::DecryptFailed)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_rejects_tampered_ciphertext() {
|
||||
// AES-GCM es authenticated: cualquier modificación del cipher
|
||||
// (incluyendo el tag) hace fallar la verificación.
|
||||
let kp = kp(13);
|
||||
let mut blob = kp.encrypt("pass").unwrap();
|
||||
let last = blob.len() - 1;
|
||||
blob[last] ^= 0xFF;
|
||||
let r = Keypair::decrypt(&blob, "pass");
|
||||
assert!(matches!(r, Err(KeypairCryptoError::DecryptFailed)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_rejects_invalid_format() {
|
||||
assert!(matches!(
|
||||
Keypair::decrypt(b"too short", "x"),
|
||||
Err(KeypairCryptoError::InvalidFormat)
|
||||
));
|
||||
let mut bogus = vec![0xFFu8; 100];
|
||||
bogus[0..8].copy_from_slice(b"NOTMINGA");
|
||||
assert!(matches!(
|
||||
Keypair::decrypt(&bogus, "x"),
|
||||
Err(KeypairCryptoError::InvalidFormat)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_passphrases_produce_distinct_blobs() {
|
||||
// Cifrar la misma key con dos passphrases distintas produce blobs
|
||||
// distintos (también porque salt y nonce son aleatorios — no es
|
||||
// determinismo, es solo que no colisionan).
|
||||
let kp = kp(17);
|
||||
let a = kp.encrypt("alpha").unwrap();
|
||||
let b = kp.encrypt("beta").unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_encrypting_same_keypair_produces_distinct_blobs() {
|
||||
// Salt y nonce aleatorios: el mismo keypair y la misma passphrase
|
||||
// producen cipher distintos en cada llamada. Sin patrón observable.
|
||||
let kp = kp(19);
|
||||
let blob1 = kp.encrypt("p").unwrap();
|
||||
let blob2 = kp.encrypt("p").unwrap();
|
||||
assert_ne!(blob1, blob2);
|
||||
// Pero ambos descifran a la misma identidad.
|
||||
assert_eq!(
|
||||
Keypair::decrypt(&blob1, "p").unwrap().did(),
|
||||
Keypair::decrypt(&blob2, "p").unwrap().did()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keypair_debug_does_not_leak_private_key() {
|
||||
// El derive de Debug expondría los bytes secretos. Lo
|
||||
// sobreescribimos para que solo muestre el DID.
|
||||
let k = kp(1);
|
||||
let s = format!("{:?}", k);
|
||||
assert!(s.contains("did:key:"));
|
||||
// No debería aparecer ningún byte de la semilla [1u8; 32] en hex
|
||||
// contiguo (fragmento "010101..." sería sospechoso si emergiera).
|
||||
assert!(!s.contains("0101010101010101"));
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Invariantes del Merkle Search Tree.
|
||||
//!
|
||||
//! La tesis del MST: dado un mismo conjunto de hashes, el árbol y su
|
||||
//! `root_hash` son únicos, sin importar el orden de inserción. Eso es lo
|
||||
//! que permite a dos repositorios saber si convergen comparando un solo
|
||||
//! hash de 32 bytes y, si difieren, descender solo por las ramas con
|
||||
//! diferencias.
|
||||
|
||||
use minga_core::{cas::ContentHash, mst::Mst};
|
||||
|
||||
fn ch(seed: u64) -> ContentHash {
|
||||
// Usamos blake3 para que la distribución de niveles (nibbles cero al
|
||||
// inicio) sea representativa, no degenerada.
|
||||
let h = blake3::hash(&seed.to_le_bytes());
|
||||
ContentHash(*h.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_empty() {
|
||||
let m = Mst::new();
|
||||
assert!(m.is_empty());
|
||||
assert_eq!(m.len(), 0);
|
||||
assert_eq!(m.iter().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_insert_single() {
|
||||
let mut m = Mst::new();
|
||||
let h = ch(1);
|
||||
assert!(m.insert(h));
|
||||
assert!(!m.insert(h)); // duplicado: no-op
|
||||
assert_eq!(m.len(), 1);
|
||||
assert!(m.contains(&h));
|
||||
assert!(!m.contains(&ch(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_iter_yields_sorted_keys() {
|
||||
let mut m = Mst::new();
|
||||
let mut hashes: Vec<ContentHash> = (0..32u64).map(ch).collect();
|
||||
for h in &hashes {
|
||||
m.insert(*h);
|
||||
}
|
||||
let collected: Vec<ContentHash> = m.iter().copied().collect();
|
||||
hashes.sort();
|
||||
assert_eq!(collected, hashes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_history_independence() {
|
||||
// Mismo conjunto, tres órdenes de inserción distintos: orden natural,
|
||||
// inverso, y reordenado por byte arbitrario. Los tres deben producir
|
||||
// exactamente el mismo árbol.
|
||||
let hashes: Vec<ContentHash> = (0..50u64).map(ch).collect();
|
||||
|
||||
let mut m_natural = Mst::new();
|
||||
for h in &hashes {
|
||||
m_natural.insert(*h);
|
||||
}
|
||||
|
||||
let mut m_reverse = Mst::new();
|
||||
for h in hashes.iter().rev() {
|
||||
m_reverse.insert(*h);
|
||||
}
|
||||
|
||||
let mut shuffled = hashes.clone();
|
||||
shuffled.sort_by_key(|h| h.0[7]);
|
||||
let mut m_shuffled = Mst::new();
|
||||
for h in &shuffled {
|
||||
m_shuffled.insert(*h);
|
||||
}
|
||||
|
||||
assert_eq!(m_natural.len(), 50);
|
||||
assert_eq!(m_natural.len(), m_reverse.len());
|
||||
assert_eq!(m_natural.len(), m_shuffled.len());
|
||||
|
||||
assert_eq!(m_natural.root_hash(), m_reverse.root_hash());
|
||||
assert_eq!(m_natural.root_hash(), m_shuffled.root_hash());
|
||||
|
||||
let s_natural: Vec<ContentHash> = m_natural.iter().copied().collect();
|
||||
let s_reverse: Vec<ContentHash> = m_reverse.iter().copied().collect();
|
||||
let s_shuffled: Vec<ContentHash> = m_shuffled.iter().copied().collect();
|
||||
assert_eq!(s_natural, s_reverse);
|
||||
assert_eq!(s_natural, s_shuffled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_set_difference_changes_root() {
|
||||
let mut m1 = Mst::new();
|
||||
m1.insert(ch(1));
|
||||
m1.insert(ch(2));
|
||||
|
||||
let mut m2 = Mst::new();
|
||||
m2.insert(ch(1));
|
||||
m2.insert(ch(3));
|
||||
|
||||
let mut m3 = Mst::new();
|
||||
m3.insert(ch(1));
|
||||
m3.insert(ch(2));
|
||||
|
||||
assert_ne!(m1.root_hash(), m2.root_hash());
|
||||
assert_eq!(m1.root_hash(), m3.root_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_root_hash_changes_with_size() {
|
||||
let mut m = Mst::new();
|
||||
let h0 = m.root_hash();
|
||||
m.insert(ch(1));
|
||||
let h1 = m.root_hash();
|
||||
m.insert(ch(2));
|
||||
let h2 = m.root_hash();
|
||||
assert_ne!(h0, h1);
|
||||
assert_ne!(h1, h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_contains_after_many_inserts() {
|
||||
let mut m = Mst::new();
|
||||
let hashes: Vec<ContentHash> = (0..200u64).map(ch).collect();
|
||||
for h in &hashes {
|
||||
m.insert(*h);
|
||||
}
|
||||
for h in &hashes {
|
||||
assert!(m.contains(h), "falta clave {h}");
|
||||
}
|
||||
assert!(!m.contains(&ch(9999)));
|
||||
assert_eq!(m.len(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_no_duplicates_inflate_size() {
|
||||
let mut m = Mst::new();
|
||||
for _ in 0..10 {
|
||||
m.insert(ch(42));
|
||||
}
|
||||
assert_eq!(m.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_diff_identical_is_empty() {
|
||||
let hs: Vec<_> = (0..30u64).map(ch).collect();
|
||||
let mut a = Mst::new();
|
||||
let mut b = Mst::new();
|
||||
for h in &hs {
|
||||
a.insert(*h);
|
||||
b.insert(*h);
|
||||
}
|
||||
let d = a.diff(&b);
|
||||
assert!(d.is_empty());
|
||||
assert_eq!(d.total(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_diff_history_independent() {
|
||||
// Mismo conjunto en orden distinto: diff vacío. Aquí estresa el
|
||||
// short-circuit de Merkle: con 1000 claves construidas en órdenes
|
||||
// opuestos, la igualdad debe detectarse en una sola comparación.
|
||||
let hs: Vec<_> = (0..1000u64).map(ch).collect();
|
||||
let mut a = Mst::new();
|
||||
for h in &hs {
|
||||
a.insert(*h);
|
||||
}
|
||||
let mut b = Mst::new();
|
||||
for h in hs.iter().rev() {
|
||||
b.insert(*h);
|
||||
}
|
||||
assert!(a.diff(&b).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_diff_one_empty_yields_other() {
|
||||
let hs: Vec<_> = (0..10u64).map(ch).collect();
|
||||
let empty = Mst::new();
|
||||
let mut full = Mst::new();
|
||||
for h in &hs {
|
||||
full.insert(*h);
|
||||
}
|
||||
|
||||
let d_full_vs_empty = full.diff(&empty);
|
||||
assert_eq!(d_full_vs_empty.only_in_self.len(), 10);
|
||||
assert!(d_full_vs_empty.only_in_other.is_empty());
|
||||
|
||||
let d_empty_vs_full = empty.diff(&full);
|
||||
assert!(d_empty_vs_full.only_in_self.is_empty());
|
||||
assert_eq!(d_empty_vs_full.only_in_other.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_diff_disjoint_sets() {
|
||||
let only_a: Vec<_> = (0..15u64).map(ch).collect();
|
||||
let only_b: Vec<_> = (100..115u64).map(ch).collect();
|
||||
let mut a = Mst::new();
|
||||
for h in &only_a {
|
||||
a.insert(*h);
|
||||
}
|
||||
let mut b = Mst::new();
|
||||
for h in &only_b {
|
||||
b.insert(*h);
|
||||
}
|
||||
let d = a.diff(&b);
|
||||
assert_eq!(d.only_in_self.len(), 15);
|
||||
assert_eq!(d.only_in_other.len(), 15);
|
||||
|
||||
// El conjunto reportado debe coincidir exactamente con los inputs.
|
||||
let mut got_a = d.only_in_self.clone();
|
||||
let mut got_b = d.only_in_other.clone();
|
||||
got_a.sort();
|
||||
got_b.sort();
|
||||
let mut want_a = only_a.clone();
|
||||
let mut want_b = only_b.clone();
|
||||
want_a.sort();
|
||||
want_b.sort();
|
||||
assert_eq!(got_a, want_a);
|
||||
assert_eq!(got_b, want_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_diff_partial_overlap() {
|
||||
let common: Vec<_> = (0..40u64).map(ch).collect();
|
||||
let only_a: Vec<_> = (40..50u64).map(ch).collect();
|
||||
let only_b: Vec<_> = (50..58u64).map(ch).collect();
|
||||
|
||||
let mut a = Mst::new();
|
||||
for h in common.iter().chain(only_a.iter()) {
|
||||
a.insert(*h);
|
||||
}
|
||||
let mut b = Mst::new();
|
||||
for h in common.iter().chain(only_b.iter()) {
|
||||
b.insert(*h);
|
||||
}
|
||||
|
||||
let d = a.diff(&b);
|
||||
// Las claves comunes no aparecen en el diff; solo las únicas.
|
||||
assert_eq!(d.only_in_self.len(), only_a.len());
|
||||
assert_eq!(d.only_in_other.len(), only_b.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_diff_is_symmetric() {
|
||||
let a_keys: Vec<_> = (0..20u64).map(ch).collect();
|
||||
let b_keys: Vec<_> = (10..30u64).map(ch).collect();
|
||||
let mut a = Mst::new();
|
||||
for h in &a_keys {
|
||||
a.insert(*h);
|
||||
}
|
||||
let mut b = Mst::new();
|
||||
for h in &b_keys {
|
||||
b.insert(*h);
|
||||
}
|
||||
let ab = a.diff(&b);
|
||||
let ba = b.diff(&a);
|
||||
assert_eq!(ab.only_in_self, ba.only_in_other);
|
||||
assert_eq!(ab.only_in_other, ba.only_in_self);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_diff_output_is_sorted() {
|
||||
// Sin importar la divergencia, el output viene ordenado por hash.
|
||||
let a_keys: Vec<_> = (0..25u64).map(ch).collect();
|
||||
let b_keys: Vec<_> = (15..40u64).map(ch).collect();
|
||||
let mut a = Mst::new();
|
||||
for h in &a_keys {
|
||||
a.insert(*h);
|
||||
}
|
||||
let mut b = Mst::new();
|
||||
for h in &b_keys {
|
||||
b.insert(*h);
|
||||
}
|
||||
let d = a.diff(&b);
|
||||
let mut sorted = d.only_in_self.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(d.only_in_self, sorted);
|
||||
let mut sorted2 = d.only_in_other.clone();
|
||||
sorted2.sort();
|
||||
assert_eq!(d.only_in_other, sorted2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_diff_apply_converges() {
|
||||
// La propiedad fundacional para sincronización P2P: si cada peer
|
||||
// calcula el diff y aplica las claves que le faltan, ambos
|
||||
// convergen al mismo conjunto y el segundo diff es vacío.
|
||||
let common: Vec<_> = (0..50u64).map(ch).collect();
|
||||
let only_a: Vec<_> = (50..70u64).map(ch).collect();
|
||||
let only_b: Vec<_> = (70..85u64).map(ch).collect();
|
||||
|
||||
let mut a = Mst::new();
|
||||
for h in common.iter().chain(only_a.iter()) {
|
||||
a.insert(*h);
|
||||
}
|
||||
let mut b = Mst::new();
|
||||
for h in common.iter().chain(only_b.iter()) {
|
||||
b.insert(*h);
|
||||
}
|
||||
|
||||
let d = a.diff(&b);
|
||||
|
||||
for h in &d.only_in_other {
|
||||
a.insert(*h);
|
||||
}
|
||||
for h in &d.only_in_self {
|
||||
b.insert(*h);
|
||||
}
|
||||
|
||||
assert_eq!(a.root_hash(), b.root_hash());
|
||||
assert!(a.diff(&b).is_empty());
|
||||
assert_eq!(a.len(), common.len() + only_a.len() + only_b.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_diff_single_key_change() {
|
||||
// Repos casi idénticos, diferenciados por una sola clave. El
|
||||
// short-circuit de Merkle debería podar todo lo demás. No medimos
|
||||
// el coste aquí (es un test de corrección), pero verificamos que
|
||||
// el resultado es exactamente la diferencia esperada.
|
||||
let hs: Vec<_> = (0..200u64).map(ch).collect();
|
||||
let mut a = Mst::new();
|
||||
for h in &hs {
|
||||
a.insert(*h);
|
||||
}
|
||||
let mut b = a.clone();
|
||||
let extra = ch(9999);
|
||||
b.insert(extra);
|
||||
|
||||
let d = a.diff(&b);
|
||||
assert!(d.only_in_self.is_empty());
|
||||
assert_eq!(d.only_in_other, vec![extra]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mst_levels_distribute_naturally() {
|
||||
// Sanity: con 1000 claves blake3, esperamos que algunas tengan nivel
|
||||
// > 0 (probabilidad de >= 1 nibble cero al inicio ≈ 1/16, así que
|
||||
// ~62 claves esperadas). Si el árbol es de un solo nivel, algo en la
|
||||
// promoción/split está mal.
|
||||
let mut m = Mst::new();
|
||||
for i in 0..1000u64 {
|
||||
m.insert(ch(i));
|
||||
}
|
||||
assert_eq!(m.len(), 1000);
|
||||
// Si todas las claves estuvieran al mismo nivel, el árbol sería un
|
||||
// único nodo gigante y `root_hash` sería trivialmente reconstruible.
|
||||
// No es una verificación profunda, pero pillaría una regresión obvia.
|
||||
assert!(m.contains(&ch(0)));
|
||||
assert!(m.contains(&ch(999)));
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Tests de roundtrip de serialización para los tipos de wire.
|
||||
//!
|
||||
//! Cualquier tipo que cruce la red debe (a) (de)serializar bit-a-bit
|
||||
//! igual sobre postcard, y (b) preservar todos sus invariantes
|
||||
//! semánticos tras un viaje. Estos tests son la red de seguridad
|
||||
//! contra cambios de schema accidentales que romperían la
|
||||
//! compatibilidad on-the-wire.
|
||||
|
||||
use minga_core::{Attestation, ContentHash, Keypair, NodeProbe, Signature, StoredNode};
|
||||
|
||||
fn roundtrip<T: serde::Serialize + for<'a> serde::Deserialize<'a> + PartialEq + std::fmt::Debug>(
|
||||
value: &T,
|
||||
) {
|
||||
let bytes = postcard::to_allocvec(value).unwrap();
|
||||
let decoded: T = postcard::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(value, &decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_hash_roundtrip() {
|
||||
let h = ContentHash([42; 32]);
|
||||
roundtrip(&h);
|
||||
|
||||
// Codifica como exactamente 32 bytes (transparent sobre [u8; 32]).
|
||||
let bytes = postcard::to_allocvec(&h).unwrap();
|
||||
assert_eq!(bytes.len(), 32);
|
||||
assert_eq!(bytes, vec![42u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_roundtrip() {
|
||||
let kp = Keypair::from_seed(&[7; 32]);
|
||||
let did = kp.did();
|
||||
roundtrip(&did);
|
||||
let bytes = postcard::to_allocvec(&did).unwrap();
|
||||
assert_eq!(bytes.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_roundtrip() {
|
||||
let kp = Keypair::from_seed(&[3; 32]);
|
||||
let sig = kp.sign(b"mensaje");
|
||||
roundtrip(&sig);
|
||||
// 64 bytes Ed25519 + cualquier overhead transparent.
|
||||
let bytes = postcard::to_allocvec(&sig).unwrap();
|
||||
assert_eq!(bytes.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_roundtrip_preserves_verify() {
|
||||
let kp = Keypair::from_seed(&[9; 32]);
|
||||
let msg = b"el mensaje original";
|
||||
let sig = kp.sign(msg);
|
||||
|
||||
let bytes = postcard::to_allocvec(&sig).unwrap();
|
||||
let decoded: Signature = postcard::from_bytes(&bytes).unwrap();
|
||||
|
||||
// El predicado criptográfico se preserva exactamente.
|
||||
assert!(kp.did().verify(msg, &decoded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_node_roundtrip() {
|
||||
let s = StoredNode {
|
||||
kind: "function_item".to_string(),
|
||||
field_name: Some("body".to_string()),
|
||||
leaf_text: None,
|
||||
children: vec![ContentHash([1; 32]), ContentHash([2; 32])],
|
||||
};
|
||||
roundtrip(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_node_with_leaf_roundtrip() {
|
||||
let s = StoredNode {
|
||||
kind: "integer_literal".to_string(),
|
||||
field_name: None,
|
||||
leaf_text: Some(b"42".to_vec()),
|
||||
children: Vec::new(),
|
||||
};
|
||||
roundtrip(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attestation_roundtrip() {
|
||||
let kp = Keypair::from_seed(&[5; 32]);
|
||||
let att = Attestation::create(&kp, ContentHash([99; 32]));
|
||||
roundtrip(&att);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attestation_roundtrip_preserves_verify() {
|
||||
let kp = Keypair::from_seed(&[11; 32]);
|
||||
let att = Attestation::create(&kp, ContentHash([77; 32]));
|
||||
|
||||
let bytes = postcard::to_allocvec(&att).unwrap();
|
||||
let decoded: Attestation = postcard::from_bytes(&bytes).unwrap();
|
||||
|
||||
assert!(decoded.verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_probe_roundtrip() {
|
||||
let probe = NodeProbe {
|
||||
level: 3,
|
||||
keys: vec![ContentHash([1; 32]), ContentHash([2; 32])],
|
||||
child_hashes: vec![
|
||||
ContentHash([10; 32]),
|
||||
ContentHash([20; 32]),
|
||||
ContentHash([30; 32]),
|
||||
],
|
||||
};
|
||||
roundtrip(&probe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_collections_serialize_compactly() {
|
||||
// postcard codifica longitudes con varint. Vec vacío = 1 byte (longitud 0).
|
||||
let probe = NodeProbe {
|
||||
level: 0,
|
||||
keys: Vec::new(),
|
||||
child_hashes: Vec::new(),
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&probe).unwrap();
|
||||
// postcard varint: u32(0) = 1 byte, vec_len(0) = 1 byte ×2 = 3 bytes total.
|
||||
assert_eq!(bytes.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_bytes_fail_decode() {
|
||||
let bogus = vec![0xFFu8; 100];
|
||||
let result: Result<Attestation, _> = postcard::from_bytes(&bogus);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Invariantes del NodeStore.
|
||||
//!
|
||||
//! El almacén tiene tres responsabilidades cruzadas que deben sostenerse
|
||||
//! simultáneamente:
|
||||
//! 1. **Round-trip exacto**: lo que entró sale igual.
|
||||
//! 2. **Hash estable**: el hash que devuelve `put` coincide con
|
||||
//! `cas::hash_node` del nodo original.
|
||||
//! 3. **Deduplicación**: subárboles compartidos se almacenan una sola vez.
|
||||
|
||||
use minga_core::{
|
||||
cas::hash_node,
|
||||
parse,
|
||||
store::{MemStore, NodeStore},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn store_round_trip_preserves_tree() {
|
||||
let original = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap();
|
||||
let mut store = MemStore::new();
|
||||
let h = store.put(&original);
|
||||
let reconstructed = store.reconstruct(&h).unwrap();
|
||||
assert_eq!(reconstructed, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_hash_matches_cas() {
|
||||
let n = parse::rust("fn f() -> bool { true }").unwrap();
|
||||
let mut store = MemStore::new();
|
||||
let put_hash = store.put(&n);
|
||||
assert_eq!(put_hash, hash_node(&n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_idempotent_put() {
|
||||
let n = parse::rust("fn f() { 1 + 2 + 3 }").unwrap();
|
||||
let mut store = MemStore::new();
|
||||
let h1 = store.put(&n);
|
||||
let len_after_first = store.len();
|
||||
let h2 = store.put(&n);
|
||||
let len_after_second = store.len();
|
||||
assert_eq!(h1, h2);
|
||||
assert_eq!(len_after_first, len_after_second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_dedup_shared_subtree() {
|
||||
// Dos funciones con cuerpo idéntico: el subárbol del bloque y todos
|
||||
// sus descendientes deben aparecer una sola vez en el almacén.
|
||||
let a = parse::rust("fn alpha() -> i32 { 1 + 2 }").unwrap();
|
||||
let b = parse::rust("fn beta() -> i32 { 1 + 2 }").unwrap();
|
||||
|
||||
let mut store = MemStore::new();
|
||||
let h_a = store.put(&a);
|
||||
let count_after_a = store.len();
|
||||
let h_b = store.put(&b);
|
||||
let count_after_b = store.len();
|
||||
|
||||
assert_ne!(h_a, h_b, "los hashes raíz deben diferir (nombres distintos)");
|
||||
|
||||
// Buscar el bloque del cuerpo en ambas y verificar mismo hash.
|
||||
let body_a = find_first_kind(&a, "block").unwrap();
|
||||
let body_b = find_first_kind(&b, "block").unwrap();
|
||||
assert_eq!(hash_node(body_a), hash_node(body_b));
|
||||
|
||||
// Crecimiento esperado al añadir b: solo los nodos que difieren entre
|
||||
// las dos funciones (el `function_item` raíz, el identificador del
|
||||
// nombre `beta`, posiblemente algún wrapper). En cualquier caso,
|
||||
// estrictamente menos que duplicar el almacén.
|
||||
assert!(
|
||||
count_after_b < 2 * count_after_a,
|
||||
"dedup falló: {count_after_b} >= 2 * {count_after_a}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_subtree_resolvable_independently() {
|
||||
// El hash de cualquier subárbol debe poder reconstruirse aunque
|
||||
// hayamos pedido un árbol mayor que lo contiene.
|
||||
let n = parse::rust("fn f() -> i32 { let x = 7; x * 2 }").unwrap();
|
||||
let mut store = MemStore::new();
|
||||
store.put(&n);
|
||||
|
||||
let block = find_first_kind(&n, "block").unwrap();
|
||||
let block_hash = hash_node(block);
|
||||
assert!(store.contains(&block_hash));
|
||||
let reconstructed_block = store.reconstruct(&block_hash).unwrap();
|
||||
assert_eq!(&reconstructed_block, block);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_unknown_hash_is_none() {
|
||||
let store = MemStore::new();
|
||||
let bogus = minga_core::ContentHash([0xAB; 32]);
|
||||
assert!(store.get(&bogus).is_none());
|
||||
assert!(store.reconstruct(&bogus).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_multiple_files_share_common_constants() {
|
||||
// Tres archivos con el literal "42" repetido: el nodo
|
||||
// `integer_literal` con texto "42" debe almacenarse una sola vez.
|
||||
let n1 = parse::rust("fn a() -> i32 { 42 }").unwrap();
|
||||
let n2 = parse::rust("fn b() -> i32 { 42 }").unwrap();
|
||||
let n3 = parse::rust("fn c() -> i32 { 42 }").unwrap();
|
||||
let mut store = MemStore::new();
|
||||
store.put(&n1);
|
||||
let after_one = store.len();
|
||||
store.put(&n2);
|
||||
store.put(&n3);
|
||||
let after_three = store.len();
|
||||
// Cota laxa: 3 archivos no triplican el almacén; comparten ~todos los
|
||||
// nodos del cuerpo (block, integer_literal "42").
|
||||
assert!(after_three < 3 * after_one);
|
||||
}
|
||||
|
||||
fn find_first_kind<'a>(
|
||||
node: &'a minga_core::SemanticNode,
|
||||
kind: &str,
|
||||
) -> Option<&'a minga_core::SemanticNode> {
|
||||
if node.kind == kind {
|
||||
return Some(node);
|
||||
}
|
||||
for c in &node.children {
|
||||
if let Some(f) = find_first_kind(c, kind) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[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" }
|
||||
serde = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
libp2p = { workspace = true }
|
||||
libp2p-stream = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
@@ -0,0 +1,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,89 @@
|
||||
//! 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 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::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.dones
|
||||
}
|
||||
}
|
||||
|
||||
/// Ejecuta la sincronización entre dos sesiones hasta convergencia.
|
||||
///
|
||||
/// Pánico si la conversación termina sin que ambas partes alcancen
|
||||
/// `is_done()` — eso sería un deadlock del protocolo y una regresión.
|
||||
pub fn run_sync(a: &mut SyncSession, b: &mut SyncSession) -> SyncStats {
|
||||
let mut from_a: VecDeque<Message> = VecDeque::new();
|
||||
let mut from_b: VecDeque<Message> = VecDeque::new();
|
||||
let mut stats = SyncStats::default();
|
||||
|
||||
from_a.extend(a.start());
|
||||
from_b.extend(b.start());
|
||||
|
||||
loop {
|
||||
let mut progress = false;
|
||||
|
||||
if let Some(msg) = from_a.pop_front() {
|
||||
stats.record(&msg);
|
||||
for out in b.handle(msg) {
|
||||
from_b.push_back(out);
|
||||
}
|
||||
progress = true;
|
||||
}
|
||||
|
||||
if let Some(msg) = from_b.pop_front() {
|
||||
stats.record(&msg);
|
||||
for out in a.handle(msg) {
|
||||
from_a.push_back(out);
|
||||
}
|
||||
progress = true;
|
||||
}
|
||||
|
||||
if !progress {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
a.is_done() && b.is_done(),
|
||||
"deadlock: sync terminó sin que ambos peers cerraran"
|
||||
);
|
||||
|
||||
stats
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! minga-p2p: protocolo de sincronización entre repositorios Minga.
|
||||
//!
|
||||
//! Este crate define el **protocolo** y la **máquina de estados** de la
|
||||
//! sincronización P2P, sin acoplarse a un transporte concreto. Un peer
|
||||
//! manipula una `SyncSession` (puramente lógica) que consume mensajes
|
||||
//! entrantes y produce mensajes salientes; el transporte real —libp2p,
|
||||
//! HTTP, in-memory, lo que sea— se reduce a serializar/deserializar y
|
||||
//! mover bytes.
|
||||
//!
|
||||
//! Este orden refleja el principio bottom-up del proyecto: validamos la
|
||||
//! convergencia del protocolo con un `harness` in-memory determinístico
|
||||
//! antes de invertir en async runtime + libp2p.
|
||||
|
||||
pub mod async_driver;
|
||||
pub mod harness;
|
||||
pub mod message;
|
||||
pub mod network;
|
||||
pub mod peer;
|
||||
pub mod session;
|
||||
|
||||
pub use async_driver::{run_sync_async, AsyncSyncError};
|
||||
pub use harness::{run_sync, SyncStats};
|
||||
pub use message::Message;
|
||||
pub use network::{DiscoveredPeer, LibP2pNode, NodeError, SYNC_PROTOCOL};
|
||||
pub use peer::{MingaPeer, PeerOpenError, PeerSyncError};
|
||||
pub use session::SyncSession;
|
||||
@@ -0,0 +1,94 @@
|
||||
//! 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 seis 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, 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>,
|
||||
},
|
||||
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,282 @@
|
||||
//! Integración libp2p con behaviour compuesto: streams Minga +
|
||||
//! Kademlia DHT.
|
||||
//!
|
||||
//! - **TCP + Noise + Yamux**: transporte autenticado y multiplexado.
|
||||
//! - **`stream::Behaviour`**: streams bidireccionales para el
|
||||
//! protocolo `/minga/sync/1.0.0`.
|
||||
//! - **`kad::Behaviour<MemoryStore>`**: tabla de routing distribuida
|
||||
//! para descubrimiento. Cada nodo arranca en modo `Server` y
|
||||
//! responde a queries del DHT.
|
||||
//!
|
||||
//! El swarm corre en una task tokio dedicada que procesa comandos
|
||||
//! externos (Dial, Listen, AddDhtPeer, FindClosestPeers) y eventos
|
||||
//! del swarm (NewListenAddr para señalar address resuelto, eventos
|
||||
//! Kad para completar queries). Los métodos públicos solo envían
|
||||
//! comandos por canal.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::StreamExt;
|
||||
use libp2p::{
|
||||
identify, identity, kad, noise,
|
||||
swarm::{NetworkBehaviour, SwarmEvent},
|
||||
tcp, yamux, Multiaddr, PeerId, StreamProtocol, Swarm, SwarmBuilder,
|
||||
};
|
||||
use libp2p_stream as stream;
|
||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
|
||||
pub const SYNC_PROTOCOL: StreamProtocol = StreamProtocol::new("/minga/sync/1.0.0");
|
||||
const IDENTIFY_PROTOCOL: &str = "/minga/0.1.0";
|
||||
|
||||
#[derive(NetworkBehaviour)]
|
||||
struct MingaBehaviour {
|
||||
stream: stream::Behaviour,
|
||||
kad: kad::Behaviour<kad::store::MemoryStore>,
|
||||
identify: identify::Behaviour,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NodeError {
|
||||
#[error("transport build failed: {0}")]
|
||||
Build(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Command {
|
||||
Dial(Multiaddr),
|
||||
Listen(Multiaddr),
|
||||
AddDhtPeer(PeerId, Multiaddr),
|
||||
FindClosestPeers(PeerId, oneshot::Sender<Vec<DiscoveredPeer>>),
|
||||
StartProviding(Vec<u8>),
|
||||
GetProviders(Vec<u8>, oneshot::Sender<Vec<PeerId>>),
|
||||
}
|
||||
|
||||
/// Peer descubierto vía DHT: identidad + direcciones conocidas.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredPeer {
|
||||
pub peer_id: PeerId,
|
||||
pub addrs: Vec<Multiaddr>,
|
||||
}
|
||||
|
||||
pub struct LibP2pNode {
|
||||
pub peer_id: PeerId,
|
||||
cmd_tx: mpsc::UnboundedSender<Command>,
|
||||
listen_rx: Mutex<mpsc::UnboundedReceiver<Multiaddr>>,
|
||||
/// Control para abrir/aceptar streams.
|
||||
pub control: stream::Control,
|
||||
}
|
||||
|
||||
impl LibP2pNode {
|
||||
pub fn new() -> Result<Self, NodeError> {
|
||||
let id = identity::Keypair::generate_ed25519();
|
||||
let peer_id = id.public().to_peer_id();
|
||||
|
||||
let mut swarm: Swarm<MingaBehaviour> = SwarmBuilder::with_existing_identity(id)
|
||||
.with_tokio()
|
||||
.with_tcp(
|
||||
tcp::Config::default(),
|
||||
noise::Config::new,
|
||||
yamux::Config::default,
|
||||
)
|
||||
.map_err(|e| NodeError::Build(format!("{e}")))?
|
||||
.with_behaviour(|key| {
|
||||
let local = key.public().to_peer_id();
|
||||
let mut kad =
|
||||
kad::Behaviour::new(local, kad::store::MemoryStore::new(local));
|
||||
// Modo Server: respondemos a queries del DHT. Por
|
||||
// defecto kad arranca en Auto, que requiere detectar
|
||||
// reachability. Para tests en localhost forzamos Server.
|
||||
kad.set_mode(Some(kad::Mode::Server));
|
||||
let identify = identify::Behaviour::new(
|
||||
identify::Config::new(IDENTIFY_PROTOCOL.to_string(), key.public())
|
||||
.with_agent_version(format!("minga/{}", env!("CARGO_PKG_VERSION"))),
|
||||
);
|
||||
MingaBehaviour {
|
||||
stream: stream::Behaviour::new(),
|
||||
kad,
|
||||
identify,
|
||||
}
|
||||
})
|
||||
.map_err(|e| NodeError::Build(format!("{e}")))?
|
||||
.with_swarm_config(|c| c.with_idle_connection_timeout(Duration::from_secs(60)))
|
||||
.build();
|
||||
|
||||
let control = swarm.behaviour().stream.new_control();
|
||||
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<Command>();
|
||||
let (listen_tx, listen_rx) = mpsc::unbounded_channel::<Multiaddr>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut pending_finds: HashMap<
|
||||
kad::QueryId,
|
||||
oneshot::Sender<Vec<DiscoveredPeer>>,
|
||||
> = HashMap::new();
|
||||
let mut pending_providers: HashMap<
|
||||
kad::QueryId,
|
||||
(Vec<PeerId>, oneshot::Sender<Vec<PeerId>>),
|
||||
> = HashMap::new();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(cmd) = cmd_rx.recv() => {
|
||||
match cmd {
|
||||
Command::Dial(addr) => {
|
||||
let _ = swarm.dial(addr);
|
||||
}
|
||||
Command::Listen(addr) => {
|
||||
let _ = swarm.listen_on(addr);
|
||||
}
|
||||
Command::AddDhtPeer(peer, addr) => {
|
||||
swarm.behaviour_mut().kad.add_address(&peer, addr);
|
||||
}
|
||||
Command::FindClosestPeers(target, tx) => {
|
||||
let qid = swarm.behaviour_mut().kad.get_closest_peers(target);
|
||||
pending_finds.insert(qid, tx);
|
||||
}
|
||||
Command::StartProviding(key) => {
|
||||
// Best-effort: si falla (sin peers cercanos para
|
||||
// replicar), seguirá viviendo en el local store
|
||||
// y se servirá vía get_providers de quien
|
||||
// tenga conexión con nosotros.
|
||||
let _ = swarm.behaviour_mut().kad.start_providing(key.into());
|
||||
}
|
||||
Command::GetProviders(key, tx) => {
|
||||
let qid = swarm.behaviour_mut().kad.get_providers(key.into());
|
||||
pending_providers.insert(qid, (Vec::new(), tx));
|
||||
}
|
||||
}
|
||||
}
|
||||
event = swarm.select_next_some() => {
|
||||
match event {
|
||||
SwarmEvent::NewListenAddr { address, .. } => {
|
||||
let _ = listen_tx.send(address);
|
||||
}
|
||||
// Identify nos dice las listen-addrs reales del
|
||||
// peer. Las inyectamos a Kad para poblar el
|
||||
// routing table sin necesidad de add_dht_peer
|
||||
// manual — la propagación pasa a ser automática.
|
||||
SwarmEvent::Behaviour(MingaBehaviourEvent::Identify(
|
||||
identify::Event::Received { peer_id, info, .. }
|
||||
)) => {
|
||||
for addr in info.listen_addrs {
|
||||
swarm.behaviour_mut().kad.add_address(&peer_id, addr);
|
||||
}
|
||||
}
|
||||
SwarmEvent::Behaviour(MingaBehaviourEvent::Kad(
|
||||
kad::Event::OutboundQueryProgressed { id, result, step, .. }
|
||||
)) => {
|
||||
match result {
|
||||
kad::QueryResult::GetClosestPeers(Ok(ok)) if step.last => {
|
||||
if let Some(tx) = pending_finds.remove(&id) {
|
||||
let infos = ok.peers.into_iter()
|
||||
.map(|p| DiscoveredPeer {
|
||||
peer_id: p.peer_id,
|
||||
addrs: p.addrs,
|
||||
})
|
||||
.collect();
|
||||
let _ = tx.send(infos);
|
||||
}
|
||||
}
|
||||
kad::QueryResult::GetClosestPeers(Err(_)) if step.last => {
|
||||
if let Some(tx) = pending_finds.remove(&id) {
|
||||
let _ = tx.send(Vec::new());
|
||||
}
|
||||
}
|
||||
kad::QueryResult::GetProviders(Ok(ok)) => {
|
||||
if let Some((collected, _)) =
|
||||
pending_providers.get_mut(&id)
|
||||
{
|
||||
if let kad::GetProvidersOk::FoundProviders {
|
||||
providers, ..
|
||||
} = ok
|
||||
{
|
||||
for p in providers {
|
||||
if !collected.contains(&p) {
|
||||
collected.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if step.last {
|
||||
if let Some((providers, tx)) =
|
||||
pending_providers.remove(&id)
|
||||
{
|
||||
let _ = tx.send(providers);
|
||||
}
|
||||
}
|
||||
}
|
||||
kad::QueryResult::GetProviders(Err(_)) if step.last => {
|
||||
if let Some((providers, tx)) =
|
||||
pending_providers.remove(&id)
|
||||
{
|
||||
let _ = tx.send(providers);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
peer_id,
|
||||
cmd_tx,
|
||||
listen_rx: Mutex::new(listen_rx),
|
||||
control,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn listen(&self, addr: Multiaddr) -> Multiaddr {
|
||||
self.cmd_tx
|
||||
.send(Command::Listen(addr))
|
||||
.expect("swarm task alive");
|
||||
let mut rx = self.listen_rx.lock().await;
|
||||
rx.recv().await.expect("listen address arrives")
|
||||
}
|
||||
|
||||
pub fn dial(&self, addr: Multiaddr) {
|
||||
let _ = self.cmd_tx.send(Command::Dial(addr));
|
||||
}
|
||||
|
||||
/// Añade un peer al routing table de Kademlia. Punto de entrada
|
||||
/// para bootstrap: tras esto, el nodo puede dirigir queries DHT
|
||||
/// a través de este peer.
|
||||
pub fn add_dht_peer(&self, peer: PeerId, addr: Multiaddr) {
|
||||
let _ = self.cmd_tx.send(Command::AddDhtPeer(peer, addr));
|
||||
}
|
||||
|
||||
/// Consulta el DHT por los peers más cercanos al `target` PeerId.
|
||||
/// Devuelve la lista resuelta (vacía si la query falla o si no
|
||||
/// hay peers conocidos). Bloquea hasta que la query completa.
|
||||
pub async fn find_closest_peers(&self, target: PeerId) -> Vec<DiscoveredPeer> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = self
|
||||
.cmd_tx
|
||||
.send(Command::FindClosestPeers(target, tx));
|
||||
rx.await.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Anuncia en el DHT que este peer tiene el contenido identificado
|
||||
/// por `key`. Otros peers pueden luego descubrirlo vía
|
||||
/// `find_providers(key)`. Best-effort: si la replicación falla
|
||||
/// inicialmente, el record vive en el store local.
|
||||
pub fn start_providing(&self, key: &[u8]) {
|
||||
let _ = self.cmd_tx.send(Command::StartProviding(key.to_vec()));
|
||||
}
|
||||
|
||||
/// Consulta el DHT por peers que han anunciado proveer `key`.
|
||||
/// Devuelve la lista de `PeerId`s que se reportan como providers.
|
||||
/// Lista vacía si nadie anuncia.
|
||||
pub async fn find_providers(&self, key: &[u8]) -> Vec<PeerId> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = self
|
||||
.cmd_tx
|
||||
.send(Command::GetProviders(key.to_vec(), tx));
|
||||
rx.await.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! `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::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::{AttestationStore, ContentHash, Keypair, MemStore, Mst, NodeStore, SemanticNode};
|
||||
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,
|
||||
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 {
|
||||
node: LibP2pNode,
|
||||
state: Arc<Mutex<PeerState>>,
|
||||
}
|
||||
|
||||
impl MingaPeer {
|
||||
pub fn new(
|
||||
keypair: Keypair,
|
||||
mst: Mst,
|
||||
store: MemStore,
|
||||
attestations: AttestationStore,
|
||||
) -> Result<Self, NodeError> {
|
||||
let node = LibP2pNode::new()?;
|
||||
let state = Arc::new(Mutex::new(PeerState {
|
||||
mst,
|
||||
store,
|
||||
attestations,
|
||||
keypair,
|
||||
persistent: None,
|
||||
}));
|
||||
Ok(Self { node, state })
|
||||
}
|
||||
|
||||
/// Abre o crea un peer persistente sobre `path`. Si el directorio
|
||||
/// no contiene un repo, se crea vacío. Si lo contiene, se carga
|
||||
/// el estado completo (MST, nodos, atestaciones) en memoria.
|
||||
/// Cualquier cambio posterior se escribe a disco vía write-through.
|
||||
pub fn open(keypair: Keypair, path: impl AsRef<Path>) -> 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);
|
||||
}
|
||||
|
||||
let node = LibP2pNode::new()?;
|
||||
let state = Arc::new(Mutex::new(PeerState {
|
||||
mst,
|
||||
store,
|
||||
attestations,
|
||||
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`.
|
||||
/// Otros peers podrán descubrirlo vía `find_providers(hash)`.
|
||||
pub fn announce_provider(&self, hash: ContentHash) {
|
||||
self.node.start_providing(&hash.0);
|
||||
}
|
||||
|
||||
/// Consulta el DHT por peers que han anunciado proveer este
|
||||
/// contenido. La unión de los `PeerId`s permite a quien busque
|
||||
/// `hash` decidir a quién dial directamente para sincronizar.
|
||||
pub async fn find_providers(&self, hash: ContentHash) -> Vec<PeerId> {
|
||||
self.node.find_providers(&hash.0).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::new(
|
||||
s.mst.clone(),
|
||||
s.store.clone(),
|
||||
s.attestations.clone(),
|
||||
s.keypair.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn merge_back(&self, session: SyncSession) {
|
||||
let (new_mst, new_store, new_atts) = session.into_parts();
|
||||
let mut s = self.state.lock().await;
|
||||
merge_into_state(&mut s, new_mst, new_store, new_atts);
|
||||
}
|
||||
|
||||
/// 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. Best-effort: si no
|
||||
// hay peers cercanos para replicar, el record vive local hasta
|
||||
// que llegue una conexión.
|
||||
self.node.start_providing(&h.0);
|
||||
|
||||
h
|
||||
}
|
||||
|
||||
/// 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::new(
|
||||
s.mst.clone(),
|
||||
s.store.clone(),
|
||||
s.attestations.clone(),
|
||||
s.keypair.clone(),
|
||||
)
|
||||
};
|
||||
if let Ok(result) = run_sync_async(session, stream.compat()).await {
|
||||
let (new_mst, new_store, new_atts) = result.into_parts();
|
||||
let mut s = state.lock().await;
|
||||
merge_into_state(&mut s, new_mst, new_store, new_atts);
|
||||
}
|
||||
// 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,
|
||||
) {
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
//! 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::{
|
||||
cas::ContentHash, empty_subtree_hash, hash_stored, AttestationStore, Did, Keypair, MemStore,
|
||||
Mst, NodeProbe, NodeStore,
|
||||
};
|
||||
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 formato 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,
|
||||
|
||||
/// 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>,
|
||||
|
||||
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,
|
||||
|
||||
/// 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_done: bool,
|
||||
received_done: bool,
|
||||
}
|
||||
|
||||
impl SyncSession {
|
||||
pub fn new(
|
||||
mst: Mst,
|
||||
store: MemStore,
|
||||
attestations: AttestationStore,
|
||||
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,
|
||||
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(),
|
||||
rejected_hellos: 0,
|
||||
rejected_delivers: 0,
|
||||
rejected_attests: 0,
|
||||
self_nonce,
|
||||
peer_nonce: None,
|
||||
sent_challenge: false,
|
||||
received_challenge: false,
|
||||
sent_hello: false,
|
||||
received_hello: false,
|
||||
sent_attestations: 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;
|
||||
}
|
||||
|
||||
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::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 {
|
||||
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]
|
||||
}
|
||||
|
||||
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 attestations(&self) -> &AttestationStore {
|
||||
&self.attestations
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
@@ -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,98 @@
|
||||
//! 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,
|
||||
);
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -0,0 +1,798 @@
|
||||
//! Invariantes del protocolo de sincronización recursivo.
|
||||
//!
|
||||
//! Tres familias de tests:
|
||||
//! - **Convergencia funcional**: tras `run_sync`, ambos peers tienen
|
||||
//! el mismo `root_hash`, `MemStore` equivalente, y reconstruyen los
|
||||
//! árboles bit a bit.
|
||||
//! - **Eficiencia estructural**: el short-circuit por hash de subárbol
|
||||
//! reduce probes y delivers cuando los repos comparten ramas.
|
||||
//! - **Seguridad**: el receptor verifica `hash_stored(stored) == hash`
|
||||
//! y rechaza nodos manipulados.
|
||||
|
||||
use minga_core::{
|
||||
cas::hash_components, hash_node, hash_stored, parse, ContentHash, Keypair, MemStore, Mst,
|
||||
NodeStore, Signature, StoredNode,
|
||||
};
|
||||
use minga_p2p::{run_sync, Message, SyncSession};
|
||||
|
||||
fn kp(seed: u8) -> Keypair {
|
||||
Keypair::from_seed(&[seed; 32])
|
||||
}
|
||||
|
||||
/// Helper que replica la construcción del payload firmado del `Hello`
|
||||
/// dentro del protocolo Minga. Usado por los tests que inyectan
|
||||
/// mensajes manualmente.
|
||||
fn hello_payload(nonce: &[u8; 32], did: &minga_core::Did, root: &ContentHash) -> [u8; 96] {
|
||||
let mut p = [0u8; 96];
|
||||
p[..32].copy_from_slice(nonce);
|
||||
p[32..64].copy_from_slice(&did.0);
|
||||
p[64..96].copy_from_slice(&root.0);
|
||||
p
|
||||
}
|
||||
|
||||
fn build_repo(sources: &[&str]) -> (Mst, MemStore, Vec<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)
|
||||
}
|
||||
|
||||
// ─── Convergencia funcional ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sync_identical_is_noop() {
|
||||
let sources = &[
|
||||
"fn add(x: i32, y: i32) -> i32 { x + y }",
|
||||
"fn neg(x: i32) -> i32 { -x }",
|
||||
];
|
||||
let (mst_a, store_a, _) = build_repo(sources);
|
||||
let (mst_b, store_b, _) = build_repo(sources);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
let stats = run_sync(&mut a, &mut b);
|
||||
|
||||
// Mismas raíces de MST: el short-circuit en Hello evita cualquier
|
||||
// probe o transferencia. Solo cruzan los 2 Hellos y los 2 Dones.
|
||||
assert_eq!(stats.hellos, 2);
|
||||
assert_eq!(stats.probe_reqs, 0);
|
||||
assert_eq!(stats.probe_ress, 0);
|
||||
assert_eq!(stats.fetches, 0);
|
||||
assert_eq!(stats.delivers, 0);
|
||||
assert_eq!(stats.dones, 2);
|
||||
|
||||
assert_eq!(a.mst().root_hash(), b.mst().root_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_one_empty_pulls_everything() {
|
||||
let sources = &["fn f(x: i32) -> i32 { x * 2 }"];
|
||||
let (mst_a, store_a, _) = build_repo(sources);
|
||||
let (mst_b, store_b, _) = build_repo(&[]);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
assert_eq!(a.mst().root_hash(), b.mst().root_hash());
|
||||
assert_eq!(a.store().len(), b.store().len());
|
||||
|
||||
for h in a.mst().iter() {
|
||||
assert!(b.store().contains(h));
|
||||
let a_tree = a.store().reconstruct(h).unwrap();
|
||||
let b_tree = b.store().reconstruct(h).unwrap();
|
||||
assert_eq!(a_tree, b_tree);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_disjoint_sets_merge() {
|
||||
let only_a = &[
|
||||
"fn alpha() -> i32 { 1 }",
|
||||
"fn beta(x: i32) -> i32 { x + 1 }",
|
||||
];
|
||||
let only_b = &[
|
||||
"fn gamma(y: i32) -> bool { y > 0 }",
|
||||
"fn delta() -> &'static str { \"hello\" }",
|
||||
];
|
||||
|
||||
let (mst_a, store_a, _) = build_repo(only_a);
|
||||
let (mst_b, store_b, _) = build_repo(only_b);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
assert_eq!(a.mst().root_hash(), b.mst().root_hash());
|
||||
assert_eq!(a.mst().len(), 4);
|
||||
assert_eq!(b.mst().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_partial_overlap_converges() {
|
||||
let common = &[
|
||||
"fn shared_one() -> i32 { 42 }",
|
||||
"fn shared_two(n: i32) -> i32 { n + 1 }",
|
||||
];
|
||||
let extra_a = &["fn only_in_a() -> bool { true }"];
|
||||
let extra_b = &["fn only_in_b(s: &str) -> usize { s.len() }"];
|
||||
|
||||
let mut sources_a: Vec<&str> = common.to_vec();
|
||||
sources_a.extend_from_slice(extra_a);
|
||||
let mut sources_b: Vec<&str> = common.to_vec();
|
||||
sources_b.extend_from_slice(extra_b);
|
||||
|
||||
let (mst_a, store_a, _) = build_repo(&sources_a);
|
||||
let (mst_b, store_b, _) = build_repo(&sources_b);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
assert_eq!(a.mst().root_hash(), b.mst().root_hash());
|
||||
assert_eq!(a.mst().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_transitive_children_pulled() {
|
||||
let big_src = r#"
|
||||
fn complicated(x: i32, y: i32) -> i32 {
|
||||
let a = x + y;
|
||||
let b = a * 2;
|
||||
match b {
|
||||
n if n > 100 => n - 50,
|
||||
n if n < 0 => -n,
|
||||
_ => b,
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let (mst_a, store_a, roots) = build_repo(&[big_src]);
|
||||
let store_a_size = store_a.len();
|
||||
let root_hash = roots[0];
|
||||
|
||||
let (mst_b, store_b, _) = build_repo(&[]);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
assert!(b.store().contains(&root_hash));
|
||||
assert_eq!(b.store().len(), store_a_size);
|
||||
|
||||
let a_tree = a.store().reconstruct(&root_hash).unwrap();
|
||||
let b_tree = b.store().reconstruct(&root_hash).unwrap();
|
||||
assert_eq!(a_tree, b_tree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_idempotent_after_convergence() {
|
||||
let sources = &["fn p() -> i32 { 1 }", "fn q(x: i32) -> i32 { x + 1 }"];
|
||||
let (mst_a, store_a, _) = build_repo(sources);
|
||||
let (mst_b, store_b, _) = build_repo(&["fn r(y: i32) -> i32 { y - 1 }"]);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
let (mst_a, store_a, _) = a.into_parts();
|
||||
let (mst_b, store_b, _) = b.into_parts();
|
||||
let mut a2 = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b2 = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
let stats = run_sync(&mut a2, &mut b2);
|
||||
|
||||
// Tras converger, la segunda corrida es 2 Hellos + 2 Dones, nada
|
||||
// estructural ni transferencias.
|
||||
assert_eq!(stats.probe_reqs, 0);
|
||||
assert_eq!(stats.probe_ress, 0);
|
||||
assert_eq!(stats.fetches, 0);
|
||||
assert_eq!(stats.delivers, 0);
|
||||
assert_eq!(stats.hellos, 2);
|
||||
assert_eq!(stats.dones, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_both_empty_terminates() {
|
||||
let mut a = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(1));
|
||||
let mut b = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(2));
|
||||
let stats = run_sync(&mut a, &mut b);
|
||||
assert_eq!(stats.hellos, 2);
|
||||
assert_eq!(stats.probe_reqs, 0);
|
||||
assert_eq!(stats.dones, 2);
|
||||
assert!(a.mst().is_empty());
|
||||
assert!(b.mst().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_three_way_via_pairwise_runs() {
|
||||
let sources_a = &["fn a1() -> i32 { 1 }", "fn shared() -> i32 { 0 }"];
|
||||
let sources_b = &["fn b1(x: i32) -> i32 { x }", "fn shared() -> i32 { 0 }"];
|
||||
let sources_c = &["fn c1() -> bool { true }"];
|
||||
|
||||
let (mst_a, store_a, _) = build_repo(sources_a);
|
||||
let (mst_b, store_b, _) = build_repo(sources_b);
|
||||
let (mst_c, store_c, _) = build_repo(sources_c);
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
run_sync(&mut a, &mut b);
|
||||
let (mst_a, store_a, _) = a.into_parts();
|
||||
let (mst_b, store_b, _) = b.into_parts();
|
||||
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
let mut c = SyncSession::without_attestations(mst_c, store_c, kp(3));
|
||||
run_sync(&mut b, &mut c);
|
||||
let (mst_b, _, _) = b.into_parts();
|
||||
let (mst_c, store_c, _) = c.into_parts();
|
||||
|
||||
let mut c = SyncSession::without_attestations(mst_c, store_c, kp(3));
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
run_sync(&mut c, &mut a);
|
||||
let (mst_c, _, _) = c.into_parts();
|
||||
let (mst_a, _, _) = a.into_parts();
|
||||
|
||||
assert_eq!(mst_a.root_hash(), mst_b.root_hash());
|
||||
assert_eq!(mst_b.root_hash(), mst_c.root_hash());
|
||||
assert_eq!(mst_a.len(), 4);
|
||||
}
|
||||
|
||||
// ─── Eficiencia estructural ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sync_subtree_short_circuit_skips_shared_branches() {
|
||||
// Construimos dos repos que comparten muchos nodos pero difieren en
|
||||
// uno. El short-circuit por hash de subárbol debería podar las
|
||||
// ramas compartidas: el número de probes y delivers debe estar
|
||||
// dominado por la divergencia, no por el tamaño total.
|
||||
let common: Vec<String> = (0..50)
|
||||
.map(|i| format!("fn shared_{}() -> i32 {{ {} }}", i, i))
|
||||
.collect();
|
||||
let common_refs: Vec<&str> = common.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let extra_a = "fn only_a() -> bool { true }".to_string();
|
||||
let mut sources_a: Vec<&str> = common_refs.clone();
|
||||
sources_a.push(&extra_a);
|
||||
|
||||
let extra_b = "fn only_b() -> bool { false }".to_string();
|
||||
let mut sources_b: Vec<&str> = common_refs.clone();
|
||||
sources_b.push(&extra_b);
|
||||
|
||||
let (mst_a, store_a, _) = build_repo(&sources_a);
|
||||
let (mst_b, store_b, _) = build_repo(&sources_b);
|
||||
let store_a_size = store_a.len();
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp(2));
|
||||
let stats = run_sync(&mut a, &mut b);
|
||||
|
||||
assert_eq!(a.mst().root_hash(), b.mst().root_hash());
|
||||
|
||||
// Cota de eficiencia: cada peer debe pedir como máximo lo que
|
||||
// realmente le falta. En este escenario, cada peer ignora una sola
|
||||
// función nueva (~docena de StoredNodes). Si el short-circuit
|
||||
// estuviera roto, transferiríamos cerca del store entero (~varios
|
||||
// cientos). La cota es laxa pero detectaría esa regresión.
|
||||
assert!(
|
||||
stats.delivers < store_a_size / 2,
|
||||
"demasiados delivers ({}); esperaba << {}",
|
||||
stats.delivers,
|
||||
store_a_size,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Seguridad: verificación criptográfica ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cas_hash_node_equals_hash_stored() {
|
||||
// El invariante fundacional para verificación: hashear el árbol
|
||||
// como `SemanticNode` y como `StoredNode` produce idéntico hash.
|
||||
// Sin esto, el receptor no podría confiar en lo que recibe.
|
||||
let node = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap();
|
||||
let direct = hash_node(&node);
|
||||
|
||||
let mut store = MemStore::new();
|
||||
let via_store = store.put(&node);
|
||||
assert_eq!(direct, via_store);
|
||||
|
||||
let stored = store.get(&direct).unwrap();
|
||||
let recomputed = hash_stored(stored);
|
||||
assert_eq!(direct, recomputed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_rejects_tampered_deliver() {
|
||||
// Construimos un mensaje Deliver donde `hash` y `stored` no son
|
||||
// consistentes — simulando un peer malicioso o un bit flip en el
|
||||
// transporte. La sesión debe rechazarlo y no contaminar su estado.
|
||||
let (mst_a, store_a, _) = build_repo(&[]);
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
let initial_store_size = a.store().len();
|
||||
let initial_mst_size = a.mst().len();
|
||||
|
||||
// Forjamos un StoredNode con identidad falsa: anunciamos un hash
|
||||
// arbitrario pero adjuntamos contenido distinto.
|
||||
let fake_stored = StoredNode {
|
||||
kind: "function_item".to_string(),
|
||||
field_name: None,
|
||||
leaf_text: None,
|
||||
children: Vec::new(),
|
||||
};
|
||||
// El hash real de fake_stored es x; anunciamos como otra cosa.
|
||||
let real_hash = hash_components("function_item", None, None, &[]);
|
||||
let bogus_hash = ContentHash([0xAB; 32]);
|
||||
assert_ne!(real_hash, bogus_hash);
|
||||
|
||||
// Inyectamos como si viniera del peer (sesión recibe Hello primero
|
||||
// para que received_hello sea true; luego le metemos el Deliver
|
||||
// tóxico). El Hello se firma con la llave del peer simulado.
|
||||
let peer_kp = kp(99);
|
||||
let peer_root = minga_core::empty_subtree_hash();
|
||||
let peer_sig = peer_kp.sign(peer_root.as_bytes());
|
||||
a.handle(Message::Hello {
|
||||
peer_did: peer_kp.did(),
|
||||
root_subtree_hash: peer_root,
|
||||
signature: peer_sig,
|
||||
});
|
||||
let _ = a.handle(Message::Deliver {
|
||||
hash: bogus_hash,
|
||||
stored: fake_stored,
|
||||
});
|
||||
|
||||
// El store y MST no deben cambiar; el contador de rechazos sí.
|
||||
assert_eq!(a.store().len(), initial_store_size);
|
||||
assert_eq!(a.mst().len(), initial_mst_size);
|
||||
assert_eq!(a.rejected_delivers(), 1);
|
||||
assert!(!a.store().contains(&bogus_hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_accepts_well_formed_deliver() {
|
||||
// Contraprueba del anterior: un Deliver con hash válido sí se
|
||||
// acepta. Verifica que el rechazo es selectivo, no global.
|
||||
let (mst_a, store_a, _) = build_repo(&[]);
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
|
||||
let stored = StoredNode {
|
||||
kind: "integer_literal".to_string(),
|
||||
field_name: None,
|
||||
leaf_text: Some(b"42".to_vec()),
|
||||
children: Vec::new(),
|
||||
};
|
||||
let real_hash = hash_stored(&stored);
|
||||
|
||||
let peer_kp = kp(99);
|
||||
let peer_root = minga_core::empty_subtree_hash();
|
||||
let peer_sig = peer_kp.sign(peer_root.as_bytes());
|
||||
a.handle(Message::Hello {
|
||||
peer_did: peer_kp.did(),
|
||||
root_subtree_hash: peer_root,
|
||||
signature: peer_sig,
|
||||
});
|
||||
a.handle(Message::Deliver {
|
||||
hash: real_hash,
|
||||
stored,
|
||||
});
|
||||
|
||||
// No estaba en awaiting_root (no llegó por probe), así que no
|
||||
// entra al MST — pero sí al store.
|
||||
assert!(a.store().contains(&real_hash));
|
||||
assert_eq!(a.rejected_delivers(), 0);
|
||||
}
|
||||
|
||||
// ─── Identidad y autenticación ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sync_captures_peer_did_after_valid_hello() {
|
||||
// Tras un sync exitoso, cada sesión conoce el DID del otro peer
|
||||
// — la primera afirmación criptográficamente verificable de la
|
||||
// identidad del interlocutor.
|
||||
let sources = &["fn f() -> i32 { 1 }"];
|
||||
let (mst_a, store_a, _) = build_repo(sources);
|
||||
let (mst_b, store_b, _) = build_repo(sources);
|
||||
|
||||
let kp_a = kp(10);
|
||||
let kp_b = kp(20);
|
||||
let did_a = kp_a.did();
|
||||
let did_b = kp_b.did();
|
||||
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp_a);
|
||||
let mut b = SyncSession::without_attestations(mst_b, store_b, kp_b);
|
||||
|
||||
assert_eq!(a.peer_did(), None);
|
||||
assert_eq!(b.peer_did(), None);
|
||||
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
// Cada peer ahora tiene la identidad verificada del otro.
|
||||
assert_eq!(a.peer_did(), Some(did_b));
|
||||
assert_eq!(b.peer_did(), Some(did_a));
|
||||
assert_eq!(a.local_did(), did_a);
|
||||
assert_eq!(b.local_did(), did_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_rejects_hello_with_tampered_signature() {
|
||||
// Un atacante que captura un Hello legítimo pero modifica un byte
|
||||
// de la firma debe ser rechazado. La sesión no marca
|
||||
// received_hello, no procesa el root, no emite ProbeReq — el
|
||||
// contador de rechazos se incrementa en su lugar.
|
||||
let (mst_a, store_a, _) = build_repo(&[]);
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
|
||||
let attacker = kp(2);
|
||||
let root = minga_core::empty_subtree_hash();
|
||||
let mut sig = attacker.sign(root.as_bytes());
|
||||
sig.0[5] ^= 0xFF;
|
||||
|
||||
let out = a.handle(Message::Hello {
|
||||
peer_did: attacker.did(),
|
||||
root_subtree_hash: root,
|
||||
signature: sig,
|
||||
});
|
||||
|
||||
assert!(out.is_empty(), "Hello con firma rota no debe producir respuesta");
|
||||
assert_eq!(a.rejected_hellos(), 1);
|
||||
assert_eq!(a.peer_did(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_rejects_hello_with_swapped_did() {
|
||||
// Otro vector: la firma es válida bajo el DID original, pero el
|
||||
// atacante reemplaza el campo `peer_did` por uno distinto. La
|
||||
// verificación falla porque la firma no fue producida por la
|
||||
// llave privada correspondiente al DID anunciado.
|
||||
let (mst_a, store_a, _) = build_repo(&[]);
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
|
||||
let real_signer = kp(50);
|
||||
let imposter = kp(51);
|
||||
let root = minga_core::empty_subtree_hash();
|
||||
let sig = real_signer.sign(root.as_bytes());
|
||||
|
||||
a.handle(Message::Hello {
|
||||
peer_did: imposter.did(), // dice ser imposter pero la firma es de real_signer
|
||||
root_subtree_hash: root,
|
||||
signature: sig,
|
||||
});
|
||||
|
||||
assert_eq!(a.rejected_hellos(), 1);
|
||||
assert_eq!(a.peer_did(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_rejects_hello_signed_over_different_root() {
|
||||
// El atacante firma un root diferente al que anuncia. La firma es
|
||||
// válida sobre `wrong_root`, pero el mensaje dice `claimed_root`.
|
||||
let (mst_a, store_a, _) = build_repo(&[]);
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
|
||||
let signer = kp(60);
|
||||
let claimed_root = ContentHash([0xAA; 32]);
|
||||
let wrong_root = ContentHash([0xBB; 32]);
|
||||
let sig_over_wrong = signer.sign(wrong_root.as_bytes());
|
||||
|
||||
a.handle(Message::Hello {
|
||||
peer_did: signer.did(),
|
||||
root_subtree_hash: claimed_root,
|
||||
signature: sig_over_wrong,
|
||||
});
|
||||
|
||||
assert_eq!(a.rejected_hellos(), 1);
|
||||
assert_eq!(a.peer_did(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_rejects_replay_of_hello_from_different_session() {
|
||||
// El test del bloque CRÍTICO: anti-replay anti-replay.
|
||||
//
|
||||
// Sesión 1: el peer "alice" responde a un Challenge de A1
|
||||
// firmando un Hello con el nonce de A1.
|
||||
//
|
||||
// Sesión 2: la misma A vuelve a abrir sesión (A2). A2 genera un
|
||||
// nonce nuevo. Un atacante intenta replicar el Hello capturado de
|
||||
// la sesión 1. Como el nonce es distinto, la firma no verifica.
|
||||
let alice = kp(50);
|
||||
let alice_root = ContentHash([0xAA; 32]);
|
||||
|
||||
// Sesión 1.
|
||||
let mut a1 = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(1));
|
||||
let nonce_a1 = a1.self_nonce();
|
||||
|
||||
// Alice firma su Hello sobre el nonce que A1 emitió.
|
||||
let payload_1 = hello_payload(&nonce_a1, &alice.did(), &alice_root);
|
||||
let sig_1 = alice.sign(&payload_1);
|
||||
let captured_hello = Message::Hello {
|
||||
peer_did: alice.did(),
|
||||
root_subtree_hash: alice_root,
|
||||
signature: sig_1,
|
||||
};
|
||||
|
||||
// En sesión 1, el Hello se acepta limpiamente.
|
||||
a1.handle(captured_hello.clone());
|
||||
assert_eq!(a1.peer_did(), Some(alice.did()));
|
||||
assert_eq!(a1.rejected_hellos(), 0);
|
||||
|
||||
// Sesión 2: A2 con nonce nuevo. El atacante replica `captured_hello`.
|
||||
let mut a2 = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(2));
|
||||
assert_ne!(a2.self_nonce(), nonce_a1, "los nonces son distintos por sesión");
|
||||
|
||||
a2.handle(captured_hello);
|
||||
|
||||
// Replay rechazado: la firma estaba sobre nonce_a1, A2 verifica
|
||||
// contra su propio nonce, mismatch criptográfico.
|
||||
assert_eq!(a2.rejected_hellos(), 1);
|
||||
assert_eq!(a2.peer_did(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_proceeds_after_valid_hello_following_rejection() {
|
||||
// Si llega un Hello inválido seguido de uno válido, la sesión se
|
||||
// recupera: acepta el válido y captura ese DID. No hay
|
||||
// "envenenamiento" persistente del estado.
|
||||
let (mst_a, store_a, _) = build_repo(&[]);
|
||||
let mut a = SyncSession::without_attestations(mst_a, store_a, kp(1));
|
||||
|
||||
let bad_signer = kp(70);
|
||||
let mut bad_sig = bad_signer.sign(b"otro mensaje");
|
||||
bad_sig.0[0] ^= 0xFF;
|
||||
let root = minga_core::empty_subtree_hash();
|
||||
a.handle(Message::Hello {
|
||||
peer_did: bad_signer.did(),
|
||||
root_subtree_hash: root,
|
||||
signature: bad_sig,
|
||||
});
|
||||
assert_eq!(a.rejected_hellos(), 1);
|
||||
assert_eq!(a.peer_did(), None);
|
||||
|
||||
let good_signer = kp(71);
|
||||
let nonce = a.self_nonce();
|
||||
let good_payload = hello_payload(&nonce, &good_signer.did(), &root);
|
||||
let good_sig = good_signer.sign(&good_payload);
|
||||
a.handle(Message::Hello {
|
||||
peer_did: good_signer.did(),
|
||||
root_subtree_hash: root,
|
||||
signature: good_sig,
|
||||
});
|
||||
assert_eq!(a.rejected_hellos(), 1);
|
||||
assert_eq!(a.peer_did(), Some(good_signer.did()));
|
||||
}
|
||||
|
||||
// Aux: dejamos `Signature` importado para que el bloque arriba siga
|
||||
// compilando en futuras refactorizaciones que lo necesiten.
|
||||
#[allow(dead_code)]
|
||||
fn _signature_marker(_: Signature) {}
|
||||
|
||||
// ─── Propagación de atestaciones ───────────────────────────────────
|
||||
|
||||
use minga_core::{Attestation, AttestationStore, Did};
|
||||
|
||||
fn build_repo_with_attests(
|
||||
sources: &[&str],
|
||||
signers: &[&Keypair],
|
||||
) -> (Mst, MemStore, AttestationStore, Vec<ContentHash>) {
|
||||
let mut mst = Mst::new();
|
||||
let mut store = MemStore::new();
|
||||
let mut attests = AttestationStore::new();
|
||||
let mut roots = Vec::new();
|
||||
for src in sources {
|
||||
let n = parse::rust(src).unwrap();
|
||||
let h = store.put(&n);
|
||||
mst.insert(h);
|
||||
for kp in signers {
|
||||
attests.add(Attestation::create(kp, h)).unwrap();
|
||||
}
|
||||
roots.push(h);
|
||||
}
|
||||
(mst, store, attests, roots)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_propagates_attestations_for_owned_content() {
|
||||
// Cada peer tiene su propio contenido y firma sus propias claves.
|
||||
// Tras sync, ambos peers conocen ambas atestaciones.
|
||||
let kp_a = kp(10);
|
||||
let kp_b = kp(20);
|
||||
|
||||
let (mst_a, store_a, atts_a, roots_a) =
|
||||
build_repo_with_attests(&["fn from_a() -> i32 { 1 }"], &[&kp_a]);
|
||||
let (mst_b, store_b, atts_b, roots_b) =
|
||||
build_repo_with_attests(&["fn from_b() -> i32 { 2 }"], &[&kp_b]);
|
||||
|
||||
let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a.clone());
|
||||
let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b.clone());
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
// A debe ahora conocer la atestación de B sobre roots_b[0], y
|
||||
// viceversa. Ambas verificables criptográficamente.
|
||||
let h_a = roots_a[0];
|
||||
let h_b = roots_b[0];
|
||||
|
||||
let a_authors_for_a: Vec<Did> = a.attestations().authors_of(&h_a);
|
||||
let a_authors_for_b: Vec<Did> = a.attestations().authors_of(&h_b);
|
||||
assert_eq!(a_authors_for_a, vec![kp_a.did()]);
|
||||
assert_eq!(a_authors_for_b, vec![kp_b.did()]);
|
||||
|
||||
let b_authors_for_a: Vec<Did> = b.attestations().authors_of(&h_a);
|
||||
let b_authors_for_b: Vec<Did> = b.attestations().authors_of(&h_b);
|
||||
assert_eq!(b_authors_for_a, vec![kp_a.did()]);
|
||||
assert_eq!(b_authors_for_b, vec![kp_b.did()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_merges_multiple_authors_for_shared_content() {
|
||||
// Ambos peers tienen el MISMO contenido (mismo hash) pero
|
||||
// atestaciones de autores DISTINTOS. Tras sync, cada peer ve el
|
||||
// conjunto completo de autores que han respaldado ese contenido.
|
||||
let kp_a = kp(30);
|
||||
let kp_b = kp(40);
|
||||
let kp_c = kp(50);
|
||||
let kp_d = kp(60);
|
||||
|
||||
let src = "fn shared() -> i32 { 99 }";
|
||||
|
||||
// A tiene firmas de A y C sobre el contenido.
|
||||
let (mst_a, store_a, atts_a, _) = build_repo_with_attests(&[src], &[&kp_a, &kp_c]);
|
||||
// B tiene firmas de B y D sobre el MISMO contenido.
|
||||
let (mst_b, store_b, atts_b, roots_b) = build_repo_with_attests(&[src], &[&kp_b, &kp_d]);
|
||||
let h = roots_b[0];
|
||||
|
||||
let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a.clone());
|
||||
let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b.clone());
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
// Ambos peers ven los cuatro autores.
|
||||
let mut a_authors = a.attestations().authors_of(&h);
|
||||
let mut b_authors = b.attestations().authors_of(&h);
|
||||
a_authors.sort_by_key(|d| d.0);
|
||||
b_authors.sort_by_key(|d| d.0);
|
||||
assert_eq!(a_authors, b_authors);
|
||||
assert_eq!(a_authors.len(), 4);
|
||||
assert!(a_authors.contains(&kp_a.did()));
|
||||
assert!(a_authors.contains(&kp_b.did()));
|
||||
assert!(a_authors.contains(&kp_c.did()));
|
||||
assert!(a_authors.contains(&kp_d.did()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_attestations_are_verified_at_receiver() {
|
||||
// Inyectamos manualmente un AttestPush con una firma corrupta
|
||||
// entre las legítimas. La sesión solo acepta las legítimas e
|
||||
// incrementa rejected_attests.
|
||||
let mut a = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(1));
|
||||
|
||||
// Hello válido del peer simulado, para que received_hello sea true.
|
||||
let peer_kp = kp(80);
|
||||
let peer_root = minga_core::empty_subtree_hash();
|
||||
let nonce = a.self_nonce();
|
||||
let peer_payload = hello_payload(&nonce, &peer_kp.did(), &peer_root);
|
||||
let peer_sig = peer_kp.sign(&peer_payload);
|
||||
a.handle(Message::Hello {
|
||||
peer_did: peer_kp.did(),
|
||||
root_subtree_hash: peer_root,
|
||||
signature: peer_sig,
|
||||
});
|
||||
|
||||
// Tres atestaciones: dos legítimas y una con firma rota.
|
||||
let alice = kp(81);
|
||||
let bob = kp(82);
|
||||
let h1 = ContentHash([1u8; 32]);
|
||||
let h2 = ContentHash([2u8; 32]);
|
||||
let h3 = ContentHash([3u8; 32]);
|
||||
|
||||
let valid1 = Attestation::create(&alice, h1);
|
||||
let valid2 = Attestation::create(&bob, h2);
|
||||
let mut tampered = Attestation::create(&alice, h3);
|
||||
tampered.signature.0[10] ^= 0xFF;
|
||||
|
||||
a.handle(Message::AttestPush {
|
||||
attestations: vec![valid1.clone(), tampered, valid2.clone()],
|
||||
});
|
||||
|
||||
// Las dos válidas se mergean; la corrupta se rechaza.
|
||||
assert_eq!(a.attestations().len(), 2);
|
||||
assert_eq!(a.rejected_attests(), 1);
|
||||
assert_eq!(a.attestations().authors_of(&h1), vec![alice.did()]);
|
||||
assert_eq!(a.attestations().authors_of(&h2), vec![bob.did()]);
|
||||
assert!(a.attestations().get(&h3).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_attest_push_before_hello_is_rejected() {
|
||||
// Una atestación que llega antes del Hello autenticado se descarta
|
||||
// — no podemos confiar en lo que dice el remitente hasta saber
|
||||
// quién es.
|
||||
let mut a = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp(1));
|
||||
|
||||
let alice = kp(90);
|
||||
let h = ContentHash([7u8; 32]);
|
||||
let att = Attestation::create(&alice, h);
|
||||
|
||||
let out = a.handle(Message::AttestPush {
|
||||
attestations: vec![att],
|
||||
});
|
||||
assert!(out.is_empty());
|
||||
assert_eq!(a.rejected_attests(), 1);
|
||||
assert_eq!(a.attestations().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_attestations_are_idempotent_across_runs() {
|
||||
// Re-correr el sync no duplica atestaciones (gracias a la
|
||||
// idempotencia de AttestationStore::add por (autor, contenido)).
|
||||
let kp_a = kp(100);
|
||||
let kp_b = kp(101);
|
||||
|
||||
let (mst_a, store_a, atts_a, _) =
|
||||
build_repo_with_attests(&["fn run_one() -> i32 { 1 }"], &[&kp_a]);
|
||||
let (mst_b, store_b, atts_b, _) =
|
||||
build_repo_with_attests(&["fn run_two() -> i32 { 2 }"], &[&kp_b]);
|
||||
|
||||
let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a.clone());
|
||||
let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b.clone());
|
||||
run_sync(&mut a, &mut b);
|
||||
let after_first_a = a.attestations().len();
|
||||
let after_first_b = b.attestations().len();
|
||||
assert_eq!(after_first_a, 2);
|
||||
assert_eq!(after_first_b, 2);
|
||||
|
||||
let (mst_a, store_a, atts_a) = a.into_parts();
|
||||
let (mst_b, store_b, atts_b) = b.into_parts();
|
||||
let mut a2 = SyncSession::new(mst_a, store_a, atts_a, kp_a);
|
||||
let mut b2 = SyncSession::new(mst_b, store_b, atts_b, kp_b);
|
||||
run_sync(&mut a2, &mut b2);
|
||||
|
||||
assert_eq!(a2.attestations().len(), after_first_a);
|
||||
assert_eq!(b2.attestations().len(), after_first_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_attestations_about_remote_content() {
|
||||
// Caso interesante: A tiene una atestación sobre contenido que
|
||||
// **NO** posee (lo recibió por gossip de un tercero). Tras sync
|
||||
// con B, B aprende esa atestación aunque A nunca tuvo el contenido
|
||||
// en su store.
|
||||
let kp_a = kp(110);
|
||||
let kp_third_party = kp(111);
|
||||
|
||||
// A no tiene contenido propio pero sí una atestación de
|
||||
// `kp_third_party` sobre un hash arbitrario.
|
||||
let phantom_hash = ContentHash([0xCD; 32]);
|
||||
let mut atts_a = AttestationStore::new();
|
||||
atts_a
|
||||
.add(Attestation::create(&kp_third_party, phantom_hash))
|
||||
.unwrap();
|
||||
|
||||
let kp_b = kp(112);
|
||||
let mut a = SyncSession::new(Mst::new(), MemStore::new(), atts_a, kp_a);
|
||||
let mut b = SyncSession::without_attestations(Mst::new(), MemStore::new(), kp_b);
|
||||
run_sync(&mut a, &mut b);
|
||||
|
||||
// B ahora conoce la atestación, aunque ni A ni B tienen el
|
||||
// contenido en su store.
|
||||
assert_eq!(b.attestations().len(), 1);
|
||||
assert_eq!(b.attestations().authors_of(&phantom_hash), vec![kp_third_party.did()]);
|
||||
assert!(!b.store().contains(&phantom_hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_attest_push_count_in_stats() {
|
||||
// Cuando ambos peers tienen atestaciones, el harness registra dos
|
||||
// AttestPushes (uno por dirección).
|
||||
let kp_a = kp(120);
|
||||
let kp_b = kp(121);
|
||||
let (mst_a, store_a, atts_a, _) =
|
||||
build_repo_with_attests(&["fn ax() -> i32 { 0 }"], &[&kp_a]);
|
||||
let (mst_b, store_b, atts_b, _) =
|
||||
build_repo_with_attests(&["fn bx() -> i32 { 0 }"], &[&kp_b]);
|
||||
|
||||
let mut a = SyncSession::new(mst_a, store_a, atts_a, kp_a);
|
||||
let mut b = SyncSession::new(mst_b, store_b, atts_b, kp_b);
|
||||
let stats = run_sync(&mut a, &mut b);
|
||||
|
||||
assert_eq!(stats.attest_pushes, 2);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Tests de roundtrip de serialización para `Message`.
|
||||
|
||||
use minga_core::{Attestation, ContentHash, Keypair, NodeProbe, StoredNode};
|
||||
use minga_p2p::Message;
|
||||
|
||||
fn roundtrip(msg: &Message) {
|
||||
let bytes = msg.encode();
|
||||
let decoded = Message::decode(&bytes).unwrap();
|
||||
assert_eq!(msg, &decoded);
|
||||
}
|
||||
|
||||
fn kp(seed: u8) -> Keypair {
|
||||
Keypair::from_seed(&[seed; 32])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_roundtrip() {
|
||||
let k = kp(1);
|
||||
let root = ContentHash([42; 32]);
|
||||
let sig = k.sign(root.as_bytes());
|
||||
let msg = Message::Hello {
|
||||
peer_did: k.did(),
|
||||
root_subtree_hash: root,
|
||||
signature: sig,
|
||||
};
|
||||
roundtrip(&msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_req_roundtrip() {
|
||||
roundtrip(&Message::ProbeReq {
|
||||
subtree_hash: ContentHash([5; 32]),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_res_with_probe_roundtrip() {
|
||||
let msg = Message::ProbeRes {
|
||||
subtree_hash: ContentHash([7; 32]),
|
||||
probe: Some(NodeProbe {
|
||||
level: 2,
|
||||
keys: vec![ContentHash([1; 32])],
|
||||
child_hashes: vec![ContentHash([10; 32]), ContentHash([20; 32])],
|
||||
}),
|
||||
};
|
||||
roundtrip(&msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_res_empty_roundtrip() {
|
||||
roundtrip(&Message::ProbeRes {
|
||||
subtree_hash: ContentHash([7; 32]),
|
||||
probe: None,
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_roundtrip() {
|
||||
roundtrip(&Message::Fetch {
|
||||
hash: ContentHash([3; 32]),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deliver_roundtrip() {
|
||||
let stored = StoredNode {
|
||||
kind: "function_item".to_string(),
|
||||
field_name: Some("body".to_string()),
|
||||
leaf_text: None,
|
||||
children: vec![ContentHash([1; 32]), ContentHash([2; 32])],
|
||||
};
|
||||
roundtrip(&Message::Deliver {
|
||||
hash: ContentHash([99; 32]),
|
||||
stored,
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attest_push_roundtrip() {
|
||||
let alice = kp(10);
|
||||
let bob = kp(20);
|
||||
let attestations = vec![
|
||||
Attestation::create(&alice, ContentHash([1; 32])),
|
||||
Attestation::create(&bob, ContentHash([2; 32])),
|
||||
];
|
||||
roundtrip(&Message::AttestPush { attestations });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_roundtrip() {
|
||||
roundtrip(&Message::Done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_bytes_decode_to_error() {
|
||||
let bogus = vec![0xFFu8; 100];
|
||||
assert!(Message::decode(&bogus).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_bytes_decode_to_error() {
|
||||
assert!(Message::decode(&[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_decode_after_encode_preserves_signatures() {
|
||||
// El roundtrip de un Hello debe preservar la firma de modo que la
|
||||
// verificación criptográfica del receptor siga funcionando.
|
||||
let k = kp(33);
|
||||
let root = ContentHash([55; 32]);
|
||||
let sig = k.sign(root.as_bytes());
|
||||
let original = Message::Hello {
|
||||
peer_did: k.did(),
|
||||
root_subtree_hash: root,
|
||||
signature: sig,
|
||||
};
|
||||
let bytes = original.encode();
|
||||
let decoded = Message::decode(&bytes).unwrap();
|
||||
let Message::Hello {
|
||||
peer_did,
|
||||
root_subtree_hash,
|
||||
signature,
|
||||
} = decoded
|
||||
else {
|
||||
panic!("variante incorrecta tras decode");
|
||||
};
|
||||
assert!(peer_did.verify(root_subtree_hash.as_bytes(), &signature));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "minga-store"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Almacenamiento persistente para Minga: stores con backing sled para nodos, atestaciones y MST."
|
||||
|
||||
[dependencies]
|
||||
minga-core = { path = "../minga-core" }
|
||||
sled = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
@@ -0,0 +1,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()]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "minga-vfs"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Virtual File System de Minga (FUSE). Aún no implementado."
|
||||
|
||||
[dependencies]
|
||||
minga-core = { path = "../minga-core" }
|
||||
@@ -0,0 +1,2 @@
|
||||
//! minga-vfs: proyección virtual del repositorio como filesystem (vía
|
||||
//! FUSE). Resuelve hashes a bloques de código bajo demanda. Pendiente.
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "yahweh-bus"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "AppBus + AppEvent — comunicación cross-widget app-level."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
@@ -0,0 +1,44 @@
|
||||
//! `yahweh_bus` — `AppBus` + `AppEvent` para comunicación cross-widget.
|
||||
//!
|
||||
//! Es un `Entity<AppBus>` que emite [`AppEvent`]. Cualquier widget se
|
||||
//! subscribe con `cx.subscribe(&bus, |this, _, ev, cx| { ... })`. La
|
||||
//! Shell crea exactamente un AppBus al boot y lo distribuye:
|
||||
//!
|
||||
//! - **Productores** (FileExplorer, DatabaseExplorer): el LayoutHost los
|
||||
//! subscribe individualmente y reenvía sus eventos tipados al bus,
|
||||
//! normalizando al formato `{provider, id, …}` agnóstico.
|
||||
//! - **Consumidores** (TextViewer, ImageViewer, …): reciben el handle del
|
||||
//! bus en su constructor y se subscriben directo.
|
||||
//!
|
||||
//! Por qué un bus y no `cx.subscribe` directo entre productor y consumidor:
|
||||
//! los viewers no saben qué explorers existen (ni viceversa). El bus
|
||||
//! desacopla — puede haber 0, 1 o N explorers de distintos providers, y
|
||||
//! varios viewers en paralelo viendo el mismo evento.
|
||||
|
||||
use gpui::EventEmitter;
|
||||
|
||||
/// Eventos cross-widget. Diseñados para ser agnósticos del dominio:
|
||||
/// `provider` es el id (string) del DataProvider que sabe interpretar el
|
||||
/// `id`. `provider_path` es el contexto opcional (ej. el .sqlite del
|
||||
/// DatabaseExplorer) que el viewer necesita para construir su provider.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AppEvent {
|
||||
/// Una entidad fue seleccionada (single click). Suele triggerear un
|
||||
/// preview en el viewer activo.
|
||||
EntitySelected {
|
||||
provider: String,
|
||||
provider_path: Option<String>,
|
||||
id: String,
|
||||
},
|
||||
/// Una entidad fue ejecutada (doble click u "Open" del menú).
|
||||
EntityOpened {
|
||||
provider: String,
|
||||
provider_path: Option<String>,
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AppBus;
|
||||
|
||||
impl EventEmitter<AppEvent> for AppBus {}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "yahweh-core"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "Tipos data compartidos: providers, layout JSON, taxonomía de módulos."
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
@@ -0,0 +1,266 @@
|
||||
//! `yahweh_core` — tipos compartidos por toda la app, sin dependencias de UI.
|
||||
//!
|
||||
//! Contiene tres bloques:
|
||||
//! 1. **Providers** (`DataProvider`, `EntityNode`, `DisplayType`) — fuente de
|
||||
//! datos jerárquicos para los exploradores. Portado intacto de `gioser_core`.
|
||||
//! 2. **Layout** (`LayerConfig`, `LayerParam`, `ModuloTipo`, `LayoutDirection`,
|
||||
//! `LayoutMode`) — el JSON describe el árbol de widgets. Portado de
|
||||
//! `gioser_core` quitando los helpers acoplados a Makepad (`LiveId`).
|
||||
//! 3. **Identidad** (`NodeId`) — id estable de un nodo del layout, derivado
|
||||
//! del `id` JSON o del path estructural.
|
||||
//!
|
||||
//! NO contiene tipos de comunicación entre widgets. Esos viven en la `shell`
|
||||
//! y se construyen sobre el sistema de eventos de GPUI (`EventEmitter`,
|
||||
//! `cx.subscribe`).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::pin::Pin;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
|
||||
// =====================================================================
|
||||
// Providers
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DisplayType {
|
||||
Folder,
|
||||
File,
|
||||
Stream,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EntityNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub display_type: DisplayType,
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DataProvider: Send + Sync {
|
||||
fn provider_id(&self) -> String;
|
||||
|
||||
async fn list_children(&self, parent_id: Option<&str>) -> Result<Vec<EntityNode>, String>;
|
||||
|
||||
async fn get_read_stream(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
) -> Result<Pin<Box<dyn AsyncRead + Send>>, String>;
|
||||
|
||||
async fn get_write_stream(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
) -> Result<Pin<Box<dyn AsyncWrite + Send>>, String>;
|
||||
|
||||
/// Default convenience: vacía un read stream a `Vec<u8>`. Los providers
|
||||
/// pueden override si tienen un fast-path.
|
||||
async fn get_data(&self, entity_id: &str) -> Result<Vec<u8>, String> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut stream = self.get_read_stream(entity_id).await?;
|
||||
let mut buffer = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut buffer)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Identidad estable de nodos del layout
|
||||
// =====================================================================
|
||||
|
||||
/// Identificador estable de un nodo del árbol de layout. Construido con
|
||||
/// `NodeId::from_layer(&LayerConfig, path)` durante el DFS del LayoutHost:
|
||||
/// si el `LayerConfig` trae `id` propio, se usa ese; si no, se sintetiza a
|
||||
/// partir del path estructural (`root/main/0`).
|
||||
///
|
||||
/// Internamente es una `String` para no atarse al sistema de hashing de
|
||||
/// ningún framework. La igualdad lexicográfica garantiza estabilidad: el
|
||||
/// mismo `id` o el mismo path producen el mismo `NodeId`.
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NodeId(pub String);
|
||||
|
||||
impl NodeId {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
|
||||
pub fn from_layer(cfg: &LayerConfig, path: &str) -> Self {
|
||||
match &cfg.id {
|
||||
Some(id) if !id.is_empty() => Self(id.clone()),
|
||||
_ => Self(path.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NodeId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Taxonomía y layout JSON
|
||||
// =====================================================================
|
||||
|
||||
/// Tipo de módulo que la Shell sabe instanciar. Cualquier `kind` del JSON se
|
||||
/// resuelve a uno de estos via `ModuloTipo::from_kind`.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ModuloTipo {
|
||||
Texto,
|
||||
Arbol,
|
||||
Imagen,
|
||||
/// Marco contenedor — define un sub-layout y aloja hijos.
|
||||
Contenedor,
|
||||
/// Tile manager autónomo (Tiled / Floating / Stacked + shortcuts).
|
||||
TileManager,
|
||||
}
|
||||
|
||||
impl ModuloTipo {
|
||||
pub fn from_kind(kind: &str) -> Self {
|
||||
match kind {
|
||||
"TextViewer" | "SectionEditor" | "Texto" => Self::Texto,
|
||||
"FileExplorer" | "DatabaseExplorer" | "Arbol" => Self::Arbol,
|
||||
"ImageViewer" | "Imagen" => Self::Imagen,
|
||||
"TileManager" | "Tiled" => Self::TileManager,
|
||||
_ => Self::Contenedor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LayoutDirection {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
Overlay,
|
||||
}
|
||||
|
||||
impl Default for LayoutDirection {
|
||||
fn default() -> Self {
|
||||
Self::Vertical
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutDirection {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"horizontal" | "Horizontal" | "row" => Self::Horizontal,
|
||||
"overlay" | "Overlay" | "stack" => Self::Overlay,
|
||||
_ => Self::Vertical,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Política global del root: cómo se presentan los hijos directos del
|
||||
/// `LayerConfig` raíz entre sí (Tiled / Stacked / Floating). Distinta de
|
||||
/// `LayoutDirection` que es por contenedor.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LayoutMode {
|
||||
Tiled,
|
||||
Stacked,
|
||||
Floating,
|
||||
}
|
||||
|
||||
impl Default for LayoutMode {
|
||||
fn default() -> Self {
|
||||
Self::Tiled
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutMode {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"stacked" | "Stacked" => Self::Stacked,
|
||||
"floating" | "Floating" => Self::Floating,
|
||||
_ => Self::Tiled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct LayerParam {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct LayerConfig {
|
||||
/// Identificador estable. Si falta, el LayoutHost sintetiza desde el path.
|
||||
pub id: Option<String>,
|
||||
/// Nombre de clase del módulo (e.g. "FileExplorer", "Split", "Tabs").
|
||||
pub kind: String,
|
||||
/// Peso flex relativo entre hermanos. `None` ⇒ 1.0.
|
||||
pub flex: Option<f64>,
|
||||
/// Solo válido para contenedores con orientación. `None` ⇒ Vertical.
|
||||
pub direction: Option<String>,
|
||||
pub params: Vec<LayerParam>,
|
||||
pub children: Vec<LayerConfig>,
|
||||
}
|
||||
|
||||
impl Default for LayerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: Some("root".to_string()),
|
||||
kind: "Split".to_string(),
|
||||
flex: Some(1.0),
|
||||
direction: Some("horizontal".to_string()),
|
||||
params: vec![],
|
||||
children: vec![
|
||||
LayerConfig {
|
||||
id: Some("nav".to_string()),
|
||||
kind: "FileExplorer".to_string(),
|
||||
flex: Some(0.3),
|
||||
direction: None,
|
||||
params: vec![],
|
||||
children: vec![],
|
||||
},
|
||||
LayerConfig {
|
||||
id: Some("main".to_string()),
|
||||
kind: "TextViewer".to_string(),
|
||||
flex: Some(0.7),
|
||||
direction: None,
|
||||
params: vec![],
|
||||
children: vec![],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LayerConfig {
|
||||
pub fn load_or_default(path: &str) -> Self {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|json| serde_json::from_str(&json).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn serialize_json(&self) -> String {
|
||||
serde_json::to_string_pretty(self).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn get_param(&self, key: &str) -> Option<&String> {
|
||||
self.params.iter().find(|p| p.key == key).map(|p| &p.value)
|
||||
}
|
||||
|
||||
pub fn flex_weight(&self) -> f64 {
|
||||
self.flex.unwrap_or(1.0).max(0.0)
|
||||
}
|
||||
|
||||
pub fn modulo_tipo(&self) -> ModuloTipo {
|
||||
ModuloTipo::from_kind(&self.kind)
|
||||
}
|
||||
|
||||
pub fn layout_direction(&self) -> LayoutDirection {
|
||||
self.direction
|
||||
.as_deref()
|
||||
.map(LayoutDirection::from_str)
|
||||
.unwrap_or(LayoutDirection::Vertical)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "yahweh-provider-fs"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "DataProvider de filesystem local."
|
||||
|
||||
[dependencies]
|
||||
yahweh-core = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Provider de filesystem local. Crate puro: cero dependencia de UI.
|
||||
//! Implementa `yahweh_core::DataProvider` listando hijos de un path con
|
||||
//! `std::fs::read_dir` y leyendo archivos a `Vec<u8>` via `tokio::io`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use yahweh_core::{DataProvider, DisplayType, EntityNode};
|
||||
|
||||
pub const PROVIDER_ID: &str = "local_fs";
|
||||
|
||||
pub struct FileDataProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl DataProvider for FileDataProvider {
|
||||
fn provider_id(&self) -> String {
|
||||
PROVIDER_ID.to_string()
|
||||
}
|
||||
|
||||
async fn list_children(&self, parent_id: Option<&str>) -> Result<Vec<EntityNode>, String> {
|
||||
let path = parent_id.unwrap_or(".");
|
||||
let mut children = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(path) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
let name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let display_type = if path.is_dir() {
|
||||
DisplayType::Folder
|
||||
} else {
|
||||
DisplayType::File
|
||||
};
|
||||
|
||||
children.push(EntityNode {
|
||||
id: path.to_string_lossy().into_owned(),
|
||||
name,
|
||||
display_type,
|
||||
mime_type: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(children)
|
||||
}
|
||||
|
||||
async fn get_read_stream(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
) -> Result<Pin<Box<dyn AsyncRead + Send>>, String> {
|
||||
let content = fs::read(Path::new(entity_id)).map_err(|e| e.to_string())?;
|
||||
Ok(Box::pin(Cursor::new(content)))
|
||||
}
|
||||
|
||||
async fn get_write_stream(
|
||||
&self,
|
||||
_entity_id: &str,
|
||||
) -> Result<Pin<Box<dyn AsyncWrite + Send>>, String> {
|
||||
Err("Escritura en streaming no implementada para FS".to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "yahweh-provider-sqlite"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "DataProvider de SQLite (jerarquía vía parent_id)."
|
||||
|
||||
[dependencies]
|
||||
yahweh-core = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Provider de SQLite. Crate puro: cero dependencia de UI.
|
||||
//! Tabla `items(id, parent_id, name, display_type, content)` con jerarquía
|
||||
//! por `parent_id NULL` = raíz.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::Connection;
|
||||
use std::io::Cursor;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use yahweh_core::{DataProvider, DisplayType, EntityNode};
|
||||
|
||||
pub const PROVIDER_ID: &str = "sqlite_db";
|
||||
|
||||
pub struct SqliteDataProvider {
|
||||
db: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl SqliteDataProvider {
|
||||
pub fn new(path: &str) -> Result<Self, String> {
|
||||
let conn = Connection::open(path).map_err(|e| e.to_string())?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS items (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
display_type TEXT NOT NULL,
|
||||
content BLOB
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Seed mínimo si la tabla está vacía — para que el DatabaseExplorer
|
||||
// tenga algo que mostrar en una primera ejecución sin pre-config.
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
if count == 0 {
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO items (id, parent_id, name, display_type, content) VALUES \
|
||||
('readme', NULL, 'README.md', 'File', ?), \
|
||||
('notes', NULL, 'notes', 'Folder', NULL), \
|
||||
('todo', 'notes', 'TODO.md', 'File', ?)",
|
||||
rusqlite::params![
|
||||
b"# Yahweh\n\nDemo readme stored in SQLite.\n",
|
||||
b"- TreeView gen\xC3\xA9rico\n- containers swappables\n- layout JSON\n",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
db: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DataProvider for SqliteDataProvider {
|
||||
fn provider_id(&self) -> String {
|
||||
PROVIDER_ID.to_string()
|
||||
}
|
||||
|
||||
async fn list_children(&self, parent_id: Option<&str>) -> Result<Vec<EntityNode>, String> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let mut stmt = db
|
||||
.prepare("SELECT id, name, display_type FROM items WHERE parent_id IS ?")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([parent_id], |row| {
|
||||
let display_type_str: String = row.get(2)?;
|
||||
let display_type = match display_type_str.as_str() {
|
||||
"Folder" => DisplayType::Folder,
|
||||
"Stream" => DisplayType::Stream,
|
||||
_ => DisplayType::File,
|
||||
};
|
||||
|
||||
Ok(EntityNode {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
display_type,
|
||||
mime_type: None,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut children = Vec::new();
|
||||
for row in rows {
|
||||
children.push(row.map_err(|e| e.to_string())?);
|
||||
}
|
||||
Ok(children)
|
||||
}
|
||||
|
||||
async fn get_read_stream(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
) -> Result<Pin<Box<dyn AsyncRead + Send>>, String> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let content: Vec<u8> = db
|
||||
.query_row(
|
||||
"SELECT content FROM items WHERE id = ?",
|
||||
[entity_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Box::pin(Cursor::new(content)))
|
||||
}
|
||||
|
||||
async fn get_write_stream(
|
||||
&self,
|
||||
_entity_id: &str,
|
||||
) -> Result<Pin<Box<dyn AsyncWrite + Send>>, String> {
|
||||
Err("Escritura en streaming no implementada para SQLite (todavía)".to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "yahweh-theme"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "Sistema de temas — paleta + gradientes, Theme como Global GPUI."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
@@ -0,0 +1,334 @@
|
||||
//! `yahweh_theme` — paleta de colores y backgrounds compartidos.
|
||||
//!
|
||||
//! El `Theme` se instala como `Global` de GPUI. Los widgets lo leen vía
|
||||
//! `cx.global::<Theme>()` durante su `render`, y se subscriben con
|
||||
//! `cx.observe_global::<Theme>(…)` para auto-redibujarse cuando cambia.
|
||||
//!
|
||||
//! Filosofía: el theme es **dato puro** (sin lógica de UI). No conoce a
|
||||
//! ningún widget concreto. Cada widget pide slots semánticos (panel_bg,
|
||||
//! row_hover, accent…) sin acoplarse a colores hex específicos.
|
||||
|
||||
use gpui::{Background, Global, Hsla, hsla, linear_color_stop, linear_gradient};
|
||||
|
||||
/// Paleta semántica del theme. Cada slot tiene un nombre funcional, no
|
||||
/// cromático — así los widgets piden "fondo de panel" sin acoplarse a
|
||||
/// "azul oscuro".
|
||||
///
|
||||
/// Convención de slots:
|
||||
/// - `bg_*` se devuelve como `Background` (soporta gradientes); los widgets
|
||||
/// lo pasan a `.bg(...)` directamente.
|
||||
/// - `fg_*`, `accent`, `border` son `Hsla` (colores planos para texto y
|
||||
/// ornamentos).
|
||||
/// - `bg_row_*` son `Hsla` porque las filas de una lista virtualizada se
|
||||
/// beneficiarían poco de un gradiente individual.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Theme {
|
||||
pub name: &'static str,
|
||||
pub is_dark: bool,
|
||||
|
||||
// Fondos.
|
||||
pub bg_app: Background,
|
||||
pub bg_panel: Background,
|
||||
pub bg_panel_alt: Background,
|
||||
pub bg_row_hover: Hsla,
|
||||
pub bg_row_active: Hsla,
|
||||
|
||||
// Foregrounds.
|
||||
pub fg_text: Hsla,
|
||||
pub fg_muted: Hsla,
|
||||
pub fg_disabled: Hsla,
|
||||
|
||||
// Acentos y ornamentos.
|
||||
pub accent: Hsla,
|
||||
pub accent_strong: Hsla,
|
||||
pub border: Hsla,
|
||||
pub border_strong: Hsla,
|
||||
|
||||
/// Marker colors para indicar "este file está abierto en container N".
|
||||
/// Paleta circular — el N-ésimo container usa `marker_palette[n % len]`.
|
||||
pub marker_palette: Vec<Hsla>,
|
||||
}
|
||||
|
||||
impl Global for Theme {}
|
||||
|
||||
impl Theme {
|
||||
pub fn global(cx: &gpui::App) -> &Self {
|
||||
cx.global::<Self>()
|
||||
}
|
||||
|
||||
/// Default — primer preset de `all()`. La Shell lo carga al boot si no
|
||||
/// hay otro persistido.
|
||||
pub fn install_default(cx: &mut gpui::App) {
|
||||
cx.set_global(Self::nebula());
|
||||
}
|
||||
|
||||
/// Reemplaza el theme global. GPUI notifica a todos los `observe_global`
|
||||
/// suscriptores en el siguiente frame.
|
||||
pub fn set(cx: &mut gpui::App, theme: Self) {
|
||||
cx.set_global(theme);
|
||||
}
|
||||
|
||||
/// Lista todos los presets en orden estable. Usado por el switcher para
|
||||
/// ciclar y por el menú de "Tema" cuando lo agreguemos.
|
||||
pub fn all() -> Vec<Self> {
|
||||
vec![
|
||||
Self::nebula(),
|
||||
Self::aurora(),
|
||||
Self::sunset(),
|
||||
Self::flat_dark(),
|
||||
Self::solarized_light(),
|
||||
Self::high_contrast(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Devuelve el preset cuyo `name` matchea (case-insensitive). `None` si
|
||||
/// el nombre no existe — útil para validar input de usuario al cargar
|
||||
/// preferencias persistidas.
|
||||
pub fn by_name(name: &str) -> Option<Self> {
|
||||
Self::all()
|
||||
.into_iter()
|
||||
.find(|t| t.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Próximo preset en la rotación de `all()`. Si `current` no está, se
|
||||
/// vuelve al primero. La rotación es circular (último → primero).
|
||||
pub fn next_after(current: &str) -> Self {
|
||||
let all = Self::all();
|
||||
let idx = all.iter().position(|t| t.name == current);
|
||||
match idx {
|
||||
Some(i) => all[(i + 1) % all.len()].clone(),
|
||||
None => all[0].clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Presets
|
||||
// =====================================================================
|
||||
|
||||
/// **Nebula** — default. Gradiente vertical violáceo profundo → teal
|
||||
/// medianoche. Pensado para sentirse moderno y descansado de noche.
|
||||
pub fn nebula() -> Self {
|
||||
let bg_app = linear_gradient(
|
||||
165.0,
|
||||
linear_color_stop(hsla(265.0 / 360.0, 0.38, 0.07, 1.0), 0.0),
|
||||
linear_color_stop(hsla(195.0 / 360.0, 0.42, 0.09, 1.0), 1.0),
|
||||
);
|
||||
let bg_panel = linear_gradient(
|
||||
165.0,
|
||||
linear_color_stop(hsla(245.0 / 360.0, 0.28, 0.10, 1.0), 0.0),
|
||||
linear_color_stop(hsla(210.0 / 360.0, 0.30, 0.12, 1.0), 1.0),
|
||||
);
|
||||
let bg_panel_alt = linear_gradient(
|
||||
165.0,
|
||||
linear_color_stop(hsla(255.0 / 360.0, 0.25, 0.13, 1.0), 0.0),
|
||||
linear_color_stop(hsla(220.0 / 360.0, 0.27, 0.14, 1.0), 1.0),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Nebula",
|
||||
is_dark: true,
|
||||
bg_app,
|
||||
bg_panel,
|
||||
bg_panel_alt,
|
||||
bg_row_hover: hsla(220.0 / 360.0, 0.30, 0.20, 0.45),
|
||||
bg_row_active: hsla(280.0 / 360.0, 0.55, 0.28, 0.65),
|
||||
fg_text: hsla(210.0 / 360.0, 0.35, 0.88, 1.0),
|
||||
fg_muted: hsla(215.0 / 360.0, 0.22, 0.58, 1.0),
|
||||
fg_disabled: hsla(215.0 / 360.0, 0.10, 0.40, 1.0),
|
||||
accent: hsla(280.0 / 360.0, 0.65, 0.65, 1.0),
|
||||
accent_strong: hsla(285.0 / 360.0, 0.78, 0.74, 1.0),
|
||||
border: hsla(225.0 / 360.0, 0.20, 0.22, 1.0),
|
||||
border_strong: hsla(280.0 / 360.0, 0.40, 0.45, 1.0),
|
||||
marker_palette: vec![
|
||||
hsla(280.0 / 360.0, 0.65, 0.55, 0.45),
|
||||
hsla(195.0 / 360.0, 0.65, 0.50, 0.45),
|
||||
hsla(35.0 / 360.0, 0.75, 0.55, 0.45),
|
||||
hsla(135.0 / 360.0, 0.55, 0.50, 0.45),
|
||||
hsla(0.0, 0.60, 0.55, 0.45),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// **Aurora** — verdes-cian-azul, evoca aurora boreal. Más frío que
|
||||
/// Nebula, contraste alto.
|
||||
pub fn aurora() -> Self {
|
||||
let bg_app = linear_gradient(
|
||||
190.0,
|
||||
linear_color_stop(hsla(170.0 / 360.0, 0.45, 0.06, 1.0), 0.0),
|
||||
linear_color_stop(hsla(220.0 / 360.0, 0.50, 0.09, 1.0), 1.0),
|
||||
);
|
||||
let bg_panel = linear_gradient(
|
||||
190.0,
|
||||
linear_color_stop(hsla(165.0 / 360.0, 0.32, 0.10, 1.0), 0.0),
|
||||
linear_color_stop(hsla(215.0 / 360.0, 0.36, 0.12, 1.0), 1.0),
|
||||
);
|
||||
let bg_panel_alt = linear_gradient(
|
||||
190.0,
|
||||
linear_color_stop(hsla(170.0 / 360.0, 0.30, 0.13, 1.0), 0.0),
|
||||
linear_color_stop(hsla(220.0 / 360.0, 0.32, 0.15, 1.0), 1.0),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Aurora",
|
||||
is_dark: true,
|
||||
bg_app,
|
||||
bg_panel,
|
||||
bg_panel_alt,
|
||||
bg_row_hover: hsla(180.0 / 360.0, 0.40, 0.22, 0.50),
|
||||
bg_row_active: hsla(160.0 / 360.0, 0.55, 0.30, 0.65),
|
||||
fg_text: hsla(180.0 / 360.0, 0.20, 0.92, 1.0),
|
||||
fg_muted: hsla(185.0 / 360.0, 0.18, 0.62, 1.0),
|
||||
fg_disabled: hsla(185.0 / 360.0, 0.10, 0.40, 1.0),
|
||||
accent: hsla(150.0 / 360.0, 0.70, 0.55, 1.0),
|
||||
accent_strong: hsla(160.0 / 360.0, 0.85, 0.65, 1.0),
|
||||
border: hsla(195.0 / 360.0, 0.25, 0.20, 1.0),
|
||||
border_strong: hsla(160.0 / 360.0, 0.55, 0.45, 1.0),
|
||||
marker_palette: vec![
|
||||
hsla(150.0 / 360.0, 0.75, 0.50, 0.45),
|
||||
hsla(195.0 / 360.0, 0.70, 0.50, 0.45),
|
||||
hsla(225.0 / 360.0, 0.70, 0.55, 0.45),
|
||||
hsla(85.0 / 360.0, 0.65, 0.50, 0.45),
|
||||
hsla(330.0 / 360.0, 0.65, 0.55, 0.45),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// **Sunset** — naranjas-rosas-violetas profundos. Cálido, alto contraste
|
||||
/// con texto claro.
|
||||
pub fn sunset() -> Self {
|
||||
let bg_app = linear_gradient(
|
||||
170.0,
|
||||
linear_color_stop(hsla(20.0 / 360.0, 0.50, 0.08, 1.0), 0.0),
|
||||
linear_color_stop(hsla(310.0 / 360.0, 0.45, 0.10, 1.0), 1.0),
|
||||
);
|
||||
let bg_panel = linear_gradient(
|
||||
170.0,
|
||||
linear_color_stop(hsla(15.0 / 360.0, 0.32, 0.12, 1.0), 0.0),
|
||||
linear_color_stop(hsla(315.0 / 360.0, 0.30, 0.13, 1.0), 1.0),
|
||||
);
|
||||
let bg_panel_alt = linear_gradient(
|
||||
170.0,
|
||||
linear_color_stop(hsla(20.0 / 360.0, 0.30, 0.15, 1.0), 0.0),
|
||||
linear_color_stop(hsla(320.0 / 360.0, 0.28, 0.16, 1.0), 1.0),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Sunset",
|
||||
is_dark: true,
|
||||
bg_app,
|
||||
bg_panel,
|
||||
bg_panel_alt,
|
||||
bg_row_hover: hsla(25.0 / 360.0, 0.40, 0.25, 0.45),
|
||||
bg_row_active: hsla(5.0 / 360.0, 0.55, 0.32, 0.65),
|
||||
fg_text: hsla(30.0 / 360.0, 0.30, 0.92, 1.0),
|
||||
fg_muted: hsla(25.0 / 360.0, 0.20, 0.62, 1.0),
|
||||
fg_disabled: hsla(25.0 / 360.0, 0.10, 0.42, 1.0),
|
||||
accent: hsla(15.0 / 360.0, 0.78, 0.62, 1.0),
|
||||
accent_strong: hsla(355.0 / 360.0, 0.85, 0.68, 1.0),
|
||||
border: hsla(15.0 / 360.0, 0.25, 0.25, 1.0),
|
||||
border_strong: hsla(355.0 / 360.0, 0.55, 0.45, 1.0),
|
||||
marker_palette: vec![
|
||||
hsla(15.0 / 360.0, 0.80, 0.55, 0.45),
|
||||
hsla(310.0 / 360.0, 0.65, 0.55, 0.45),
|
||||
hsla(45.0 / 360.0, 0.80, 0.55, 0.45),
|
||||
hsla(285.0 / 360.0, 0.65, 0.60, 0.45),
|
||||
hsla(355.0 / 360.0, 0.70, 0.55, 0.45),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// **Flat Dark** — sin gradientes, paleta cool gris-azulado. Para quien
|
||||
/// prefiere monocromía. Útil para contrastar visualmente con los temas
|
||||
/// de gradiente.
|
||||
pub fn flat_dark() -> Self {
|
||||
let bg_app: Background = hsla(220.0 / 360.0, 0.15, 0.09, 1.0).into();
|
||||
let bg_panel: Background = hsla(220.0 / 360.0, 0.15, 0.12, 1.0).into();
|
||||
let bg_panel_alt: Background = hsla(220.0 / 360.0, 0.15, 0.14, 1.0).into();
|
||||
Self {
|
||||
name: "Flat Dark",
|
||||
is_dark: true,
|
||||
bg_app,
|
||||
bg_panel,
|
||||
bg_panel_alt,
|
||||
bg_row_hover: hsla(220.0 / 360.0, 0.20, 0.20, 1.0),
|
||||
bg_row_active: hsla(220.0 / 360.0, 0.40, 0.30, 1.0),
|
||||
fg_text: hsla(210.0 / 360.0, 0.20, 0.85, 1.0),
|
||||
fg_muted: hsla(215.0 / 360.0, 0.15, 0.55, 1.0),
|
||||
fg_disabled: hsla(215.0 / 360.0, 0.10, 0.40, 1.0),
|
||||
accent: hsla(210.0 / 360.0, 0.70, 0.55, 1.0),
|
||||
accent_strong: hsla(210.0 / 360.0, 0.85, 0.65, 1.0),
|
||||
border: hsla(220.0 / 360.0, 0.15, 0.20, 1.0),
|
||||
border_strong: hsla(220.0 / 360.0, 0.30, 0.35, 1.0),
|
||||
marker_palette: vec![
|
||||
hsla(210.0 / 360.0, 0.65, 0.55, 0.40),
|
||||
hsla(160.0 / 360.0, 0.55, 0.50, 0.40),
|
||||
hsla(30.0 / 360.0, 0.75, 0.55, 0.40),
|
||||
hsla(0.0, 0.55, 0.55, 0.40),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// **Solarized Light** — preset claro inspirado en la paleta clásica de
|
||||
/// Schoonover. Sin gradientes (en light un gradiente sutil pasa
|
||||
/// desapercibido y solo introduce ruido).
|
||||
pub fn solarized_light() -> Self {
|
||||
let bg_app: Background = hsla(44.0 / 360.0, 0.87, 0.94, 1.0).into();
|
||||
let bg_panel: Background = hsla(46.0 / 360.0, 0.42, 0.88, 1.0).into();
|
||||
let bg_panel_alt: Background = hsla(46.0 / 360.0, 0.42, 0.92, 1.0).into();
|
||||
Self {
|
||||
name: "Solarized Light",
|
||||
is_dark: false,
|
||||
bg_app,
|
||||
bg_panel,
|
||||
bg_panel_alt,
|
||||
bg_row_hover: hsla(46.0 / 360.0, 0.45, 0.80, 0.65),
|
||||
bg_row_active: hsla(45.0 / 360.0, 0.55, 0.72, 0.85),
|
||||
fg_text: hsla(196.0 / 360.0, 0.13, 0.30, 1.0),
|
||||
fg_muted: hsla(196.0 / 360.0, 0.13, 0.45, 1.0),
|
||||
fg_disabled: hsla(196.0 / 360.0, 0.10, 0.62, 1.0),
|
||||
accent: hsla(205.0 / 360.0, 0.69, 0.42, 1.0),
|
||||
accent_strong: hsla(205.0 / 360.0, 0.82, 0.38, 1.0),
|
||||
border: hsla(46.0 / 360.0, 0.30, 0.78, 1.0),
|
||||
border_strong: hsla(205.0 / 360.0, 0.40, 0.55, 1.0),
|
||||
marker_palette: vec![
|
||||
hsla(205.0 / 360.0, 0.69, 0.42, 0.30),
|
||||
hsla(175.0 / 360.0, 0.74, 0.32, 0.30),
|
||||
hsla(45.0 / 360.0, 1.00, 0.36, 0.30),
|
||||
hsla(331.0 / 360.0, 0.74, 0.42, 0.30),
|
||||
hsla(18.0 / 360.0, 0.89, 0.40, 0.30),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// **High Contrast** — accesibilidad. Negro puro con texto blanco y
|
||||
/// ornamentos amarillo/verde fuertes. Suficientemente diferente para
|
||||
/// notar inmediatamente al usar el switcher.
|
||||
pub fn high_contrast() -> Self {
|
||||
let bg_app: Background = hsla(0.0, 0.0, 0.0, 1.0).into();
|
||||
let bg_panel: Background = hsla(0.0, 0.0, 0.05, 1.0).into();
|
||||
let bg_panel_alt: Background = hsla(0.0, 0.0, 0.10, 1.0).into();
|
||||
Self {
|
||||
name: "High Contrast",
|
||||
is_dark: true,
|
||||
bg_app,
|
||||
bg_panel,
|
||||
bg_panel_alt,
|
||||
bg_row_hover: hsla(60.0 / 360.0, 1.00, 0.50, 0.35),
|
||||
bg_row_active: hsla(120.0 / 360.0, 1.00, 0.40, 0.55),
|
||||
fg_text: hsla(0.0, 0.0, 1.0, 1.0),
|
||||
fg_muted: hsla(0.0, 0.0, 0.75, 1.0),
|
||||
fg_disabled: hsla(0.0, 0.0, 0.50, 1.0),
|
||||
accent: hsla(60.0 / 360.0, 1.00, 0.60, 1.0),
|
||||
accent_strong: hsla(60.0 / 360.0, 1.00, 0.75, 1.0),
|
||||
border: hsla(0.0, 0.0, 0.30, 1.0),
|
||||
border_strong: hsla(60.0 / 360.0, 1.00, 0.60, 1.0),
|
||||
marker_palette: vec![
|
||||
hsla(60.0 / 360.0, 1.00, 0.55, 0.50),
|
||||
hsla(120.0 / 360.0, 1.00, 0.50, 0.50),
|
||||
hsla(180.0 / 360.0, 1.00, 0.55, 0.50),
|
||||
hsla(0.0, 1.00, 0.60, 0.50),
|
||||
hsla(300.0 / 360.0, 1.00, 0.65, 0.50),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "yahweh-widget-container-core"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "Tipos compartidos para contenedores (ChildSlot, etc.). Imported por Splitter, Tabs, Tiled y la Shell."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
yahweh-core = { workspace = true }
|
||||
@@ -0,0 +1,38 @@
|
||||
//! `yahweh_widget_container_core` — tipos compartidos por todos los
|
||||
//! contenedores (Splitter, Tabs, Tiled, futuros).
|
||||
//!
|
||||
//! La pieza más relevante es [`ChildSlot`]: el "paquete" con que la Shell
|
||||
//! le entrega a un contenedor un hijo ya instanciado. La identidad
|
||||
//! estable (`id: NodeId`) es lo que permite **swappear el kind del
|
||||
//! contenedor sin perder los hijos**: cuando el JSON cambia
|
||||
//! `kind: "Split"` por `kind: "Tabs"`, el LayoutHost descarta el viejo
|
||||
//! contenedor pero pasa los mismos `ChildSlot` (con los mismos AnyView ya
|
||||
//! con estado) al contenedor nuevo. Esa preservación es la promesa
|
||||
//! arquitectónica de la app.
|
||||
//!
|
||||
//! `flex` y `label` son metadatos opcionales que cada contenedor
|
||||
//! interpreta a su gusto:
|
||||
//! - Splitter: usa `flex` para repartir; ignora `label`.
|
||||
//! - Tabs: usa `label` para el título de la pestaña; ignora `flex`.
|
||||
//! - Tiled: usa ambos opcionalmente (peso de tile, label hover).
|
||||
|
||||
use gpui::AnyView;
|
||||
use yahweh_core::NodeId;
|
||||
|
||||
/// Slot de un hijo entregado a un contenedor. La Shell construye el
|
||||
/// `Vec<ChildSlot>` haciendo DFS sobre el `LayerConfig` del JSON.
|
||||
#[derive(Clone)]
|
||||
pub struct ChildSlot {
|
||||
/// Identidad estable (proviene del campo `id` del JSON, o se
|
||||
/// sintetiza desde el path estructural).
|
||||
pub id: NodeId,
|
||||
/// Peso flex relativo entre hermanos. Útil para Splitter / Tiled;
|
||||
/// los contenedores que no lo usan lo ignoran.
|
||||
pub flex: f32,
|
||||
/// Texto opcional para decoración (título de tab, label de tile, etc).
|
||||
/// Si `None`, los contenedores que lo necesiten caen al `id` como
|
||||
/// fallback razonable.
|
||||
pub label: Option<String>,
|
||||
/// El widget instanciado, listo para colgar del árbol de render.
|
||||
pub view: AnyView,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "yahweh-widget-splitter"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "SplitContainer — n hijos con flex weights y divisores arrastrables."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
yahweh-core = { workspace = true }
|
||||
yahweh-theme = { workspace = true }
|
||||
yahweh-widget-container-core = { workspace = true }
|
||||
@@ -0,0 +1,362 @@
|
||||
//! `yahweh_widget_splitter` — `SplitContainer` genérico.
|
||||
//!
|
||||
//! Aloja `n` hijos `AnyView` con flex weights individuales y un divisor
|
||||
//! arrastrable entre cada par adyacente. Dirección horizontal o vertical
|
||||
//! intercambiable. Emite [`SplitEvent::FlexChanged`] cuando un drag termina,
|
||||
//! para que el host (LayoutHost / DemoApp) persista los flex.
|
||||
//!
|
||||
//! El SplitContainer NO conoce a sus hijos: los recibe vía
|
||||
//! `set_children(Vec<ChildSlot>)`. Eso permite que el LayoutHost reuse las
|
||||
//! mismas instancias cuando el JSON cambia el `kind` del contenedor (Split
|
||||
//! → Tabs → Tiled) — los AnyView siguen vivos, solo cambia su contenedor.
|
||||
//!
|
||||
//! Drag: usamos el patrón canónico de gpui (ver `data_table.rs` ejemplo) —
|
||||
//! cada divider tiene un `canvas(prepaint, paint)` que en su paint registra
|
||||
//! handlers de `MouseDown / MouseMove / MouseUp` a nivel de window vía
|
||||
//! `window.on_mouse_event`. Esto garantiza que el drag continúa aunque el
|
||||
//! cursor salga del divider.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, EventEmitter, IntoElement, Length, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, Pixels, Point, Render, Window, canvas, div, prelude::*, px,
|
||||
};
|
||||
|
||||
use yahweh_core::{LayoutDirection, NodeId};
|
||||
use yahweh_theme::Theme;
|
||||
pub use yahweh_widget_container_core::ChildSlot;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SplitEvent {
|
||||
/// Un drag actualizó los flex weights. Se emite UNA vez por movimiento
|
||||
/// (cada frame durante un drag), con los IDs y flex finales de los dos
|
||||
/// hijos adyacentes al divisor.
|
||||
FlexChanged {
|
||||
left_id: NodeId,
|
||||
right_id: NodeId,
|
||||
left_flex: f32,
|
||||
right_flex: f32,
|
||||
},
|
||||
/// El drag terminó (mouseup). Útil para persistir batched.
|
||||
DragEnd,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Widget
|
||||
// =====================================================================
|
||||
|
||||
/// Estado interno del drag activo. `divider_index` apunta al espacio entre
|
||||
/// `children[i]` y `children[i+1]`. Los snapshots `flex_*_initial` y
|
||||
/// `start_pos_main` se capturan en MouseDown — durante MouseMove se
|
||||
/// recalcula el flex desde el delta.
|
||||
struct DragState {
|
||||
divider_index: usize,
|
||||
start_pos_main: Pixels,
|
||||
flex_left_initial: f32,
|
||||
flex_right_initial: f32,
|
||||
/// Longitud total del SplitContainer en el eje principal al iniciar el
|
||||
/// drag (capturada de `bounds`). Usada para convertir delta_px ↔
|
||||
/// delta_flex preservando el sum total.
|
||||
total_main_size: Pixels,
|
||||
total_flex_initial: f32,
|
||||
}
|
||||
|
||||
pub struct SplitContainer {
|
||||
children: Vec<ChildSlot>,
|
||||
direction: LayoutDirection,
|
||||
drag: Option<DragState>,
|
||||
/// Bounds del frame anterior. Capturados vía canvas absolute en cada
|
||||
/// paint. Lo usamos al iniciar drag para resolver `total_main_size`.
|
||||
bounds: Rc<RefCell<Option<Bounds<Pixels>>>>,
|
||||
}
|
||||
|
||||
impl EventEmitter<SplitEvent> for SplitContainer {}
|
||||
|
||||
impl SplitContainer {
|
||||
pub fn new(direction: LayoutDirection, cx: &mut Context<Self>) -> Self {
|
||||
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
||||
Self {
|
||||
children: Vec::new(),
|
||||
direction,
|
||||
drag: None,
|
||||
bounds: Rc::new(RefCell::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_children(&mut self, children: Vec<ChildSlot>, cx: &mut Context<Self>) {
|
||||
self.children = children;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_direction(&mut self, direction: LayoutDirection, cx: &mut Context<Self>) {
|
||||
if self.direction != direction {
|
||||
self.direction = direction;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn direction(&self) -> LayoutDirection {
|
||||
self.direction
|
||||
}
|
||||
|
||||
pub fn children(&self) -> &[ChildSlot] {
|
||||
&self.children
|
||||
}
|
||||
|
||||
// -------- Drag handlers --------
|
||||
|
||||
fn start_drag(&mut self, divider_index: usize, position: Point<Pixels>) {
|
||||
if divider_index >= self.children.len().saturating_sub(1) {
|
||||
return;
|
||||
}
|
||||
let bounds = match *self.bounds.borrow() {
|
||||
Some(b) => b,
|
||||
None => return,
|
||||
};
|
||||
let raw_main = main_axis(self.direction, bounds.size.width, bounds.size.height);
|
||||
// Restamos el espacio que ocupan los divisores — son fixed-size en el
|
||||
// eje principal, no participan del flex. El "espacio disponible
|
||||
// para flex" es lo que importa para convertir delta_px → delta_flex.
|
||||
let dividers_total = px(DIVIDER_THICKNESS) * (self.children.len().saturating_sub(1) as f32);
|
||||
let total_main = raw_main - dividers_total;
|
||||
if total_main <= px(0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let total_flex: f32 = self.children.iter().map(|c| c.flex.max(0.0)).sum();
|
||||
let total_flex = total_flex.max(0.001);
|
||||
|
||||
let start_main = main_axis_pt(self.direction, position);
|
||||
|
||||
self.drag = Some(DragState {
|
||||
divider_index,
|
||||
start_pos_main: start_main,
|
||||
flex_left_initial: self.children[divider_index].flex,
|
||||
flex_right_initial: self.children[divider_index + 1].flex,
|
||||
total_main_size: total_main,
|
||||
total_flex_initial: total_flex,
|
||||
});
|
||||
}
|
||||
|
||||
fn continue_drag(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
let Some(drag) = &self.drag else { return };
|
||||
let drag_idx = drag.divider_index;
|
||||
if drag_idx + 1 >= self.children.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
let cur_main = main_axis_pt(self.direction, position);
|
||||
let delta_px = cur_main - drag.start_pos_main;
|
||||
// delta_flex = delta_px / total_main_size * total_flex_initial.
|
||||
let total_main_f = f32::from(drag.total_main_size).max(1.0);
|
||||
let delta_flex = (f32::from(delta_px) / total_main_f) * drag.total_flex_initial;
|
||||
|
||||
const MIN_FLEX: f32 = 0.05;
|
||||
let new_left = (drag.flex_left_initial + delta_flex).max(MIN_FLEX);
|
||||
let new_right = (drag.flex_right_initial - delta_flex).max(MIN_FLEX);
|
||||
|
||||
// Solo aplicamos si NINGUNO se aplastó al mínimo y se "comió" el
|
||||
// delta — eso significa que el drag llegó al borde de un hijo.
|
||||
let fits = (drag.flex_left_initial + delta_flex) >= MIN_FLEX
|
||||
&& (drag.flex_right_initial - delta_flex) >= MIN_FLEX;
|
||||
if !fits {
|
||||
// Recortamos: aplicamos los mínimos pero no propagamos delta más
|
||||
// allá del límite. Resultado: el divisor "frena" en el borde.
|
||||
}
|
||||
|
||||
self.children[drag_idx].flex = new_left;
|
||||
self.children[drag_idx + 1].flex = new_right;
|
||||
|
||||
let left_id = self.children[drag_idx].id.clone();
|
||||
let right_id = self.children[drag_idx + 1].id.clone();
|
||||
cx.emit(SplitEvent::FlexChanged {
|
||||
left_id,
|
||||
right_id,
|
||||
left_flex: new_left,
|
||||
right_flex: new_right,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn end_drag(&mut self, cx: &mut Context<Self>) {
|
||||
if self.drag.take().is_some() {
|
||||
cx.emit(SplitEvent::DragEnd);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Helpers de eje
|
||||
// =====================================================================
|
||||
|
||||
fn main_axis(dir: LayoutDirection, w: Pixels, h: Pixels) -> Pixels {
|
||||
match dir {
|
||||
LayoutDirection::Horizontal => w,
|
||||
_ => h,
|
||||
}
|
||||
}
|
||||
|
||||
fn main_axis_pt(dir: LayoutDirection, p: Point<Pixels>) -> Pixels {
|
||||
match dir {
|
||||
LayoutDirection::Horizontal => p.x,
|
||||
_ => p.y,
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Render
|
||||
// =====================================================================
|
||||
|
||||
const DIVIDER_THICKNESS: f32 = 4.0;
|
||||
|
||||
impl Render for SplitContainer {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
let direction = self.direction;
|
||||
let entity = cx.entity();
|
||||
let bounds_holder = self.bounds.clone();
|
||||
|
||||
let total_flex: f32 = self
|
||||
.children
|
||||
.iter()
|
||||
.map(|c| c.flex.max(0.0))
|
||||
.sum::<f32>()
|
||||
.max(0.001);
|
||||
|
||||
// Root flex container.
|
||||
let mut root = div().size_full().relative();
|
||||
root = match direction {
|
||||
LayoutDirection::Horizontal => root.flex().flex_row(),
|
||||
_ => root.flex().flex_col(),
|
||||
};
|
||||
|
||||
// Canvas absolute para capturar bounds del SplitContainer en cada
|
||||
// frame. No participa del flex (absolute), no captura clicks
|
||||
// (canvas sin id es no-interactivo).
|
||||
root = root.child({
|
||||
let bounds_holder = bounds_holder.clone();
|
||||
canvas(
|
||||
move |bounds, _w, _cx| {
|
||||
*bounds_holder.borrow_mut() = Some(bounds);
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full()
|
||||
});
|
||||
|
||||
// Children + dividers entre cada par.
|
||||
let n = self.children.len();
|
||||
for (i, child) in self.children.iter().enumerate() {
|
||||
let weight = (child.flex.max(0.0) / total_flex).max(0.001);
|
||||
|
||||
let mut item = div().relative();
|
||||
// flex_grow fraccional — el helper `flex_grow()` solo setea 1.0,
|
||||
// así que vamos directo al campo subyacente para repartir
|
||||
// proporcionalmente según el `flex` de cada slot.
|
||||
item.style().flex_grow = Some(weight);
|
||||
item.style().flex_shrink = Some(1.0);
|
||||
|
||||
// CRUCIAL: el default de flexbox es `min-width: auto` (= min
|
||||
// content size). Si no lo aplastamos a 0, taffy clamp-ea al
|
||||
// tamaño mínimo del contenido (un TreeView con label largo, un
|
||||
// uniform_list, etc.) y el divisor no puede pasar de ese punto
|
||||
// — el cursor avanza pero el divisor se queda. Forzando min=0
|
||||
// y overflow:hidden en el wrapper, el child puede shrink-arse a
|
||||
// donde sea y el contenido se recorta.
|
||||
item.style().min_size.width = Some(Length::Definite(px(0.0).into()));
|
||||
item.style().min_size.height = Some(Length::Definite(px(0.0).into()));
|
||||
|
||||
// Eje cruzado: full. Eje principal: lo decide flex.
|
||||
let item = match direction {
|
||||
LayoutDirection::Horizontal => item.h_full(),
|
||||
_ => item.w_full(),
|
||||
}
|
||||
.overflow_hidden()
|
||||
.child(child.view.clone());
|
||||
|
||||
root = root.child(item);
|
||||
|
||||
// Divisor entre i e i+1 (no después del último).
|
||||
if i + 1 < n {
|
||||
let divider_idx = i;
|
||||
let entity_for_canvas = entity.clone();
|
||||
|
||||
let mut divider = div();
|
||||
let divider_bg = if self.drag.as_ref().map(|d| d.divider_index) == Some(divider_idx)
|
||||
{
|
||||
theme.accent_strong
|
||||
} else {
|
||||
theme.border_strong
|
||||
};
|
||||
divider = divider.bg(divider_bg).hover(|s| s.bg(theme.accent));
|
||||
|
||||
divider = match direction {
|
||||
LayoutDirection::Horizontal => divider
|
||||
.w(px(DIVIDER_THICKNESS))
|
||||
.h_full()
|
||||
.cursor_ew_resize(),
|
||||
_ => divider
|
||||
.w_full()
|
||||
.h(px(DIVIDER_THICKNESS))
|
||||
.cursor_ns_resize(),
|
||||
};
|
||||
|
||||
// Canvas con handlers de drag a nivel de window.
|
||||
let divider = divider.child(
|
||||
canvas(
|
||||
|_, _, _| (),
|
||||
move |canvas_bounds: Bounds<Pixels>, _, window, _| {
|
||||
// MouseDown sobre el divisor → start_drag.
|
||||
window.on_mouse_event({
|
||||
let entity = entity_for_canvas.clone();
|
||||
move |ev: &MouseDownEvent, _, _w: &mut Window, cx: &mut App| {
|
||||
if ev.button != MouseButton::Left {
|
||||
return;
|
||||
}
|
||||
if !canvas_bounds.contains(&ev.position) {
|
||||
return;
|
||||
}
|
||||
entity.update(cx, |this, _| {
|
||||
this.start_drag(divider_idx, ev.position);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// MouseMove anywhere → continue_drag si hay drag.
|
||||
window.on_mouse_event({
|
||||
let entity = entity_for_canvas.clone();
|
||||
move |ev: &MouseMoveEvent, _, _w: &mut Window, cx: &mut App| {
|
||||
if !ev.dragging() {
|
||||
return;
|
||||
}
|
||||
entity.update(cx, |this, cx| {
|
||||
if this.drag.is_some() {
|
||||
this.continue_drag(ev.position, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// MouseUp anywhere → end_drag.
|
||||
window.on_mouse_event({
|
||||
let entity = entity_for_canvas.clone();
|
||||
move |_: &MouseUpEvent, _, _w: &mut Window, cx: &mut App| {
|
||||
entity.update(cx, |this, cx| this.end_drag(cx));
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
.size_full(),
|
||||
);
|
||||
|
||||
root = root.child(divider);
|
||||
}
|
||||
}
|
||||
|
||||
root
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "yahweh-widget-tabs"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "TabContainer — n hijos, uno visible, header con tabs clickeables."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
yahweh-core = { workspace = true }
|
||||
yahweh-theme = { workspace = true }
|
||||
yahweh-widget-container-core = { workspace = true }
|
||||
@@ -0,0 +1,192 @@
|
||||
//! `yahweh_widget_tabs` — `TabContainer`.
|
||||
//!
|
||||
//! `n` hijos `AnyView`, **uno visible** por vez (la pestaña activa). Header
|
||||
//! horizontal con un botón por hijo; click cambia la pestaña activa. La
|
||||
//! identidad del hijo activo se preserva por `NodeId`, así que swappear de
|
||||
//! Split → Tabs y volver no resetea cuál está abierto.
|
||||
//!
|
||||
//! API alineada con `SplitContainer` (mismo `set_children`) para que el
|
||||
//! LayoutHost los use intercambiablemente.
|
||||
|
||||
use gpui::{
|
||||
ClickEvent, Context, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*,
|
||||
px,
|
||||
};
|
||||
|
||||
use yahweh_core::NodeId;
|
||||
use yahweh_theme::Theme;
|
||||
use yahweh_widget_container_core::ChildSlot;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum TabsEvent {
|
||||
/// Una pestaña distinta quedó activa (por click o `set_active`).
|
||||
TabActivated { id: NodeId, index: usize },
|
||||
}
|
||||
|
||||
pub struct TabContainer {
|
||||
children: Vec<ChildSlot>,
|
||||
/// Id del hijo activo. Lo guardamos por id (no por índice) para que
|
||||
/// reorders/inserts no rompan la selección.
|
||||
active_id: Option<NodeId>,
|
||||
}
|
||||
|
||||
impl EventEmitter<TabsEvent> for TabContainer {}
|
||||
|
||||
impl TabContainer {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
||||
Self {
|
||||
children: Vec::new(),
|
||||
active_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_children(&mut self, children: Vec<ChildSlot>, cx: &mut Context<Self>) {
|
||||
// Si el id activo previo sigue presente, preservarlo. Si no, caer
|
||||
// al primero (o None si vacío).
|
||||
let still_present = self
|
||||
.active_id
|
||||
.as_ref()
|
||||
.map(|id| children.iter().any(|c| &c.id == id))
|
||||
.unwrap_or(false);
|
||||
if !still_present {
|
||||
self.active_id = children.first().map(|c| c.id.clone());
|
||||
}
|
||||
self.children = children;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_active(&mut self, id: NodeId, cx: &mut Context<Self>) {
|
||||
if self.children.iter().any(|c| c.id == id) && self.active_id.as_ref() != Some(&id) {
|
||||
let index = self.children.iter().position(|c| c.id == id).unwrap_or(0);
|
||||
self.active_id = Some(id.clone());
|
||||
cx.emit(TabsEvent::TabActivated { id, index });
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_id(&self) -> Option<&NodeId> {
|
||||
self.active_id.as_ref()
|
||||
}
|
||||
|
||||
fn active_index(&self) -> Option<usize> {
|
||||
let id = self.active_id.as_ref()?;
|
||||
self.children.iter().position(|c| &c.id == id)
|
||||
}
|
||||
|
||||
fn on_tab_click(
|
||||
&mut self,
|
||||
id: NodeId,
|
||||
_click: &ClickEvent,
|
||||
_w: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.set_active(id, cx);
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_HEADER_HEIGHT: f32 = 30.0;
|
||||
|
||||
impl Render for TabContainer {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
let active_idx = self.active_index();
|
||||
|
||||
// Header — una "pestaña" por hijo. Cada tab usa una stripe inferior
|
||||
// (un div hijo de 2px de alto) como indicador de "activa", porque
|
||||
// gpui no expone `border_b_color` por separado del border global.
|
||||
let mut header = div()
|
||||
.h(px(TAB_HEADER_HEIGHT))
|
||||
.w_full()
|
||||
.border_b_1()
|
||||
.border_color(theme.border)
|
||||
.bg(theme.bg_panel.clone())
|
||||
.flex()
|
||||
.flex_row();
|
||||
|
||||
for (i, child) in self.children.iter().enumerate() {
|
||||
let is_active = active_idx == Some(i);
|
||||
let label_text = child
|
||||
.label
|
||||
.clone()
|
||||
.unwrap_or_else(|| child.id.as_str().to_string());
|
||||
let id_for_click = child.id.clone();
|
||||
let tab_id: SharedString =
|
||||
SharedString::from(format!("tab-{}", child.id));
|
||||
|
||||
let bg = if is_active {
|
||||
theme.bg_panel_alt.clone()
|
||||
} else {
|
||||
theme.bg_panel.clone()
|
||||
};
|
||||
let fg = if is_active {
|
||||
theme.fg_text
|
||||
} else {
|
||||
theme.fg_muted
|
||||
};
|
||||
let stripe_color = if is_active {
|
||||
theme.accent_strong
|
||||
} else {
|
||||
gpui::hsla(0.0, 0.0, 0.0, 0.0)
|
||||
};
|
||||
|
||||
header = header.child(
|
||||
div()
|
||||
.id(tab_id)
|
||||
.h_full()
|
||||
.border_r_1()
|
||||
.border_color(theme.border)
|
||||
.bg(bg)
|
||||
.text_color(fg)
|
||||
.text_size(px(12.0))
|
||||
.hover(|s| s.opacity(0.85))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
// Etiqueta + padding centrado.
|
||||
div()
|
||||
.flex_grow()
|
||||
.px(px(14.0))
|
||||
.flex()
|
||||
.items_center()
|
||||
.child(SharedString::from(label_text)),
|
||||
)
|
||||
.child(
|
||||
// Stripe inferior de 2px — indicador de activa.
|
||||
div().h(px(2.0)).w_full().bg(stripe_color),
|
||||
)
|
||||
.on_click(cx.listener(move |this, click, w, cx| {
|
||||
this.on_tab_click(id_for_click.clone(), click, w, cx);
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Cuerpo — solo el child activo. Si no hay ninguno (children
|
||||
// vacío), pintamos un mensaje neutro.
|
||||
let body = match active_idx.and_then(|i| self.children.get(i)) {
|
||||
Some(child) => div()
|
||||
.flex_grow()
|
||||
.min_h(px(0.0))
|
||||
.bg(theme.bg_panel_alt.clone())
|
||||
.child(child.view.clone())
|
||||
.into_any_element(),
|
||||
None => div()
|
||||
.flex_grow()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_color(theme.fg_muted)
|
||||
.text_size(px(11.0))
|
||||
.child("(sin hijos)")
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(header)
|
||||
.child(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "yahweh-widget-text-input"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "TextInput minimalista para diálogos (rename, prompts). Single-line, sin selección/clipboard."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
yahweh-theme = { workspace = true }
|
||||
@@ -0,0 +1,156 @@
|
||||
//! `yahweh_widget_text_input` — input de texto minimal.
|
||||
//!
|
||||
//! Diseñado para diálogos cortos (rename, prompts). NO es un editor — no
|
||||
//! soporta:
|
||||
//! - cursor positioning con flechas / mouse,
|
||||
//! - selección con shift / arrastre,
|
||||
//! - copy / cut / paste,
|
||||
//! - IME / multilínea.
|
||||
//!
|
||||
//! Soporta lo justo:
|
||||
//! - escribir caracteres (cualquier `key_char` printable los appendea al final),
|
||||
//! - `Backspace` quita el último char,
|
||||
//! - `Enter` emite [`TextInputEvent::Confirmed`] con el texto actual,
|
||||
//! - `Escape` emite [`TextInputEvent::Cancelled`].
|
||||
//!
|
||||
//! Cuando montes el widget, llamá `request_focus(window)` para que reciba
|
||||
//! teclas de inmediato. El padre se subscribe vía `cx.subscribe(&input,
|
||||
//! …)` para recibir Confirmed/Cancelled.
|
||||
//!
|
||||
//! Cuando necesitemos algo serio (selección, posiciones, IME), portamos el
|
||||
//! ejemplo `gpui::examples::input` o adoptamos `gpui-input` cuando exista.
|
||||
|
||||
use gpui::{
|
||||
Context, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent, Render,
|
||||
SharedString, Window, div, prelude::*, px,
|
||||
};
|
||||
|
||||
use yahweh_theme::Theme;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TextInputEvent {
|
||||
/// El usuario apretó Enter. El payload es el texto actual.
|
||||
Confirmed(String),
|
||||
/// El usuario apretó Escape. El padre suele cerrar el modal.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
pub struct TextInput {
|
||||
text: String,
|
||||
focus_handle: FocusHandle,
|
||||
/// Placeholder visible cuando `text` está vacío.
|
||||
placeholder: SharedString,
|
||||
}
|
||||
|
||||
impl EventEmitter<TextInputEvent> for TextInput {}
|
||||
|
||||
impl Focusable for TextInput {
|
||||
fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
pub fn new(initial: impl Into<String>, cx: &mut Context<Self>) -> Self {
|
||||
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
||||
Self {
|
||||
text: initial.into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
placeholder: SharedString::from(""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Setea el placeholder mostrado cuando el campo está vacío.
|
||||
#[allow(dead_code)]
|
||||
pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
|
||||
self.placeholder = placeholder.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Reemplaza el contenido completo (e.g. al abrir un modal pre-cargado).
|
||||
pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
|
||||
self.text = text.into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Pide focus para que las próximas teclas vayan al input. Llamar
|
||||
/// cuando montás el widget en un modal para que esté "activo".
|
||||
pub fn request_focus(&self, window: &mut Window) {
|
||||
window.focus(&self.focus_handle);
|
||||
}
|
||||
|
||||
fn handle_key_down(
|
||||
&mut self,
|
||||
event: &KeyDownEvent,
|
||||
_w: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let key = event.keystroke.key.as_str();
|
||||
match key {
|
||||
"enter" => {
|
||||
cx.emit(TextInputEvent::Confirmed(self.text.clone()));
|
||||
return;
|
||||
}
|
||||
"escape" => {
|
||||
cx.emit(TextInputEvent::Cancelled);
|
||||
return;
|
||||
}
|
||||
"backspace" => {
|
||||
self.text.pop();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Char "imprimible": tomamos `key_char` (que respeta el layout +
|
||||
// modificadores) si está presente. `key_char` es el que el sistema
|
||||
// dice "esto es lo que el usuario realmente escribió".
|
||||
if let Some(ch) = event.keystroke.key_char.as_deref() {
|
||||
// Solo apendeamos si NO contiene control chars (newline,
|
||||
// backspace, etc — que llegarían como key_char en algunas
|
||||
// plataformas).
|
||||
if !ch.chars().any(|c| c.is_control()) {
|
||||
self.text.push_str(ch);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TextInput {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
let is_empty = self.text.is_empty();
|
||||
let display: SharedString = if is_empty {
|
||||
self.placeholder.clone()
|
||||
} else {
|
||||
// Cursor siempre al final — sin movimiento de cursor.
|
||||
SharedString::from(format!("{}|", self.text))
|
||||
};
|
||||
let text_color = if is_empty {
|
||||
theme.fg_disabled
|
||||
} else {
|
||||
theme.fg_text
|
||||
};
|
||||
|
||||
div()
|
||||
.id("yahweh-text-input")
|
||||
.track_focus(&self.focus_handle)
|
||||
.key_context("YahwehTextInput")
|
||||
.on_key_down(cx.listener(Self::handle_key_down))
|
||||
.px(px(10.0))
|
||||
.py(px(6.0))
|
||||
.min_w(px(200.0))
|
||||
.bg(theme.bg_panel.clone())
|
||||
.border_1()
|
||||
.border_color(theme.accent_strong)
|
||||
.rounded(px(4.0))
|
||||
.text_size(px(13.0))
|
||||
.text_color(text_color)
|
||||
.child(display)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "yahweh-widget-tiled"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "TiledContainer — n hijos en grid auto cols×rows."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
yahweh-core = { workspace = true }
|
||||
yahweh-theme = { workspace = true }
|
||||
yahweh-widget-container-core = { workspace = true }
|
||||
@@ -0,0 +1,327 @@
|
||||
//! `yahweh_widget_tiled` — `TiledContainer`.
|
||||
//!
|
||||
//! Distribuye `n` hijos en una grilla auto-calculada: `cols = ⌈√n⌉`,
|
||||
//! `rows = ⌈n/cols⌉`. Las celdas tienen el mismo peso.
|
||||
//!
|
||||
//! ## Drag-to-swap
|
||||
//!
|
||||
//! Cada tile tiene una franja superior de 18px (la "title bar") con cursor
|
||||
//! de `move`: arrastrarla dispara un swap. Anatomía:
|
||||
//!
|
||||
//! 1. Mouse down sobre la title bar de tile A → record `dragging_idx = A`.
|
||||
//! 2. Mouse move (window-level) actualiza `hover_idx` chequeando bounds
|
||||
//! de cada tile capturados en cada paint.
|
||||
//! 3. Mouse up → si `hover_idx != dragging_idx` y son válidos, emitimos
|
||||
//! [`TiledEvent::Reordered { from, to }`] para que el LayoutHost lo
|
||||
//! persista (swap_children en el LayoutModel).
|
||||
//!
|
||||
//! Mientras dura el drag, el tile origen pinta un overlay translúcido y el
|
||||
//! tile destino se resalta con border `accent_strong`. Sin el LayoutHost
|
||||
//! persistiendo, el reorder es solo emisión — el `set_children` que viene
|
||||
//! después del rebuild aplica el orden nuevo.
|
||||
//!
|
||||
//! Filosofía: el TiledContainer NO mantiene un orden propio en `Vec`, ni
|
||||
//! reordena `self.children` localmente. Toda mutación va vía el modelo
|
||||
//! (single source of truth). Eso garantiza que persiste, sobrevive a
|
||||
//! reload y se ve consistente con el JSON.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, EventEmitter, IntoElement, Length, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, Pixels, Point, Render, Window, canvas, div, prelude::*, px,
|
||||
};
|
||||
|
||||
use yahweh_core::NodeId;
|
||||
use yahweh_theme::Theme;
|
||||
use yahweh_widget_container_core::ChildSlot;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum TiledEvent {
|
||||
/// Drag-and-drop terminó con un swap entre el tile en `from_index` y
|
||||
/// el de `to_index`. Los IDs van por valor para que el suscriptor no
|
||||
/// tenga que reconsultar el container.
|
||||
Reordered {
|
||||
from_index: usize,
|
||||
from_id: NodeId,
|
||||
to_index: usize,
|
||||
to_id: NodeId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct DragState {
|
||||
from_index: usize,
|
||||
/// Índice sobre el que el cursor está actualmente. `None` si está
|
||||
/// fuera de cualquier tile.
|
||||
hover_index: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct TiledContainer {
|
||||
children: Vec<ChildSlot>,
|
||||
drag: Option<DragState>,
|
||||
/// Bounds de cada tile en el último frame, indexados por posición en
|
||||
/// `children`. Capturados via canvas en cada tile para que el drag
|
||||
/// pueda hit-testear sin reflexión sobre el árbol.
|
||||
tile_bounds: Rc<RefCell<Vec<Option<Bounds<Pixels>>>>>,
|
||||
}
|
||||
|
||||
impl EventEmitter<TiledEvent> for TiledContainer {}
|
||||
|
||||
impl TiledContainer {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
||||
Self {
|
||||
children: Vec::new(),
|
||||
drag: None,
|
||||
tile_bounds: Rc::new(RefCell::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_children(&mut self, children: Vec<ChildSlot>, cx: &mut Context<Self>) {
|
||||
// Resize el vector de bounds para que el index sea válido en cada
|
||||
// paint; los bounds reales se llenan en el canvas.
|
||||
let n = children.len();
|
||||
self.tile_bounds.borrow_mut().resize(n, None);
|
||||
self.children = children;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn children(&self) -> &[ChildSlot] {
|
||||
&self.children
|
||||
}
|
||||
|
||||
fn start_drag(&mut self, idx: usize, cx: &mut Context<Self>) {
|
||||
if idx >= self.children.len() {
|
||||
return;
|
||||
}
|
||||
self.drag = Some(DragState {
|
||||
from_index: idx,
|
||||
hover_index: None,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn update_hover(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
let Some(drag) = &mut self.drag else { return };
|
||||
// Hit-test contra los bounds capturados.
|
||||
let bounds = self.tile_bounds.borrow();
|
||||
let mut new_hover = None;
|
||||
for (i, b) in bounds.iter().enumerate() {
|
||||
if let Some(b) = b {
|
||||
if b.contains(&position) {
|
||||
new_hover = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if drag.hover_index != new_hover {
|
||||
drag.hover_index = new_hover;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn end_drag(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(drag) = self.drag.take() else { return };
|
||||
if let Some(to) = drag.hover_index {
|
||||
if to != drag.from_index
|
||||
&& to < self.children.len()
|
||||
&& drag.from_index < self.children.len()
|
||||
{
|
||||
let from_id = self.children[drag.from_index].id.clone();
|
||||
let to_id = self.children[to].id.clone();
|
||||
cx.emit(TiledEvent::Reordered {
|
||||
from_index: drag.from_index,
|
||||
from_id,
|
||||
to_index: to,
|
||||
to_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
const TILE_GAP: f32 = 4.0;
|
||||
const TITLE_BAR_HEIGHT: f32 = 20.0;
|
||||
|
||||
impl Render for TiledContainer {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
let n = self.children.len();
|
||||
|
||||
if n == 0 {
|
||||
return div()
|
||||
.size_full()
|
||||
.bg(theme.bg_panel.clone())
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child("(tiled vacío)");
|
||||
}
|
||||
|
||||
let cols = (n as f32).sqrt().ceil() as usize;
|
||||
let cols = cols.max(1);
|
||||
let rows = (n + cols - 1) / cols;
|
||||
let drag = self.drag.clone();
|
||||
let entity = cx.entity();
|
||||
let bounds_holder = self.tile_bounds.clone();
|
||||
|
||||
let mut col_container = div()
|
||||
.size_full()
|
||||
.bg(theme.bg_app.clone())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(TILE_GAP))
|
||||
.p(px(TILE_GAP));
|
||||
|
||||
for r in 0..rows {
|
||||
let mut row_div = div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.flex_grow()
|
||||
.gap(px(TILE_GAP));
|
||||
row_div.style().min_size.height = Some(Length::Definite(px(0.0).into()));
|
||||
|
||||
for c in 0..cols {
|
||||
let idx = r * cols + c;
|
||||
let mut tile = div().h_full();
|
||||
tile.style().flex_grow = Some(1.0);
|
||||
tile.style().flex_shrink = Some(1.0);
|
||||
tile.style().min_size.width = Some(Length::Definite(px(0.0).into()));
|
||||
|
||||
let is_dragging_src = drag.as_ref().map(|d| d.from_index) == Some(idx);
|
||||
let is_drop_target = drag.as_ref().and_then(|d| d.hover_index) == Some(idx)
|
||||
&& drag.as_ref().map(|d| d.from_index) != Some(idx);
|
||||
|
||||
let border_color = if is_drop_target {
|
||||
theme.accent_strong
|
||||
} else {
|
||||
theme.border
|
||||
};
|
||||
|
||||
let tile = tile
|
||||
.bg(theme.bg_panel.clone())
|
||||
.border_1()
|
||||
.border_color(border_color)
|
||||
.rounded(px(4.0))
|
||||
.overflow_hidden();
|
||||
|
||||
let tile = if let Some(child) = self.children.get(idx) {
|
||||
let child = child.clone();
|
||||
let opacity = if is_dragging_src { 0.45 } else { 1.0 };
|
||||
|
||||
// Canvas que captura el bounds del tile entero (para
|
||||
// hit-test del drop target).
|
||||
let bounds_holder_inner = bounds_holder.clone();
|
||||
let bounds_canvas = canvas(
|
||||
move |bounds, _w, _cx| {
|
||||
let mut b = bounds_holder_inner.borrow_mut();
|
||||
if idx < b.len() {
|
||||
b[idx] = Some(bounds);
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full();
|
||||
|
||||
// Title bar — drag handle. Canvas con window-level
|
||||
// mouse handlers, mismo patrón que SplitContainer.
|
||||
let entity_for_canvas = entity.clone();
|
||||
let title_canvas = canvas(
|
||||
|_, _, _| (),
|
||||
move |canvas_bounds: Bounds<Pixels>, _, window, _| {
|
||||
window.on_mouse_event({
|
||||
let entity = entity_for_canvas.clone();
|
||||
move |ev: &MouseDownEvent, _, _w: &mut Window, cx: &mut App| {
|
||||
if ev.button != MouseButton::Left {
|
||||
return;
|
||||
}
|
||||
if !canvas_bounds.contains(&ev.position) {
|
||||
return;
|
||||
}
|
||||
entity.update(cx, |this, cx| this.start_drag(idx, cx));
|
||||
}
|
||||
});
|
||||
window.on_mouse_event({
|
||||
let entity = entity_for_canvas.clone();
|
||||
move |ev: &MouseMoveEvent, _, _w: &mut Window, cx: &mut App| {
|
||||
if !ev.dragging() {
|
||||
return;
|
||||
}
|
||||
entity.update(cx, |this, cx| {
|
||||
if this.drag.is_some() {
|
||||
this.update_hover(ev.position, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
window.on_mouse_event({
|
||||
let entity = entity_for_canvas.clone();
|
||||
move |_: &MouseUpEvent, _, _w: &mut Window, cx: &mut App| {
|
||||
entity.update(cx, |this, cx| this.end_drag(cx));
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
.size_full();
|
||||
|
||||
// El layout del tile: title bar arriba (con label +
|
||||
// canvas drag), body abajo (con la AnyView del child).
|
||||
let label_text = child
|
||||
.label
|
||||
.clone()
|
||||
.unwrap_or_else(|| child.id.as_str().to_string());
|
||||
|
||||
tile.flex().flex_col().opacity(opacity).child(
|
||||
div()
|
||||
.h(px(TITLE_BAR_HEIGHT))
|
||||
.w_full()
|
||||
.px(px(8.0))
|
||||
.bg(theme.bg_panel_alt.clone())
|
||||
.border_b_1()
|
||||
.border_color(theme.border)
|
||||
.text_size(px(10.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.cursor_move()
|
||||
.relative()
|
||||
.child(
|
||||
// Label + drag canvas (canvas absolute
|
||||
// sobre la franja entera).
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_full()
|
||||
.child(gpui::SharedString::from(label_text)),
|
||||
)
|
||||
.child(title_canvas),
|
||||
)
|
||||
.child(
|
||||
// Body — overlay con bounds canvas + el AnyView.
|
||||
div()
|
||||
.flex_grow()
|
||||
.min_h(px(0.0))
|
||||
.relative()
|
||||
.child(bounds_canvas)
|
||||
.child(child.view.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
tile.opacity(0.35).into_any_element()
|
||||
};
|
||||
|
||||
row_div = row_div.child(tile);
|
||||
}
|
||||
|
||||
col_container = col_container.child(row_div);
|
||||
}
|
||||
|
||||
col_container
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "yahweh-widget-tree"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "TreeView genérico — widget agnóstico de dominio sobre GPUI."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
yahweh-theme = { workspace = true }
|
||||
@@ -0,0 +1,415 @@
|
||||
//! `yahweh_widget_tree` — TreeView genérico, agnóstico del dominio.
|
||||
//!
|
||||
//! Anatomía: el host (FileExplorer, DatabaseExplorer, …) calcula una lista
|
||||
//! plana `Vec<TreeRow>` por DFS y la empuja con `set_rows`. El TreeView solo
|
||||
//! renderea, captura interacciones y emite [`TreeEvent`]. Todo lo de
|
||||
//! dominio (qué carga al expandir un branch, qué hacer en doble click, etc)
|
||||
//! lo decide el host suscribiéndose vía `cx.subscribe`.
|
||||
//!
|
||||
//! Esta es la pieza que reemplaza al `gioser_tree::Tree` de Makepad. La
|
||||
//! diferencia clave es de plomería: en GPUI no hay un global action queue
|
||||
//! ni Buttons que capten clicks indebidamente — cada `div` tiene su
|
||||
//! `.on_click` propio y la propagación se detiene explícitamente. Lo que
|
||||
//! peleamos en Makepad acá no existe.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
ClickEvent, Context, ElementId, Entity, EventEmitter, Hsla, IntoElement, MouseButton,
|
||||
MouseDownEvent, Pixels, Point, Render, SharedString, Window, div, prelude::*, px,
|
||||
uniform_list,
|
||||
};
|
||||
use yahweh_theme::Theme;
|
||||
|
||||
// =====================================================================
|
||||
// Modelo público
|
||||
// =====================================================================
|
||||
|
||||
/// Identificador opaco de una fila. Wrapper sobre `String` — el host elige
|
||||
/// la representación (path, primary key, GUID). El TreeView lo trata como
|
||||
/// dato opaco y lo usa de key del HashMap interno.
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub struct RowId(pub String);
|
||||
|
||||
impl RowId {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for RowId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for RowId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RowId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum RowKind {
|
||||
Branch,
|
||||
#[default]
|
||||
Leaf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TreeRow {
|
||||
pub id: RowId,
|
||||
pub label: String,
|
||||
pub depth: u32,
|
||||
pub kind: RowKind,
|
||||
/// Solo aplica a `Branch`. El TreeView NO muta este campo — el host lo
|
||||
/// pasa derivado de su propio `expanded: HashSet`.
|
||||
pub expanded: bool,
|
||||
/// Icono opcional (emoji o glyph) que se renderea entre chevron y label.
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for RowId {
|
||||
fn default() -> Self {
|
||||
Self(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Eventos que el TreeView emite hacia su parent (`cx.subscribe(&tree, …)`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TreeEvent {
|
||||
/// Click primario sobre el cuerpo de la fila (NO el chevron). El
|
||||
/// TreeView ya actualizó su `active_id` internamente — esto es
|
||||
/// notificación.
|
||||
RowClicked(RowId),
|
||||
/// Doble click sobre el cuerpo. Para Branch se emite además el toggle.
|
||||
RowDoubleClicked(RowId),
|
||||
/// Click en chevron, o doble click sobre Branch.
|
||||
ChevronToggled(RowId),
|
||||
/// Right-click. `id == None` cuando fue área vacía debajo de la última
|
||||
/// fila. La posición es absoluta para que el host posicione su menú.
|
||||
ContextMenuRequested {
|
||||
id: Option<RowId>,
|
||||
position: Point<Pixels>,
|
||||
},
|
||||
/// Cambio del `active_id` interno (por click, set_active externo, etc).
|
||||
/// Se emite incluso cuando el cambio fue inducido externamente.
|
||||
ActiveChanged(Option<RowId>),
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Widget
|
||||
// =====================================================================
|
||||
|
||||
pub struct TreeView {
|
||||
rows: Vec<TreeRow>,
|
||||
/// Mapa id → índice en `rows`. Se reconstruye en cada `set_rows`. Útil
|
||||
/// para resolver `id → row` en O(1) cuando vienen acciones desde un row.
|
||||
index: HashMap<RowId, usize>,
|
||||
/// Fila activa (cursor row).
|
||||
active_id: Option<RowId>,
|
||||
/// Marker colors externos (cross-container highlighting).
|
||||
selected: HashMap<RowId, Hsla>,
|
||||
|
||||
/// Id estable del elemento raíz para GPUI — lo necesita `uniform_list`
|
||||
/// para mantener el scroll state entre frames.
|
||||
list_id: SharedString,
|
||||
}
|
||||
|
||||
impl EventEmitter<TreeEvent> for TreeView {}
|
||||
|
||||
impl TreeView {
|
||||
/// Crea un TreeView vacío. El parámetro `id` es libre — se usa solo
|
||||
/// para identificar el `uniform_list` interno (debe ser único por
|
||||
/// instancia). Ej.: `"file-tree"`, `"db-tree"`.
|
||||
pub fn new(id: impl Into<SharedString>, cx: &mut Context<Self>) -> Self {
|
||||
// Observar el theme global — cuando cambia, redibujamos para que el
|
||||
// hover/active/marker reflejen la paleta nueva sin esperar el próximo
|
||||
// evento de input.
|
||||
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
||||
|
||||
Self {
|
||||
rows: Vec::new(),
|
||||
index: HashMap::new(),
|
||||
active_id: None,
|
||||
selected: HashMap::new(),
|
||||
list_id: id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// API pública: el host pushea las filas. Triggerea redraw.
|
||||
pub fn set_rows(&mut self, rows: Vec<TreeRow>, cx: &mut Context<Self>) {
|
||||
self.index = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| (r.id.clone(), i))
|
||||
.collect();
|
||||
self.rows = rows;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn rows(&self) -> &[TreeRow] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
pub fn set_active(&mut self, id: Option<RowId>, cx: &mut Context<Self>) {
|
||||
if self.active_id != id {
|
||||
self.active_id = id.clone();
|
||||
cx.emit(TreeEvent::ActiveChanged(id));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_id(&self) -> Option<&RowId> {
|
||||
self.active_id.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_selected(&mut self, sel: HashMap<RowId, Hsla>, cx: &mut Context<Self>) {
|
||||
self.selected = sel;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn add_selected(&mut self, id: RowId, color: Hsla, cx: &mut Context<Self>) {
|
||||
self.selected.insert(id, color);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn remove_selected(&mut self, id: &RowId, cx: &mut Context<Self>) {
|
||||
if self.selected.remove(id).is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- internos -----
|
||||
|
||||
fn handle_row_click(&mut self, id: RowId, click: &ClickEvent, cx: &mut Context<Self>) {
|
||||
// Activar.
|
||||
let new_active = Some(id.clone());
|
||||
if self.active_id != new_active {
|
||||
self.active_id = new_active.clone();
|
||||
cx.emit(TreeEvent::ActiveChanged(new_active));
|
||||
}
|
||||
cx.emit(TreeEvent::RowClicked(id.clone()));
|
||||
|
||||
if click.click_count() >= 2 {
|
||||
cx.emit(TreeEvent::RowDoubleClicked(id.clone()));
|
||||
// Doble click sobre Branch: toggle implícito.
|
||||
if let Some(row) = self.index.get(&id).and_then(|i| self.rows.get(*i)) {
|
||||
if matches!(row.kind, RowKind::Branch) {
|
||||
cx.emit(TreeEvent::ChevronToggled(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_chevron_click(&mut self, id: RowId, _click: &ClickEvent, cx: &mut Context<Self>) {
|
||||
cx.emit(TreeEvent::ChevronToggled(id));
|
||||
}
|
||||
|
||||
fn handle_right_click(
|
||||
&mut self,
|
||||
id: Option<RowId>,
|
||||
event: &MouseDownEvent,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
cx.emit(TreeEvent::ContextMenuRequested {
|
||||
id,
|
||||
position: event.position,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Render
|
||||
// =====================================================================
|
||||
|
||||
const ROW_HEIGHT: f32 = 22.0;
|
||||
const INDENT_PX: f32 = 14.0;
|
||||
const CHEVRON_PX: f32 = 14.0;
|
||||
|
||||
impl Render for TreeView {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
let row_count = self.rows.len();
|
||||
let entity = cx.entity();
|
||||
|
||||
// Snapshot inmutable para que el closure de uniform_list pueda
|
||||
// accederlo sin tomar prestado `self`.
|
||||
let rows = self.rows.clone();
|
||||
let active_id = self.active_id.clone();
|
||||
let selected = self.selected.clone();
|
||||
let list_id: ElementId = self.list_id.clone().into();
|
||||
|
||||
div()
|
||||
.id("yahweh-tree-root")
|
||||
.key_context("YahwehTree")
|
||||
.size_full()
|
||||
.bg(theme.bg_panel.clone())
|
||||
.text_color(theme.fg_text)
|
||||
// Right-click sobre área vacía (debajo de las rows) — sin id de
|
||||
// row. La capa de rows captura su propio right-click y stoppea
|
||||
// propagación, así que esto solo se dispara en el "fondo".
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener({
|
||||
move |this, e: &MouseDownEvent, _, cx| {
|
||||
this.handle_right_click(None, e, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
uniform_list(list_id, row_count, move |range: Range<usize>, _w, _cx| {
|
||||
range
|
||||
.filter_map(|i| rows.get(i).cloned())
|
||||
.map(|row| {
|
||||
render_row(
|
||||
row,
|
||||
&theme,
|
||||
&active_id,
|
||||
&selected,
|
||||
entity.clone(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Render por fila — fuera del `impl Render` para mantener el tamaño
|
||||
// manejable y aislar el closure de uniform_list.
|
||||
// =====================================================================
|
||||
|
||||
fn render_row(
|
||||
row: TreeRow,
|
||||
theme: &Theme,
|
||||
active_id: &Option<RowId>,
|
||||
selected: &HashMap<RowId, Hsla>,
|
||||
entity: Entity<TreeView>,
|
||||
) -> impl IntoElement {
|
||||
let id_for_chev = row.id.clone();
|
||||
let id_for_body = row.id.clone();
|
||||
let id_for_ctx = row.id.clone();
|
||||
|
||||
let is_active = active_id.as_ref() == Some(&row.id);
|
||||
let marker = selected.get(&row.id).copied();
|
||||
|
||||
let chevron_glyph = match (row.kind, row.expanded) {
|
||||
(RowKind::Branch, true) => "▾",
|
||||
(RowKind::Branch, false) => "▸",
|
||||
(RowKind::Leaf, _) => " ",
|
||||
};
|
||||
let icon = row.icon.clone().unwrap_or_default();
|
||||
let label = row.label.clone();
|
||||
let depth = row.depth as f32;
|
||||
let is_branch = matches!(row.kind, RowKind::Branch);
|
||||
|
||||
// Background del row. Capas: marker (si hay) → active → hover (gestionado
|
||||
// por gpui via .hover()).
|
||||
let row_bg = if is_active {
|
||||
Some(theme.bg_row_active)
|
||||
} else {
|
||||
marker
|
||||
};
|
||||
|
||||
// Element id estable por fila — uniform_list es virtualizado, los ids
|
||||
// tienen que ser únicos para que GPUI re-use el cache de hitboxes.
|
||||
let element_id: ElementId = SharedString::from(format!("row::{}", row.id)).into();
|
||||
|
||||
let mut row_div = div()
|
||||
.id(element_id)
|
||||
.flex()
|
||||
.flex_row()
|
||||
.items_center()
|
||||
.h(px(ROW_HEIGHT))
|
||||
.w_full()
|
||||
.pl(px(depth * INDENT_PX))
|
||||
.text_size(px(13.0))
|
||||
.hover(|s| s.bg(theme.bg_row_hover));
|
||||
|
||||
if let Some(bg) = row_bg {
|
||||
row_div = row_div.bg(bg);
|
||||
}
|
||||
|
||||
// Chevron — área propia, click stop_propagation para no disparar el
|
||||
// body click.
|
||||
let chevron_id: ElementId =
|
||||
SharedString::from(format!("chev::{}", id_for_chev)).into();
|
||||
let chevron = {
|
||||
let entity = entity.clone();
|
||||
let id = id_for_chev.clone();
|
||||
div()
|
||||
.id(chevron_id)
|
||||
.w(px(CHEVRON_PX))
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_color(theme.fg_muted)
|
||||
.text_size(px(11.0))
|
||||
.child(SharedString::from(chevron_glyph.to_string()))
|
||||
.when(is_branch, |this| {
|
||||
this.on_click(move |click, _w, cx| {
|
||||
cx.stop_propagation();
|
||||
entity.update(cx, |tree, cx| {
|
||||
tree.handle_chevron_click(id.clone(), click, cx);
|
||||
});
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
// Body — icono opcional + label, captura el click primario.
|
||||
let body = {
|
||||
let entity_body = entity.clone();
|
||||
let entity_ctx = entity.clone();
|
||||
let id_body = id_for_body.clone();
|
||||
let id_ctx = id_for_ctx.clone();
|
||||
let body_id: ElementId =
|
||||
SharedString::from(format!("body::{}", id_for_body)).into();
|
||||
|
||||
let mut content = div()
|
||||
.id(body_id)
|
||||
.flex()
|
||||
.flex_row()
|
||||
.items_center()
|
||||
.gap(px(4.0))
|
||||
.px(px(4.0))
|
||||
.flex_grow()
|
||||
.h_full()
|
||||
.on_click(move |click, _w, cx| {
|
||||
entity_body.update(cx, |tree, cx| {
|
||||
tree.handle_row_click(id_body.clone(), click, cx);
|
||||
});
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
move |e: &MouseDownEvent, _w, cx| {
|
||||
cx.stop_propagation();
|
||||
entity_ctx.update(cx, |tree, cx| {
|
||||
tree.handle_right_click(Some(id_ctx.clone()), e, cx);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if !icon.is_empty() {
|
||||
content = content.child(SharedString::from(icon.clone()));
|
||||
}
|
||||
content.child(SharedString::from(label.clone()))
|
||||
};
|
||||
|
||||
row_div.child(chevron).child(body)
|
||||
}
|
||||
Reference in New Issue
Block a user