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}