Skip to main content

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}