Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,16 @@ anti-entropy digests, and clients can probe/register/publish records over
the client API and resolve via either BINDA's own JSON resolver protocol
or its native DNS-shaped protocol — both fully Unicode-native, with no
Punycode/ASCII-compatibility step anywhere in BINDA. Overall test coverage
sits at ~94% (line/region), including real end-to-end integration tests
for the gossip conformance-challenge protocol (a rogue peer answering
incorrectly, a peer answering correctly, and full convergence between two
real nodes over UDP) and local fake-server tests exercising the actual
network code paths of NTP sync and FCrDNS verification without needing
real internet access.
sits at ~98% (line/region across the whole workspace, 142 + 25 = 167
tests), including real end-to-end integration tests for the gossip
conformance-challenge protocol (a rogue peer answering incorrectly, a
peer answering correctly, and full convergence between two real nodes
over UDP), local fake-server tests exercising the actual network code
paths of NTP sync and FCrDNS verification without needing real internet
access, and coverage of every listener's bind-failure path. `main()`
itself is excluded from the coverage count (`#[coverage(off)]`) since
it's pure top-level wiring around already-covered pieces and, by design,
never returns in a real run.

Still missing/simplified, in rough priority order:

Expand All @@ -168,9 +172,14 @@ Still missing/simplified, in rough priority order:
(65,507 bytes), not a policy limit. Source addresses are still
spoofable, so this doesn't stop a distributed flood; it's a first line
of defense, not the whole story.
- The last significant coverage gaps are `main()`'s own async
orchestration (spawning the four listener tasks and joining them —
thin glue, exercised in practice by every manual run but not under a
test harness) and the small fraction of `ntp`/`fcrdns` that only
triggers against a real, uncontrolled remote server's exact reply
timing rather than the local fake servers the test suite uses instead.
- The remaining ~2% is essentially irreducible without contrived tests:
a handful of `node.rs` lines are the "kick off an infinite listener
loop" statements inside tests that intentionally never let that loop
finish (so the async state machine's completion path is never counted,
even though the loop's real behavior is thoroughly exercised via real
socket round-trips elsewhere in the same test); a couple of `fcrdns`/
`ntp` lines are a test-helper's own defensive error arm that only fires
if a background thread's socket read fails after the test has already
gotten what it needed; and a few are error variants (like a JSON
encode failure) that aren't reachable through this project's own types
without constructing a deliberately-broken value.
82 changes: 50 additions & 32 deletions crates/binda-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,10 @@ mod tests {

let domain = DomainName::new("example.binda").unwrap();
let register_req = envelope_register(&signing_key, domain, 1_000);
match handle_client_request(&mut store, &time, register_req, true) {
ClientResponse::Registered { .. } => {}
other => panic!("expected Registered, got {other:?}"),
}
assert!(matches!(
handle_client_request(&mut store, &time, register_req, true),
ClientResponse::Registered { .. }
));
}

#[test]
Expand All @@ -164,10 +164,28 @@ mod tests {
let signing_key = SigningKey::generate(&mut OsRng);
let domain = DomainName::new("example.binda").unwrap();
let register_req = envelope_register(&signing_key, domain, 1_000);
match handle_client_request(&mut store, &time, register_req, true) {
ClientResponse::Error { .. } => {}
other => panic!("expected Error, got {other:?}"),
assert!(matches!(
handle_client_request(&mut store, &time, register_req, true),
ClientResponse::Error { .. }
));
}

#[test]
fn register_with_bad_signature_is_refused() {
let time = MockTimeSource::new(1_000);
let mut store = RegistryStore::new();
let signing_key = SigningKey::generate(&mut OsRng);
handle_client_request(&mut store, &time, probe(&signing_key, 1_000), true);

let domain = DomainName::new("example.binda").unwrap();
let mut req = envelope_register(&signing_key, domain, 1_000);
if let ClientRequest::Register(envelope) = &mut req {
envelope.signature = vec![0u8; 64];
}
assert!(matches!(
handle_client_request(&mut store, &time, req, true),
ClientResponse::Error { .. }
));
}

#[test]
Expand All @@ -189,10 +207,10 @@ mod tests {

let domain = DomainName::new("example.binda").unwrap();
let register_req = envelope_register(&signing_key, domain, 1_000);
match handle_client_request(&mut store, &time, register_req, false) {
ClientResponse::Error { .. } => {}
other => panic!("expected Error, got {other:?}"),
}
assert!(matches!(
handle_client_request(&mut store, &time, register_req, false),
ClientResponse::Error { .. }
));
}

fn probe(signing_key: &SigningKey, timestamp: u64) -> ClientRequest {
Expand All @@ -216,10 +234,10 @@ mod tests {
if let ClientRequest::Probe(envelope) = &mut req {
envelope.signature = vec![0u8; 64]; // wrong signature
}
match handle_client_request(&mut store, &time, req, true) {
ClientResponse::Error { .. } => {}
other => panic!("expected Error, got {other:?}"),
}
assert!(matches!(
handle_client_request(&mut store, &time, req, true),
ClientResponse::Error { .. }
));
}

#[test]
Expand All @@ -238,15 +256,15 @@ mod tests {
envelope_register(&owner_key, domain.clone(), 1_000),
true,
);
match handle_client_request(
&mut store,
&time,
envelope_register(&other_key, domain, 1_000),
true,
) {
ClientResponse::Error { .. } => {}
other => panic!("expected Error, got {other:?}"),
}
assert!(matches!(
handle_client_request(
&mut store,
&time,
envelope_register(&other_key, domain, 1_000),
true,
),
ClientResponse::Error { .. }
));
}

fn envelope_set_records(
Expand Down Expand Up @@ -309,10 +327,10 @@ mod tests {
);

let req = envelope_set_records(&stranger_key, domain, Vec::new(), 1_000);
match handle_client_request(&mut store, &time, req, true) {
ClientResponse::Error { .. } => {}
other => panic!("expected Error, got {other:?}"),
}
assert!(matches!(
handle_client_request(&mut store, &time, req, true),
ClientResponse::Error { .. }
));
}

#[test]
Expand All @@ -325,9 +343,9 @@ mod tests {
if let ClientRequest::SetRecords(envelope) = &mut req {
envelope.signature = vec![0u8; 64];
}
match handle_client_request(&mut store, &time, req, true) {
ClientResponse::Error { .. } => {}
other => panic!("expected Error, got {other:?}"),
}
assert!(matches!(
handle_client_request(&mut store, &time, req, true),
ClientResponse::Error { .. }
));
}
}
15 changes: 15 additions & 0 deletions crates/binda-core/src/collision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,21 @@ mod tests {
assert_eq!(resolve(WinCondition::LowerWins, a, a), a);
}

#[test]
fn resolve_lower_wins_picks_lesser_key_regardless_of_argument_order() {
let a = RegistrationToken {
issued_at_millis: 2,
nonce: [0; 8],
};
let b = RegistrationToken {
issued_at_millis: 1,
nonce: [0; 8],
};
// a > b here, so LowerWins must pick b even though it's the
// second argument.
assert_eq!(resolve(WinCondition::LowerWins, a, b), b);
}

#[test]
fn negotiation_proposals_increment_round() {
let mut negotiation = Negotiation::new();
Expand Down
28 changes: 28 additions & 0 deletions crates/binda-core/src/dns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,34 @@ mod tests {
assert_eq!(ancount, 1);
}

#[test]
fn builds_answer_with_ns_record() {
let query = DnsQuery {
id: 1,
domain: DomainName::new("example.binda").unwrap(),
qtype: TYPE_NS,
qclass: CLASS_IN,
};
let record = Record {
name: "@".into(),
record_type: RecordType::Ns,
ttl_secs: 300,
value: "ns1.example.binda".into(),
};
let response = build_response(&query, true, &[record]);
let ancount = u16::from_be_bytes([response[6], response[7]]);
assert_eq!(ancount, 1);
assert!(record_matches_qtype(
&Record {
name: "@".into(),
record_type: RecordType::Ns,
ttl_secs: 300,
value: "ns1.example.binda".into(),
},
TYPE_NS
));
}

#[test]
fn builds_answer_with_mx_record_parsing_preference_prefix() {
let query = DnsQuery {
Expand Down
115 changes: 115 additions & 0 deletions crates/binda-core/src/fcrdns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,4 +475,119 @@ mod tests {
let result = reverse_lookup("203.0.113.5".parse().unwrap(), &["127.0.0.1:1"]);
assert!(result.is_err());
}

#[test]
fn is_forward_confirmed_false_when_reverse_lookup_itself_fails() {
let ip: IpAddr = "203.0.113.5".parse().unwrap();
assert!(!is_forward_confirmed(
ip,
"host.example.net",
&["127.0.0.1:1"]
));
}

#[test]
fn is_forward_confirmed_false_when_forward_lookup_itself_fails() {
let ip: IpAddr = "203.0.113.5".parse().unwrap();
let ptr_rdata = encode_name("host.example.net");
// Server only answers the one (reverse) query, so the follow-up
// forward lookup finds nothing listening and errors out.
let resolver = spawn_fake_resolver(ptr_rdata, Vec::new(), 1);
assert!(!is_forward_confirmed(ip, "host.example.net", &[&resolver]));
}

#[test]
fn forward_lookup_returns_aaaa_record_from_a_real_query_response() {
let ipv6 = std::net::Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1);
// Serve the same 16-byte AAAA rdata for every query; the A query
// fails to parse as 4 bytes (ignored), the AAAA query succeeds.
let resolver = spawn_fake_resolver(Vec::new(), ipv6.octets().to_vec(), 2);
let ips = forward_lookup("host.example.net", &[&resolver]).unwrap();
assert!(ips.contains(&IpAddr::V6(ipv6)));
}

#[test]
fn encode_query_skips_an_empty_label_from_a_trailing_dot() {
let with_dot = encode_query(1, "example.com.", TYPE_A);
let without_dot = encode_query(1, "example.com", TYPE_A);
assert_eq!(with_dot, without_dot);
}

#[test]
fn read_name_rejects_a_compression_pointer_loop() {
let mut buf = vec![0u8; 12];
let pointer_pos = buf.len();
// A pointer that points at itself, so following it never
// terminates without the jump-count guard.
buf.push(0xC0 | ((pointer_pos >> 8) as u8));
buf.push((pointer_pos & 0xFF) as u8);
assert_eq!(read_name(&buf, pointer_pos), None);
}

#[test]
fn parse_response_rejects_a_too_short_message() {
assert_eq!(parse_response(&[0u8; 4], 1), None);
}

#[test]
fn parse_response_rejects_a_mismatched_id() {
let buf = vec![0u8; 48];
// buf's id (first two bytes) is 0, which won't match.
assert_eq!(parse_response(&buf, 1), None);
}

#[test]
fn query_falls_back_to_a_later_resolver_after_an_earlier_one_fails() {
let ptr_rdata = encode_name("host.example.net");
let working = spawn_fake_resolver(ptr_rdata, Vec::new(), 1);
let ip: IpAddr = "203.0.113.5".parse().unwrap();
// Nothing listens on the first resolver; the second is real.
let names = reverse_lookup(ip, &["127.0.0.1:1", &working]).unwrap();
assert_eq!(names, vec!["host.example.net".to_string()]);
}

/// A fake resolver that always answers with `fixed_rtype`, regardless
/// of what type was actually queried — used to exercise the "server
/// answered with a record type we didn't ask for" filtering path.
fn spawn_mismatched_type_resolver(fixed_rtype: u16, rdata: Vec<u8>) -> String {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
let addr = socket.local_addr().unwrap().to_string();
std::thread::spawn(move || {
let mut buf = [0u8; 512];
let Ok((len, from)) = socket.recv_from(&mut buf) else {
return;
};
let id = &buf[0..2];
let question = &buf[12..len];
let mut response = Vec::new();
response.extend_from_slice(id);
response.extend_from_slice(&0x8180u16.to_be_bytes());
response.extend_from_slice(&1u16.to_be_bytes());
response.extend_from_slice(&1u16.to_be_bytes());
response.extend_from_slice(&0u16.to_be_bytes());
response.extend_from_slice(&0u16.to_be_bytes());
response.extend_from_slice(question);
response.extend_from_slice(&[0xC0, 0x0C]);
response.extend_from_slice(&fixed_rtype.to_be_bytes());
response.extend_from_slice(&CLASS_IN.to_be_bytes());
response.extend_from_slice(&300u32.to_be_bytes());
response.extend_from_slice(&(rdata.len() as u16).to_be_bytes());
response.extend_from_slice(&rdata);
let _ = socket.send_to(&response, from);
});
addr
}

#[test]
fn forward_lookup_ignores_an_answer_of_the_wrong_record_type() {
// The server always answers with a PTR record no matter what was
// asked; forward_lookup wants A/AAAA, so every answer should be
// filtered out rather than misinterpreted.
let resolver = spawn_mismatched_type_resolver(TYPE_PTR, encode_name("irrelevant"));
let ips = forward_lookup("host.example.net", &[&resolver]).unwrap();
assert!(ips.is_empty());
}
}
31 changes: 23 additions & 8 deletions crates/binda-core/src/gossip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,28 @@ mod tests {

#[test]
fn wrong_challenge_answer_is_distinguishable_from_correct() {
let challenge = ConformanceChallenge::random(2_000);
let correct = challenge.expected_answer();
let wrong = if correct == challenge.token_a {
challenge.token_b
} else {
challenge.token_a
};
assert_ne!(correct, wrong);
// Run enough rounds that the correct answer lands on token_a at
// least once and on token_b at least once, exercising both arms
// of picking "the other one" deterministically rather than
// leaving it to which way a single random challenge happened to
// fall.
let mut saw_a_correct = false;
let mut saw_b_correct = false;
for i in 0..200 {
let challenge = ConformanceChallenge::random(i);
let correct = challenge.expected_answer();
let wrong = if correct == challenge.token_a {
saw_a_correct = true;
challenge.token_b
} else {
saw_b_correct = true;
challenge.token_a
};
assert_ne!(correct, wrong);
if saw_a_correct && saw_b_correct {
break;
}
}
assert!(saw_a_correct && saw_b_correct);
}
}
Loading
Loading