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
7use super::classifier::NntpCommand;
8
9/// Action to take in response to a command
10#[derive(Debug, Clone, PartialEq)]
11pub enum CommandAction {
12    /// Intercept and send authentication response to client
13    InterceptAuth(AuthAction),
14    /// Reject the command with an error message
15    Reject(&'static str),
16    /// Forward the command to backend (stateless)
17    ForwardStateless,
18    /// Forward the command and switch to high-throughput mode (article by message-ID)
19    ForwardHighThroughput,
20}
21
22/// Specific authentication action
23#[derive(Debug, Clone, PartialEq)]
24pub enum AuthAction {
25    /// Send password required response
26    RequestPassword,
27    /// Send authentication accepted response
28    AcceptAuth,
29}
30
31/// Handler for processing commands and determining actions
32pub struct CommandHandler;
33
34impl CommandHandler {
35    /// Process a command and return the action to take
36    pub fn handle_command(command: &str) -> CommandAction {
37        match NntpCommand::classify(command) {
38            NntpCommand::AuthUser => CommandAction::InterceptAuth(AuthAction::RequestPassword),
39            NntpCommand::AuthPass => CommandAction::InterceptAuth(AuthAction::AcceptAuth),
40            NntpCommand::Stateful => {
41                CommandAction::Reject("Command not supported by this proxy (stateless proxy mode)")
42            }
43            NntpCommand::NonRoutable => CommandAction::Reject(
44                "Command not supported by this proxy (per-command routing mode)",
45            ),
46            NntpCommand::ArticleByMessageId => CommandAction::ForwardHighThroughput,
47            NntpCommand::Stateless => CommandAction::ForwardStateless,
48        }
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn test_auth_user_command() {
58        let action = CommandHandler::handle_command("AUTHINFO USER test");
59        assert_eq!(
60            action,
61            CommandAction::InterceptAuth(AuthAction::RequestPassword)
62        );
63    }
64
65    #[test]
66    fn test_auth_pass_command() {
67        let action = CommandHandler::handle_command("AUTHINFO PASS secret");
68        assert_eq!(action, CommandAction::InterceptAuth(AuthAction::AcceptAuth));
69    }
70
71    #[test]
72    fn test_stateful_command_rejected() {
73        let action = CommandHandler::handle_command("GROUP alt.test");
74        assert!(
75            matches!(action, CommandAction::Reject(msg) if msg.contains("stateless")),
76            "Expected Reject with 'stateless' in message"
77        );
78    }
79
80    #[test]
81    fn test_article_by_message_id() {
82        let action = CommandHandler::handle_command("ARTICLE <test@example.com>");
83        assert_eq!(action, CommandAction::ForwardHighThroughput);
84    }
85
86    #[test]
87    fn test_stateless_command() {
88        let action = CommandHandler::handle_command("LIST");
89        assert_eq!(action, CommandAction::ForwardStateless);
90
91        let action = CommandHandler::handle_command("HELP");
92        assert_eq!(action, CommandAction::ForwardStateless);
93    }
94
95    #[test]
96    fn test_all_stateful_commands_rejected() {
97        // Test various stateful commands
98        let stateful_commands = vec![
99            "GROUP alt.test",
100            "NEXT",
101            "LAST",
102            "LISTGROUP alt.test",
103            "ARTICLE 123",
104            "HEAD 456",
105            "BODY 789",
106            "STAT",
107            "XOVER 1-100",
108        ];
109
110        for cmd in stateful_commands {
111            match CommandHandler::handle_command(cmd) {
112                CommandAction::Reject(msg) => {
113                    assert!(msg.contains("stateless") || msg.contains("not supported"));
114                }
115                other => panic!("Expected Reject for '{}', got {:?}", cmd, other),
116            }
117        }
118    }
119
120    #[test]
121    fn test_all_article_by_msgid_as_high_throughput() {
122        // All message-ID based article commands should be high-throughput
123        let msgid_commands = vec![
124            "ARTICLE <test@example.com>",
125            "BODY <msg@server.org>",
126            "HEAD <id@host.net>",
127            "STAT <unique@domain.com>",
128        ];
129
130        for cmd in msgid_commands {
131            assert_eq!(
132                CommandHandler::handle_command(cmd),
133                CommandAction::ForwardHighThroughput,
134                "Command '{}' should be high-throughput",
135                cmd
136            );
137        }
138    }
139
140    #[test]
141    fn test_various_stateless_commands() {
142        let stateless_commands = vec![
143            "HELP",
144            "LIST",
145            "LIST ACTIVE",
146            "LIST NEWSGROUPS",
147            "DATE",
148            "CAPABILITIES",
149            "QUIT",
150        ];
151
152        for cmd in stateless_commands {
153            assert_eq!(
154                CommandHandler::handle_command(cmd),
155                CommandAction::ForwardStateless,
156                "Command '{}' should be stateless",
157                cmd
158            );
159        }
160    }
161
162    #[test]
163    fn test_case_insensitive_handling() {
164        // Test that command handling is case-insensitive
165        assert_eq!(
166            CommandHandler::handle_command("list"),
167            CommandAction::ForwardStateless
168        );
169        assert_eq!(
170            CommandHandler::handle_command("LiSt"),
171            CommandAction::ForwardStateless
172        );
173        assert_eq!(
174            CommandHandler::handle_command("QUIT"),
175            CommandAction::ForwardStateless
176        );
177        assert_eq!(
178            CommandHandler::handle_command("quit"),
179            CommandAction::ForwardStateless
180        );
181    }
182
183    #[test]
184    fn test_empty_command() {
185        // Empty command should be treated as stateless (unknown)
186        let action = CommandHandler::handle_command("");
187        assert_eq!(action, CommandAction::ForwardStateless);
188    }
189
190    #[test]
191    fn test_whitespace_handling() {
192        // Command with leading/trailing whitespace
193        let action = CommandHandler::handle_command("  LIST  ");
194        assert_eq!(action, CommandAction::ForwardStateless);
195
196        // Auth command with extra whitespace
197        let action = CommandHandler::handle_command("  AUTHINFO USER test  ");
198        assert_eq!(
199            action,
200            CommandAction::InterceptAuth(AuthAction::RequestPassword)
201        );
202    }
203
204    #[test]
205    fn test_malformed_auth_commands() {
206        // AUTHINFO without subcommand
207        let action = CommandHandler::handle_command("AUTHINFO");
208        assert_eq!(action, CommandAction::ForwardStateless);
209
210        // AUTHINFO with unknown subcommand
211        let action = CommandHandler::handle_command("AUTHINFO INVALID");
212        assert_eq!(action, CommandAction::ForwardStateless);
213    }
214
215    #[test]
216    fn test_auth_commands_without_arguments() {
217        // AUTHINFO USER without username (still intercept)
218        let action = CommandHandler::handle_command("AUTHINFO USER");
219        assert_eq!(
220            action,
221            CommandAction::InterceptAuth(AuthAction::RequestPassword)
222        );
223
224        // AUTHINFO PASS without password (still intercept)
225        let action = CommandHandler::handle_command("AUTHINFO PASS");
226        assert_eq!(action, CommandAction::InterceptAuth(AuthAction::AcceptAuth));
227    }
228
229    #[test]
230    fn test_article_commands_with_newlines() {
231        // Command with CRLF
232        let action = CommandHandler::handle_command("ARTICLE <msg@test.com>\r\n");
233        assert_eq!(action, CommandAction::ForwardHighThroughput);
234
235        // Command with just LF
236        let action = CommandHandler::handle_command("LIST\n");
237        assert_eq!(action, CommandAction::ForwardStateless);
238    }
239
240    #[test]
241    fn test_very_long_commands() {
242        // Very long stateless command
243        let long_cmd = format!("LIST {}", "A".repeat(10000));
244        let action = CommandHandler::handle_command(&long_cmd);
245        assert_eq!(action, CommandAction::ForwardStateless);
246
247        // Very long GROUP name (stateful)
248        let long_group = format!("GROUP {}", "alt.".repeat(1000));
249        match CommandHandler::handle_command(&long_group) {
250            CommandAction::Reject(_) => {} // Expected
251            other => panic!("Expected Reject for long GROUP, got {:?}", other),
252        }
253    }
254
255    #[test]
256    fn test_command_action_equality() {
257        // Test that CommandAction implements PartialEq correctly
258        assert_eq!(
259            CommandAction::ForwardStateless,
260            CommandAction::ForwardStateless
261        );
262        assert_eq!(
263            CommandAction::ForwardHighThroughput,
264            CommandAction::ForwardHighThroughput
265        );
266        assert_eq!(
267            CommandAction::InterceptAuth(AuthAction::RequestPassword),
268            CommandAction::InterceptAuth(AuthAction::RequestPassword)
269        );
270
271        // Test inequality
272        assert_ne!(
273            CommandAction::ForwardStateless,
274            CommandAction::ForwardHighThroughput
275        );
276        assert_ne!(
277            CommandAction::InterceptAuth(AuthAction::RequestPassword),
278            CommandAction::InterceptAuth(AuthAction::AcceptAuth)
279        );
280    }
281
282    #[test]
283    fn test_reject_messages() {
284        // Verify reject messages are informative
285        assert!(
286            matches!(
287                CommandHandler::handle_command("GROUP alt.test"),
288                CommandAction::Reject(msg) if !msg.is_empty() && msg.len() > 10
289            ),
290            "Expected Reject with meaningful message"
291        );
292    }
293
294    #[test]
295    fn test_unknown_commands_forwarded() {
296        // Unknown commands should be forwarded as stateless
297        // The backend server will handle the error
298        let unknown_commands = ["INVALIDCOMMAND", "XYZABC", "RANDOM DATA", "12345"];
299
300        assert!(
301            unknown_commands.iter().all(|cmd| {
302                CommandHandler::handle_command(cmd) == CommandAction::ForwardStateless
303            }),
304            "All unknown commands should be forwarded as stateless"
305        );
306    }
307
308    #[test]
309    fn test_non_routable_commands_rejected() {
310        // POST should be rejected
311        assert!(
312            matches!(
313                CommandHandler::handle_command("POST"),
314                CommandAction::Reject(msg) if msg.contains("routing")
315            ),
316            "Expected Reject for POST"
317        );
318
319        // IHAVE should be rejected
320        assert!(
321            matches!(
322                CommandHandler::handle_command("IHAVE <test@example.com>"),
323                CommandAction::Reject(msg) if msg.contains("routing")
324            ),
325            "Expected Reject for IHAVE"
326        );
327
328        // NEWGROUPS should be rejected
329        assert!(
330            matches!(
331                CommandHandler::handle_command("NEWGROUPS 20240101 000000 GMT"),
332                CommandAction::Reject(msg) if msg.contains("routing")
333            ),
334            "Expected Reject for NEWGROUPS"
335        );
336
337        // NEWNEWS should be rejected
338        assert!(
339            matches!(
340                CommandHandler::handle_command("NEWNEWS * 20240101 000000 GMT"),
341                CommandAction::Reject(msg) if msg.contains("routing")
342            ),
343            "Expected Reject for NEWNEWS"
344        );
345    }
346
347    #[test]
348    fn test_reject_message_content() {
349        // Verify different reject messages for different command types
350        let CommandAction::Reject(stateful_reject) =
351            CommandHandler::handle_command("GROUP alt.test")
352        else {
353            panic!("Expected Reject")
354        };
355
356        let CommandAction::Reject(routing_reject) = CommandHandler::handle_command("POST") else {
357            panic!("Expected Reject")
358        };
359
360        // They should have different messages
361        assert!(stateful_reject.contains("stateless"));
362        assert!(routing_reject.contains("routing"));
363        assert_ne!(stateful_reject, routing_reject);
364    }
365}