#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::missing_panics_doc,
clippy::cast_possible_truncation
)]
use std::sync::Arc;
use ant_node::replication::commitment_state::{BuiltCommitment, ResponderCommitmentState};
use ant_node::replication::config::MAX_BYTE_CHALLENGE_KEYS;
use ant_node::replication::protocol::{
SubtreeAuditChallenge, SubtreeAuditResponse, SubtreeByteChallenge, SubtreeByteItem,
SubtreeByteResponse,
};
use ant_node::replication::storage_commitment_audit::{
handle_subtree_byte_challenge, handle_subtree_challenge,
};
use ant_node::replication::subtree::{verify_subtree_proof, StructureVerdict};
use ant_node::storage::{LmdbStorage, LmdbStorageConfig};
use saorsa_core::identity::PeerId;
use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey};
use tempfile::TempDir;
async fn test_storage() -> (LmdbStorage, TempDir) {
let temp_dir = TempDir::new().expect("create temp dir");
let config = LmdbStorageConfig {
root_dir: temp_dir.path().to_path_buf(),
..LmdbStorageConfig::test_default()
};
let storage = LmdbStorage::new(config).await.expect("create storage");
(storage, temp_dir)
}
fn keypair() -> (MlDsaPublicKey, MlDsaSecretKey) {
ml_dsa_65().generate_keypair().unwrap()
}
fn chunk_content(i: u8) -> Vec<u8> {
(0..1024u32).map(|n| (n as u8) ^ i).collect()
}
struct Responder {
peer_id: PeerId,
peer_id_bytes: [u8; 32],
state: Arc<ResponderCommitmentState>,
}
impl Responder {
async fn new(storage: &LmdbStorage, indices: &[u8]) -> Self {
let (pk, sk) = keypair();
let peer_id_bytes = *blake3::hash(&pk.to_bytes()).as_bytes();
let peer_id = PeerId::from_bytes(peer_id_bytes);
let mut entries = Vec::new();
for &i in indices {
let content = chunk_content(i);
let addr = LmdbStorage::compute_address(&content);
storage.put(&addr, &content).await.expect("put chunk");
let bytes_hash = *blake3::hash(&content).as_bytes();
entries.push((addr, bytes_hash));
}
let built =
BuiltCommitment::build(entries, &peer_id_bytes, &sk, &pk.to_bytes()).expect("build");
let state = Arc::new(ResponderCommitmentState::new());
state.rotate(built);
Self {
peer_id,
peer_id_bytes,
state,
}
}
fn current_hash(&self) -> [u8; 32] {
self.state.current().unwrap().hash()
}
fn address(i: u8) -> [u8; 32] {
LmdbStorage::compute_address(&chunk_content(i))
}
}
fn challenge_for(responder: &Responder, pin: [u8; 32], nonce: [u8; 32]) -> SubtreeAuditChallenge {
SubtreeAuditChallenge {
challenge_id: 42,
nonce,
challenged_peer_id: responder.peer_id_bytes,
expected_commitment_hash: pin,
}
}
#[tokio::test]
async fn honest_responder_answers_with_valid_proof() {
let (storage, _t) = test_storage().await;
let indices: Vec<u8> = (1..=64u8).collect();
let r = Responder::new(&storage, &indices).await;
let pin = r.current_hash();
let nonce = [0x11u8; 32];
let challenge = challenge_for(&r, pin, nonce);
let resp =
handle_subtree_challenge(&challenge, &storage, &r.peer_id, false, Some(&r.state)).await;
match resp {
SubtreeAuditResponse::Proof {
challenge_id,
commitment,
proof,
} => {
assert_eq!(challenge_id, 42);
assert_eq!(
ant_node::replication::commitment::commitment_hash(&commitment),
Some(pin),
);
assert_eq!(
verify_subtree_proof(&proof, &nonce, &commitment),
StructureVerdict::Valid,
"honest responder's proof must verify"
);
}
other => panic!("expected Proof, got {other:?}"),
}
}
#[tokio::test]
async fn bootstrapping_responder_reports_bootstrapping() {
let (storage, _t) = test_storage().await;
let r = Responder::new(&storage, &[1, 2, 3, 4]).await;
let pin = r.current_hash();
let challenge = challenge_for(&r, pin, [0x11u8; 32]);
let resp = handle_subtree_challenge(
&challenge,
&storage,
&r.peer_id,
true,
Some(&r.state),
)
.await;
assert!(
matches!(
resp,
SubtreeAuditResponse::Bootstrapping { challenge_id: 42 }
),
"expected Bootstrapping, got {resp:?}"
);
}
#[tokio::test]
async fn wrong_target_peer_is_rejected() {
let (storage, _t) = test_storage().await;
let r = Responder::new(&storage, &[1, 2, 3, 4]).await;
let pin = r.current_hash();
let mut challenge = challenge_for(&r, pin, [0x11u8; 32]);
challenge.challenged_peer_id = [0x99u8; 32];
let resp =
handle_subtree_challenge(&challenge, &storage, &r.peer_id, false, Some(&r.state)).await;
match resp {
SubtreeAuditResponse::Rejected {
challenge_id,
reason,
..
} => {
assert_eq!(challenge_id, 42);
assert!(
reason.contains("does not match this node"),
"expected wrong-peer rejection, got: {reason}"
);
}
other => panic!("expected Rejected(wrong peer), got {other:?}"),
}
}
#[tokio::test]
async fn unknown_pinned_hash_is_rejected() {
let (storage, _t) = test_storage().await;
let r = Responder::new(&storage, &[1, 2, 3, 4]).await;
let bogus_pin = [0x99u8; 32];
let challenge = challenge_for(&r, bogus_pin, [0x11u8; 32]);
let resp =
handle_subtree_challenge(&challenge, &storage, &r.peer_id, false, Some(&r.state)).await;
match resp {
SubtreeAuditResponse::Rejected { reason, .. } => {
assert!(
reason.contains("unknown commitment hash"),
"expected unknown-commitment-hash rejection, got: {reason}"
);
}
other => panic!("expected Rejected(unknown commitment hash), got {other:?}"),
}
}
#[tokio::test]
async fn missing_commitment_state_is_rejected() {
let (storage, _t) = test_storage().await;
let r = Responder::new(&storage, &[1, 2, 3, 4]).await;
let pin = r.current_hash();
let challenge = challenge_for(&r, pin, [0x11u8; 32]);
let resp = handle_subtree_challenge(&challenge, &storage, &r.peer_id, false, None).await;
assert!(
matches!(resp, SubtreeAuditResponse::Rejected { .. }),
"expected Rejected when no commitment state, got {resp:?}"
);
}
#[tokio::test]
async fn committed_key_with_missing_bytes_is_rejected() {
let (storage, _t) = test_storage().await;
let indices: Vec<u8> = (1..=32u8).collect();
let r = Responder::new(&storage, &indices).await;
let pin = r.current_hash();
for &i in &indices {
let addr = Responder::address(i);
storage.delete(&addr).await.expect("delete chunk");
}
let challenge = challenge_for(&r, pin, [0x11u8; 32]);
let resp =
handle_subtree_challenge(&challenge, &storage, &r.peer_id, false, Some(&r.state)).await;
match resp {
SubtreeAuditResponse::Rejected { reason, .. } => {
assert!(
reason.contains("missing bytes for committed key"),
"expected missing-bytes rejection, got: {reason}"
);
}
other => panic!("expected Rejected(missing bytes), got {other:?}"),
}
}
#[tokio::test]
async fn byte_challenge_serves_original_bytes_for_committed_keys() {
let (storage, _t) = test_storage().await;
let r = Responder::new(&storage, &[1, 2, 3, 4]).await;
let pin = r.current_hash();
let keys = vec![Responder::address(1), Responder::address(2)];
let challenge = SubtreeByteChallenge {
challenge_id: 43,
nonce: [0x22u8; 32],
challenged_peer_id: r.peer_id_bytes,
expected_commitment_hash: pin,
keys: keys.clone(),
};
let resp =
handle_subtree_byte_challenge(&challenge, &storage, &r.peer_id, false, Some(&r.state))
.await;
match resp {
SubtreeByteResponse::Items {
challenge_id,
items,
} => {
assert_eq!(challenge_id, 43);
assert_eq!(items.len(), keys.len(), "one item per requested key");
for (item, (i, key)) in items.iter().zip([1u8, 2].into_iter().zip(keys)) {
match item {
SubtreeByteItem::Present { key: k, bytes } => {
assert_eq!(*k, key);
assert_eq!(*bytes, chunk_content(i), "must serve the ORIGINAL bytes");
}
other @ SubtreeByteItem::Absent { .. } => {
panic!("expected Present for stored committed key, got {other:?}")
}
}
}
}
other => panic!("expected Items, got {other:?}"),
}
}
#[tokio::test]
async fn oversize_byte_challenge_is_rejected() {
let (storage, _t) = test_storage().await;
let r = Responder::new(&storage, &[1, 2, 3, 4]).await;
let pin = r.current_hash();
let keys: Vec<[u8; 32]> = (0..=MAX_BYTE_CHALLENGE_KEYS)
.map(|i| [u8::try_from(i % 251).unwrap_or(0); 32])
.collect();
assert!(keys.len() > MAX_BYTE_CHALLENGE_KEYS);
let challenge = SubtreeByteChallenge {
challenge_id: 44,
nonce: [0x33u8; 32],
challenged_peer_id: r.peer_id_bytes,
expected_commitment_hash: pin,
keys,
};
let resp =
handle_subtree_byte_challenge(&challenge, &storage, &r.peer_id, false, Some(&r.state))
.await;
match resp {
SubtreeByteResponse::Rejected { reason, .. } => {
assert!(
reason.contains("max"),
"expected per-challenge key-cap rejection, got: {reason}"
);
}
other => panic!("expected Rejected(oversize), got {other:?}"),
}
}