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//! - `502` Command not implemented  
17//!   <https://www.rfc-editor.org/rfc/rfc3977.html#section-3.2.1>
18//!   Used when a command is recognized but not supported by this server
19
20use super::classifier::NntpCommand;
21
22/// Action to take in response to a command
23#[derive(Debug, Clone, PartialEq)]
24#[non_exhaustive]
25pub enum CommandAction {
26    /// Intercept and send authentication response to client
27    InterceptAuth(AuthAction),
28    /// Reject the command with an error message (NNTP response format with CRLF)
29    Reject(&'static str),
30    /// Forward the command to backend (stateless)
31    ForwardStateless,
32}
33
34/// Specific authentication action
35#[derive(Debug, Clone, PartialEq)]
36#[non_exhaustive]
37pub enum AuthAction {
38    /// Send password required response (username provided)
39    RequestPassword(String),
40    /// Validate credentials and send appropriate response
41    ValidateAndRespond { password: String },
42}
43
44/// Handler for processing commands and determining actions
45pub struct CommandHandler;
46
47impl CommandHandler {
48    /// Process a command and return the action to take
49    pub fn handle_command(command: &str) -> CommandAction {
50        match NntpCommand::classify(command) {
51            NntpCommand::AuthUser => {
52                // Extract username from "AUTHINFO USER <username>"
53                let username = command
54                    .trim()
55                    .strip_prefix("AUTHINFO USER")
56                    .or_else(|| command.trim().strip_prefix("authinfo user"))
57                    .unwrap_or("")
58                    .trim()
59                    .to_string();
60                CommandAction::InterceptAuth(AuthAction::RequestPassword(username))
61            }
62            NntpCommand::AuthPass => {
63                // Extract password from "AUTHINFO PASS <password>"
64                let password = command
65                    .trim()
66                    .strip_prefix("AUTHINFO PASS")
67                    .or_else(|| command.trim().strip_prefix("authinfo pass"))
68                    .unwrap_or("")
69                    .trim()
70                    .to_string();
71                CommandAction::InterceptAuth(AuthAction::ValidateAndRespond { password })
72            }
73            NntpCommand::Stateful => {
74                // RFC 3977 Section 3.2.1: 502 Command not implemented
75                // https://www.rfc-editor.org/rfc/rfc3977.html#section-3.2.1
76                CommandAction::Reject("502 Command not implemented in stateless proxy mode\r\n")
77            }
78            NntpCommand::NonRoutable => {
79                // RFC 3977 Section 3.2.1: 502 Command not implemented
80                // https://www.rfc-editor.org/rfc/rfc3977.html#section-3.2.1
81                CommandAction::Reject("502 Command not implemented in per-command routing mode\r\n")
82            }
83            NntpCommand::ArticleByMessageId => CommandAction::ForwardStateless,
84            NntpCommand::Stateless => CommandAction::ForwardStateless,
85        }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn test_auth_user_command() {
95        let action = CommandHandler::handle_command("AUTHINFO USER test");
96        assert!(matches!(
97            action,
98            CommandAction::InterceptAuth(AuthAction::RequestPassword(ref username)) if username == "test"
99        ));
100    }
101
102    #[test]
103    fn test_auth_pass_command() {
104        let action = CommandHandler::handle_command("AUTHINFO PASS secret");
105        assert!(matches!(
106            action,
107            CommandAction::InterceptAuth(AuthAction::ValidateAndRespond { ref password }) if password == "secret"
108        ));
109    }
110
111    #[test]
112    fn test_stateful_command_rejected() {
113        let action = CommandHandler::handle_command("GROUP alt.test");
114        assert!(
115            matches!(action, CommandAction::Reject(msg) if msg.contains("stateless")),
116            "Expected Reject with 'stateless' in message"
117        );
118    }
119
120    #[test]
121    fn test_article_by_message_id() {
122        let action = CommandHandler::handle_command("ARTICLE <test@example.com>");
123        assert_eq!(action, CommandAction::ForwardStateless);
124    }
125
126    #[test]
127    fn test_stateless_command() {
128        let action = CommandHandler::handle_command("LIST");
129        assert_eq!(action, CommandAction::ForwardStateless);
130
131        let action = CommandHandler::handle_command("HELP");
132        assert_eq!(action, CommandAction::ForwardStateless);
133    }
134
135    #[test]
136    fn test_all_stateful_commands_rejected() {
137        // Test various stateful commands
138        let stateful_commands = vec![
139            "GROUP alt.test",
140            "NEXT",
141            "LAST",
142            "LISTGROUP alt.test",
143            "ARTICLE 123",
144            "HEAD 456",
145            "BODY 789",
146            "STAT",
147            "XOVER 1-100",
148        ];
149
150        for cmd in stateful_commands {
151            match CommandHandler::handle_command(cmd) {
152                CommandAction::Reject(msg) => {
153                    assert!(msg.contains("stateless") || msg.contains("not supported"));
154                }
155                other => panic!("Expected Reject for '{}', got {:?}", cmd, other),
156            }
157        }
158    }
159
160    #[test]
161    fn test_all_article_by_msgid_forwarded() {
162        // All message-ID based article commands should be forwarded as stateless
163        let msgid_commands = vec![
164            "ARTICLE <test@example.com>",
165            "BODY <msg@server.org>",
166            "HEAD <id@host.net>",
167            "STAT <unique@domain.com>",
168        ];
169
170        for cmd in msgid_commands {
171            assert_eq!(
172                CommandHandler::handle_command(cmd),
173                CommandAction::ForwardStateless,
174                "Command '{}' should be forwarded as stateless",
175                cmd
176            );
177        }
178    }
179
180    #[test]
181    fn test_various_stateless_commands() {
182        let stateless_commands = vec![
183            "HELP",
184            "LIST",
185            "LIST ACTIVE",
186            "LIST NEWSGROUPS",
187            "DATE",
188            "CAPABILITIES",
189            "QUIT",
190        ];
191
192        for cmd in stateless_commands {
193            assert_eq!(
194                CommandHandler::handle_command(cmd),
195                CommandAction::ForwardStateless,
196                "Command '{}' should be stateless",
197                cmd
198            );
199        }
200    }
201
202    #[test]
203    fn test_case_insensitive_handling() {
204        // Test that command handling is case-insensitive
205        assert_eq!(
206            CommandHandler::handle_command("list"),
207            CommandAction::ForwardStateless
208        );
209        assert_eq!(
210            CommandHandler::handle_command("LiSt"),
211            CommandAction::ForwardStateless
212        );
213        assert_eq!(
214            CommandHandler::handle_command("QUIT"),
215            CommandAction::ForwardStateless
216        );
217        assert_eq!(
218            CommandHandler::handle_command("quit"),
219            CommandAction::ForwardStateless
220        );
221    }
222
223    #[test]
224    fn test_empty_command() {
225        // Empty command should be treated as stateless (unknown)
226        let action = CommandHandler::handle_command("");
227        assert_eq!(action, CommandAction::ForwardStateless);
228    }
229
230    #[test]
231    fn test_whitespace_handling() {
232        // Command with leading/trailing whitespace
233        let action = CommandHandler::handle_command("  LIST  ");
234        assert_eq!(action, CommandAction::ForwardStateless);
235
236        // Auth command with extra whitespace
237        let action = CommandHandler::handle_command("  AUTHINFO USER test  ");
238        assert!(matches!(
239            action,
240            CommandAction::InterceptAuth(AuthAction::RequestPassword(ref username)) if username == "test"
241        ));
242    }
243
244    #[test]
245    fn test_malformed_auth_commands() {
246        // AUTHINFO without subcommand
247        let action = CommandHandler::handle_command("AUTHINFO");
248        assert_eq!(action, CommandAction::ForwardStateless);
249
250        // AUTHINFO with unknown subcommand
251        let action = CommandHandler::handle_command("AUTHINFO INVALID");
252        assert_eq!(action, CommandAction::ForwardStateless);
253    }
254
255    #[test]
256    fn test_auth_commands_without_arguments() {
257        // AUTHINFO USER without username (still intercept, empty username)
258        let action = CommandHandler::handle_command("AUTHINFO USER");
259        assert!(matches!(
260            action,
261            CommandAction::InterceptAuth(AuthAction::RequestPassword(ref username)) if username.is_empty()
262        ));
263
264        // AUTHINFO PASS without password (still intercept, empty password)
265        let action = CommandHandler::handle_command("AUTHINFO PASS");
266        assert!(matches!(
267            action,
268            CommandAction::InterceptAuth(AuthAction::ValidateAndRespond { ref password }) if password.is_empty()
269        ));
270    }
271
272    #[test]
273    fn test_article_commands_with_newlines() {
274        // Command with CRLF
275        let action = CommandHandler::handle_command("ARTICLE <msg@test.com>\r\n");
276        assert_eq!(action, CommandAction::ForwardStateless);
277
278        // Command with just LF
279        let action = CommandHandler::handle_command("LIST\n");
280        assert_eq!(action, CommandAction::ForwardStateless);
281    }
282
283    #[test]
284    fn test_very_long_commands() {
285        // Very long stateless command
286        let long_cmd = format!("LIST {}", "A".repeat(10000));
287        let action = CommandHandler::handle_command(&long_cmd);
288        assert_eq!(action, CommandAction::ForwardStateless);
289
290        // Very long GROUP name (stateful)
291        let long_group = format!("GROUP {}", "alt.".repeat(1000));
292        match CommandHandler::handle_command(&long_group) {
293            CommandAction::Reject(_) => {} // Expected
294            other => panic!("Expected Reject for long GROUP, got {:?}", other),
295        }
296    }
297
298    #[test]
299    fn test_command_action_equality() {
300        // Test that CommandAction implements PartialEq correctly
301        assert_eq!(
302            CommandAction::ForwardStateless,
303            CommandAction::ForwardStateless
304        );
305        assert_eq!(
306            CommandAction::InterceptAuth(AuthAction::RequestPassword("test".to_string())),
307            CommandAction::InterceptAuth(AuthAction::RequestPassword("test".to_string()))
308        );
309
310        // Test inequality
311        assert_ne!(
312            CommandAction::InterceptAuth(AuthAction::RequestPassword("user1".to_string())),
313            CommandAction::InterceptAuth(AuthAction::ValidateAndRespond {
314                password: "pass1".to_string()
315            })
316        );
317    }
318
319    #[test]
320    fn test_reject_messages() {
321        // Verify reject messages are informative
322        assert!(
323            matches!(
324                CommandHandler::handle_command("GROUP alt.test"),
325                CommandAction::Reject(msg) if !msg.is_empty() && msg.len() > 10
326            ),
327            "Expected Reject with meaningful message"
328        );
329    }
330
331    #[test]
332    fn test_unknown_commands_forwarded() {
333        // Unknown commands should be forwarded as stateless
334        // The backend server will handle the error
335        let unknown_commands = ["INVALIDCOMMAND", "XYZABC", "RANDOM DATA", "12345"];
336
337        assert!(
338            unknown_commands.iter().all(|cmd| {
339                CommandHandler::handle_command(cmd) == CommandAction::ForwardStateless
340            }),
341            "All unknown commands should be forwarded as stateless"
342        );
343    }
344
345    #[test]
346    fn test_non_routable_commands_rejected() {
347        // POST should be rejected
348        assert!(
349            matches!(
350                CommandHandler::handle_command("POST"),
351                CommandAction::Reject(msg) if msg.contains("routing")
352            ),
353            "Expected Reject for POST"
354        );
355
356        // IHAVE should be rejected
357        assert!(
358            matches!(
359                CommandHandler::handle_command("IHAVE <test@example.com>"),
360                CommandAction::Reject(msg) if msg.contains("routing")
361            ),
362            "Expected Reject for IHAVE"
363        );
364
365        // NEWGROUPS should be rejected
366        assert!(
367            matches!(
368                CommandHandler::handle_command("NEWGROUPS 20240101 000000 GMT"),
369                CommandAction::Reject(msg) if msg.contains("routing")
370            ),
371            "Expected Reject for NEWGROUPS"
372        );
373
374        // NEWNEWS should be rejected
375        assert!(
376            matches!(
377                CommandHandler::handle_command("NEWNEWS * 20240101 000000 GMT"),
378                CommandAction::Reject(msg) if msg.contains("routing")
379            ),
380            "Expected Reject for NEWNEWS"
381        );
382    }
383
384    #[test]
385    fn test_reject_message_content() {
386        // Verify different reject messages for different command types
387        let CommandAction::Reject(stateful_reject) =
388            CommandHandler::handle_command("GROUP alt.test")
389        else {
390            panic!("Expected Reject")
391        };
392
393        let CommandAction::Reject(routing_reject) = CommandHandler::handle_command("POST") else {
394            panic!("Expected Reject")
395        };
396
397        // They should have different messages
398        assert!(stateful_reject.contains("stateless"));
399        assert!(routing_reject.contains("routing"));
400        assert_ne!(stateful_reject, routing_reject);
401    }
402
403    #[test]
404    fn test_reject_response_format() {
405        // RFC 3977 Section 3.1: Response format is "xyz text\r\n"
406        // https://www.rfc-editor.org/rfc/rfc3977.html#section-3.1
407
408        let CommandAction::Reject(response) = CommandHandler::handle_command("GROUP alt.test")
409        else {
410            panic!("Expected Reject")
411        };
412
413        // Must start with 3-digit status code
414        assert!(response.len() >= 3, "Response too short");
415        assert!(
416            response[0..3].chars().all(|c| c.is_ascii_digit()),
417            "First 3 chars must be digits, got: {}",
418            &response[0..3]
419        );
420
421        // Must have space after status code
422        assert_eq!(&response[3..4], " ", "Must have space after status code");
423
424        // Must end with CRLF
425        assert!(response.ends_with("\r\n"), "Response must end with CRLF");
426
427        // Status code must be 502 (Command not implemented)
428        // https://www.rfc-editor.org/rfc/rfc3977.html#section-3.2.1
429        assert!(
430            response.starts_with("502 "),
431            "Expected 502 status code, got: {}",
432            response
433        );
434    }
435
436    #[test]
437    fn test_all_reject_responses_are_valid_nntp() {
438        // Test all commands that produce Reject responses
439        let reject_commands = vec![
440            "GROUP alt.test",
441            "NEXT",
442            "LAST",
443            "POST",
444            "IHAVE <test@example.com>",
445            "NEWGROUPS 20240101 000000 GMT",
446        ];
447
448        for cmd in reject_commands {
449            let CommandAction::Reject(response) = CommandHandler::handle_command(cmd) else {
450                panic!("Expected Reject for command: {}", cmd);
451            };
452
453            // All must be valid NNTP format
454            assert!(
455                response.len() >= 5,
456                "Response too short for {}: {}",
457                cmd,
458                response
459            );
460            assert!(
461                response.starts_with(|c: char| c.is_ascii_digit()),
462                "Must start with digit for {}: {}",
463                cmd,
464                response
465            );
466            assert!(
467                response.ends_with("\r\n"),
468                "Must end with CRLF for {}: {}",
469                cmd,
470                response
471            );
472            assert!(
473                response.contains(' '),
474                "Must have space separator for {}: {}",
475                cmd,
476                response
477            );
478        }
479    }
480
481    #[test]
482    fn test_502_status_code_usage() {
483        // RFC 3977 Section 3.2.1: 502 is "Command not implemented"
484        // https://www.rfc-editor.org/rfc/rfc3977.html#section-3.2.1
485        // "The command is not presently implemented by the server, although
486        //  it may be implemented in the future."
487
488        // Stateful commands in stateless mode
489        let CommandAction::Reject(response) = CommandHandler::handle_command("GROUP alt.test")
490        else {
491            panic!("Expected Reject");
492        };
493        assert!(
494            response.starts_with("502 "),
495            "Stateful commands should return 502, got: {}",
496            response
497        );
498
499        // Non-routable commands in routing mode
500        let CommandAction::Reject(response) = CommandHandler::handle_command("POST") else {
501            panic!("Expected Reject");
502        };
503        assert!(
504            response.starts_with("502 "),
505            "Non-routable commands should return 502, got: {}",
506            response
507        );
508    }
509
510    #[test]
511    fn test_response_messages_are_descriptive() {
512        // Responses should explain why the command is rejected
513        let CommandAction::Reject(stateful) = CommandHandler::handle_command("GROUP alt.test")
514        else {
515            panic!("Expected Reject");
516        };
517        assert!(
518            stateful.to_lowercase().contains("stateless")
519                || stateful.to_lowercase().contains("mode"),
520            "Should explain stateless mode restriction: {}",
521            stateful
522        );
523
524        let CommandAction::Reject(routing) = CommandHandler::handle_command("POST") else {
525            panic!("Expected Reject");
526        };
527        assert!(
528            routing.to_lowercase().contains("routing") || routing.to_lowercase().contains("mode"),
529            "Should explain routing mode restriction: {}",
530            routing
531        );
532    }
533}