1use crate::protocol::{
21 RequestContext, RequestKind, RequestResponseMetadata, RequestRouteClass, StatusCode, codes,
22};
23
24#[derive(Debug, Clone, Copy, PartialEq)]
26#[non_exhaustive]
27pub enum CommandAction<'a> {
28 InterceptAuth(AuthAction<'a>),
30 Reject(RejectResponse),
32 ForwardStateless,
34 InterceptCapabilities,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct RejectResponse {
41 status: u16,
42 wire: &'static str,
43}
44
45impl RejectResponse {
46 #[must_use]
47 pub const fn new(status: u16, wire: &'static str) -> Self {
48 Self { status, wire }
49 }
50
51 #[must_use]
52 pub fn status(self) -> StatusCode {
53 StatusCode::new(self.status)
54 }
55
56 #[must_use]
57 pub(crate) fn metadata(self) -> RequestResponseMetadata {
58 RequestResponseMetadata::new(self.status(), self.len().into())
59 }
60
61 #[must_use]
62 pub const fn as_str(self) -> &'static str {
63 self.wire
64 }
65
66 #[must_use]
67 pub const fn as_bytes(self) -> &'static [u8] {
68 self.wire.as_bytes()
69 }
70
71 #[must_use]
72 pub const fn len(self) -> usize {
73 self.wire.len()
74 }
75
76 #[must_use]
77 pub const fn is_empty(self) -> bool {
78 self.wire.is_empty()
79 }
80}
81
82impl std::ops::Deref for RejectResponse {
83 type Target = str;
84
85 fn deref(&self) -> &Self::Target {
86 self.wire
87 }
88}
89
90impl std::fmt::Display for RejectResponse {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 self.wire.fmt(f)
93 }
94}
95
96const POST_REJECT: RejectResponse = RejectResponse::new(440, "440 Posting not permitted\r\n");
97const TRANSIT_REJECT: RejectResponse = RejectResponse::new(
98 codes::FEATURE_NOT_SUPPORTED,
99 "503 Feature not supported in per-command routing mode\r\n",
100);
101const STATEFUL_REJECT: RejectResponse = RejectResponse::new(
102 codes::FEATURE_NOT_SUPPORTED,
103 "503 Feature not supported in stateless proxy mode\r\n",
104);
105
106#[derive(Debug, Clone, Copy, PartialEq)]
108#[non_exhaustive]
109pub enum AuthAction<'a> {
110 RequestPassword(&'a str),
112 ValidateAndRespond { password: &'a str },
114 UnknownSubcommand,
116}
117
118pub struct CommandHandler;
120
121impl CommandHandler {
122 #[must_use]
124 pub fn classify_request(request: &RequestContext) -> CommandAction<'_> {
125 match request.kind() {
126 RequestKind::AuthInfo => strip_authinfo_arg(request.args(), b"USER").map_or_else(
127 || {
128 strip_authinfo_arg(request.args(), b"PASS").map_or(
129 CommandAction::InterceptAuth(AuthAction::UnknownSubcommand),
130 |password| {
131 CommandAction::InterceptAuth(AuthAction::ValidateAndRespond {
132 password,
133 })
134 },
135 )
136 },
137 |username| CommandAction::InterceptAuth(AuthAction::RequestPassword(username)),
138 ),
139 RequestKind::Capabilities => CommandAction::InterceptCapabilities,
140 RequestKind::Post => CommandAction::Reject(POST_REJECT),
141 RequestKind::Ihave => CommandAction::Reject(TRANSIT_REJECT),
142 _ => match request.route_class() {
143 RequestRouteClass::ArticleByMessageId | RequestRouteClass::Stateless => {
144 CommandAction::ForwardStateless
145 }
146 RequestRouteClass::Stateful => CommandAction::Reject(STATEFUL_REJECT),
147 RequestRouteClass::Reject => CommandAction::Reject(TRANSIT_REJECT),
148 RequestRouteClass::Local => CommandAction::ForwardStateless,
149 },
150 }
151 }
152}
153
154fn strip_authinfo_arg<'a>(args: &'a [u8], subcommand: &[u8]) -> Option<&'a str> {
155 let args = trim_ascii(args);
156 let split = args
157 .iter()
158 .position(u8::is_ascii_whitespace)
159 .unwrap_or(args.len());
160 let head = &args[..split];
161 let tail = trim_ascii(args.get(split..).unwrap_or_default());
162
163 head.eq_ignore_ascii_case(subcommand)
164 .then(|| std::str::from_utf8(tail).ok())
165 .flatten()
166}
167
168fn trim_ascii(bytes: &[u8]) -> &[u8] {
169 let start = bytes
170 .iter()
171 .position(|byte| !byte.is_ascii_whitespace())
172 .unwrap_or(bytes.len());
173 let end = bytes
174 .iter()
175 .rposition(|byte| !byte.is_ascii_whitespace())
176 .map_or(start, |index| index + 1);
177 &bytes[start..end]
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 fn classify(command: &str) -> CommandAction<'static> {
185 RequestContext::parse(command.as_bytes())
186 .map_or(CommandAction::Reject(STATEFUL_REJECT), |request| {
187 CommandHandler::classify_request(Box::leak(Box::new(request)))
188 })
189 }
190
191 #[test]
192 fn test_auth_user_command() {
193 let action = classify("AUTHINFO USER test");
194 assert!(matches!(
195 action,
196 CommandAction::InterceptAuth(AuthAction::RequestPassword(username)) if username == "test"
197 ));
198 }
199
200 #[test]
201 fn test_auth_pass_command() {
202 let action = classify("AUTHINFO PASS secret");
203 assert!(matches!(
204 action,
205 CommandAction::InterceptAuth(AuthAction::ValidateAndRespond { password }) if password == "secret"
206 ));
207 }
208
209 #[test]
210 fn test_stateful_command_rejected() {
211 let action = classify("GROUP alt.test");
212 assert!(
213 matches!(action, CommandAction::Reject(msg) if msg.contains("stateless")),
214 "Expected Reject with 'stateless' in message"
215 );
216 }
217
218 #[test]
219 fn test_article_by_message_id() {
220 let action = classify("ARTICLE <test@example.com>");
221 assert_eq!(action, CommandAction::ForwardStateless);
222 }
223
224 #[test]
225 fn test_stateless_command() {
226 let action = classify("LIST");
227 assert_eq!(action, CommandAction::ForwardStateless);
228
229 let action = classify("HELP");
230 assert_eq!(action, CommandAction::ForwardStateless);
231 }
232
233 #[test]
234 fn test_all_stateful_commands_rejected() {
235 let stateful_commands = vec![
237 "GROUP alt.test",
238 "NEXT",
239 "LAST",
240 "LISTGROUP alt.test",
241 "ARTICLE 123",
242 "HEAD 456",
243 "BODY 789",
244 "STAT",
245 "XOVER 1-100",
246 ];
247
248 for cmd in stateful_commands {
249 match classify(cmd) {
250 CommandAction::Reject(msg) => {
251 assert!(msg.contains("stateless") || msg.contains("not supported"));
252 }
253 other => panic!("Expected Reject for '{cmd}', got {other:?}"),
254 }
255 }
256 }
257
258 #[test]
259 fn test_all_article_by_msgid_forwarded() {
260 let msgid_commands = vec![
262 "ARTICLE <test@example.com>",
263 "BODY <msg@server.org>",
264 "HEAD <id@host.net>",
265 "STAT <unique@domain.com>",
266 ];
267
268 for cmd in msgid_commands {
269 assert_eq!(
270 classify(cmd),
271 CommandAction::ForwardStateless,
272 "Command '{cmd}' should be forwarded as stateless"
273 );
274 }
275 }
276
277 #[test]
278 fn test_various_stateless_commands() {
279 let stateless_commands = vec![
280 "HELP",
281 "LIST",
282 "LIST ACTIVE",
283 "LIST NEWSGROUPS",
284 "DATE",
285 "QUIT",
286 ];
287
288 for cmd in stateless_commands {
289 assert_eq!(
290 classify(cmd),
291 CommandAction::ForwardStateless,
292 "Command '{cmd}' should be stateless"
293 );
294 }
295 }
296
297 #[test]
298 fn test_capabilities_intercepted_not_forwarded() {
299 assert_eq!(
302 classify("CAPABILITIES"),
303 CommandAction::InterceptCapabilities,
304 );
305 assert_eq!(
306 classify("capabilities"),
307 CommandAction::InterceptCapabilities,
308 );
309 assert_eq!(
310 classify("Capabilities"),
311 CommandAction::InterceptCapabilities,
312 );
313 }
314
315 #[test]
321 fn test_mixed_case_authinfo_extraction() {
322 let action = classify("Authinfo User testuser");
324 assert!(
325 matches!(
326 action,
327 CommandAction::InterceptAuth(AuthAction::RequestPassword(u)) if u == "testuser"
328 ),
329 "Expected username 'testuser', got: {action:?}"
330 );
331
332 let action = classify("AUTHINFO user anotheruser");
334 assert!(
335 matches!(
336 action,
337 CommandAction::InterceptAuth(AuthAction::RequestPassword(u)) if u == "anotheruser"
338 ),
339 "Expected username 'anotheruser', got: {action:?}"
340 );
341
342 let action = classify("aUtHiNfO pAsS mypassword");
344 assert!(
345 matches!(
346 action,
347 CommandAction::InterceptAuth(AuthAction::ValidateAndRespond { password: p }) if p == "mypassword"
348 ),
349 "Expected password 'mypassword', got: {action:?}"
350 );
351
352 let action = classify("Authinfo Pass s3cr3t");
354 assert!(
355 matches!(
356 action,
357 CommandAction::InterceptAuth(AuthAction::ValidateAndRespond { password: p }) if p == "s3cr3t"
358 ),
359 "Expected password 's3cr3t', got: {action:?}"
360 );
361 }
362
363 #[test]
364 fn test_case_insensitive_handling() {
365 assert_eq!(classify("list"), CommandAction::ForwardStateless);
367 assert_eq!(classify("LiSt"), CommandAction::ForwardStateless);
368 assert_eq!(classify("QUIT"), CommandAction::ForwardStateless);
369 assert_eq!(classify("quit"), CommandAction::ForwardStateless);
370 }
371
372 #[test]
373 fn test_empty_command() {
374 let action = classify("");
376 assert!(matches!(action, CommandAction::Reject(_)));
377 }
378
379 #[test]
380 fn test_whitespace_handling() {
381 let action = classify(" LIST");
383 assert!(matches!(action, CommandAction::Reject(_)));
384
385 let action = classify("LIST ");
387 assert_eq!(action, CommandAction::ForwardStateless);
388
389 let action = classify("AUTHINFO USER test ");
391 assert!(matches!(
392 action,
393 CommandAction::InterceptAuth(AuthAction::RequestPassword(username)) if username == "test"
394 ));
395 }
396
397 #[test]
398 fn test_malformed_auth_commands() {
399 let action = classify("AUTHINFO");
401 assert!(matches!(
402 action,
403 CommandAction::InterceptAuth(AuthAction::UnknownSubcommand)
404 ));
405
406 let action = classify("AUTHINFO INVALID");
409 assert!(
410 matches!(
411 action,
412 CommandAction::InterceptAuth(AuthAction::UnknownSubcommand)
413 ),
414 "Unknown AUTHINFO subcommand must produce InterceptAuth(UnknownSubcommand), got: {action:?}"
415 );
416 }
417
418 #[test]
419 fn test_auth_commands_without_arguments() {
420 let action = classify("AUTHINFO USER");
422 assert!(matches!(
423 action,
424 CommandAction::InterceptAuth(AuthAction::RequestPassword(username)) if username.is_empty()
425 ));
426
427 let action = classify("AUTHINFO PASS");
429 assert!(matches!(
430 action,
431 CommandAction::InterceptAuth(AuthAction::ValidateAndRespond { password }) if password.is_empty()
432 ));
433 }
434
435 #[test]
436 fn test_article_commands_with_newlines() {
437 let action = classify("ARTICLE <msg@test.com>\r\n");
439 assert_eq!(action, CommandAction::ForwardStateless);
440
441 let action = classify("LIST\n");
443 assert_eq!(action, CommandAction::ForwardStateless);
444 }
445
446 #[test]
447 fn test_very_long_commands() {
448 let long_cmd = format!("LIST {}", "A".repeat(10000));
450 assert!(matches!(classify(&long_cmd), CommandAction::Reject(_)));
451
452 let long_group = format!("GROUP {}", "alt.".repeat(1000));
454 match classify(&long_group) {
455 CommandAction::Reject(_) => {} other => panic!("Expected Reject for long GROUP, got {other:?}"),
457 }
458 }
459
460 #[test]
461 fn test_command_action_equality() {
462 assert_eq!(
464 CommandAction::ForwardStateless,
465 CommandAction::ForwardStateless
466 );
467 assert_eq!(
468 CommandAction::InterceptAuth(AuthAction::RequestPassword("test")),
469 CommandAction::InterceptAuth(AuthAction::RequestPassword("test"))
470 );
471
472 assert_ne!(
474 CommandAction::InterceptAuth(AuthAction::RequestPassword("user1")),
475 CommandAction::InterceptAuth(AuthAction::ValidateAndRespond { password: "pass1" })
476 );
477 }
478
479 #[test]
480 fn test_reject_messages() {
481 assert!(
483 matches!(
484 classify("GROUP alt.test"),
485 CommandAction::Reject(msg) if !msg.is_empty() && msg.len() > 10
486 ),
487 "Expected Reject with meaningful message"
488 );
489 }
490
491 #[test]
492 fn test_unknown_commands_rejected() {
493 let unknown_commands = ["INVALIDCOMMAND", "XYZABC", "RANDOM DATA", "12345"];
495
496 assert!(
497 unknown_commands
498 .iter()
499 .all(|cmd| { matches!(classify(cmd), CommandAction::Reject(STATEFUL_REJECT)) }),
500 "All unknown commands should be rejected from stateless routing"
501 );
502 }
503
504 #[test]
505 fn test_non_routable_commands_rejected() {
506 assert!(
508 matches!(
509 classify("POST"),
510 CommandAction::Reject(msg) if msg.starts_with("440")
511 ),
512 "POST must return 440 (Posting not permitted), got: {:?}",
513 classify("POST")
514 );
515
516 assert!(
518 matches!(
519 classify("IHAVE <test@example.com>"),
520 CommandAction::Reject(msg) if msg.contains("routing")
521 ),
522 "Expected Reject for IHAVE"
523 );
524
525 assert_eq!(
527 classify("NEWGROUPS 20240101 000000 GMT"),
528 CommandAction::ForwardStateless,
529 "NEWGROUPS should be forwarded as stateless"
530 );
531 assert_eq!(
532 classify("NEWNEWS * 20240101 000000 GMT"),
533 CommandAction::ForwardStateless,
534 "NEWNEWS should be forwarded as stateless"
535 );
536 }
537
538 #[test]
539 fn test_reject_message_content() {
540 let CommandAction::Reject(stateful_reject) = classify("GROUP alt.test") else {
542 panic!("Expected Reject")
543 };
544
545 let CommandAction::Reject(post_reject) = classify("POST") else {
546 panic!("Expected Reject")
547 };
548
549 let CommandAction::Reject(ihave_reject) = classify("IHAVE <x@y>") else {
550 panic!("Expected Reject")
551 };
552
553 assert!(stateful_reject.contains("stateless"));
555 assert!(post_reject.starts_with("440"));
557 assert!(
558 post_reject.to_lowercase().contains("posting")
559 || post_reject.to_lowercase().contains("permitted")
560 );
561 assert!(ihave_reject.contains("routing"));
563 assert_ne!(stateful_reject, post_reject);
565 assert_ne!(stateful_reject, ihave_reject);
566 assert_ne!(post_reject, ihave_reject);
567 }
568
569 #[test]
570 fn test_reject_response_format() {
571 let CommandAction::Reject(response) = classify("GROUP alt.test") else {
575 panic!("Expected Reject")
576 };
577
578 assert!(response.len() >= 3, "Response too short");
580 assert!(
581 response[0..3].chars().all(|c| c.is_ascii_digit()),
582 "First 3 chars must be digits, got: {}",
583 &response[0..3]
584 );
585
586 assert_eq!(&response[3..4], " ", "Must have space after status code");
588
589 assert!(response.ends_with("\r\n"), "Response must end with CRLF");
591
592 assert!(
596 response.starts_with("503 "),
597 "Expected 503 status code, got: {response}"
598 );
599 }
600
601 #[test]
602 fn test_all_reject_responses_are_valid_nntp() {
603 let reject_commands = vec![
605 "GROUP alt.test",
606 "NEXT",
607 "LAST",
608 "POST",
609 "IHAVE <test@example.com>",
610 ];
611
612 for cmd in reject_commands {
613 let CommandAction::Reject(response) = classify(cmd) else {
614 panic!("Expected Reject for command: {cmd}");
615 };
616
617 assert!(
619 response.len() >= 5,
620 "Response too short for {cmd}: {response}"
621 );
622 assert!(
623 response.starts_with(|c: char| c.is_ascii_digit()),
624 "Must start with digit for {cmd}: {response}"
625 );
626 assert!(
627 response.ends_with("\r\n"),
628 "Must end with CRLF for {cmd}: {response}"
629 );
630 assert!(
631 response.contains(' '),
632 "Must have space separator for {cmd}: {response}"
633 );
634 }
635 }
636
637 #[test]
638 fn test_503_status_code_usage() {
639 let CommandAction::Reject(response) = classify("GROUP alt.test") else {
645 panic!("Expected Reject");
646 };
647 assert!(
648 response.starts_with("503 "),
649 "Stateful commands should return 503, got: {response}"
650 );
651
652 let CommandAction::Reject(response) = classify("POST") else {
654 panic!("Expected Reject");
655 };
656 assert!(
657 response.starts_with("440 "),
658 "POST must return 440 (posting not permitted), got: {response}"
659 );
660
661 let CommandAction::Reject(response) = classify("IHAVE <x@y>") else {
663 panic!("Expected Reject");
664 };
665 assert!(
666 response.starts_with("503 "),
667 "IHAVE should return 503, got: {response}"
668 );
669 }
670
671 #[test]
672 fn reject_actions_expose_typed_status_codes() {
673 let CommandAction::Reject(response) = classify("GROUP alt.test") else {
674 panic!("Expected Reject");
675 };
676 assert_eq!(response.status().as_u16(), 503);
677
678 let CommandAction::Reject(response) = classify("POST") else {
679 panic!("Expected Reject");
680 };
681 assert_eq!(response.status().as_u16(), 440);
682 }
683
684 #[test]
685 fn test_response_messages_are_descriptive() {
686 let CommandAction::Reject(stateful) = classify("GROUP alt.test") else {
688 panic!("Expected Reject");
689 };
690 assert!(
691 stateful.to_lowercase().contains("stateless")
692 || stateful.to_lowercase().contains("mode"),
693 "Should explain stateless mode restriction: {stateful}"
694 );
695
696 let CommandAction::Reject(post) = classify("POST") else {
697 panic!("Expected Reject");
698 };
699 assert!(
700 post.to_lowercase().contains("posting") || post.to_lowercase().contains("permitted"),
701 "POST rejection should mention posting or permitted: {post}"
702 );
703 }
704}