From b6b23af046db51dab91dda847d40396fd7c6807c Mon Sep 17 00:00:00 2001 From: Paperclip Date: Wed, 5 Aug 2026 12:30:35 +0000 Subject: [PATCH] test(driad): add Hickory G0 proof harness Co-Authored-By: Paperclip --- Cargo.toml | 5 ++ docs/amt-01-hickory-proof.md | 90 ++++++++++++++++++++++ src/hickory_proof.rs | 106 ++++++++++++++++++++++++++ src/lib.rs | 2 + tests/hickory_driad_proof.rs | 140 +++++++++++++++++++++++++++++++++++ 5 files changed, 343 insertions(+) create mode 100644 docs/amt-01-hickory-proof.md create mode 100644 src/hickory_proof.rs create mode 100644 tests/hickory_driad_proof.rs diff --git a/Cargo.toml b/Cargo.toml index 9fc1216..2545faa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,9 +38,14 @@ native = [ "dep:clap", ] +# Proof-only dependency set for the AMT-01 Hickory feasibility gate. +hickory-proof = ["dep:hickory-proto", "dep:getrandom03"] + [dependencies] serde = { version = "1.0", features = ["derive"] } getrandom = "0.2" +hickory-proto = { version = "=0.25.2", default-features = false, features = ["std", "wasm-bindgen"], optional = true } +getrandom03 = { package = "getrandom", version = "0.3", features = ["wasm_js"], optional = true } # WASM dependencies (optional) wasm-bindgen = { version = "0.2", optional = true } diff --git a/docs/amt-01-hickory-proof.md b/docs/amt-01-hickory-proof.md new file mode 100644 index 0000000..244e4f6 --- /dev/null +++ b/docs/amt-01-hickory-proof.md @@ -0,0 +1,90 @@ +# AMT-01 Hickory proof + +This branch is a proof artifact for the AMT-01 G0 gate. The production +`DriadResolver`, native resolver, and `JsDriad` exports are unchanged. The +candidate is compiled only with the `hickory-proof` feature. + +## Behavior matrix + +`tests/hickory_driad_proof.rs` exercises `hickory-proto` 0.25.2 against: + +- IPv4 and IPv6 reverse-query names, TYPE260, IN class, transaction IDs, and + trailing-dot normalization; +- compressed answer owners plus IPv4, IPv6, and uncompressed type-3 relay data; +- multiple TYPE260 answers, invalid-record skipping, and lowest-precedence + selection; +- malformed RDATA and type-3 names, mismatched transaction IDs and questions, + and wrong answer owners and classes. + +Run the proof and the existing native suite: + +```bash +cargo test --test hickory_driad_proof --no-default-features \ + --features native,hickory-proof +cargo test --lib --no-default-features --features native,hickory-proof +``` + +At the pinned baseline `810719fe`, the proof reports 4/4 passing and the +existing suite reports 121/121 passing. + +## Dependency and native-build impact + +The proof pins `hickory-proto` exactly to 0.25.2 with `default-features = false` +and enables only `std` and `wasm-bindgen`. It explicitly enables +`getrandom` 0.3's `wasm_js` feature, as required by the browser target. + +On Rust 1.97.1, clean native release builds measured: + +| Build | Normal dependency nodes | Wall time | +|---|---:|---:| +| `native` baseline | 59 | 51.755 s | +| `native,hickory-proof` | 132 | 68.648 s | +| Delta | +73 | +16.893 s (+32.6%) | + +The package count is intentionally conservative: it counts unique rendered +normal-dependency tree lines and therefore includes build-visible proc macros. + +## WASM measurement command + +The candidate exports `hickory_proof_accepts` only when both `wasm` and +`hickory-proof` are enabled, preventing release LTO from eliminating Hickory +from the candidate measurement. + +```bash +rm -rf target/amt01-baseline target/amt01-candidate +CARGO_TARGET_DIR=target/amt01-baseline cargo build --lib --release \ + --target wasm32-unknown-unknown --no-default-features --features wasm +CARGO_TARGET_DIR=target/amt01-candidate cargo build --lib --release \ + --target wasm32-unknown-unknown --no-default-features \ + --features wasm,hickory-proof +wc -c target/amt01-{baseline,candidate}/wasm32-unknown-unknown/release/amt_protocol.wasm +gzip -9 -c target/amt01-baseline/wasm32-unknown-unknown/release/amt_protocol.wasm | wc -c +gzip -9 -c target/amt01-candidate/wasm32-unknown-unknown/release/amt_protocol.wasm | wc -c +``` + +The current proof environment could not install `rust-std` for +`wasm32-unknown-unknown`: `rustup target add wasm32-unknown-unknown` rolled back +with `error opening file for download: cleaning up cached downloads: No such +file or directory`. Raw and gzip WASM deltas therefore remain an explicit G0 +blocker until the command above runs in CI or a working Rust target environment. + +## Consumer inventory + +The existing public boundary remains required: + +- Rust: `DriadResolver` is re-exported from `src/lib.rs`; native + `src/native/resolver.rs` uses query construction and TYPE260/A/AAAA parsing. +- WASM/TypeScript: `JsDriad` retains `buildQuery`, `buildDnsQuery`, + `parseDnsResponse`, `buildDnsAQuery`, and `parseDnsAResponse`. The IWA loader, + `DriadDohResolver`, and `AMTGatewayManager` consume those names. +- Native bindings: FFI and JNI use the Rust query-name API. No binding is + changed by this proof. + +No persisted DNS state exists. A later cleared implementation can replace the +private framing atomically behind these APIs without a dual parser. + +## G0 disposition + +**Blocked.** Native behavior feasibility is proven, but the dependency increase +is substantial and the required release-WASM raw/compressed delta is not yet +measured. This branch does not authorize or implement the production cutover. diff --git a/src/hickory_proof.rs b/src/hickory_proof.rs new file mode 100644 index 0000000..5d717cd --- /dev/null +++ b/src/hickory_proof.rs @@ -0,0 +1,106 @@ +//! Feature-gated AMT-01 feasibility proof. This is not used by production DRIAD. + +use hickory_proto::op::{Message, MessageType, Query, ResponseCode}; +use hickory_proto::rr::{DNSClass, Name, RData, RecordType}; +use std::net::IpAddr; + +const AMTRELAY_TYPE: RecordType = RecordType::Unknown(260); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProofRelay { + Ip(IpAddr), + DnsName(String), +} + +pub fn build_query(qname: &str, transaction_id: u16) -> Option> { + let name = canonical_name(qname)?; + let mut message = Message::new(); + message + .set_id(transaction_id) + .set_message_type(MessageType::Query) + .set_recursion_desired(true) + .add_query(Query::query(name, AMTRELAY_TYPE)); + message.to_vec().ok() +} + +pub fn select_relay(data: &[u8], transaction_id: u16, qname: &str) -> Option { + let expected_name = canonical_name(qname)?; + let message = Message::from_vec(data).ok()?; + if message.id() != transaction_id + || message.message_type() != MessageType::Response + || message.response_code() != ResponseCode::NoError + || message.queries().len() != 1 + { + return None; + } + + let query = &message.queries()[0]; + if query.name() != &expected_name + || query.query_type() != AMTRELAY_TYPE + || query.query_class() != DNSClass::IN + { + return None; + } + + message + .answers() + .iter() + .filter(|answer| { + answer.name() == &expected_name + && answer.dns_class() == DNSClass::IN + && answer.record_type() == AMTRELAY_TYPE + }) + .filter_map(|answer| match answer.data() { + RData::Unknown { rdata, .. } => { + parse_amtrelay(rdata.anything()).map(|(precedence, relay)| (precedence, relay)) + } + _ => None, + }) + .min_by_key(|(precedence, _)| *precedence) + .map(|(_, relay)| relay) +} + +fn canonical_name(name: &str) -> Option { + Name::from_ascii(format!("{}.", name.trim_end_matches('.'))).ok() +} + +fn parse_amtrelay(rdata: &[u8]) -> Option<(u8, ProofRelay)> { + let (&precedence, payload) = rdata.split_first()?; + let (&relay_type, relay) = payload.split_first()?; + let relay = match relay_type & 0x7f { + 1 if relay.len() == 4 => { + ProofRelay::Ip(IpAddr::from([relay[0], relay[1], relay[2], relay[3]])) + } + 2 if relay.len() == 16 => { + let mut octets = [0; 16]; + octets.copy_from_slice(relay); + ProofRelay::Ip(IpAddr::from(octets)) + } + 3 => ProofRelay::DnsName(parse_uncompressed_name(relay)?), + _ => return None, + }; + Some((precedence, relay)) +} + +fn parse_uncompressed_name(data: &[u8]) -> Option { + let mut labels = Vec::new(); + let mut offset = 0; + loop { + let len = *data.get(offset)? as usize; + offset += 1; + if len == 0 { + return (offset == data.len() && !labels.is_empty()).then(|| labels.join(".")); + } + if len > 63 || offset + len > data.len() { + return None; + } + labels.push(std::str::from_utf8(&data[offset..offset + len]).ok()?); + offset += len; + } +} + +#[cfg(feature = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn hickory_proof_accepts(data: &[u8], transaction_id: u16, qname: &str) -> bool { + select_relay(data, transaction_id, qname).is_some() +} diff --git a/src/lib.rs b/src/lib.rs index c8d5b34..b48e60d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,8 @@ pub mod gateway; pub mod igmp; pub mod mld; pub mod driad; +#[cfg(feature = "hickory-proof")] +pub mod hickory_proof; pub mod subscription; #[cfg(feature = "wasm")] diff --git a/tests/hickory_driad_proof.rs b/tests/hickory_driad_proof.rs new file mode 100644 index 0000000..5e6deda --- /dev/null +++ b/tests/hickory_driad_proof.rs @@ -0,0 +1,140 @@ +#![cfg(feature = "hickory-proof")] + +use amt_protocol::hickory_proof::{build_query, select_relay, ProofRelay}; +use std::net::{IpAddr, Ipv4Addr}; + +const QNAME: &str = "128.95.25.69.in-addr.arpa"; +const TXID: u16 = 0x5a17; + +fn response(question: &[u8], answers: &[(&[u8], u16, u16, &[u8])]) -> Vec { + let mut wire = question.to_vec(); + wire[2] = 0x81; + wire[3] = 0x80; + wire[6..8].copy_from_slice(&(answers.len() as u16).to_be_bytes()); + for (owner, rr_type, class, rdata) in answers { + wire.extend_from_slice(owner); + wire.extend_from_slice(&rr_type.to_be_bytes()); + wire.extend_from_slice(&class.to_be_bytes()); + wire.extend_from_slice(&60u32.to_be_bytes()); + wire.extend_from_slice(&(rdata.len() as u16).to_be_bytes()); + wire.extend_from_slice(rdata); + } + wire +} + +fn query() -> Vec { + build_query(QNAME, TXID).expect("Hickory query") +} + +#[test] +fn query_is_golden_for_ipv4_ipv6_and_trailing_dot() { + let v4 = query(); + assert_eq!(&v4[..4], &[0x5a, 0x17, 0x01, 0x00]); + assert_eq!(&v4[v4.len() - 4..], &[0x01, 0x04, 0x00, 0x01]); + assert_eq!( + build_query(QNAME, TXID), + build_query(&format!("{QNAME}."), TXID) + ); + + let v6_name = "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"; + let v6 = build_query(v6_name, TXID).expect("IPv6 reverse query"); + assert_eq!(&v6[v6.len() - 4..], &[0x01, 0x04, 0x00, 0x01]); +} + +#[test] +fn parses_compressed_ipv4_ipv6_and_type3_answers() { + let q = query(); + let owner = [0xc0, 0x0c]; + let ipv4 = [10, 1, 192, 0, 2, 10]; + let wire = response(&q, &[(&owner, 260, 1, &ipv4)]); + assert_eq!( + select_relay(&wire, TXID, QNAME), + Some(ProofRelay::Ip(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)))) + ); + + let mut ipv6 = vec![20, 2]; + ipv6.extend_from_slice( + &"2001:db8::5" + .parse::() + .unwrap() + .octets(), + ); + let wire = response(&q, &[(&owner, 260, 1, &ipv6)]); + assert_eq!( + select_relay(&wire, TXID, QNAME), + Some(ProofRelay::Ip("2001:db8::5".parse().unwrap())) + ); + + let hostname = [ + 30, 3, 5, b's', b'f', b'o', b'1', b'2', 5, b'b', b'c', b'a', b's', b't', 2, b'i', b'd', 0, + ]; + let wire = response(&q, &[(&owner, 260, 1, &hostname)]); + assert_eq!( + select_relay(&wire, TXID, QNAME), + Some(ProofRelay::DnsName("sfo12.bcast.id".into())) + ); +} + +#[test] +fn selects_lowest_precedence_and_skips_malformed_records() { + let q = query(); + let owner = [0xc0, 0x0c]; + let malformed = [1, 1, 192, 0, 2]; + let lower_priority = [20, 1, 192, 0, 2, 20]; + let preferred = [5, 1, 192, 0, 2, 5]; + let wire = response( + &q, + &[ + (&owner, 260, 1, &malformed), + (&owner, 260, 1, &lower_priority), + (&owner, 260, 1, &preferred), + ], + ); + assert_eq!( + select_relay(&wire, TXID, QNAME), + Some(ProofRelay::Ip("192.0.2.5".parse().unwrap())) + ); +} + +#[test] +fn rejects_wrong_transaction_question_owner_class_and_malformed_names() { + let q = query(); + let owner = [0xc0, 0x0c]; + let valid = [5, 1, 192, 0, 2, 5]; + let wire = response(&q, &[(&owner, 260, 1, &valid)]); + assert_eq!(select_relay(&wire, TXID + 1, QNAME), None); + assert_eq!(select_relay(&wire, TXID, "wrong.example"), None); + + let wrong_owner = [5, b'w', b'r', b'o', b'n', b'g', 0]; + assert_eq!( + select_relay( + &response(&q, &[(&wrong_owner, 260, 1, &valid)]), + TXID, + QNAME + ), + None + ); + assert_eq!( + select_relay(&response(&q, &[(&owner, 260, 3, &valid)]), TXID, QNAME), + None + ); + + let compressed_type3 = [5, 3, 0xc0, 0x0c]; + assert_eq!( + select_relay( + &response(&q, &[(&owner, 260, 1, &compressed_type3)]), + TXID, + QNAME + ), + None + ); + let trailing_garbage = [5, 3, 1, b'a', 0, 0]; + assert_eq!( + select_relay( + &response(&q, &[(&owner, 260, 1, &trailing_garbage)]), + TXID, + QNAME + ), + None + ); +}