fix: escape DNS label text per RFC 1035 §5.1 (closes #36) #54

Merged
razvandimescu merged 5 commits from fix/qname-label-escaping into main 2026-04-10 13:53:47 +08:00
Showing only changes of commit 25af23c4dc - Show all commits

View File

@@ -84,6 +84,11 @@ impl BytePacketBuffer {
/// Read a qname, handling label compression (pointer jumps). /// Read a qname, handling label compression (pointer jumps).
/// Converts wire format like [3]www[6]google[3]com[0] into "www.google.com". /// Converts wire format like [3]www[6]google[3]com[0] into "www.google.com".
///
/// Label bytes are escaped per RFC 1035 §5.1:
/// - literal `.` within a label → `\.`
/// - literal `\` → `\\`
/// - bytes outside `0x21..=0x7E` (excluding `.` and `\`) → `\DDD` (3-digit decimal)
pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> { pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> {
let mut pos = self.pos(); let mut pos = self.pos();
let mut jumped = false; let mut jumped = false;
@@ -121,7 +126,13 @@ impl BytePacketBuffer {
let str_buffer = self.get_range(pos, len as usize)?; let str_buffer = self.get_range(pos, len as usize)?;
for &b in str_buffer { for &b in str_buffer {
outstr.push(b.to_ascii_lowercase() as char); let c = b.to_ascii_lowercase();
match c {
b'.' => outstr.push_str("\\."),
b'\\' => outstr.push_str("\\\\"),
0x21..=0x7E => outstr.push(c as char),
_ => outstr.push_str(&format!("\\{:03}", c)),
}
} }
delim = "."; delim = ".";
@@ -163,23 +174,23 @@ impl BytePacketBuffer {
Ok(()) Ok(())
} }
/// Write a qname in wire format, parsing RFC 1035 §5.1 text escapes.
///
/// Dots are label separators unless escaped as `\.`. `\\` yields a literal
/// backslash, and `\DDD` (three decimal digits) yields an arbitrary byte.
pub fn write_qname(&mut self, qname: &str) -> Result<()> { pub fn write_qname(&mut self, qname: &str) -> Result<()> {
if qname.is_empty() || qname == "." { if qname.is_empty() || qname == "." {
self.write_u8(0)?; self.write_u8(0)?;
return Ok(()); return Ok(());
} }
for label in qname.split('.') { let labels = parse_escaped_labels(qname)?;
let len = label.len(); for label in &labels {
if len == 0 { if label.len() > 0x3f {
continue; // skip empty labels from trailing dot
}
if len > 0x3f {
return Err("Single label exceeds 63 characters of length".into()); return Err("Single label exceeds 63 characters of length".into());
} }
self.write_u8(label.len() as u8)?;
self.write_u8(len as u8)?; for b in label {
for b in label.as_bytes() {
self.write_u8(*b)?; self.write_u8(*b)?;
} }
} }
@@ -212,3 +223,180 @@ impl BytePacketBuffer {
Ok(()) Ok(())
} }
} }
fn parse_escaped_labels(qname: &str) -> Result<Vec<Vec<u8>>> {
let mut labels: Vec<Vec<u8>> = Vec::new();
let mut current: Vec<u8> = Vec::new();
let mut chars = qname.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some(d1) if d1.is_ascii_digit() => {
let d2 = chars
.next()
.and_then(|c| c.to_digit(10))
.ok_or("invalid \\DDD escape: expected 3 digits")?;
let d3 = chars
.next()
.and_then(|c| c.to_digit(10))
.ok_or("invalid \\DDD escape: expected 3 digits")?;
let val = d1.to_digit(10).unwrap() * 100 + d2 * 10 + d3;
if val > 255 {
return Err(format!("\\DDD escape out of range: {}", val).into());
}
current.push(val as u8);
}
Some(other) => {
let mut buf = [0u8; 4];
current.extend_from_slice(other.encode_utf8(&mut buf).as_bytes());
}
None => return Err("trailing backslash in qname".into()),
}
} else if c == '.' {
if !current.is_empty() {
labels.push(std::mem::take(&mut current));
}
} else {
let mut buf = [0u8; 4];
current.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
}
if !current.is_empty() {
labels.push(current);
}
Ok(labels)
}
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip(wire: &[u8]) -> String {
let mut buf = BytePacketBuffer::from_bytes(wire);
let mut out = String::new();
buf.read_qname(&mut out).unwrap();
out
}
fn write_then_read(text: &str) -> String {
let mut buf = BytePacketBuffer::new();
buf.write_qname(text).unwrap();
let wire_end = buf.pos();
buf.seek(0).unwrap();
let mut out = String::new();
buf.read_qname(&mut out).unwrap();
assert_eq!(
buf.pos(),
wire_end,
"reader should consume exactly what writer wrote"
);
out
}
#[test]
fn read_plain_domain() {
// [3]www[6]google[3]com[0]
let wire = b"\x03www\x06google\x03com\x00";
assert_eq!(roundtrip(wire), "www.google.com");
}
#[test]
fn read_label_with_literal_dot_is_escaped() {
// fanf2's example: [8]exa.mple[3]com[0] — two labels, first contains 0x2E
let wire = b"\x08exa.mple\x03com\x00";
assert_eq!(roundtrip(wire), "exa\\.mple.com");
}
#[test]
fn read_label_with_backslash_is_escaped() {
// [4]a\bc[3]com[0]
let wire = b"\x04a\\bc\x03com\x00";
assert_eq!(roundtrip(wire), "a\\\\bc.com");
}
#[test]
fn read_label_with_nonprintable_byte_uses_decimal_escape() {
// [4]\x00foo[3]com[0] — null byte at label start
let wire = b"\x04\x00foo\x03com\x00";
assert_eq!(roundtrip(wire), "\\000foo.com");
}
#[test]
fn read_label_with_space_uses_decimal_escape() {
// Space (0x20) is outside 0x21..=0x7E, so it must be decimal-escaped.
let wire = b"\x05a b c\x00";
assert_eq!(roundtrip(wire), "a\\032b\\032c");
}
#[test]
fn write_plain_domain() {
let mut buf = BytePacketBuffer::new();
buf.write_qname("www.google.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x03www\x06google\x03com\x00");
}
#[test]
fn write_escaped_dot_does_not_split_label() {
let mut buf = BytePacketBuffer::new();
buf.write_qname("exa\\.mple.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x08exa.mple\x03com\x00");
}
#[test]
fn write_escaped_backslash() {
let mut buf = BytePacketBuffer::new();
buf.write_qname("a\\\\bc.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x04a\\bc\x03com\x00");
}
#[test]
fn write_decimal_escape_yields_raw_byte() {
let mut buf = BytePacketBuffer::new();
buf.write_qname("\\000foo.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x04\x00foo\x03com\x00");
}
#[test]
fn write_rejects_out_of_range_decimal_escape() {
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname("\\999foo.com").is_err());
}
#[test]
fn write_rejects_trailing_backslash() {
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname("foo\\").is_err());
}
#[test]
fn write_rejects_short_decimal_escape() {
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname("\\1").is_err());
}
#[test]
fn roundtrip_preserves_dot_in_label() {
assert_eq!(write_then_read("exa\\.mple.com"), "exa\\.mple.com");
}
#[test]
fn roundtrip_preserves_backslash_in_label() {
assert_eq!(write_then_read("a\\\\b.com"), "a\\\\b.com");
}
#[test]
fn roundtrip_preserves_nonprintable_byte() {
assert_eq!(write_then_read("\\000foo.com"), "\\000foo.com");
}
#[test]
fn root_name_empty_and_dot_both_produce_single_zero() {
let mut a = BytePacketBuffer::new();
a.write_qname("").unwrap();
let mut b = BytePacketBuffer::new();
b.write_qname(".").unwrap();
assert_eq!(&a.buf[..a.pos], b"\x00");
assert_eq!(&b.buf[..b.pos], b"\x00");
}
}