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,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
|
||||
}
|
||||
Reference in New Issue
Block a user