cellos_supervisor/dns_proxy/parser.rs
1//! Minimal DNS message parser scoped to what the SEAM-1 / L2-04 proxy needs.
2//!
3//! Pure logic, no I/O. The proxy parses the wire-format query, makes an
4//! allowlist decision against [`crate::dns_proxy::DnsProxyConfig::hostname_allowlist`],
5//! then either forwards the bytes verbatim upstream (allow path) or builds a
6//! REFUSED response from the parsed view (deny path).
7//!
8//! ## Phase 2 scope (multi-question)
9//!
10//! - **Exactly one question on the hot path.** A DNS message with `QDCOUNT == 0`
11//! is rejected as malformed (nothing to evaluate). HIGH-D1 hardened the
12//! single-question API ([`parse_query`]) further: any packet declaring
13//! `QDCOUNT != 1` is rejected at the parser layer with
14//! [`DnsParseError::QdcountUnsupported`]. RFC 1035 §4.1.2 permits
15//! `QDCOUNT > 1`, but real-world recursive resolvers (Unbound, Knot, BIND)
16//! serve at most the first question and the supervisor's threat model
17//! (hostile guests) does not justify the policy-bypass / log-evasion
18//! surface that "parse first question, forward all bytes" exposes. The
19//! typed offline API [`parse_query_multi`] still returns one
20//! [`QuestionOutcome`] per declared question for tools that legitimately
21//! need to walk multi-question packets (e.g. fixture inspection).
22//! - **No pointer-compression in QNAME.** RFC 1035 §4.1.4 allows a label to be
23//! replaced by a 2-byte pointer to an earlier label. Real workload-side
24//! queries almost never use compression — the questions section is the only
25//! labels the message carries — so the parser rejects pointers as a defense
26//! against malformed/adversarial inputs that could otherwise drive the
27//! parser into a cycle. Compression in *responses* is handled by passing
28//! the wire bytes through untouched on the allow path.
29//! - **IN class only.** `qclass != 1` is rejected for any question.
30//! - **RFC 1035 length bounds.** Labels capped at 63 octets; total QNAME
31//! capped at 253 octets (the RFC 1035 §3.1 maximum once you remove the
32//! trailing root label and the length prefixes).
33//!
34//! ## What the parser surfaces
35//!
36//! [`DnsQueryView`] carries the transaction id (echoed back in the proxy's
37//! response when denying), the flags word (so the proxy can OR in QR=1 +
38//! RCODE=REFUSED for the deny path), the lowercased trailing-dot-stripped
39//! query name, and the raw wire-format `qtype` / `qclass` fields. The proxy
40//! maps the raw `qtype` to the bounded [`cellos_core::DnsQueryType`] enum
41//! via `cellos_core::qtype_to_dns_query_type`; query types outside that set
42//! produce a `denied_query_type` decision rather than a parser error.
43
44use thiserror::Error;
45
46/// Maximum labels per RFC 1035 §3.1 (one octet length).
47pub(crate) const MAX_LABEL_LEN: usize = 63;
48/// Maximum total QNAME length per RFC 1035 §3.1 (sum of label lengths + dots).
49pub(crate) const MAX_QNAME_LEN: usize = 253;
50/// DNS message header is fixed at 12 octets per RFC 1035 §4.1.1.
51pub(crate) const DNS_HEADER_LEN: usize = 12;
52/// `IN` (Internet) class. The only class the parser accepts.
53pub(crate) const QCLASS_IN: u16 = 1;
54/// Wire-format pointer indicator: top two bits set in a label-length octet
55/// signal a 2-byte compression pointer rather than a literal label.
56/// We test `(b & POINTER_MASK) == POINTER_MASK` to require BOTH bits set —
57/// a length octet with only the second-highest bit set (0x40-0x7f) is
58/// reserved by RFC 1035 and treated as a label-overflow when the value
59/// happens to exceed [`MAX_LABEL_LEN`].
60const POINTER_MASK: u8 = 0b1100_0000;
61
62/// Errors the parser can surface. Each maps to a `reasonCode` on the emitted
63/// `dns_query` event:
64/// - [`DnsParseError::TooShort`], [`DnsParseError::QdcountZero`],
65/// [`DnsParseError::QdcountUnsupported`],
66/// [`DnsParseError::LabelOverflow`], [`DnsParseError::NameOverflow`],
67/// [`DnsParseError::CompressionRejected`],
68/// [`DnsParseError::UnsupportedClass`],
69/// [`DnsParseError::InvalidLabelByte`] → `malformed_query`
70/// (proxy drops the packet — no response — matching common resolver
71/// behaviour against malformed input).
72#[derive(Debug, Error, PartialEq, Eq)]
73pub enum DnsParseError {
74 /// Packet smaller than the 12-byte header or truncated mid-question.
75 #[error("dns packet too short")]
76 TooShort,
77 /// Header `QDCOUNT` field was `0`. A query with no questions is malformed —
78 /// there is nothing to evaluate against the allowlist. The
79 /// `T2.B` multi-question expansion accepts `QDCOUNT >= 1`; only the
80 /// degenerate zero case is now refused at the parser layer.
81 #[error("dns QDCOUNT must be at least 1, got 0")]
82 QdcountZero,
83 /// Header `QDCOUNT` was greater than 1 on the hot-path single-question
84 /// API. HIGH-D1 closed the policy-bypass / log-evasion gap that arose
85 /// from "parse FIRST question, forward all bytes verbatim": a hostile
86 /// guest could craft a `[allowed.example.com, attacker.tld]` packet,
87 /// pass the allowlist on Q1, and have Q2 silently resolved by the
88 /// upstream resolver. The supervisor's contract is one allow-listed
89 /// query in, one logged event out — so the parser now refuses any
90 /// `QDCOUNT != 1` on the proxy path. Tools that need multi-question
91 /// inspection should use [`parse_query_multi`].
92 #[error("dns QDCOUNT must be exactly 1 on the proxy path, got {0}")]
93 QdcountUnsupported(u16),
94 /// A QNAME label exceeded the RFC 1035 63-octet maximum.
95 #[error("dns label exceeds 63 octets")]
96 LabelOverflow,
97 /// Total QNAME length exceeded the RFC 1035 253-octet maximum.
98 #[error("dns QNAME exceeds 253 octets")]
99 NameOverflow,
100 /// Encountered a pointer-compressed label in a query QNAME — the parser
101 /// rejects these as a defense against adversarial inputs.
102 #[error("dns QNAME pointer-compression rejected")]
103 CompressionRejected,
104 /// `qclass` was not `IN` (1). The parser only forwards Internet-class queries.
105 #[error("dns qclass {0} not supported (only IN/1)")]
106 UnsupportedClass(u16),
107 /// Label byte fell outside RFC 1035's preferred ASCII set.
108 #[error("dns label contains invalid byte 0x{0:02x}")]
109 InvalidLabelByte(u8),
110}
111
112/// Minimal view of a parsed single-question DNS query.
113///
114/// The proxy uses [`Self::txn_id`] + [`Self::flags`] to construct deny / SERVFAIL
115/// responses without re-parsing the question section. [`Self::qname`] is
116/// already lowercased and trailing-dot-stripped so allowlist comparison is a
117/// straight `eq_ignore_ascii_case` (or wildcard suffix match).
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct DnsQueryView {
120 /// 16-bit DNS transaction id from the message header. Echoed back to the
121 /// workload in any response the proxy emits.
122 pub txn_id: u16,
123 /// Raw flags word from the header. The proxy uses this to derive the
124 /// response flags (set QR=1, copy RD, set RA=0, write the new RCODE).
125 pub flags: u16,
126 /// Lowercased, trailing-dot-stripped query name.
127 pub qname: String,
128 /// Raw 16-bit `qtype` field. Map via
129 /// [`cellos_core::qtype_to_dns_query_type`] before allowlist evaluation.
130 pub qtype: u16,
131 /// Raw 16-bit `qclass` field. The parser only ever surfaces `IN` (1) here —
132 /// other classes return [`DnsParseError::UnsupportedClass`] before this
133 /// view is built.
134 pub qclass: u16,
135}
136
137/// Per-question outcome returned by [`parse_query_multi`].
138///
139/// Today this only carries the [`Self::Parsed`] variant; the enum exists (and
140/// is `#[non_exhaustive]`) so future extensions — e.g. a per-question soft
141/// failure that does not abort the whole packet — can land additively without
142/// breaking call sites that pattern-match on the variant. Callers should reach
143/// for [`Self::as_view`] rather than destructuring directly when they only
144/// need the underlying [`DnsQueryView`].
145#[non_exhaustive]
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum QuestionOutcome {
148 /// The question was parsed cleanly into a [`DnsQueryView`].
149 Parsed(DnsQueryView),
150}
151
152impl QuestionOutcome {
153 /// Borrow the underlying [`DnsQueryView`] when the outcome is
154 /// [`Self::Parsed`]. Returns `None` for any future variant that does not
155 /// carry a parsed view (currently unreachable, but kept honest by the
156 /// `#[non_exhaustive]` marker).
157 pub fn as_view(&self) -> Option<&DnsQueryView> {
158 match self {
159 QuestionOutcome::Parsed(v) => Some(v),
160 }
161 }
162}
163
164/// Parse the question section of a workload DNS query (single-question API).
165///
166/// Returns a [`DnsQueryView`] over the sole question on success, or a
167/// [`DnsParseError`] describing why the packet is rejected.
168///
169/// HIGH-D1 hardened this entry point: any packet declaring `QDCOUNT != 1`
170/// is now rejected with [`DnsParseError::QdcountZero`] (for `QDCOUNT == 0`)
171/// or [`DnsParseError::QdcountUnsupported`] (for `QDCOUNT > 1`). The
172/// previous behaviour — "parse FIRST question, leave trailing questions
173/// to be forwarded verbatim" — created a policy-bypass / log-evasion
174/// vector where a hostile guest could ride one allowlisted question on the
175/// front of a multi-question packet and have the upstream resolver answer
176/// the rest. RFC 1035 §4.1.2 permits `QDCOUNT > 1` but every modern
177/// recursive resolver (Unbound, Knot, BIND) services only the first
178/// question on UDP, so refusing here costs nothing in real-world
179/// compatibility while closing the bypass. Tools that legitimately need
180/// to inspect every declared question (offline fixture inspection, etc.)
181/// should reach for [`parse_query_multi`] instead.
182///
183/// On error, callers should drop the packet (no response) and emit a
184/// `dns_query` event with `reasonCode: malformed_query`.
185pub fn parse_query(packet: &[u8]) -> Result<DnsQueryView, DnsParseError> {
186 if packet.len() < DNS_HEADER_LEN {
187 return Err(DnsParseError::TooShort);
188 }
189 let txn_id = u16::from_be_bytes([packet[0], packet[1]]);
190 let flags = u16::from_be_bytes([packet[2], packet[3]]);
191 let qdcount = u16::from_be_bytes([packet[4], packet[5]]);
192 if qdcount == 0 {
193 return Err(DnsParseError::QdcountZero);
194 }
195 if qdcount != 1 {
196 // HIGH-D1: refuse multi-question packets on the hot path. See the
197 // `QdcountUnsupported` doc comment for the threat model.
198 return Err(DnsParseError::QdcountUnsupported(qdcount));
199 }
200
201 let (view, _next) = parse_one_question(packet, DNS_HEADER_LEN, txn_id, flags)?;
202 Ok(view)
203}
204
205/// Parse every question declared by `QDCOUNT` and return one
206/// [`QuestionOutcome`] per question.
207///
208/// `QDCOUNT == 0` is rejected with [`DnsParseError::QdcountZero`]; any
209/// per-question parse failure (truncation, label overflow, unsupported class,
210/// etc.) aborts the whole walk and surfaces the error — partial outcomes are
211/// not returned. This matches the single-question API's "drop on malformed"
212/// stance: a multi-question packet with one bad question is treated as
213/// malformed in its entirety.
214///
215/// The single-question API ([`parse_query`]) and this multi-question API are
216/// equivalent on a packet with exactly one question: both surface the same
217/// [`DnsQueryView`] for that question.
218pub fn parse_query_multi(packet: &[u8]) -> Result<Vec<QuestionOutcome>, DnsParseError> {
219 if packet.len() < DNS_HEADER_LEN {
220 return Err(DnsParseError::TooShort);
221 }
222 let txn_id = u16::from_be_bytes([packet[0], packet[1]]);
223 let flags = u16::from_be_bytes([packet[2], packet[3]]);
224 let qdcount = u16::from_be_bytes([packet[4], packet[5]]);
225 if qdcount == 0 {
226 return Err(DnsParseError::QdcountZero);
227 }
228
229 let mut outcomes = Vec::with_capacity(qdcount as usize);
230 let mut idx = DNS_HEADER_LEN;
231 for _ in 0..qdcount {
232 let (view, next) = parse_one_question(packet, idx, txn_id, flags)?;
233 outcomes.push(QuestionOutcome::Parsed(view));
234 idx = next;
235 }
236 Ok(outcomes)
237}
238
239/// Parse exactly one question starting at `start`.
240///
241/// Returns the [`DnsQueryView`] for the question and the byte offset of the
242/// first byte AFTER the question's `qclass` field — i.e. the start offset of
243/// the next question (or of the answer/authority/additional sections, when
244/// the parser is invoked on a response packet, which the proxy never does).
245///
246/// `txn_id` and `flags` are header-derived and shared across all questions in
247/// a single packet; the helper accepts them as parameters so it can build a
248/// per-question [`DnsQueryView`] without re-reading the header on every call.
249fn parse_one_question(
250 packet: &[u8],
251 start: usize,
252 txn_id: u16,
253 flags: u16,
254) -> Result<(DnsQueryView, usize), DnsParseError> {
255 // Walk the QNAME starting at `start`. The parser rejects pointer-compressed
256 // labels — a length byte with the top two bits set is treated as a pointer
257 // and refused.
258 let mut idx = start;
259 let mut qname = String::new();
260 loop {
261 if idx >= packet.len() {
262 return Err(DnsParseError::TooShort);
263 }
264 let len_byte = packet[idx];
265 if len_byte == 0 {
266 // Root label terminator. Advance past it and break.
267 idx += 1;
268 break;
269 }
270 if (len_byte & POINTER_MASK) == POINTER_MASK {
271 return Err(DnsParseError::CompressionRejected);
272 }
273 let label_len = len_byte as usize;
274 if label_len > MAX_LABEL_LEN {
275 return Err(DnsParseError::LabelOverflow);
276 }
277 idx += 1;
278 if idx + label_len > packet.len() {
279 return Err(DnsParseError::TooShort);
280 }
281 let label = &packet[idx..idx + label_len];
282 for &b in label {
283 // RFC 1035 preferred name syntax is letters/digits/hyphen + dot.
284 // scope: accept the broader RFC 2181 set (any byte except 0
285 // and the ASCII control / high-bit / unprintable ranges) and
286 // lowercase ASCII A-Z so allowlist matches are case-insensitive.
287 if b == 0 || !(0x20..=0x7e).contains(&b) {
288 return Err(DnsParseError::InvalidLabelByte(b));
289 }
290 }
291 if !qname.is_empty() {
292 qname.push('.');
293 }
294 // Lowercase ASCII A-Z; non-ASCII already filtered above.
295 for &b in label {
296 qname.push((b as char).to_ascii_lowercase());
297 }
298 if qname.len() > MAX_QNAME_LEN {
299 return Err(DnsParseError::NameOverflow);
300 }
301 idx += label_len;
302 }
303
304 // qtype + qclass: 4 octets after the QNAME.
305 if idx + 4 > packet.len() {
306 return Err(DnsParseError::TooShort);
307 }
308 let qtype = u16::from_be_bytes([packet[idx], packet[idx + 1]]);
309 let qclass = u16::from_be_bytes([packet[idx + 2], packet[idx + 3]]);
310 if qclass != QCLASS_IN {
311 return Err(DnsParseError::UnsupportedClass(qclass));
312 }
313 let view = DnsQueryView {
314 txn_id,
315 flags,
316 qname,
317 qtype,
318 qclass,
319 };
320 Ok((view, idx + 4))
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 /// Helper: build a wire-format DNS query for a single-question A record.
328 fn build_query(qname: &str, qtype: u16, qclass: u16) -> Vec<u8> {
329 let mut p = Vec::new();
330 // header: txn_id=0x1234, flags=0x0100 (RD), QDCOUNT=1, ANCOUNT=0, NSCOUNT=0, ARCOUNT=0
331 p.extend_from_slice(&[
332 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
333 ]);
334 for label in qname.split('.') {
335 p.push(label.len() as u8);
336 p.extend_from_slice(label.as_bytes());
337 }
338 p.push(0); // root
339 p.extend_from_slice(&qtype.to_be_bytes());
340 p.extend_from_slice(&qclass.to_be_bytes());
341 p
342 }
343
344 /// Helper: append a question (qname / qtype / qclass) to an existing packet
345 /// body. Caller is responsible for fixing up `QDCOUNT` separately.
346 fn append_question(p: &mut Vec<u8>, qname: &str, qtype: u16, qclass: u16) {
347 for label in qname.split('.') {
348 p.push(label.len() as u8);
349 p.extend_from_slice(label.as_bytes());
350 }
351 p.push(0);
352 p.extend_from_slice(&qtype.to_be_bytes());
353 p.extend_from_slice(&qclass.to_be_bytes());
354 }
355
356 /// Helper: build a wire-format DNS query containing N questions.
357 fn build_multi_query(questions: &[(&str, u16, u16)]) -> Vec<u8> {
358 let mut p = Vec::new();
359 let qd = questions.len() as u16;
360 p.extend_from_slice(&[0x12, 0x34, 0x01, 0x00]);
361 p.extend_from_slice(&qd.to_be_bytes());
362 p.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
363 for (name, qtype, qclass) in questions {
364 append_question(&mut p, name, *qtype, *qclass);
365 }
366 p
367 }
368
369 #[test]
370 fn parses_well_formed_a_query() {
371 let pkt = build_query("api.example.com", 1, 1);
372 let v = parse_query(&pkt).expect("parse ok");
373 assert_eq!(v.txn_id, 0x1234);
374 assert_eq!(v.qname, "api.example.com");
375 assert_eq!(v.qtype, 1);
376 assert_eq!(v.qclass, 1);
377 }
378
379 #[test]
380 fn parses_aaaa_query() {
381 let pkt = build_query("ipv6.example.com", 28, 1);
382 let v = parse_query(&pkt).expect("parse ok");
383 assert_eq!(v.qtype, 28);
384 assert_eq!(v.qname, "ipv6.example.com");
385 }
386
387 #[test]
388 fn lowercases_uppercase_qname() {
389 let pkt = build_query("API.Example.COM", 1, 1);
390 let v = parse_query(&pkt).expect("parse ok");
391 assert_eq!(v.qname, "api.example.com");
392 }
393
394 #[test]
395 fn parses_https_query_type_65() {
396 let pkt = build_query("svc.example.com", 65, 1);
397 let v = parse_query(&pkt).expect("parse ok");
398 assert_eq!(v.qtype, 65);
399 }
400
401 #[test]
402 fn rejects_truncated_header() {
403 let pkt = vec![0x00; 6]; // only 6 octets — half a header
404 assert_eq!(parse_query(&pkt), Err(DnsParseError::TooShort));
405 }
406
407 #[test]
408 fn rejects_truncated_qname() {
409 // header says 1 question, but body cuts off mid-label.
410 let mut pkt = vec![
411 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
412 ];
413 pkt.push(5); // claims 5-byte label
414 pkt.extend_from_slice(b"abc"); // only 3 bytes provided
415 assert_eq!(parse_query(&pkt), Err(DnsParseError::TooShort));
416 }
417
418 #[test]
419 fn rejects_truncated_qtype_qclass() {
420 // QNAME terminates but no qtype/qclass follow.
421 let mut pkt = vec![
422 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
423 ];
424 pkt.push(0); // root label only
425 // missing 4 bytes of qtype+qclass
426 assert_eq!(parse_query(&pkt), Err(DnsParseError::TooShort));
427 }
428
429 #[test]
430 fn parse_query_rejects_qdcount_two() {
431 // HIGH-D1 regression: a multi-question packet with one allowlisted
432 // question (Q1) and one disallowed question (Q2) was the original
433 // bypass — parse_query would surface Q1, the allowlist gate would
434 // pass, and the raw bytes (Q1 + Q2) would be forwarded verbatim
435 // upstream. The parser now rejects QDCOUNT != 1 outright; the hot
436 // path will short-circuit on `MalformedQuery`, drop the packet,
437 // and never forward it.
438 let pkt = build_multi_query(&[
439 ("allowed.example.com", 1, 1),
440 ("attacker.tld", 1, 1), // not on any allowlist
441 ]);
442 assert_eq!(parse_query(&pkt), Err(DnsParseError::QdcountUnsupported(2)));
443 }
444
445 #[test]
446 fn parse_query_rejects_qdcount_three() {
447 // Generalisation: any QDCOUNT > 1 trips the same gate.
448 let pkt = build_multi_query(&[
449 ("api.example.com", 1, 1),
450 ("svc.example.com", 28, 1),
451 ("alt.example.com", 65, 1),
452 ]);
453 assert_eq!(parse_query(&pkt), Err(DnsParseError::QdcountUnsupported(3)));
454 }
455
456 #[test]
457 fn parse_query_rejects_qdcount_max() {
458 // u16::MAX — pathological header. The parser bails on the header
459 // count before walking even the first question, so no truncation
460 // error is surfaced.
461 let mut pkt = vec![0x12, 0x34, 0x01, 0x00];
462 pkt.extend_from_slice(&u16::MAX.to_be_bytes()); // QDCOUNT = 65535
463 pkt.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
464 // Append one well-formed question — irrelevant; the header gate fires first.
465 append_question(&mut pkt, "api.example.com", 1, 1);
466 assert_eq!(
467 parse_query(&pkt),
468 Err(DnsParseError::QdcountUnsupported(u16::MAX))
469 );
470 }
471
472 #[test]
473 fn rejects_qdcount_zero_single() {
474 let pkt = vec![
475 0x12, 0x34, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
476 ];
477 assert_eq!(parse_query(&pkt), Err(DnsParseError::QdcountZero));
478 }
479
480 #[test]
481 fn rejects_qdcount_zero_multi() {
482 // The multi-question entry point ALSO rejects QDCOUNT=0 — a packet
483 // with no questions is malformed regardless of which API the caller
484 // reaches for.
485 let pkt = vec![
486 0x12, 0x34, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
487 ];
488 assert_eq!(parse_query_multi(&pkt), Err(DnsParseError::QdcountZero));
489 }
490
491 #[test]
492 fn rejects_pointer_compression() {
493 // Header + label byte 0xc0 (top two bits set) → pointer.
494 let mut pkt = vec![
495 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
496 ];
497 pkt.push(0xc0);
498 pkt.push(0x0c); // pointer to offset 12
499 pkt.extend_from_slice(&[0, 1, 0, 1]);
500 assert_eq!(parse_query(&pkt), Err(DnsParseError::CompressionRejected));
501 }
502
503 #[test]
504 fn rejects_oversized_label() {
505 // Label length 64 — over the 63-octet RFC 1035 ceiling. The 64
506 // value happens to overlap with the two top bits set in 0x40 (0b01000000)
507 // so it's a length, not a pointer; we catch it via the explicit
508 // `MAX_LABEL_LEN` check.
509 let mut pkt = vec![
510 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
511 ];
512 pkt.push(64);
513 pkt.extend(std::iter::repeat_n(b'a', 64));
514 pkt.push(0);
515 pkt.extend_from_slice(&[0, 1, 0, 1]);
516 assert_eq!(parse_query(&pkt), Err(DnsParseError::LabelOverflow));
517 }
518
519 #[test]
520 fn rejects_oversized_qname() {
521 // Build a name with many 50-octet labels — once the running total
522 // exceeds 253 the parser bails. Each label costs `len + 1` bytes
523 // (length prefix); we stop once accumulated qname > 253.
524 let mut pkt = vec![
525 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
526 ];
527 // 6 labels of 50 octets = 50*6 + 5 dots = 305 octets > 253.
528 for _ in 0..6 {
529 pkt.push(50);
530 pkt.extend(std::iter::repeat_n(b'a', 50));
531 }
532 pkt.push(0);
533 pkt.extend_from_slice(&[0, 1, 0, 1]);
534 assert_eq!(parse_query(&pkt), Err(DnsParseError::NameOverflow));
535 }
536
537 #[test]
538 fn rejects_non_in_class() {
539 // qclass=3 (CHAOS) — only IN is accepted.
540 let pkt = build_query("api.example.com", 1, 3);
541 assert_eq!(parse_query(&pkt), Err(DnsParseError::UnsupportedClass(3)));
542 }
543
544 #[test]
545 fn rejects_invalid_label_byte() {
546 // Inject a 0x00 byte mid-label (0x00 must only appear as the
547 // root-label terminator, never inside a label).
548 let mut pkt = vec![
549 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
550 ];
551 pkt.push(3);
552 pkt.extend_from_slice(&[b'a', 0x00, b'b']);
553 pkt.push(0);
554 pkt.extend_from_slice(&[0, 1, 0, 1]);
555 assert!(matches!(
556 parse_query(&pkt),
557 Err(DnsParseError::InvalidLabelByte(_))
558 ));
559 }
560
561 #[test]
562 fn parses_root_only_query_as_empty_name() {
563 // A query for "." (the root) — degenerate but well-formed. We treat
564 // the empty qname as "" (lowercased, no labels). Allowlist matching
565 // will not match it (empty allowlist string is not a legal entry),
566 // so the proxy emits `denied_not_in_allowlist`.
567 let mut pkt = vec![
568 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
569 ];
570 pkt.push(0);
571 pkt.extend_from_slice(&[0, 1, 0, 1]);
572 let v = parse_query(&pkt).expect("parse ok");
573 assert_eq!(v.qname, "");
574 }
575
576 // ---- T2.B / A4 multi-question parser tests ---------------------------
577
578 #[test]
579 fn parse_query_multi_returns_three_distinct_outcomes_for_qdcount_three() {
580 let pkt = build_multi_query(&[
581 ("api.example.com", 1, 1),
582 ("svc.example.com", 28, 1),
583 ("alt.example.com", 65, 1),
584 ]);
585 let outcomes = parse_query_multi(&pkt).expect("parse ok");
586 assert_eq!(outcomes.len(), 3);
587
588 // Pull views via the typed accessor (D9 — no string-shaped access).
589 let v0 = outcomes[0].as_view().expect("variant carries view");
590 let v1 = outcomes[1].as_view().expect("variant carries view");
591 let v2 = outcomes[2].as_view().expect("variant carries view");
592
593 assert_eq!(v0.qname, "api.example.com");
594 assert_eq!(v0.qtype, 1);
595 assert_eq!(v1.qname, "svc.example.com");
596 assert_eq!(v1.qtype, 28);
597 assert_eq!(v2.qname, "alt.example.com");
598 assert_eq!(v2.qtype, 65);
599
600 // Distinctness — each question yields its own view, not a shared one.
601 assert_ne!(v0, v1);
602 assert_ne!(v1, v2);
603 assert_ne!(v0, v2);
604 }
605
606 #[test]
607 fn parse_query_multi_truncation_in_second_question_returns_too_short() {
608 // Build a complete first question, then a second QNAME that promises
609 // a 5-byte label but only delivers 2 bytes. QDCOUNT=2.
610 let mut pkt = vec![0x12, 0x34, 0x01, 0x00, 0x00, 0x02];
611 pkt.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
612 append_question(&mut pkt, "api.example.com", 1, 1);
613 // Mid-second-question truncation: 5-byte label, only 2 bytes follow,
614 // and no qtype/qclass at all.
615 pkt.push(5);
616 pkt.extend_from_slice(b"ab");
617 assert_eq!(parse_query_multi(&pkt), Err(DnsParseError::TooShort));
618 }
619
620 #[test]
621 fn parse_query_multi_unsupported_class_in_second_question() {
622 // First question is fine (IN); second uses class 3 (CHAOS).
623 let pkt = build_multi_query(&[("api.example.com", 1, 1), ("evil.example.com", 1, 3)]);
624 assert_eq!(
625 parse_query_multi(&pkt),
626 Err(DnsParseError::UnsupportedClass(3))
627 );
628 }
629
630 #[test]
631 fn parse_query_and_parse_query_multi_agree_on_single_question_packets() {
632 // For a packet declaring exactly one question, both APIs must surface
633 // the same DnsQueryView — `parse_query_multi` over `[q]` and
634 // `parse_query` over the same bytes are equivalent.
635 let pkt = build_query("api.example.com", 28, 1);
636 let single = parse_query(&pkt).expect("single ok");
637 let multi = parse_query_multi(&pkt).expect("multi ok");
638 assert_eq!(multi.len(), 1);
639 let v_multi = multi[0].as_view().expect("variant carries view");
640 assert_eq!(&single, v_multi);
641 }
642}