1use super::classifier::NntpCommand;
21
22#[derive(Debug, Clone, PartialEq)]
24#[non_exhaustive]
25pub enum CommandAction {
26 InterceptAuth(AuthAction),
28 Reject(&'static str),
30 ForwardStateless,
32}
33
34#[derive(Debug, Clone, PartialEq)]
36#[non_exhaustive]
37pub enum AuthAction {
38 RequestPassword(String),
40 ValidateAndRespond { password: String },
42}
43
44pub struct CommandHandler;
46
47impl CommandHandler {
48 pub fn handle_command(command: &str) -> CommandAction {
50 match NntpCommand::classify(command) {
51 NntpCommand::AuthUser => {
52 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 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 CommandAction::Reject("502 Command not implemented in stateless proxy mode\r\n")
77 }
78 NntpCommand::NonRoutable => {
79 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 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 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 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 let action = CommandHandler::handle_command("");
227 assert_eq!(action, CommandAction::ForwardStateless);
228 }
229
230 #[test]
231 fn test_whitespace_handling() {
232 let action = CommandHandler::handle_command(" LIST ");
234 assert_eq!(action, CommandAction::ForwardStateless);
235
236 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 let action = CommandHandler::handle_command("AUTHINFO");
248 assert_eq!(action, CommandAction::ForwardStateless);
249
250 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 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 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 let action = CommandHandler::handle_command("ARTICLE <msg@test.com>\r\n");
276 assert_eq!(action, CommandAction::ForwardStateless);
277
278 let action = CommandHandler::handle_command("LIST\n");
280 assert_eq!(action, CommandAction::ForwardStateless);
281 }
282
283 #[test]
284 fn test_very_long_commands() {
285 let long_cmd = format!("LIST {}", "A".repeat(10000));
287 let action = CommandHandler::handle_command(&long_cmd);
288 assert_eq!(action, CommandAction::ForwardStateless);
289
290 let long_group = format!("GROUP {}", "alt.".repeat(1000));
292 match CommandHandler::handle_command(&long_group) {
293 CommandAction::Reject(_) => {} other => panic!("Expected Reject for long GROUP, got {:?}", other),
295 }
296 }
297
298 #[test]
299 fn test_command_action_equality() {
300 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 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 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 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 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 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 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 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 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 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 let CommandAction::Reject(response) = CommandHandler::handle_command("GROUP alt.test")
409 else {
410 panic!("Expected Reject")
411 };
412
413 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 assert_eq!(&response[3..4], " ", "Must have space after status code");
423
424 assert!(response.ends_with("\r\n"), "Response must end with CRLF");
426
427 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 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 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 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 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 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}