#![cfg(any(feature = "alloc", feature = "std"))]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use core::net::{Ipv4Addr, Ipv6Addr};
use std::{vec, vec::Vec};
use mdns_proto::{
Name,
wire::{
DEFAULT_COMPRESSION_TABLE, Header, MessageBuilder, MessageReader, NameRef, Rdata, ResourceType,
},
};
fn encode(push: impl FnOnce(&mut MessageBuilder<'_, DEFAULT_COMPRESSION_TABLE>)) -> Vec<u8> {
let mut buf = [0u8; 4096];
let mut b: MessageBuilder<'_, DEFAULT_COMPRESSION_TABLE> =
MessageBuilder::try_new(&mut buf, Header::new()).expect("builder");
push(&mut b);
let n = b.finish().expect("finish");
buf[..n].to_vec()
}
fn dotted(n: &NameRef<'_>) -> String {
let mut parts = Vec::new();
for label in n.labels() {
let label = label.expect("label parses");
if label.is_empty() {
break;
}
parts.push(String::from_utf8_lossy(label).to_ascii_lowercase());
}
parts.join(".")
}
fn txt_segments(msg: &[u8]) -> Vec<Vec<u8>> {
let r = MessageReader::try_parse(msg).expect("parse");
let rec = r.answers().next().expect("answer").expect("record");
match rec.rdata_view().expect("rdata") {
Rdata::Txt(t) => t.segments().map(|s| s.expect("segment").to_vec()).collect(),
other => panic!("expected TXT, got {other:?}"),
}
}
#[test]
fn a_record_roundtrips() {
let name = Name::try_from_str("host.local.").unwrap();
let msg = encode(|b| {
b.push_a_answer(&name, 120, Ipv4Addr::new(192, 0, 2, 7), true)
.unwrap()
});
let r = MessageReader::try_parse(&msg).unwrap();
let rec = r.answers().next().unwrap().unwrap();
assert_eq!(dotted(rec.name()), "host.local");
assert_eq!(rec.ttl(), 120);
assert!(rec.cache_flush(), "A set cache_flush=true");
let Rdata::A(a) = rec.rdata_view().unwrap() else {
panic!("expected A")
};
assert_eq!(a.addr(), Ipv4Addr::new(192, 0, 2, 7));
}
#[test]
fn aaaa_record_roundtrips() {
let name = Name::try_from_str("host.local.").unwrap();
let addr = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1);
let msg = encode(|b| b.push_aaaa_answer(&name, 120, addr, true).unwrap());
let r = MessageReader::try_parse(&msg).unwrap();
let rec = r.answers().next().unwrap().unwrap();
let Rdata::AAAA(a) = rec.rdata_view().unwrap() else {
panic!("expected AAAA")
};
assert_eq!(a.addr(), addr);
}
#[test]
fn srv_record_roundtrips() {
let name = Name::try_from_str("inst._http._tcp.local.").unwrap();
let target = Name::try_from_str("host.local.").unwrap();
let msg = encode(|b| {
b.push_srv_answer(&name, 120, 0, 0, 8080, &target, true)
.unwrap()
});
let r = MessageReader::try_parse(&msg).unwrap();
let rec = r.answers().next().unwrap().unwrap();
let Rdata::Srv(s) = rec.rdata_view().unwrap() else {
panic!("expected SRV")
};
assert_eq!(s.priority(), 0);
assert_eq!(s.weight(), 0);
assert_eq!(s.port(), 8080);
assert_eq!(dotted(s.target()), "host.local");
}
#[test]
fn ptr_record_roundtrips() {
let name = Name::try_from_str("_http._tcp.local.").unwrap();
let target = Name::try_from_str("inst._http._tcp.local.").unwrap();
let msg = encode(|b| b.push_ptr_answer(&name, 4500, &target).unwrap());
let r = MessageReader::try_parse(&msg).unwrap();
let rec = r.answers().next().unwrap().unwrap();
assert!(!rec.cache_flush(), "PTR is shared — no cache-flush bit");
let Rdata::Ptr(p) = rec.rdata_view().unwrap() else {
panic!("expected PTR")
};
assert_eq!(dotted(p.target()), "inst._http._tcp.local");
}
#[test]
fn txt_record_roundtrips() {
let name = Name::try_from_str("inst._http._tcp.local.").unwrap();
let segs: [&[u8]; 2] = [b"path=/", b"txtvers=1"];
let msg = encode(|b| b.push_txt_answer(&name, 4500, segs, true).unwrap());
assert_eq!(
txt_segments(&msg),
vec![b"path=/".to_vec(), b"txtvers=1".to_vec()]
);
}
#[test]
fn nsec_record_roundtrips() {
let name = Name::try_from_str("host.local.").unwrap();
let msg = encode(|b| {
b.push_nsec_additional(&name, 120, &[1, 16, 28, 33], true)
.unwrap()
});
let r = MessageReader::try_parse(&msg).unwrap();
let rec = r.additional().next().unwrap().unwrap();
assert_eq!(rec.rtype(), ResourceType::Nsec);
assert!(
matches!(rec.rdata_view().unwrap(), Rdata::Nsec(_)),
"NSEC rdata must decode"
);
}
#[test]
fn txt_empty_encodes_as_single_zero_length_string() {
let name = Name::try_from_str("inst._http._tcp.local.").unwrap();
let msg = encode(|b| {
b.push_txt_answer(&name, 120, core::iter::empty::<&[u8]>(), true)
.unwrap()
});
let r = MessageReader::try_parse(&msg).unwrap();
let rec = r.answers().next().unwrap().unwrap();
assert_eq!(
rec.rdata(),
&[0u8],
"empty TXT rdata must be a single 0x00 length byte"
);
assert_eq!(
txt_segments(&msg),
vec![Vec::<u8>::new()],
"exactly one empty segment"
);
}
#[test]
fn txt_boolean_key_has_no_equals() {
let name = Name::try_from_str("inst._http._tcp.local.").unwrap();
let msg = encode(|b| {
b.push_txt_answer(&name, 120, [b"passreq".as_slice()], true)
.unwrap()
});
assert_eq!(txt_segments(&msg), vec![b"passreq".to_vec()]);
}
#[test]
fn txt_empty_value_keeps_trailing_equals() {
let name = Name::try_from_str("inst._http._tcp.local.").unwrap();
let msg = encode(|b| {
b.push_txt_answer(&name, 120, [b"key=".as_slice()], true)
.unwrap()
});
assert_eq!(txt_segments(&msg), vec![b"key=".to_vec()]);
}
#[test]
fn txt_value_may_contain_equals() {
let name = Name::try_from_str("inst._http._tcp.local.").unwrap();
let msg = encode(|b| {
b.push_txt_answer(&name, 120, [b"k=a=b=c".as_slice()], true)
.unwrap()
});
assert_eq!(txt_segments(&msg), vec![b"k=a=b=c".to_vec()]);
}
#[test]
fn txt_value_is_opaque_binary() {
let name = Name::try_from_str("inst._http._tcp.local.").unwrap();
let raw: &[u8] = &[b'k', b'=', 0x00, 0xFF, 0x01, 0x80];
let msg = encode(|b| b.push_txt_answer(&name, 120, [raw], true).unwrap());
assert_eq!(txt_segments(&msg), vec![raw.to_vec()]);
}
#[test]
fn txt_segment_at_255_bytes_roundtrips() {
let name = Name::try_from_str("inst._http._tcp.local.").unwrap();
let seg = [b'a'; 255];
let msg = encode(|b| {
b.push_txt_answer(&name, 120, [seg.as_slice()], true)
.unwrap()
});
assert_eq!(txt_segments(&msg), vec![seg.to_vec()]);
}
#[test]
fn txt_segment_over_255_bytes_is_rejected() {
let name = Name::try_from_str("inst._http._tcp.local.").unwrap();
let big = [b'a'; 256];
let mut buf = [0u8; 1024];
let mut b: MessageBuilder<'_, DEFAULT_COMPRESSION_TABLE> =
MessageBuilder::try_new(&mut buf, Header::new()).unwrap();
assert!(
b.push_txt_answer(&name, 120, [big.as_slice()], true)
.is_err(),
"a TXT string longer than 255 bytes cannot be length-prefixed"
);
}
#[test]
fn txt_multiple_segments_preserve_order() {
let name = Name::try_from_str("inst._http._tcp.local.").unwrap();
let segs: [&[u8]; 3] = [b"first=1", b"second=2", b"third=3"];
let msg = encode(|b| b.push_txt_answer(&name, 120, segs, true).unwrap());
assert_eq!(
txt_segments(&msg),
vec![
b"first=1".to_vec(),
b"second=2".to_vec(),
b"third=3".to_vec()
]
);
}
#[test]
fn wire_names_match_case_insensitively() {
let mut buf = Vec::new();
buf.extend_from_slice(&[3, b'F', b'O', b'O', 5, b'L', b'o', b'C', b'a', b'L', 0]); let off_b = buf.len();
buf.extend_from_slice(&[3, b'f', b'o', b'o', 5, b'l', b'o', b'c', b'a', b'l', 0]); let (a, _) = NameRef::try_parse(&buf, 0).unwrap();
let (b, _) = NameRef::try_parse(&buf, off_b).unwrap();
assert!(
a.equals_ignoring_case(&b),
"mixed-case wire names must compare equal"
);
}
#[test]
fn utf8_instance_name_roundtrips() {
let instance = Name::try_from_str("Café._http._tcp.local.").unwrap();
let service = Name::try_from_str("_http._tcp.local.").unwrap();
let msg = encode(|b| b.push_ptr_answer(&service, 4500, &instance).unwrap());
let r = MessageReader::try_parse(&msg).unwrap();
let rec = r.answers().next().unwrap().unwrap();
let Rdata::Ptr(p) = rec.rdata_view().unwrap() else {
panic!("expected PTR")
};
let first = p.target().labels().next().unwrap().unwrap();
assert_eq!(
first,
"café".as_bytes(),
"UTF-8 instance label must round-trip (ASCII case-folded, 'é' preserved)"
);
}