Skip to main content

nntp_proxy/command/
handler.rs

1//! Command handling with action types
2//!
3//! This module provides a `CommandHandler` that processes NNTP commands
4//! and returns actions to be taken, separating command interpretation
5//! from command execution.
6//!
7//! # NNTP Response Codes
8//!
9//! Response codes follow RFC 3977 Section 3.2:
10//! <https://www.rfc-editor.org/rfc/rfc3977.html#section-3.2>
11//!
12//! ## Codes Used
13//!
14//! - `480` Authentication required
15//!   <https://www.rfc-editor.org/rfc/rfc4643.html#section-2.4.1>
16//! - `503` Feature not supported\
17//!   <https://www.rfc-editor.org/rfc/rfc3977.html#section-3.2.1>
18//!   Used when a feature (e.g. stateful commands in per-command mode) is not supported
19
20use crate::protocol::{
21    RequestContext, RequestKind, RequestResponseMetadata, RequestRouteClass, StatusCode, codes,
22};
23
24/// Action to take in response to a command
25#[derive(Debug, Clone, Copy, PartialEq)]
26#[non_exhaustive]
27pub enum CommandAction<'a> {
28    /// Intercept and send authentication response to client
29    InterceptAuth(AuthAction<'a>),
30    /// Reject the command with an error message (NNTP response format with CRLF)
31    Reject(RejectResponse),
32    /// Forward the command to backend (stateless)
33    ForwardStateless,
34    /// Intercept CAPABILITIES and return a synthetic proxy-accurate capability list
35    InterceptCapabilities,
36}
37
38/// Static local reject response with typed status metadata.
39#[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/// Specific authentication action
107#[derive(Debug, Clone, Copy, PartialEq)]
108#[non_exhaustive]
109pub enum AuthAction<'a> {
110    /// Send password required response (username provided)
111    RequestPassword(&'a str),
112    /// Validate credentials and send appropriate response
113    ValidateAndRespond { password: &'a str },
114    /// AUTHINFO with an unrecognized subcommand — reject with 501 per RFC 4643 §2.3.1
115    UnknownSubcommand,
116}
117
118/// Handler for processing commands and determining actions
119pub struct CommandHandler;
120
121impl CommandHandler {
122    /// Classify an already parsed request context and return the action to take.
123    #[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        // Test various stateful commands
236        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        // All message-ID based article commands should be forwarded as stateless
261        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        // RFC 3977 §5.2 + RFC 4643 §3.1: CAPABILITIES must be intercepted by the proxy
300        // to return an accurate capability list, not forwarded to the backend.
301        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    /// Bug 2 regression test: RFC 4643 §2.3.1 — AUTHINFO is case-insensitive.
316    ///
317    /// Before fix: the username/password extractor only stripped exact "AUTHINFO USER" or
318    /// "authinfo user" prefixes, so mixed-case commands (e.g. "Authinfo User foo") classified
319    /// correctly but returned an empty username.
320    #[test]
321    fn test_mixed_case_authinfo_extraction() {
322        // "Authinfo User" — Titlecase keyword, Titlecase subcommand
323        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        // "AUTHINFO user" — uppercase keyword, lowercase subcommand
333        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        // "aUtHiNfO pAsS" — fully mixed case
343        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        // "Authinfo Pass" — Titlecase keyword + Titlecase subcommand
353        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        // Test that command handling is case-insensitive
366        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        // Empty command is not a valid typed request and falls into stateful fallback.
375        let action = classify("");
376        assert!(matches!(action, CommandAction::Reject(_)));
377    }
378
379    #[test]
380    fn test_whitespace_handling() {
381        // Leading whitespace is invalid at the typed request boundary.
382        let action = classify("  LIST");
383        assert!(matches!(action, CommandAction::Reject(_)));
384
385        // Command with trailing whitespace
386        let action = classify("LIST  ");
387        assert_eq!(action, CommandAction::ForwardStateless);
388
389        // Auth command with trailing whitespace
390        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        // AUTHINFO without subcommand is intercepted as an AUTHINFO syntax error.
400        let action = classify("AUTHINFO");
401        assert!(matches!(
402            action,
403            CommandAction::InterceptAuth(AuthAction::UnknownSubcommand)
404        ));
405
406        // AUTHINFO with unknown subcommand — intercepted so auth state can be checked first.
407        // Returns 501 if not authenticated, 502 if already authenticated (RFC 4643 §2.3.1/§2.2).
408        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        // AUTHINFO USER without username (still intercept, empty username)
421        let action = classify("AUTHINFO USER");
422        assert!(matches!(
423            action,
424            CommandAction::InterceptAuth(AuthAction::RequestPassword(username)) if username.is_empty()
425        ));
426
427        // AUTHINFO PASS without password (still intercept, empty password)
428        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        // Command with CRLF
438        let action = classify("ARTICLE <msg@test.com>\r\n");
439        assert_eq!(action, CommandAction::ForwardStateless);
440
441        // Command with just LF
442        let action = classify("LIST\n");
443        assert_eq!(action, CommandAction::ForwardStateless);
444    }
445
446    #[test]
447    fn test_very_long_commands() {
448        // Oversized requests are rejected before stateless routing.
449        let long_cmd = format!("LIST {}", "A".repeat(10000));
450        assert!(matches!(classify(&long_cmd), CommandAction::Reject(_)));
451
452        // Very long GROUP name (stateful)
453        let long_group = format!("GROUP {}", "alt.".repeat(1000));
454        match classify(&long_group) {
455            CommandAction::Reject(_) => {} // Expected
456            other => panic!("Expected Reject for long GROUP, got {other:?}"),
457        }
458    }
459
460    #[test]
461    fn test_command_action_equality() {
462        // Test that CommandAction implements PartialEq correctly
463        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        // Test inequality
473        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        // Verify reject messages are informative
482        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        // Unknown extension commands require stateful fallback, not stateless multiplexing.
494        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        // POST must return 440 per RFC 3977 §6.3.1 (posting not permitted)
507        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        // IHAVE should be rejected with 503 (feature not supported)
517        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        // NEWGROUPS/NEWNEWS are stateless (RFC 3977 §7.3-7.4) — forwarded, not rejected
526        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        // Verify different reject messages for different command types
541        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        // Stateful commands rejected with stateless-mode message
554        assert!(stateful_reject.contains("stateless"));
555        // POST rejected with RFC 3977 §6.3.1 440 response
556        assert!(post_reject.starts_with("440"));
557        assert!(
558            post_reject.to_lowercase().contains("posting")
559                || post_reject.to_lowercase().contains("permitted")
560        );
561        // IHAVE rejected with routing-mode message
562        assert!(ihave_reject.contains("routing"));
563        // All rejections are distinct
564        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        // RFC 3977 Section 3.1: Response format is "xyz text\r\n"
572        // https://www.rfc-editor.org/rfc/rfc3977.html#section-3.1
573
574        let CommandAction::Reject(response) = classify("GROUP alt.test") else {
575            panic!("Expected Reject")
576        };
577
578        // Must start with 3-digit status code
579        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        // Must have space after status code
587        assert_eq!(&response[3..4], " ", "Must have space after status code");
588
589        // Must end with CRLF
590        assert!(response.ends_with("\r\n"), "Response must end with CRLF");
591
592        // Status code must be 503 (Feature not supported)
593        // RFC 3977 §3.2.1: 503 = "Feature not supported" is correct for commands the
594        // proxy structurally cannot support (e.g. stateful commands in per-command mode)
595        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        // Test all commands that produce Reject responses
604        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            // All must be valid NNTP format
618            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        // RFC 3977 §3.2.1: 503 is "Feature not supported"
640        // Correct for commands the proxy structurally cannot support
641        // (e.g. stateful GROUP in per-command mode, or transit-only IHAVE)
642
643        // Stateful commands in stateless mode use 503
644        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        // POST uses 440 per RFC 3977 §6.3.1 (posting not permitted)
653        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        // IHAVE uses 503 (transit feature not supported in reader proxy)
662        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        // Responses should explain why the command is rejected
687        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}