Skip to main content

netget/server/nsq/
wire.rs

1//! nsqd's TCP protocol (V2): the client's commands and the server's frames.
2//!
3//! Everything here is a pure function of its arguments, shared by the session loop, the action
4//! executor, the tests and the `nsq_frame` fuzz target. **The model never writes a frame**: it
5//! supplies a verdict, an error code and text, or message bodies, and this module decides the
6//! sizes, the frame types, the message ids and the timestamps' layout.
7//!
8//! Client → server, after the four-byte magic `"  V2"`:
9//!
10//! ```text
11//! <COMMAND> <params separated by spaces>\n
12//! [ 4-byte big-endian size ][ body ]          IDENTIFY, PUB, MPUB, DPUB and AUTH only
13//! ```
14//!
15//! Server → client, every frame:
16//!
17//! ```text
18//! [ 4-byte size = 4 + len(data) ][ 4-byte frame type ][ data ]
19//! frame type 0 response ("OK", "CLOSE_WAIT", "_heartbeat_", IDENTIFY's JSON)
20//!            1 error    ("E_CODE description")
21//!            2 message  (timestamp i64 ns | attempts u16 | 16-byte ASCII id | body)
22//! ```
23//!
24//! Error codes and texts follow nsqd 1.3's `protocol_v2.go` so a client that matches on them
25//! sees what it would see from nsqd. Every size a peer declares is judged against its bound
26//! from the size field alone, before anything is allocated for the body.
27//! FIN, REQ and TOUCH require the [16-byte hexadecimal message ID from the protocol
28//! specification](https://nsq.io/clients/tcp_protocol_spec.html#fin). This is stricter than
29//! nsqd 1.3's length-only check: control bytes cannot become command delimiters on re-encoding.
30
31use serde_json::{json, Value};
32
33/// The protocol magic a V2 client sends first.
34pub const MAGIC_V2: &[u8; 4] = b"  V2";
35
36pub const FRAME_RESPONSE: u32 = 0;
37pub const FRAME_ERROR: u32 = 1;
38pub const FRAME_MESSAGE: u32 = 2;
39
40/// The longest command line, newline included. nsqd reads commands through a 16 KiB buffer;
41/// no command is longer than `REQ <16-byte id> <timeout>` or `SUB <64> <64>`, so 1 KiB is ample
42/// and a line past it is not a command.
43pub const MAX_LINE: usize = 1024;
44
45/// nsqd's `--max-msg-size` default: the largest single message body.
46pub const MAX_MSG_SIZE: usize = 1024 * 1024;
47
48/// nsqd's `--max-body-size` default: the largest IDENTIFY, MPUB or AUTH body.
49pub const MAX_BODY_SIZE: usize = 5 * 1024 * 1024;
50
51/// The most messages one MPUB may declare: nsqd's own bound, `(max_body_size - 4) / 5` (a
52/// four-byte count, then at least a four-byte size and one byte per message).
53pub const MAX_MPUB_MESSAGES: usize = (MAX_BODY_SIZE - 4) / 5;
54
55/// nsqd's `--max-rdy-count` default.
56pub const MAX_RDY_COUNT: u64 = 2500;
57
58/// nsqd's `--max-req-timeout` default, in milliseconds.
59pub const MAX_REQ_TIMEOUT_MS: i64 = 60 * 60 * 1000;
60
61/// nsqd's `--msg-timeout` and `--max-msg-timeout` defaults, in milliseconds.
62pub const MSG_TIMEOUT_MS: i64 = 60_000;
63pub const MAX_MSG_TIMEOUT_MS: i64 = 15 * 60 * 1000;
64
65/// nsqd's `--max-heartbeat-interval` default, in milliseconds, and the least a client may ask.
66pub const MAX_HEARTBEAT_MS: i64 = 60_000;
67pub const MIN_HEARTBEAT_MS: i64 = 1000;
68
69/// Message ids are 16 bytes on the wire.
70pub const MSG_ID_LEN: usize = 16;
71
72/// The longest topic or channel name, `#ephemeral` included.
73pub const MAX_NAME_LEN: usize = 64;
74
75/// The response frame nsqd sends as a heartbeat.
76pub const HEARTBEAT: &[u8] = b"_heartbeat_";
77
78/// Every error code nsqd sends. A code outside this list is refused by the executor.
79pub const ERROR_CODES: &[&str] = &[
80    "E_INVALID",
81    "E_BAD_BODY",
82    "E_BAD_TOPIC",
83    "E_BAD_CHANNEL",
84    "E_BAD_MESSAGE",
85    "E_PUB_FAILED",
86    "E_MPUB_FAILED",
87    "E_DPUB_FAILED",
88    "E_FIN_FAILED",
89    "E_REQ_FAILED",
90    "E_TOUCH_FAILED",
91    "E_AUTH_FAILED",
92    "E_UNAUTHORIZED",
93    "E_AUTH_DISABLED",
94    "E_TOO_MANY_CHANNEL_CONSUMERS",
95];
96
97/// Whether nsqd closes the connection after sending this error. Only the three per-message
98/// failures are recoverable (`ClientErr` in nsqd); every other code is a `FatalClientErr`.
99pub fn is_fatal(code: &str) -> bool {
100    !matches!(code, "E_FIN_FAILED" | "E_REQ_FAILED" | "E_TOUCH_FAILED")
101}
102
103/// A command the client sent.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum Command {
106    Identify(Vec<u8>),
107    Sub {
108        topic: String,
109        channel: String,
110    },
111    Rdy(u64),
112    Fin(String),
113    Req {
114        id: String,
115        timeout_ms: i64,
116    },
117    Touch(String),
118    Pub {
119        topic: String,
120        body: Vec<u8>,
121    },
122    Mpub {
123        topic: String,
124        messages: Vec<Vec<u8>>,
125    },
126    Dpub {
127        topic: String,
128        defer_ms: i64,
129        body: Vec<u8>,
130    },
131    Nop,
132    Cls,
133    Auth(Vec<u8>),
134}
135
136impl Command {
137    /// The command's name as it appears on the wire.
138    pub fn name(&self) -> &'static str {
139        match self {
140            Command::Identify(_) => "IDENTIFY",
141            Command::Sub { .. } => "SUB",
142            Command::Rdy(_) => "RDY",
143            Command::Fin(_) => "FIN",
144            Command::Req { .. } => "REQ",
145            Command::Touch(_) => "TOUCH",
146            Command::Pub { .. } => "PUB",
147            Command::Mpub { .. } => "MPUB",
148            Command::Dpub { .. } => "DPUB",
149            Command::Nop => "NOP",
150            Command::Cls => "CLS",
151            Command::Auth(_) => "AUTH",
152        }
153    }
154}
155
156/// A refusal in nsqd's own vocabulary: the error frame's code and description, and whether the
157/// connection closes after it.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct WireError {
160    pub code: &'static str,
161    pub message: String,
162    pub fatal: bool,
163}
164
165impl WireError {
166    fn fatal(code: &'static str, message: impl Into<String>) -> Self {
167        Self {
168            code,
169            message: message.into(),
170            fatal: true,
171        }
172    }
173
174    /// Which bound or rule refused the command, for the `decision=` tag.
175    pub fn decision(&self) -> &'static str {
176        if self.message.contains("too big") || self.message.contains("too long") {
177            "fail_closed_too_large"
178        } else if self.message.contains("message count") {
179            "fail_closed_mpub_count"
180        } else {
181            "fail_closed_bad_command"
182        }
183    }
184
185    pub fn to_frame(&self) -> Vec<u8> {
186        error_frame(self.code, &self.message)
187    }
188}
189
190/// Whether `name` is a valid topic or channel name: `^[.a-zA-Z0-9_-]+(#ephemeral)?$`, 1 to 64
191/// characters in all.
192pub fn valid_name(name: &str) -> bool {
193    if name.is_empty() || name.len() > MAX_NAME_LEN {
194        return false;
195    }
196    let stem = name.strip_suffix("#ephemeral").unwrap_or(name);
197    !stem.is_empty()
198        && stem
199            .bytes()
200            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
201}
202
203/// Whether a name carries the `#ephemeral` suffix.
204pub fn is_ephemeral(name: &str) -> bool {
205    name.ends_with("#ephemeral")
206}
207
208/// Parse the next command from `buf`.
209///
210/// `Ok(None)` means more bytes are needed; `Ok(Some((command, used)))` a whole command of `used`
211/// bytes. A body command's declared size is judged as soon as its four bytes are present, so an
212/// over-size declaration is refused without waiting for (or allocating) the body. Parsing is a
213/// pure function of the buffer, so a caller may stop waiting for bytes at any point and resume.
214pub fn parse_command(buf: &[u8]) -> Result<Option<(Command, usize)>, WireError> {
215    let window = &buf[..buf.len().min(MAX_LINE)];
216    let Some(nl) = window.iter().position(|b| *b == b'\n') else {
217        if buf.len() >= MAX_LINE {
218            return Err(WireError::fatal(
219                "E_INVALID",
220                format!("command line too long (over {MAX_LINE} bytes)"),
221            ));
222        }
223        return Ok(None);
224    };
225    let mut line = &buf[..nl];
226    if line.last() == Some(&b'\r') {
227        line = &line[..line.len() - 1];
228    }
229    let line = String::from_utf8_lossy(line).into_owned();
230    let params: Vec<&str> = line.split(' ').collect();
231    let after_line = nl + 1;
232
233    let body = |label: &str, min: i64, max: usize| -> Result<Option<(Vec<u8>, usize)>, WireError> {
234        let rest = &buf[after_line..];
235        if rest.len() < 4 {
236            return Ok(None);
237        }
238        let size = i32::from_be_bytes(rest[..4].try_into().unwrap()) as i64;
239        let (code, what) = match label {
240            "PUB" | "DPUB" => ("E_BAD_MESSAGE", "message"),
241            _ => ("E_BAD_BODY", "body"),
242        };
243        if size < min {
244            let kind = if what == "message" {
245                "message body"
246            } else {
247                "body"
248            };
249            return Err(WireError::fatal(
250                code,
251                format!("{label} invalid {kind} size {size}"),
252            ));
253        }
254        if size as usize > max {
255            return Err(WireError::fatal(
256                code,
257                format!("{label} {what} too big {size} > {max}"),
258            ));
259        }
260        let size = size as usize;
261        if rest.len() < 4 + size {
262            return Ok(None);
263        }
264        Ok(Some((rest[4..4 + size].to_vec(), after_line + 4 + size)))
265    };
266
267    let topic_param = |label: &str| -> Result<String, WireError> {
268        let topic = params[1];
269        if !valid_name(topic) {
270            return Err(WireError::fatal(
271                "E_BAD_TOPIC",
272                format!("{label} topic name {topic:?} is not valid"),
273            ));
274        }
275        Ok(topic.to_string())
276    };
277
278    let command = match params[0] {
279        "IDENTIFY" => match body("IDENTIFY", 1, MAX_BODY_SIZE)? {
280            None => return Ok(None),
281            Some((body, used)) => return Ok(Some((Command::Identify(body), used))),
282        },
283        "AUTH" => match body("AUTH", 1, MAX_BODY_SIZE)? {
284            None => return Ok(None),
285            Some((body, used)) => return Ok(Some((Command::Auth(body), used))),
286        },
287        "PUB" => {
288            if params.len() < 2 {
289                return Err(WireError::fatal(
290                    "E_INVALID",
291                    "PUB insufficient number of parameters",
292                ));
293            }
294            let topic = topic_param("PUB")?;
295            match body("PUB", 1, MAX_MSG_SIZE)? {
296                None => return Ok(None),
297                Some((body, used)) => return Ok(Some((Command::Pub { topic, body }, used))),
298            }
299        }
300        "DPUB" => {
301            if params.len() < 3 {
302                return Err(WireError::fatal(
303                    "E_INVALID",
304                    "DPUB insufficient number of parameters",
305                ));
306            }
307            let topic = topic_param("DPUB")?;
308            let defer_ms: i64 = params[2].parse().map_err(|_| {
309                WireError::fatal(
310                    "E_INVALID",
311                    format!("DPUB could not parse timeout {}", params[2]),
312                )
313            })?;
314            if !(0..=MAX_REQ_TIMEOUT_MS).contains(&defer_ms) {
315                return Err(WireError::fatal(
316                    "E_INVALID",
317                    format!("DPUB timeout {defer_ms} out of range 0-{MAX_REQ_TIMEOUT_MS}"),
318                ));
319            }
320            match body("DPUB", 1, MAX_MSG_SIZE)? {
321                None => return Ok(None),
322                Some((body, used)) => {
323                    return Ok(Some((
324                        Command::Dpub {
325                            topic,
326                            defer_ms,
327                            body,
328                        },
329                        used,
330                    )))
331                }
332            }
333        }
334        "MPUB" => {
335            if params.len() < 2 {
336                return Err(WireError::fatal(
337                    "E_INVALID",
338                    "MPUB insufficient number of parameters",
339                ));
340            }
341            let topic = topic_param("MPUB")?;
342            // The message count is judged from its own four bytes, before the body arrives.
343            let rest = &buf[after_line..];
344            if rest.len() >= 8 {
345                let size = i32::from_be_bytes(rest[..4].try_into().unwrap()) as i64;
346                if (1..=MAX_BODY_SIZE as i64).contains(&size) {
347                    let count = i32::from_be_bytes(rest[4..8].try_into().unwrap()) as i64;
348                    check_mpub_count(count)?;
349                }
350            }
351            match body("MPUB", 1, MAX_BODY_SIZE)? {
352                None => return Ok(None),
353                Some((body, used)) => {
354                    let messages = split_mpub(&body)?;
355                    return Ok(Some((Command::Mpub { topic, messages }, used)));
356                }
357            }
358        }
359        "SUB" => {
360            if params.len() < 3 {
361                return Err(WireError::fatal(
362                    "E_INVALID",
363                    "SUB insufficient number of parameters",
364                ));
365            }
366            let topic = topic_param("SUB")?;
367            let channel = params[2];
368            if !valid_name(channel) {
369                return Err(WireError::fatal(
370                    "E_BAD_CHANNEL",
371                    format!("SUB channel name {channel:?} is not valid"),
372                ));
373            }
374            Command::Sub {
375                topic,
376                channel: channel.to_string(),
377            }
378        }
379        "RDY" => {
380            let count: i64 = match params.get(1) {
381                None => 1,
382                Some(p) => p.parse().map_err(|_| {
383                    WireError::fatal("E_INVALID", format!("RDY could not parse count {p}"))
384                })?,
385            };
386            if count < 0 || count as u64 > MAX_RDY_COUNT {
387                return Err(WireError::fatal(
388                    "E_INVALID",
389                    format!("RDY count {count} out of range 0-{MAX_RDY_COUNT}"),
390                ));
391            }
392            Command::Rdy(count as u64)
393        }
394        "FIN" => {
395            if params.len() < 2 {
396                return Err(WireError::fatal(
397                    "E_INVALID",
398                    "FIN insufficient number of params",
399                ));
400            }
401            Command::Fin(message_id(params[1])?)
402        }
403        "TOUCH" => {
404            if params.len() < 2 {
405                return Err(WireError::fatal(
406                    "E_INVALID",
407                    "TOUCH insufficient number of params",
408                ));
409            }
410            Command::Touch(message_id(params[1])?)
411        }
412        "REQ" => {
413            if params.len() < 3 {
414                return Err(WireError::fatal(
415                    "E_INVALID",
416                    "REQ insufficient number of params",
417                ));
418            }
419            let id = message_id(params[1])?;
420            let timeout_ms: i64 = params[2].parse().map_err(|_| {
421                WireError::fatal(
422                    "E_INVALID",
423                    format!("REQ could not parse timeout {}", params[2]),
424                )
425            })?;
426            // nsqd clamps an out-of-range timeout rather than refusing it.
427            Command::Req {
428                id,
429                timeout_ms: timeout_ms.clamp(0, MAX_REQ_TIMEOUT_MS),
430            }
431        }
432        "NOP" => Command::Nop,
433        "CLS" => Command::Cls,
434        other => {
435            return Err(WireError::fatal(
436                "E_INVALID",
437                format!(
438                    "invalid command {}",
439                    crate::utils::sanitize::line_field(other)
440                ),
441            ))
442        }
443    };
444    Ok(Some((command, after_line)))
445}
446
447fn check_mpub_count(count: i64) -> Result<(), WireError> {
448    if count <= 0 || count as usize > MAX_MPUB_MESSAGES {
449        return Err(WireError::fatal(
450            "E_BAD_BODY",
451            format!("MPUB invalid message count {count}"),
452        ));
453    }
454    Ok(())
455}
456
457fn message_id(param: &str) -> Result<String, WireError> {
458    // IDs are 16 ASCII hex bytes in the NSQ wire specification. Length alone
459    // admitted control bytes: an ID ending in CR, followed by an ignored extra
460    // parameter, changed meaning when re-encoding put the CR immediately before LF.
461    if param.len() != MSG_ID_LEN || !param.bytes().all(|byte| byte.is_ascii_hexdigit()) {
462        return Err(WireError::fatal("E_INVALID", "Invalid Message ID"));
463    }
464    Ok(param.to_string())
465}
466
467/// Split an MPUB body: a four-byte count, then each message as a four-byte size and its bytes.
468pub fn split_mpub(body: &[u8]) -> Result<Vec<Vec<u8>>, WireError> {
469    if body.len() < 4 {
470        return Err(WireError::fatal(
471            "E_BAD_BODY",
472            "MPUB failed to read message count",
473        ));
474    }
475    let count = i32::from_be_bytes(body[..4].try_into().unwrap()) as i64;
476    check_mpub_count(count)?;
477    let mut at = 4usize;
478    // Bounded by check_mpub_count, and every message costs at least five bytes of a body that
479    // is itself bounded.
480    let mut messages = Vec::with_capacity((count as usize).min(body.len() / 5));
481    for _ in 0..count {
482        if body.len() < at + 4 {
483            return Err(WireError::fatal(
484                "E_BAD_MESSAGE",
485                "MPUB failed to read message body size",
486            ));
487        }
488        let size = i32::from_be_bytes(body[at..at + 4].try_into().unwrap()) as i64;
489        at += 4;
490        if size <= 0 {
491            return Err(WireError::fatal(
492                "E_BAD_MESSAGE",
493                format!("MPUB invalid message({}) body size {size}", messages.len()),
494            ));
495        }
496        if size as usize > MAX_MSG_SIZE {
497            return Err(WireError::fatal(
498                "E_BAD_MESSAGE",
499                format!("MPUB message too big {size} > {MAX_MSG_SIZE}"),
500            ));
501        }
502        let size = size as usize;
503        if body.len() < at + size {
504            return Err(WireError::fatal(
505                "E_BAD_MESSAGE",
506                "MPUB failed to read message body",
507            ));
508        }
509        messages.push(body[at..at + size].to_vec());
510        at += size;
511    }
512    Ok(messages)
513}
514
515/// Encode a command as a client writes it. Used by the tests, the fuzz target and the proptest
516/// round trip; the server never sends one.
517pub fn encode_command(command: &Command) -> Vec<u8> {
518    let with_body = |line: String, body: &[u8]| {
519        let mut out = line.into_bytes();
520        out.extend_from_slice(&(body.len() as u32).to_be_bytes());
521        out.extend_from_slice(body);
522        out
523    };
524    match command {
525        Command::Identify(body) => with_body("IDENTIFY\n".to_string(), body),
526        Command::Auth(body) => with_body("AUTH\n".to_string(), body),
527        Command::Pub { topic, body } => with_body(format!("PUB {topic}\n"), body),
528        Command::Dpub {
529            topic,
530            defer_ms,
531            body,
532        } => with_body(format!("DPUB {topic} {defer_ms}\n"), body),
533        Command::Mpub { topic, messages } => {
534            let mut body = (messages.len() as u32).to_be_bytes().to_vec();
535            for m in messages {
536                body.extend_from_slice(&(m.len() as u32).to_be_bytes());
537                body.extend_from_slice(m);
538            }
539            with_body(format!("MPUB {topic}\n"), &body)
540        }
541        Command::Sub { topic, channel } => format!("SUB {topic} {channel}\n").into_bytes(),
542        Command::Rdy(n) => format!("RDY {n}\n").into_bytes(),
543        Command::Fin(id) => format!("FIN {id}\n").into_bytes(),
544        Command::Req { id, timeout_ms } => format!("REQ {id} {timeout_ms}\n").into_bytes(),
545        Command::Touch(id) => format!("TOUCH {id}\n").into_bytes(),
546        Command::Nop => b"NOP\n".to_vec(),
547        Command::Cls => b"CLS\n".to_vec(),
548    }
549}
550
551/// A frame: its type and data.
552pub fn encode_frame(frame_type: u32, data: &[u8]) -> Vec<u8> {
553    let mut out = Vec::with_capacity(8 + data.len());
554    out.extend_from_slice(&((data.len() + 4) as u32).to_be_bytes());
555    out.extend_from_slice(&frame_type.to_be_bytes());
556    out.extend_from_slice(data);
557    out
558}
559
560pub fn response_frame(data: &[u8]) -> Vec<u8> {
561    encode_frame(FRAME_RESPONSE, data)
562}
563
564/// `E_CODE description`, the description reduced to one line of printable text.
565pub fn error_frame(code: &str, message: &str) -> Vec<u8> {
566    let message = crate::utils::sanitize::line_field(message);
567    let text = if message.trim().is_empty() {
568        code.to_string()
569    } else {
570        format!("{code} {}", message.trim())
571    };
572    encode_frame(FRAME_ERROR, text.as_bytes())
573}
574
575/// A message frame: timestamp (ns since the epoch), attempts, the 16-byte id, the body.
576pub fn message_frame(
577    timestamp_ns: i64,
578    attempts: u16,
579    id: &[u8; MSG_ID_LEN],
580    body: &[u8],
581) -> Vec<u8> {
582    let mut data = Vec::with_capacity(8 + 2 + MSG_ID_LEN + body.len());
583    data.extend_from_slice(&timestamp_ns.to_be_bytes());
584    data.extend_from_slice(&attempts.to_be_bytes());
585    data.extend_from_slice(id);
586    data.extend_from_slice(body);
587    encode_frame(FRAME_MESSAGE, &data)
588}
589
590/// A message id NetGet generates: 16 lower-case hex digits, as nsqd's GUIDs are.
591pub fn message_id_for(n: u64) -> [u8; MSG_ID_LEN] {
592    let mut id = [0u8; MSG_ID_LEN];
593    id.copy_from_slice(format!("{n:016x}").as_bytes());
594    id
595}
596
597/// A frame read back: `(frame type, data)`.
598#[derive(Debug, Clone, PartialEq, Eq)]
599pub struct Frame {
600    pub frame_type: u32,
601    pub data: Vec<u8>,
602}
603
604/// A message frame's data, read back.
605#[derive(Debug, Clone, PartialEq, Eq)]
606pub struct Message {
607    pub timestamp_ns: i64,
608    pub attempts: u16,
609    pub id: [u8; MSG_ID_LEN],
610    pub body: Vec<u8>,
611}
612
613/// The largest frame [`parse_frame`] accepts: a message frame carrying the largest body.
614pub const MAX_FRAME_DATA: usize = 8 + 2 + MSG_ID_LEN + MAX_MSG_SIZE;
615
616/// Read one frame from `buf`: `Ok(None)` for more bytes, `Err` for a size no frame this server
617/// writes could have. Used by the tests and the fuzz target to read the server's side.
618pub fn parse_frame(buf: &[u8]) -> Result<Option<(Frame, usize)>, &'static str> {
619    if buf.len() < 4 {
620        return Ok(None);
621    }
622    let size = u32::from_be_bytes(buf[..4].try_into().unwrap()) as usize;
623    if size < 4 {
624        return Err("frame size below the four-byte frame type");
625    }
626    if size - 4 > MAX_FRAME_DATA {
627        return Err("frame size past the largest message frame");
628    }
629    if buf.len() < 4 + size {
630        return Ok(None);
631    }
632    let frame_type = u32::from_be_bytes(buf[4..8].try_into().unwrap());
633    Ok(Some((
634        Frame {
635            frame_type,
636            data: buf[8..4 + size].to_vec(),
637        },
638        4 + size,
639    )))
640}
641
642/// Split a message frame's data.
643pub fn parse_message(data: &[u8]) -> Option<Message> {
644    if data.len() < 8 + 2 + MSG_ID_LEN {
645        return None;
646    }
647    let mut id = [0u8; MSG_ID_LEN];
648    id.copy_from_slice(&data[10..10 + MSG_ID_LEN]);
649    Some(Message {
650        timestamp_ns: i64::from_be_bytes(data[..8].try_into().unwrap()),
651        attempts: u16::from_be_bytes(data[8..10].try_into().unwrap()),
652        id,
653        body: data[10 + MSG_ID_LEN..].to_vec(),
654    })
655}
656
657/// What an IDENTIFY body asked for, as far as this server honours it.
658#[derive(Debug, Clone, PartialEq)]
659pub struct Identify {
660    pub feature_negotiation: bool,
661    /// `None`: the client disabled heartbeats (`-1`). `Some(0)`: the server's default.
662    pub heartbeat_ms: Option<i64>,
663    pub msg_timeout_ms: i64,
664    pub client_id: String,
665    pub hostname: String,
666    pub user_agent: String,
667}
668
669/// Read an IDENTIFY body, refusing what nsqd refuses.
670pub fn parse_identify(body: &[u8]) -> Result<Identify, WireError> {
671    let value: Value = serde_json::from_slice(body)
672        .map_err(|_| WireError::fatal("E_BAD_BODY", "IDENTIFY failed to decode JSON body"))?;
673    if !value.is_object() {
674        return Err(WireError::fatal(
675            "E_BAD_BODY",
676            "IDENTIFY failed to decode JSON body",
677        ));
678    }
679    let text = |k: &str| {
680        value
681            .get(k)
682            .and_then(Value::as_str)
683            .map(|s| crate::utils::truncate_for_log(&crate::utils::sanitize::line_field(s), 128))
684            .unwrap_or_default()
685    };
686    let heartbeat = value
687        .get("heartbeat_interval")
688        .and_then(Value::as_i64)
689        .unwrap_or(0);
690    let heartbeat_ms = match heartbeat {
691        -1 => None,
692        0 => Some(0),
693        n if (MIN_HEARTBEAT_MS..=MAX_HEARTBEAT_MS).contains(&n) => Some(n),
694        n => {
695            return Err(WireError::fatal(
696                "E_BAD_BODY",
697                format!("IDENTIFY heartbeat interval ({n}) is invalid"),
698            ))
699        }
700    };
701    let msg_timeout = value
702        .get("msg_timeout")
703        .and_then(Value::as_i64)
704        .unwrap_or(0);
705    let msg_timeout_ms = match msg_timeout {
706        0 => MSG_TIMEOUT_MS,
707        n if (1000..=MAX_MSG_TIMEOUT_MS).contains(&n) => n,
708        n => {
709            return Err(WireError::fatal(
710                "E_BAD_BODY",
711                format!("IDENTIFY msg timeout ({n}) is invalid"),
712            ))
713        }
714    };
715    Ok(Identify {
716        feature_negotiation: value
717            .get("feature_negotiation")
718            .and_then(Value::as_bool)
719            .unwrap_or(false),
720        heartbeat_ms,
721        msg_timeout_ms,
722        client_id: text("client_id"),
723        hostname: text("hostname"),
724        user_agent: text("user_agent"),
725    })
726}
727
728/// The feature-negotiation reply nsqd sends to an IDENTIFY that asked for one. TLS, deflate,
729/// snappy and auth are off, so a client never upgrades the stream.
730pub fn identify_response(msg_timeout_ms: i64) -> Vec<u8> {
731    json!({
732        "max_rdy_count": MAX_RDY_COUNT,
733        "version": concat!("netget-", env!("CARGO_PKG_VERSION")),
734        "max_msg_timeout": MAX_MSG_TIMEOUT_MS,
735        "msg_timeout": msg_timeout_ms,
736        "tls_v1": false,
737        "deflate": false,
738        "deflate_level": 6,
739        "max_deflate_level": 6,
740        "snappy": false,
741        "sample_rate": 0,
742        "auth_required": false,
743        "output_buffer_size": 16384,
744        "output_buffer_timeout": 250
745    })
746    .to_string()
747    .into_bytes()
748}