Skip to main content

netget/server/netbios_ns/
packet.rs

1//! NetBIOS Name Service wire codec (RFC 1001 §14, RFC 1002 §4).
2//!
3//! Pure functions, no I/O and no LLM. Everything here is exercised directly from
4//! `tests/server/netbios_ns/e2e_test.rs` against literal bytes captured from Samba's
5//! `nmblookup`, so the encoding is pinned by something NetGet did not write.
6//!
7//! # The header is 12 octets, not 16
8//!
9//! RFC 1002 §4.2.1.1 gives six 16-bit fields — `NAME_TRN_ID`, `FLAGS`, `QDCOUNT`, `ANCOUNT`,
10//! `NSCOUNT`, `ARCOUNT` — which is 12 octets, exactly DNS's header. It is easy to write 16
11//! by counting the fields wrong; a captured `nmblookup` query has its question section
12//! starting at offset 12 and settles it.
13//!
14//! # First-level encoding is the thing implementations get wrong
15//!
16//! A NetBIOS name is *always* 16 octets: 15 octets of name, space-padded (0x20), followed by
17//! one **suffix** octet that selects the service (0x00 workstation, 0x20 file server, 0x1B
18//! domain master browser…). Those 16 octets are then expanded to 32 by splitting each octet
19//! into two 4-bit nibbles, high nibble first, and adding `'A'` (0x41) to each — so every
20//! output character lands in `A..=P`. See [`encode_first_level`].
21
22use anyhow::{bail, Context, Result};
23use std::net::Ipv4Addr;
24
25/// RFC 1002 §4.2.1.1 header: six 16-bit fields.
26pub const HEADER_LEN: usize = 12;
27
28/// The 16 octets of an unencoded NetBIOS name: 15 of name plus one suffix.
29pub const NAME_LEN: usize = 16;
30
31/// First-level encoding doubles [`NAME_LEN`].
32pub const ENCODED_NAME_LEN: usize = 32;
33
34/// RFC 1002 §4.1: an NBNS datagram is at most 576 octets.
35pub const MAX_DATAGRAM: usize = 576;
36
37// --- Question / RR types -------------------------------------------------------------------
38
39/// `NB` — the NetBIOS general name service resource record.
40pub const QTYPE_NB: u16 = 0x0020;
41/// `NBSTAT` — node status.
42pub const QTYPE_NBSTAT: u16 = 0x0021;
43/// `NULL`, carried by a negative name query response (RFC 1002 §4.2.14).
44pub const RRTYPE_NULL: u16 = 0x000A;
45/// `IN` — the only class NBNS uses.
46pub const CLASS_IN: u16 = 0x0001;
47
48// --- OPCODE (FLAGS bits 11..14) ------------------------------------------------------------
49
50pub const OPCODE_QUERY: u16 = 0;
51pub const OPCODE_REGISTRATION: u16 = 5;
52pub const OPCODE_RELEASE: u16 = 6;
53pub const OPCODE_WACK: u16 = 7;
54pub const OPCODE_REFRESH: u16 = 8;
55
56// --- FLAGS ---------------------------------------------------------------------------------
57
58/// `R` — set on a response.
59pub const FLAG_RESPONSE: u16 = 0x8000;
60/// `AA` — authoritative answer.
61pub const NM_FLAG_AA: u16 = 0x0400;
62/// `TC` — truncated.
63pub const NM_FLAG_TC: u16 = 0x0200;
64/// `RD` — recursion desired.
65pub const NM_FLAG_RD: u16 = 0x0100;
66/// `RA` — recursion available.
67pub const NM_FLAG_RA: u16 = 0x0080;
68/// `B` — broadcast.
69pub const NM_FLAG_B: u16 = 0x0010;
70
71const OPCODE_SHIFT: u16 = 11;
72const OPCODE_MASK: u16 = 0x7800;
73const RCODE_MASK: u16 = 0x000F;
74
75// --- RCODE ---------------------------------------------------------------------------------
76
77pub const RCODE_OK: u16 = 0;
78/// Request was invalidly formatted.
79pub const RCODE_FMT_ERR: u16 = 0x1;
80/// Server failure.
81pub const RCODE_SRV_ERR: u16 = 0x2;
82/// Name not found (the ordinary "no such name" answer).
83pub const RCODE_NAM_ERR: u16 = 0x3;
84/// Unsupported request.
85pub const RCODE_IMP_ERR: u16 = 0x4;
86/// Refused.
87pub const RCODE_RFS_ERR: u16 = 0x5;
88/// Name is already active (registration only).
89pub const RCODE_ACT_ERR: u16 = 0x6;
90/// Name is in conflict (registration only).
91pub const RCODE_CFT_ERR: u16 = 0x7;
92
93/// Map a spoken rcode name to its numeric value. Returns `None` for anything unrecognised so
94/// the caller can name the accepted set in its error.
95pub fn rcode_by_name(name: &str) -> Option<u16> {
96    match name.trim().to_ascii_lowercase().replace('-', "_").as_str() {
97        "ok" | "no_error" | "success" => Some(RCODE_OK),
98        "format_error" | "fmt_err" => Some(RCODE_FMT_ERR),
99        "server_failure" | "srv_err" => Some(RCODE_SRV_ERR),
100        "name_not_found" | "nam_err" | "name_error" => Some(RCODE_NAM_ERR),
101        "unsupported_request" | "imp_err" => Some(RCODE_IMP_ERR),
102        "refused" | "rfs_err" => Some(RCODE_RFS_ERR),
103        "name_active" | "act_err" => Some(RCODE_ACT_ERR),
104        "name_conflict" | "cft_err" => Some(RCODE_CFT_ERR),
105        _ => None,
106    }
107}
108
109/// Every rcode name this codec accepts, for error messages and documentation.
110pub const RCODE_NAMES: &[&str] = &[
111    "format_error",
112    "server_failure",
113    "name_not_found",
114    "unsupported_request",
115    "refused",
116    "name_active",
117    "name_conflict",
118];
119
120// --- NB_FLAGS (name query response RDATA, RFC 1002 §4.2.13) --------------------------------
121
122/// `G` — the name is a group name rather than a unique name.
123pub const NB_FLAG_GROUP: u16 = 0x8000;
124const ONT_SHIFT: u16 = 13;
125
126/// Owner node type, the `ONT` field of `NB_FLAGS` / `NAME_FLAGS`.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum NodeType {
129    /// Broadcast node.
130    B,
131    /// Point-to-point node.
132    P,
133    /// Mixed node.
134    M,
135    /// Hybrid node (Microsoft's extension; encoded as ONT 3).
136    H,
137}
138
139impl NodeType {
140    /// The two `ONT` bits, already shifted into their position in a flags word.
141    pub fn ont_bits(self) -> u16 {
142        let value = match self {
143            NodeType::B => 0,
144            NodeType::P => 1,
145            NodeType::M => 2,
146            NodeType::H => 3,
147        };
148        value << ONT_SHIFT
149    }
150
151    /// Parse `"b"`/`"p"`/`"m"`/`"h"` (case-insensitive, `"b-node"` accepted too).
152    pub fn parse(s: &str) -> Option<Self> {
153        match s.trim().to_ascii_lowercase().trim_end_matches("-node") {
154            "b" | "broadcast" => Some(NodeType::B),
155            "p" | "point-to-point" | "peer" => Some(NodeType::P),
156            "m" | "mixed" => Some(NodeType::M),
157            "h" | "hybrid" => Some(NodeType::H),
158            _ => None,
159        }
160    }
161
162    pub fn as_str(self) -> &'static str {
163        match self {
164            NodeType::B => "b",
165            NodeType::P => "p",
166            NodeType::M => "m",
167            NodeType::H => "h",
168        }
169    }
170}
171
172// --- NAME_FLAGS (node status response, RFC 1002 §4.2.18) -----------------------------------
173
174/// `G` — group name.
175pub const NAME_FLAG_GROUP: u16 = 0x8000;
176/// `ACT` — the name is active. A name list entry that is not active is meaningless, so this
177/// is set for every name unless the caller explicitly asks otherwise.
178pub const NAME_FLAG_ACTIVE: u16 = 0x0400;
179
180/// The `STATISTICS` block of a node status response is a fixed 46 octets (RFC 1002 §4.2.18).
181pub const STATISTICS_LEN: usize = 46;
182
183// ===========================================================================================
184// First-level encoding
185// ===========================================================================================
186
187/// Expand 16 octets of NetBIOS name into the 32 characters that go on the wire.
188///
189/// Each octet becomes two characters: `'A' + (octet >> 4)` then `'A' + (octet & 0x0F)`. Every
190/// output character is therefore in `A..=P`.
191///
192/// The `'*'` wildcard name (`0x2A` followed by fifteen NULs) encodes to
193/// `CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`, which is the literal a real `nmblookup -A` puts on the
194/// wire and what the tests pin this against.
195pub fn encode_first_level(name: &[u8; NAME_LEN]) -> [u8; ENCODED_NAME_LEN] {
196    let mut out = [0u8; ENCODED_NAME_LEN];
197    for (i, byte) in name.iter().enumerate() {
198        out[i * 2] = b'A' + (byte >> 4);
199        out[i * 2 + 1] = b'A' + (byte & 0x0F);
200    }
201    out
202}
203
204/// Inverse of [`encode_first_level`].
205///
206/// Rejects anything outside `A..=P`: a character outside that range cannot have come from a
207/// nibble, so accepting it would silently invent a name.
208pub fn decode_first_level(encoded: &[u8]) -> Result<[u8; NAME_LEN]> {
209    if encoded.len() != ENCODED_NAME_LEN {
210        bail!(
211            "first-level encoded name must be {} octets, got {}",
212            ENCODED_NAME_LEN,
213            encoded.len()
214        );
215    }
216    let mut out = [0u8; NAME_LEN];
217    for i in 0..NAME_LEN {
218        let hi = encoded[i * 2];
219        let lo = encoded[i * 2 + 1];
220        if !(b'A'..=b'P').contains(&hi) || !(b'A'..=b'P').contains(&lo) {
221            bail!(
222                "first-level encoded name contains a character outside A-P at offset {}: {:?}",
223                i * 2,
224                String::from_utf8_lossy(encoded)
225            );
226        }
227        out[i] = ((hi - b'A') << 4) | (lo - b'A');
228    }
229    Ok(out)
230}
231
232/// The wildcard name — "whatever names you hold" — used by node status requests.
233pub const WILDCARD_NAME: &str = "*";
234
235/// Build the 16 unencoded octets from a human name plus its suffix.
236///
237/// Ordinary names are **space**-padded to 15 octets. The wildcard `*` is the exception: RFC
238/// 1001 §17 defines it as `'*'` followed by fifteen **NUL** octets, and a real `nmblookup -A`
239/// puts exactly that on the wire — encoding it with spaces produces
240/// `CKCACACACACACACACACACACACACACAAA`, which no NBNS implementation recognises as the
241/// wildcard. Padding it with spaces was a live bug here until the captured Samba datagram
242/// disagreed with what this function produced.
243///
244/// A name longer than 15 characters is an error rather than a silent truncation — truncating
245/// changes which host is being talked about.
246pub fn pad_netbios_name(name: &str, suffix: u8) -> Result<[u8; NAME_LEN]> {
247    let bytes = name.as_bytes();
248    if bytes.len() > NAME_LEN - 1 {
249        bail!(
250            "NetBIOS name '{}' is {} characters; the wire format allows at most {} \
251             (the 16th octet is the service suffix)",
252            name,
253            bytes.len(),
254            NAME_LEN - 1
255        );
256    }
257    if !bytes.is_ascii() {
258        bail!("NetBIOS name '{}' must be ASCII", name);
259    }
260    let pad = if name == WILDCARD_NAME { 0x00 } else { b' ' };
261    let mut out = [pad; NAME_LEN];
262    out[..bytes.len()].copy_from_slice(bytes);
263    out[NAME_LEN - 1] = suffix;
264    Ok(out)
265}
266
267/// Split the 16 unencoded octets back into a trimmed name and its suffix.
268///
269/// Both pad octets are trimmed: spaces for an ordinary name, NULs for the wildcard. `trim_end`
270/// alone does not remove NULs — `char::is_whitespace` is false for `\0` — so the wildcard
271/// would otherwise reach the model as `"*\0\0…"`, a name it cannot match on or echo back.
272pub fn split_netbios_name(raw: &[u8; NAME_LEN]) -> (String, u8) {
273    let name = String::from_utf8_lossy(&raw[..NAME_LEN - 1])
274        .trim_end_matches([' ', '\0'])
275        .to_string();
276    (name, raw[NAME_LEN - 1])
277}
278
279/// The full wire NAME field: length octet 0x20, the 32 encoded characters, then the scope
280/// labels, then the root terminator.
281pub fn encode_name_field(name: &str, suffix: u8, scope: Option<&str>) -> Result<Vec<u8>> {
282    let raw = pad_netbios_name(name, suffix)?;
283    let encoded = encode_first_level(&raw);
284
285    let mut out = Vec::with_capacity(1 + ENCODED_NAME_LEN + 2);
286    out.push(ENCODED_NAME_LEN as u8);
287    out.extend_from_slice(&encoded);
288
289    if let Some(scope) = scope {
290        for label in scope.split('.').filter(|l| !l.is_empty()) {
291            if label.len() > 63 {
292                bail!("scope label '{}' exceeds 63 octets", label);
293            }
294            out.push(label.len() as u8);
295            out.extend_from_slice(label.as_bytes());
296        }
297    }
298    out.push(0);
299    Ok(out)
300}
301
302/// A NAME field read off the wire.
303#[derive(Debug, Clone)]
304pub struct NameField {
305    /// The field exactly as it appeared, terminator included. Responses echo this verbatim so
306    /// the scope survives a round trip without this codec having to re-encode it.
307    pub raw: Vec<u8>,
308    /// Trimmed 15-character portion.
309    pub name: String,
310    /// The service selector octet.
311    pub suffix: u8,
312    /// Dotted scope, if the sender supplied one.
313    pub scope: Option<String>,
314    /// Offset one past the field.
315    pub end: usize,
316}
317
318/// Read a NAME field starting at `pos`.
319///
320/// Label compression (a leading `0xC0`) is refused rather than followed. NBNS requests never
321/// use it — a question is the first thing in the datagram, so there is nothing to point at —
322/// and following a pointer would leave us unable to echo the field verbatim.
323pub fn read_name_field(buf: &[u8], pos: usize) -> Result<NameField> {
324    let mut cursor = pos;
325    let mut first: Option<[u8; NAME_LEN]> = None;
326    let mut scope_labels: Vec<String> = Vec::new();
327
328    loop {
329        let len = *buf
330            .get(cursor)
331            .context("datagram ended inside a NetBIOS NAME field")?;
332        if len & 0xC0 == 0xC0 {
333            bail!("compressed (pointer) NetBIOS NAME field is not supported");
334        }
335        if len & 0xC0 != 0 {
336            bail!("reserved label length bits set in a NetBIOS NAME field");
337        }
338        cursor += 1;
339        if len == 0 {
340            break;
341        }
342        let end = cursor + len as usize;
343        let label = buf
344            .get(cursor..end)
345            .context("datagram ended inside a NetBIOS NAME label")?;
346        if first.is_none() {
347            if len as usize != ENCODED_NAME_LEN {
348                bail!(
349                    "first NetBIOS label is {} octets; first-level encoding always produces {}",
350                    len,
351                    ENCODED_NAME_LEN
352                );
353            }
354            first = Some(decode_first_level(label)?);
355        } else {
356            scope_labels.push(String::from_utf8_lossy(label).into_owned());
357        }
358        cursor = end;
359    }
360
361    let raw16 = first.context("NetBIOS NAME field carried no name label")?;
362    let (name, suffix) = split_netbios_name(&raw16);
363
364    Ok(NameField {
365        raw: buf[pos..cursor].to_vec(),
366        name,
367        suffix,
368        scope: if scope_labels.is_empty() {
369            None
370        } else {
371            Some(scope_labels.join("."))
372        },
373        end: cursor,
374    })
375}
376
377// ===========================================================================================
378// Header
379// ===========================================================================================
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382pub struct Header {
383    pub trn_id: u16,
384    pub flags: u16,
385    pub qdcount: u16,
386    pub ancount: u16,
387    pub nscount: u16,
388    pub arcount: u16,
389}
390
391impl Header {
392    pub fn parse(buf: &[u8]) -> Result<Self> {
393        if buf.len() < HEADER_LEN {
394            bail!(
395                "NBNS datagram is {} octets; the header alone is {}",
396                buf.len(),
397                HEADER_LEN
398            );
399        }
400        let at = |i: usize| u16::from_be_bytes([buf[i], buf[i + 1]]);
401        Ok(Self {
402            trn_id: at(0),
403            flags: at(2),
404            qdcount: at(4),
405            ancount: at(6),
406            nscount: at(8),
407            arcount: at(10),
408        })
409    }
410
411    pub fn encode(&self) -> [u8; HEADER_LEN] {
412        let mut out = [0u8; HEADER_LEN];
413        out[0..2].copy_from_slice(&self.trn_id.to_be_bytes());
414        out[2..4].copy_from_slice(&self.flags.to_be_bytes());
415        out[4..6].copy_from_slice(&self.qdcount.to_be_bytes());
416        out[6..8].copy_from_slice(&self.ancount.to_be_bytes());
417        out[8..10].copy_from_slice(&self.nscount.to_be_bytes());
418        out[10..12].copy_from_slice(&self.arcount.to_be_bytes());
419        out
420    }
421
422    pub fn opcode(&self) -> u16 {
423        (self.flags & OPCODE_MASK) >> OPCODE_SHIFT
424    }
425
426    pub fn is_response(&self) -> bool {
427        self.flags & FLAG_RESPONSE != 0
428    }
429
430    pub fn rcode(&self) -> u16 {
431        self.flags & RCODE_MASK
432    }
433
434    pub fn recursion_desired(&self) -> bool {
435        self.flags & NM_FLAG_RD != 0
436    }
437
438    pub fn broadcast(&self) -> bool {
439        self.flags & NM_FLAG_B != 0
440    }
441}
442
443/// Human name for an opcode, for logs.
444pub fn opcode_name(opcode: u16) -> &'static str {
445    match opcode {
446        OPCODE_QUERY => "QUERY",
447        OPCODE_REGISTRATION => "REGISTRATION",
448        OPCODE_RELEASE => "RELEASE",
449        OPCODE_WACK => "WACK",
450        OPCODE_REFRESH => "REFRESH",
451        _ => "UNKNOWN",
452    }
453}
454
455/// Human name for a question type, for logs and for the event payload.
456pub fn qtype_name(qtype: u16) -> &'static str {
457    match qtype {
458        QTYPE_NB => "NB",
459        QTYPE_NBSTAT => "NBSTAT",
460        RRTYPE_NULL => "NULL",
461        _ => "UNKNOWN",
462    }
463}
464
465// ===========================================================================================
466// Request
467// ===========================================================================================
468
469/// One address a NAME REGISTRATION REQUEST is claiming.
470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471pub struct AddressEntry {
472    pub flags: u16,
473    pub address: Ipv4Addr,
474}
475
476impl AddressEntry {
477    pub fn is_group(&self) -> bool {
478        self.flags & NB_FLAG_GROUP != 0
479    }
480}
481
482/// A decoded inbound request.
483#[derive(Debug, Clone)]
484pub struct NbnsRequest {
485    pub header: Header,
486    pub question_name: NameField,
487    pub qtype: u16,
488    pub qclass: u16,
489    /// Addresses carried in the ADDITIONAL section — a registration's claimed address.
490    pub addresses: Vec<AddressEntry>,
491}
492
493/// Parse a request datagram.
494///
495/// Refuses anything that is already a response: an NBNS server answering a response would
496/// amplify a spoofed packet back at whoever it was aimed at.
497pub fn parse_request(buf: &[u8]) -> Result<NbnsRequest> {
498    if buf.len() > MAX_DATAGRAM {
499        bail!(
500            "NBNS datagram is {} octets; RFC 1002 §4.1 caps it at {}",
501            buf.len(),
502            MAX_DATAGRAM
503        );
504    }
505    let header = Header::parse(buf)?;
506    if header.is_response() {
507        bail!("datagram is a response (R=1), not a request");
508    }
509    if header.qdcount != 1 {
510        bail!(
511            "NBNS request must carry exactly one question, QDCOUNT={}",
512            header.qdcount
513        );
514    }
515
516    let question_name = read_name_field(buf, HEADER_LEN)?;
517    let after_name = question_name.end;
518    let qtype = u16::from_be_bytes([
519        *buf.get(after_name)
520            .context("question truncated before TYPE")?,
521        *buf.get(after_name + 1)
522            .context("question truncated inside TYPE")?,
523    ]);
524    let qclass = u16::from_be_bytes([
525        *buf.get(after_name + 2)
526            .context("question truncated before CLASS")?,
527        *buf.get(after_name + 3)
528            .context("question truncated inside CLASS")?,
529    ]);
530
531    // The ADDITIONAL section of a registration carries the address being claimed. Anything
532    // that does not parse is skipped rather than failing the whole datagram: the question is
533    // what the model is being asked about, and a mangled additional record must not silence
534    // the server.
535    let mut addresses = Vec::new();
536    if header.arcount > 0 {
537        let mut cursor = after_name + 4;
538        for _ in 0..header.arcount {
539            let Ok(rr_name) = read_name_field(buf, cursor) else {
540                break;
541            };
542            let mut p = rr_name.end;
543            // TYPE(2) CLASS(2) TTL(4) RDLENGTH(2)
544            if p + 10 > buf.len() {
545                break;
546            }
547            let rr_type = u16::from_be_bytes([buf[p], buf[p + 1]]);
548            let rdlength = u16::from_be_bytes([buf[p + 8], buf[p + 9]]) as usize;
549            p += 10;
550            if p + rdlength > buf.len() {
551                break;
552            }
553            if rr_type == QTYPE_NB {
554                let mut q = p;
555                while q + 6 <= p + rdlength {
556                    let flags = u16::from_be_bytes([buf[q], buf[q + 1]]);
557                    let address = Ipv4Addr::new(buf[q + 2], buf[q + 3], buf[q + 4], buf[q + 5]);
558                    addresses.push(AddressEntry { flags, address });
559                    q += 6;
560                }
561            }
562            cursor = p + rdlength;
563        }
564    }
565
566    Ok(NbnsRequest {
567        header,
568        question_name,
569        qtype,
570        qclass,
571        addresses,
572    })
573}
574
575// ===========================================================================================
576// Responses
577// ===========================================================================================
578
579/// Flags for a response: R, the request's opcode, AA, RA, plus RD echoed back, and the rcode.
580fn response_flags(opcode: u16, recursion_desired: bool, rcode: u16) -> u16 {
581    let mut flags = FLAG_RESPONSE | ((opcode << OPCODE_SHIFT) & OPCODE_MASK) | NM_FLAG_AA;
582    if recursion_desired {
583        // RFC 1002 §4.2.13: RD is echoed and RA set when the server is willing to recurse.
584        flags |= NM_FLAG_RD | NM_FLAG_RA;
585    }
586    flags | (rcode & RCODE_MASK)
587}
588
589/// POSITIVE NAME QUERY RESPONSE (RFC 1002 §4.2.13), and — with `opcode` set to
590/// [`OPCODE_REGISTRATION`] — POSITIVE NAME REGISTRATION RESPONSE (§4.2.6). The two differ
591/// only in the OPCODE, which is why the server supplies it from the request rather than the
592/// model supplying it as a parameter.
593pub fn encode_name_query_response(
594    trn_id: u16,
595    opcode: u16,
596    recursion_desired: bool,
597    name_field: &[u8],
598    addresses: &[AddressEntry],
599    ttl: u32,
600) -> Result<Vec<u8>> {
601    if addresses.is_empty() {
602        bail!("a positive NetBIOS name response must carry at least one address");
603    }
604    let header = Header {
605        trn_id,
606        flags: response_flags(opcode, recursion_desired, RCODE_OK),
607        qdcount: 0,
608        ancount: 1,
609        nscount: 0,
610        arcount: 0,
611    };
612
613    let rdlength = addresses.len() * 6;
614    let rdlength = u16::try_from(rdlength).context("too many addresses for one response")?;
615
616    let mut out = Vec::with_capacity(HEADER_LEN + name_field.len() + 10 + rdlength as usize);
617    out.extend_from_slice(&header.encode());
618    out.extend_from_slice(name_field);
619    out.extend_from_slice(&QTYPE_NB.to_be_bytes());
620    out.extend_from_slice(&CLASS_IN.to_be_bytes());
621    out.extend_from_slice(&ttl.to_be_bytes());
622    out.extend_from_slice(&rdlength.to_be_bytes());
623    for entry in addresses {
624        out.extend_from_slice(&entry.flags.to_be_bytes());
625        out.extend_from_slice(&entry.address.octets());
626    }
627    Ok(out)
628}
629
630/// NEGATIVE NAME QUERY RESPONSE (RFC 1002 §4.2.14): one answer RR of type NULL, zero RDATA,
631/// and a non-zero RCODE. Also serves as a negative registration response when `opcode` is
632/// [`OPCODE_REGISTRATION`].
633pub fn encode_negative_response(
634    trn_id: u16,
635    opcode: u16,
636    recursion_desired: bool,
637    name_field: &[u8],
638    rcode: u16,
639) -> Result<Vec<u8>> {
640    if rcode == RCODE_OK {
641        bail!("a negative NetBIOS response must carry a non-zero RCODE");
642    }
643    let header = Header {
644        trn_id,
645        flags: response_flags(opcode, recursion_desired, rcode),
646        qdcount: 0,
647        ancount: 1,
648        nscount: 0,
649        arcount: 0,
650    };
651
652    let mut out = Vec::with_capacity(HEADER_LEN + name_field.len() + 10);
653    out.extend_from_slice(&header.encode());
654    out.extend_from_slice(name_field);
655    out.extend_from_slice(&RRTYPE_NULL.to_be_bytes());
656    out.extend_from_slice(&CLASS_IN.to_be_bytes());
657    out.extend_from_slice(&0u32.to_be_bytes()); // TTL
658    out.extend_from_slice(&0u16.to_be_bytes()); // RDLENGTH
659    Ok(out)
660}
661
662/// One entry of a node status name list.
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
664pub struct NodeName {
665    /// The 16 **unencoded** octets. RFC 1002 §4.2.18's NODE_NAME is raw ASCII, not
666    /// first-level encoded — encoding it here is a classic way to produce a name list that
667    /// every real client renders as gibberish.
668    pub raw: [u8; NAME_LEN],
669    pub flags: u16,
670}
671
672/// NODE STATUS RESPONSE (RFC 1002 §4.2.18).
673///
674/// RDATA is `NUM_NAMES` (1 octet), then 18 octets per name (16 raw + 2 flag octets), then the
675/// fixed 46-octet statistics block whose first six octets are the adapter's `UNIT_ID` (its
676/// MAC). Every other statistic is reported as zero: NetGet has no adapter counters to report
677/// and inventing them would be fabricating data about a machine.
678pub fn encode_node_status_response(
679    trn_id: u16,
680    name_field: &[u8],
681    names: &[NodeName],
682    mac: [u8; 6],
683) -> Result<Vec<u8>> {
684    if names.is_empty() {
685        bail!("a node status response must list at least one name");
686    }
687    let num_names = u8::try_from(names.len())
688        .context("a node status response can list at most 255 names (NUM_NAMES is one octet)")?;
689
690    let rdlength = 1 + names.len() * 18 + STATISTICS_LEN;
691    let rdlength = u16::try_from(rdlength).context("node status RDATA exceeds 65535 octets")?;
692
693    // A node status response is not an answer to a name query, so RD/RA do not apply; RFC
694    // 1002 §4.2.18 shows AA set and nothing else.
695    let header = Header {
696        trn_id,
697        flags: FLAG_RESPONSE | NM_FLAG_AA,
698        qdcount: 0,
699        ancount: 1,
700        nscount: 0,
701        arcount: 0,
702    };
703
704    let mut out = Vec::with_capacity(HEADER_LEN + name_field.len() + 10 + rdlength as usize);
705    out.extend_from_slice(&header.encode());
706    out.extend_from_slice(name_field);
707    out.extend_from_slice(&QTYPE_NBSTAT.to_be_bytes());
708    out.extend_from_slice(&CLASS_IN.to_be_bytes());
709    out.extend_from_slice(&0u32.to_be_bytes()); // TTL is 0 for node status
710    out.extend_from_slice(&rdlength.to_be_bytes());
711    out.push(num_names);
712    for entry in names {
713        out.extend_from_slice(&entry.raw);
714        out.extend_from_slice(&entry.flags.to_be_bytes());
715    }
716    let mut statistics = [0u8; STATISTICS_LEN];
717    statistics[..6].copy_from_slice(&mac);
718    out.extend_from_slice(&statistics);
719
720    if out.len() > MAX_DATAGRAM {
721        bail!(
722            "node status response is {} octets, over the {} octet limit; list fewer names",
723            out.len(),
724            MAX_DATAGRAM
725        );
726    }
727    Ok(out)
728}
729
730/// Read a `suffix` action parameter.
731///
732/// **One copy, shared by the server's actions and the client's, because the two disagreeing
733/// silently is a wrong-name bug rather than a wrong-value one.** `FILESERVER<0x20>` and
734/// `FILESERVER<0x14>` are different names, so a suffix that decodes to the wrong octet does
735/// not fail — it resolves, or answers for, a name nobody asked about.
736///
737/// Accepted: a number `0..=255`, or a string with an explicit `0x` prefix. A **bare** digit
738/// string such as `"20"` is refused, deliberately: NetBIOS suffixes are written in hex by
739/// convention (`<20>` is the file server) while JSON strings of digits read as decimal, and
740/// `"20"` is simultaneously a valid spelling of 32 and of 20 with only the sender knowing
741/// which. That is the same ambiguity the root `CLAUDE.md` records for `send_tcp_data`'s
742/// text-or-hex field, and the same answer: make the sender say which, rather than sniff.
743pub fn parse_suffix_value(value: Option<&serde_json::Value>) -> Result<u8> {
744    let Some(value) = value else { return Ok(0) };
745    match value {
746        serde_json::Value::Null => Ok(0),
747        serde_json::Value::Number(n) => {
748            let n = n
749                .as_u64()
750                .context("'suffix' must be a whole number between 0 and 255")?;
751            u8::try_from(n).context("'suffix' must be between 0 and 255")
752        }
753        serde_json::Value::String(s) => {
754            let trimmed = s.trim();
755            match trimmed
756                .strip_prefix("0x")
757                .or_else(|| trimmed.strip_prefix("0X"))
758            {
759                Some(hex) => u8::from_str_radix(hex, 16).with_context(|| {
760                    format!("'suffix' string '{s}' is not a hex octet (0x00-0xff)")
761                }),
762                None => bail!(
763                    "'suffix' string '{s}' is ambiguous: NetBIOS suffixes are conventionally \
764                     written in hex, but a bare string of digits reads as decimal, so '{s}' \
765                     could mean two different names. Give a number (32) or an explicit hex \
766                     string (\"0x20\")."
767                ),
768            }
769        }
770        other => bail!("'suffix' must be a number (32) or a hex string (\"0x20\"), got {other}"),
771    }
772}
773
774/// Parse `"00:11:22:33:44:55"` (or `-` separated) into six octets.
775///
776/// A MAC reaches the model as a formatted string, never as a byte array — models cannot
777/// reliably produce byte arrays, and this is the one place a hardware address is needed.
778pub fn parse_mac(text: &str) -> Result<[u8; 6]> {
779    let parts: Vec<&str> = text.split(['-', ':']).collect();
780    if parts.len() != 6 {
781        bail!(
782            "MAC address '{}' must have six octets separated by ':' or '-', e.g. \
783             \"00:11:22:33:44:55\"",
784            text
785        );
786    }
787    let mut out = [0u8; 6];
788    for (i, part) in parts.iter().enumerate() {
789        out[i] = u8::from_str_radix(part, 16)
790            .with_context(|| format!("'{}' in MAC address '{}' is not a hex octet", part, text))?;
791    }
792    Ok(out)
793}
794
795/// Render six octets back as `"00:11:22:33:44:55"`.
796pub fn format_mac(mac: &[u8; 6]) -> String {
797    mac.iter()
798        .map(|b| format!("{:02x}", b))
799        .collect::<Vec<_>>()
800        .join(":")
801}