1const ARTICLE_CASES: &[&[u8]] = &[b"ARTICLE", b"article", b"Article"];
6const BODY_CASES: &[&[u8]] = &[b"BODY", b"body", b"Body"];
7const HEAD_CASES: &[&[u8]] = &[b"HEAD", b"head", b"Head"];
8const STAT_CASES: &[&[u8]] = &[b"STAT", b"stat", b"Stat"];
9const GROUP_CASES: &[&[u8]] = &[b"GROUP", b"group", b"Group"];
10const AUTHINFO_CASES: &[&[u8]] = &[b"AUTHINFO", b"authinfo", b"Authinfo"];
11const LIST_CASES: &[&[u8]] = &[b"LIST", b"list", b"List"];
12const DATE_CASES: &[&[u8]] = &[b"DATE", b"date", b"Date"];
13const CAPABILITIES_CASES: &[&[u8]] = &[b"CAPABILITIES", b"capabilities", b"Capabilities"];
14const MODE_CASES: &[&[u8]] = &[b"MODE", b"mode", b"Mode"];
15const HELP_CASES: &[&[u8]] = &[b"HELP", b"help", b"Help"];
16const QUIT_CASES: &[&[u8]] = &[b"QUIT", b"quit", b"Quit"];
17const XOVER_CASES: &[&[u8]] = &[b"XOVER", b"xover", b"Xover"];
18const OVER_CASES: &[&[u8]] = &[b"OVER", b"over", b"Over"];
19const XHDR_CASES: &[&[u8]] = &[b"XHDR", b"xhdr", b"Xhdr"];
20const HDR_CASES: &[&[u8]] = &[b"HDR", b"hdr", b"Hdr"];
21const NEXT_CASES: &[&[u8]] = &[b"NEXT", b"next", b"Next"];
22const LAST_CASES: &[&[u8]] = &[b"LAST", b"last", b"Last"];
23const LISTGROUP_CASES: &[&[u8]] = &[b"LISTGROUP", b"listgroup", b"Listgroup"];
24const POST_CASES: &[&[u8]] = &[b"POST", b"post", b"Post"];
25const IHAVE_CASES: &[&[u8]] = &[b"IHAVE", b"ihave", b"Ihave"];
26const NEWGROUPS_CASES: &[&[u8]] = &[b"NEWGROUPS", b"newgroups", b"Newgroups"];
27const NEWNEWS_CASES: &[&[u8]] = &[b"NEWNEWS", b"newnews", b"Newnews"];
28
29#[inline]
31fn matches_any(cmd: &[u8], cases: &[&[u8]]) -> bool {
32    cases.contains(&cmd)
33}
34
35#[derive(Debug, PartialEq)]
37pub enum NntpCommand {
38    AuthUser,
40    AuthPass,
41    Stateful,
43    NonRoutable,
45    Stateless,
47    ArticleByMessageId,
49}
50
51impl NntpCommand {
52    #[inline]
58    pub fn classify(command: &str) -> Self {
59        let trimmed = command.trim();
60        let bytes = trimmed.as_bytes();
61
62        let cmd_end = memchr::memchr(b' ', bytes).unwrap_or(bytes.len());
64        let cmd = &bytes[..cmd_end];
65
66        #[inline]
68        fn is_message_id_arg(bytes: &[u8], cmd_end: usize) -> bool {
69            if cmd_end >= bytes.len() {
70                return false;
71            }
72            let args = &bytes[cmd_end + 1..];
73            let first_non_ws = args.iter().position(|&b| !b.is_ascii_whitespace());
75
76            if let Some(pos) = first_non_ws {
77                args[pos] == b'<'
78            } else {
79                false
80            }
81        }
82
83        if matches_any(cmd, ARTICLE_CASES)
86            || matches_any(cmd, BODY_CASES)
87            || matches_any(cmd, HEAD_CASES)
88            || matches_any(cmd, STAT_CASES)
89        {
90            return if is_message_id_arg(bytes, cmd_end) {
91                Self::ArticleByMessageId
92            } else {
93                Self::Stateful
94            };
95        }
96
97        if matches_any(cmd, GROUP_CASES) {
99            return Self::Stateful;
100        }
101
102        if matches_any(cmd, AUTHINFO_CASES) {
104            if cmd_end + 1 < bytes.len() {
105                let args = &bytes[cmd_end + 1..];
106                if args.len() >= 4 {
107                    match &args[..4] {
108                        b"USER" | b"user" | b"User" => return Self::AuthUser,
109                        b"PASS" | b"pass" | b"Pass" => return Self::AuthPass,
110                        _ => {}
111                    }
112                }
113            }
114            return Self::Stateless;
115        }
116
117        if matches_any(cmd, LIST_CASES)
119            || matches_any(cmd, DATE_CASES)
120            || matches_any(cmd, CAPABILITIES_CASES)
121            || matches_any(cmd, MODE_CASES)
122            || matches_any(cmd, HELP_CASES)
123            || matches_any(cmd, QUIT_CASES)
124        {
125            return Self::Stateless;
126        }
127
128        if matches_any(cmd, XOVER_CASES)
130            || matches_any(cmd, OVER_CASES)
131            || matches_any(cmd, XHDR_CASES)
132            || matches_any(cmd, HDR_CASES)
133        {
134            return Self::Stateful;
135        }
136
137        if matches_any(cmd, NEXT_CASES)
139            || matches_any(cmd, LAST_CASES)
140            || matches_any(cmd, LISTGROUP_CASES)
141        {
142            return Self::Stateful;
143        }
144
145        if matches_any(cmd, POST_CASES)
147            || matches_any(cmd, IHAVE_CASES)
148            || matches_any(cmd, NEWGROUPS_CASES)
149            || matches_any(cmd, NEWNEWS_CASES)
150        {
151            return Self::NonRoutable;
152        }
153
154        Self::Stateless
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_nntp_command_classification() {
165        assert_eq!(
167            NntpCommand::classify("AUTHINFO USER testuser"),
168            NntpCommand::AuthUser
169        );
170        assert_eq!(
171            NntpCommand::classify("AUTHINFO PASS testpass"),
172            NntpCommand::AuthPass
173        );
174        assert_eq!(
175            NntpCommand::classify("  AUTHINFO USER  whitespace  "),
176            NntpCommand::AuthUser
177        );
178
179        assert_eq!(
181            NntpCommand::classify("GROUP alt.test"),
182            NntpCommand::Stateful
183        );
184        assert_eq!(NntpCommand::classify("NEXT"), NntpCommand::Stateful);
185        assert_eq!(NntpCommand::classify("LAST"), NntpCommand::Stateful);
186        assert_eq!(
187            NntpCommand::classify("LISTGROUP alt.test"),
188            NntpCommand::Stateful
189        );
190        assert_eq!(
191            NntpCommand::classify("ARTICLE 12345"),
192            NntpCommand::Stateful
193        );
194        assert_eq!(NntpCommand::classify("ARTICLE"), NntpCommand::Stateful);
195        assert_eq!(NntpCommand::classify("HEAD 67890"), NntpCommand::Stateful);
196        assert_eq!(NntpCommand::classify("STAT"), NntpCommand::Stateful);
197        assert_eq!(NntpCommand::classify("XOVER 1-100"), NntpCommand::Stateful);
198
199        assert_eq!(
201            NntpCommand::classify("ARTICLE <message@example.com>"),
202            NntpCommand::ArticleByMessageId
203        );
204        assert_eq!(
205            NntpCommand::classify("BODY <test@server.org>"),
206            NntpCommand::ArticleByMessageId
207        );
208        assert_eq!(
209            NntpCommand::classify("HEAD <another@example.net>"),
210            NntpCommand::ArticleByMessageId
211        );
212        assert_eq!(
213            NntpCommand::classify("STAT <id@host.com>"),
214            NntpCommand::ArticleByMessageId
215        );
216
217        assert_eq!(NntpCommand::classify("HELP"), NntpCommand::Stateless);
219        assert_eq!(NntpCommand::classify("LIST"), NntpCommand::Stateless);
220        assert_eq!(NntpCommand::classify("DATE"), NntpCommand::Stateless);
221        assert_eq!(
222            NntpCommand::classify("CAPABILITIES"),
223            NntpCommand::Stateless
224        );
225        assert_eq!(NntpCommand::classify("QUIT"), NntpCommand::Stateless);
226        assert_eq!(NntpCommand::classify("LIST ACTIVE"), NntpCommand::Stateless);
227        assert_eq!(
228            NntpCommand::classify("UNKNOWN COMMAND"),
229            NntpCommand::Stateless
230        );
231    }
232
233    #[test]
234    fn test_case_insensitivity() {
235        assert_eq!(NntpCommand::classify("list"), NntpCommand::Stateless);
237        assert_eq!(NntpCommand::classify("LiSt"), NntpCommand::Stateless);
238        assert_eq!(NntpCommand::classify("QUIT"), NntpCommand::Stateless);
239        assert_eq!(NntpCommand::classify("quit"), NntpCommand::Stateless);
240        assert_eq!(
241            NntpCommand::classify("group alt.test"),
242            NntpCommand::Stateful
243        );
244        assert_eq!(
245            NntpCommand::classify("GROUP alt.test"),
246            NntpCommand::Stateful
247        );
248    }
249
250    #[test]
251    fn test_empty_and_whitespace_commands() {
252        assert_eq!(NntpCommand::classify(""), NntpCommand::Stateless);
254
255        assert_eq!(NntpCommand::classify("   "), NntpCommand::Stateless);
257
258        assert_eq!(NntpCommand::classify("\t\t  "), NntpCommand::Stateless);
260    }
261
262    #[test]
263    fn test_malformed_authinfo_commands() {
264        assert_eq!(NntpCommand::classify("AUTHINFO"), NntpCommand::Stateless);
266
267        assert_eq!(
269            NntpCommand::classify("AUTHINFO INVALID"),
270            NntpCommand::Stateless
271        );
272
273        assert_eq!(
275            NntpCommand::classify("AUTHINFO USER"),
276            NntpCommand::AuthUser
277        );
278
279        assert_eq!(
281            NntpCommand::classify("AUTHINFO PASS"),
282            NntpCommand::AuthPass
283        );
284    }
285
286    #[test]
287    fn test_article_commands_with_various_message_ids() {
288        assert_eq!(
290            NntpCommand::classify("ARTICLE <test@example.com>"),
291            NntpCommand::ArticleByMessageId
292        );
293
294        assert_eq!(
296            NntpCommand::classify("ARTICLE <msg.123@news.example.co.uk>"),
297            NntpCommand::ArticleByMessageId
298        );
299
300        assert_eq!(
302            NntpCommand::classify("ARTICLE <user+tag@domain.com>"),
303            NntpCommand::ArticleByMessageId
304        );
305
306        assert_eq!(
308            NntpCommand::classify("BODY <test@test.com>"),
309            NntpCommand::ArticleByMessageId
310        );
311
312        assert_eq!(
314            NntpCommand::classify("HEAD <id@host>"),
315            NntpCommand::ArticleByMessageId
316        );
317
318        assert_eq!(
320            NntpCommand::classify("STAT <msg@server>"),
321            NntpCommand::ArticleByMessageId
322        );
323    }
324
325    #[test]
326    fn test_article_commands_without_message_id() {
327        assert_eq!(
329            NntpCommand::classify("ARTICLE 12345"),
330            NntpCommand::Stateful
331        );
332
333        assert_eq!(NntpCommand::classify("ARTICLE"), NntpCommand::Stateful);
335
336        assert_eq!(NntpCommand::classify("BODY 999"), NntpCommand::Stateful);
338
339        assert_eq!(NntpCommand::classify("HEAD 123"), NntpCommand::Stateful);
341    }
342
343    #[test]
344    fn test_special_characters_in_commands() {
345        assert_eq!(NntpCommand::classify("LIST\r\n"), NntpCommand::Stateless);
347
348        assert_eq!(
350            NntpCommand::classify("  LIST   ACTIVE  "),
351            NntpCommand::Stateless
352        );
353
354        assert_eq!(
356            NntpCommand::classify("LIST\tACTIVE"),
357            NntpCommand::Stateless
358        );
359    }
360
361    #[test]
362    fn test_very_long_commands() {
363        let long_command = format!("LIST {}", "A".repeat(1000));
365        assert_eq!(NntpCommand::classify(&long_command), NntpCommand::Stateless);
366
367        let long_group = format!("GROUP {}", "alt.".repeat(100));
369        assert_eq!(NntpCommand::classify(&long_group), NntpCommand::Stateful);
370
371        let long_msgid = format!("ARTICLE <{}@example.com>", "x".repeat(500));
373        assert_eq!(
374            NntpCommand::classify(&long_msgid),
375            NntpCommand::ArticleByMessageId
376        );
377    }
378
379    #[test]
380    fn test_list_command_variations() {
381        assert_eq!(NntpCommand::classify("LIST"), NntpCommand::Stateless);
383
384        assert_eq!(NntpCommand::classify("LIST ACTIVE"), NntpCommand::Stateless);
386
387        assert_eq!(
389            NntpCommand::classify("LIST NEWSGROUPS"),
390            NntpCommand::Stateless
391        );
392
393        assert_eq!(
395            NntpCommand::classify("LIST OVERVIEW.FMT"),
396            NntpCommand::Stateless
397        );
398    }
399
400    #[test]
401    fn test_boundary_conditions() {
402        assert_eq!(NntpCommand::classify("X"), NntpCommand::Stateless);
404
405        assert_eq!(
407            NntpCommand::classify("NOTARTICLE <test@example.com>"),
408            NntpCommand::Stateless
409        );
410
411        assert_eq!(
413            NntpCommand::classify("ARTICLE test@example.com"),
414            NntpCommand::Stateful
415        );
416    }
417
418    #[test]
419    fn test_non_routable_commands() {
420        assert_eq!(NntpCommand::classify("POST"), NntpCommand::NonRoutable);
422
423        assert_eq!(
425            NntpCommand::classify("IHAVE <test@example.com>"),
426            NntpCommand::NonRoutable
427        );
428
429        assert_eq!(
431            NntpCommand::classify("NEWGROUPS 20240101 000000 GMT"),
432            NntpCommand::NonRoutable
433        );
434
435        assert_eq!(
437            NntpCommand::classify("NEWNEWS * 20240101 000000 GMT"),
438            NntpCommand::NonRoutable
439        );
440    }
441
442    #[test]
443    fn test_non_routable_case_insensitive() {
444        assert_eq!(NntpCommand::classify("post"), NntpCommand::NonRoutable);
445
446        assert_eq!(NntpCommand::classify("Post"), NntpCommand::NonRoutable);
447
448        assert_eq!(
449            NntpCommand::classify("IHAVE <msg>"),
450            NntpCommand::NonRoutable
451        );
452
453        assert_eq!(
454            NntpCommand::classify("ihave <msg>"),
455            NntpCommand::NonRoutable
456        );
457    }
458}