1use std::collections::HashMap;
6use std::io::{self, BufRead, BufReader, Read, Write};
7use std::net::{TcpStream, ToSocketAddrs};
8use std::path::Path;
9use std::time::Duration;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum TransferType {
14 Ascii,
15 Binary,
16}
17
18impl TransferType {
19 pub fn as_str(&self) -> &'static str {
20 match self {
21 TransferType::Ascii => "A",
22 TransferType::Binary => "I",
23 }
24 }
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum TransferMode {
30 Stream,
31 Block,
32}
33
34impl TransferMode {
35 pub fn as_str(&self) -> &'static str {
36 match self {
37 TransferMode::Stream => "S",
38 TransferMode::Block => "B",
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct FtpResponse {
46 pub code: u32,
47 pub message: String,
48}
49
50impl FtpResponse {
51 pub fn is_positive(&self) -> bool {
52 self.code >= 100 && self.code < 400
53 }
54
55 pub fn is_positive_completion(&self) -> bool {
56 self.code >= 200 && self.code < 300
57 }
58
59 pub fn is_positive_intermediate(&self) -> bool {
60 self.code >= 300 && self.code < 400
61 }
62
63 pub fn is_negative(&self) -> bool {
64 self.code >= 400
65 }
66}
67
68#[derive(Debug)]
70pub struct FtpSession {
71 pub name: String,
72 pub host: Option<String>,
73 pub port: u16,
74 pub user: Option<String>,
75 pub pwd: Option<String>,
76 pub connected: bool,
77 pub logged_in: bool,
78 pub transfer_type: TransferType,
79 pub transfer_mode: TransferMode,
80 pub passive: bool,
81 stream: Option<TcpStream>,
82}
83
84impl FtpSession {
85 pub fn new(name: &str) -> Self {
86 Self {
87 name: name.to_string(),
88 host: None,
89 port: 21,
90 user: None,
91 pwd: None,
92 connected: false,
93 logged_in: false,
94 transfer_type: TransferType::Binary,
95 transfer_mode: TransferMode::Stream,
96 passive: true,
97 stream: None,
98 }
99 }
100
101 fn send_command(&mut self, cmd: &str) -> io::Result<()> {
102 if let Some(ref mut stream) = self.stream {
103 write!(stream, "{}\r\n", cmd)?;
104 stream.flush()
105 } else {
106 Err(io::Error::new(io::ErrorKind::NotConnected, "not connected"))
107 }
108 }
109
110 fn read_response(&mut self) -> io::Result<FtpResponse> {
111 let stream = self
112 .stream
113 .as_mut()
114 .ok_or_else(|| io::Error::new(io::ErrorKind::NotConnected, "not connected"))?;
115
116 let mut reader = BufReader::new(stream.try_clone()?);
117 let mut full_message = String::new();
118 let mut code = 0u32;
119 let mut multiline = false;
120 let mut first_code = String::new();
121
122 loop {
123 let mut line = String::new();
124 reader.read_line(&mut line)?;
125 let line = line.trim_end();
126
127 if line.len() < 3 {
128 continue;
129 }
130
131 if code == 0 {
132 first_code = line[..3].to_string();
133 code = first_code.parse().unwrap_or(0);
134
135 if line.len() > 3 && line.chars().nth(3) == Some('-') {
136 multiline = true;
137 }
138 }
139
140 full_message.push_str(line);
141 full_message.push('\n');
142
143 if multiline {
144 if line.starts_with(&first_code)
145 && line.len() > 3
146 && line.chars().nth(3) == Some(' ')
147 {
148 break;
149 }
150 } else {
151 break;
152 }
153 }
154
155 Ok(FtpResponse {
156 code,
157 message: full_message,
158 })
159 }
160
161 pub fn connect(&mut self, host: &str, port: Option<u16>) -> io::Result<FtpResponse> {
163 let port = port.unwrap_or(21);
164 let addr_str = format!("{}:{}", host, port);
165 let dns_timeout = Duration::from_secs(10);
166
167 let (tx, rx) = std::sync::mpsc::channel();
169 let dns = addr_str.clone();
170 std::thread::Builder::new()
171 .name("zftp-dns".to_string())
172 .spawn(move || {
173 let _ = tx.send(dns.to_socket_addrs().map(|a| a.collect::<Vec<_>>()));
174 })
175 .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
176
177 let addrs = rx
178 .recv_timeout(dns_timeout)
179 .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "DNS resolution timed out"))?
180 .map_err(|e| {
181 tracing::warn!(host, error = %e, "zftp: DNS failed");
182 e
183 })?;
184
185 let sock_addr = addrs
186 .into_iter()
187 .next()
188 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid address"))?;
189
190 let stream = TcpStream::connect_timeout(&sock_addr, Duration::from_secs(30))?;
191
192 stream.set_read_timeout(Some(Duration::from_secs(60)))?;
193 stream.set_write_timeout(Some(Duration::from_secs(60)))?;
194
195 self.stream = Some(stream);
196 self.host = Some(host.to_string());
197 self.port = port;
198 self.connected = true;
199
200 self.read_response()
201 }
202
203 pub fn login(&mut self, user: &str, pass: Option<&str>) -> io::Result<FtpResponse> {
205 self.send_command(&format!("USER {}", user))?;
206 let resp = self.read_response()?;
207
208 if resp.code == 331 {
209 let password = pass.unwrap_or("");
210 self.send_command(&format!("PASS {}", password))?;
211 let resp = self.read_response()?;
212
213 if resp.is_positive_completion() {
214 self.logged_in = true;
215 self.user = Some(user.to_string());
216 }
217 return Ok(resp);
218 }
219
220 if resp.is_positive_completion() {
221 self.logged_in = true;
222 self.user = Some(user.to_string());
223 }
224
225 Ok(resp)
226 }
227
228 pub fn set_type(&mut self, transfer_type: TransferType) -> io::Result<FtpResponse> {
230 self.send_command(&format!("TYPE {}", transfer_type.as_str()))?;
231 let resp = self.read_response()?;
232 if resp.is_positive_completion() {
233 self.transfer_type = transfer_type;
234 }
235 Ok(resp)
236 }
237
238 pub fn cd(&mut self, path: &str) -> io::Result<FtpResponse> {
240 self.send_command(&format!("CWD {}", path))?;
241 self.read_response()
242 }
243
244 pub fn cdup(&mut self) -> io::Result<FtpResponse> {
246 self.send_command("CDUP")?;
247 self.read_response()
248 }
249
250 pub fn pwd(&mut self) -> io::Result<(FtpResponse, Option<String>)> {
252 self.send_command("PWD")?;
253 let resp = self.read_response()?;
254
255 let pwd = if resp.is_positive_completion() {
256 if let Some(start) = resp.message.find('"') {
257 if let Some(end) = resp.message[start + 1..].find('"') {
258 Some(resp.message[start + 1..start + 1 + end].to_string())
259 } else {
260 None
261 }
262 } else {
263 None
264 }
265 } else {
266 None
267 };
268
269 Ok((resp, pwd))
270 }
271
272 pub fn list(&mut self, path: Option<&str>) -> io::Result<(FtpResponse, Vec<String>)> {
274 let data_stream = self.enter_passive_mode()?;
275
276 let cmd = match path {
277 Some(p) => format!("LIST {}", p),
278 None => "LIST".to_string(),
279 };
280 self.send_command(&cmd)?;
281 let resp = self.read_response()?;
282
283 if !resp.is_positive() {
284 return Ok((resp, Vec::new()));
285 }
286
287 let mut reader = BufReader::new(data_stream);
288 let mut lines = Vec::new();
289 let mut line = String::new();
290 while reader.read_line(&mut line)? > 0 {
291 lines.push(line.trim_end().to_string());
292 line.clear();
293 }
294
295 let final_resp = self.read_response()?;
296
297 Ok((final_resp, lines))
298 }
299
300 pub fn nlst(&mut self, path: Option<&str>) -> io::Result<(FtpResponse, Vec<String>)> {
302 let data_stream = self.enter_passive_mode()?;
303
304 let cmd = match path {
305 Some(p) => format!("NLST {}", p),
306 None => "NLST".to_string(),
307 };
308 self.send_command(&cmd)?;
309 let resp = self.read_response()?;
310
311 if !resp.is_positive() {
312 return Ok((resp, Vec::new()));
313 }
314
315 let mut reader = BufReader::new(data_stream);
316 let mut lines = Vec::new();
317 let mut line = String::new();
318 while reader.read_line(&mut line)? > 0 {
319 lines.push(line.trim_end().to_string());
320 line.clear();
321 }
322
323 let final_resp = self.read_response()?;
324
325 Ok((final_resp, lines))
326 }
327
328 fn enter_passive_mode(&mut self) -> io::Result<TcpStream> {
329 self.send_command("PASV")?;
330 let resp = self.read_response()?;
331
332 if !resp.is_positive_completion() {
333 return Err(io::Error::new(io::ErrorKind::Other, resp.message));
334 }
335
336 let (ip, port) = parse_pasv_response(&resp.message)?;
337 let addr = format!("{}:{}", ip, port);
338
339 TcpStream::connect_timeout(
340 &addr.to_socket_addrs()?.next().ok_or_else(|| {
341 io::Error::new(io::ErrorKind::InvalidInput, "invalid PASV address")
342 })?,
343 Duration::from_secs(30),
344 )
345 }
346
347 pub fn get(&mut self, remote: &str, local: &Path) -> io::Result<FtpResponse> {
349 let mut data_stream = self.enter_passive_mode()?;
350
351 self.send_command(&format!("RETR {}", remote))?;
352 let resp = self.read_response()?;
353
354 if !resp.is_positive() {
355 return Ok(resp);
356 }
357
358 let mut file = std::fs::File::create(local)?;
359 let mut buf = [0u8; 8192];
360 loop {
361 let n = data_stream.read(&mut buf)?;
362 if n == 0 {
363 break;
364 }
365 file.write_all(&buf[..n])?;
366 }
367
368 self.read_response()
369 }
370
371 pub fn put(&mut self, local: &Path, remote: &str) -> io::Result<FtpResponse> {
373 let mut data_stream = self.enter_passive_mode()?;
374
375 self.send_command(&format!("STOR {}", remote))?;
376 let resp = self.read_response()?;
377
378 if !resp.is_positive() {
379 return Ok(resp);
380 }
381
382 let mut file = std::fs::File::open(local)?;
383 let mut buf = [0u8; 8192];
384 loop {
385 let n = file.read(&mut buf)?;
386 if n == 0 {
387 break;
388 }
389 data_stream.write_all(&buf[..n])?;
390 }
391 drop(data_stream);
392
393 self.read_response()
394 }
395
396 pub fn delete(&mut self, path: &str) -> io::Result<FtpResponse> {
398 self.send_command(&format!("DELE {}", path))?;
399 self.read_response()
400 }
401
402 pub fn mkdir(&mut self, path: &str) -> io::Result<FtpResponse> {
404 self.send_command(&format!("MKD {}", path))?;
405 self.read_response()
406 }
407
408 pub fn rmdir(&mut self, path: &str) -> io::Result<FtpResponse> {
410 self.send_command(&format!("RMD {}", path))?;
411 self.read_response()
412 }
413
414 pub fn rename(&mut self, from: &str, to: &str) -> io::Result<FtpResponse> {
416 self.send_command(&format!("RNFR {}", from))?;
417 let resp = self.read_response()?;
418
419 if !resp.is_positive_intermediate() {
420 return Ok(resp);
421 }
422
423 self.send_command(&format!("RNTO {}", to))?;
424 self.read_response()
425 }
426
427 pub fn size(&mut self, path: &str) -> io::Result<(FtpResponse, Option<u64>)> {
429 self.send_command(&format!("SIZE {}", path))?;
430 let resp = self.read_response()?;
431
432 let size = if resp.is_positive_completion() {
433 resp.message
434 .split_whitespace()
435 .last()
436 .and_then(|s| s.parse().ok())
437 } else {
438 None
439 };
440
441 Ok((resp, size))
442 }
443
444 pub fn quote(&mut self, cmd: &str) -> io::Result<FtpResponse> {
446 self.send_command(cmd)?;
447 self.read_response()
448 }
449
450 pub fn close(&mut self) -> io::Result<FtpResponse> {
452 if !self.connected {
453 return Ok(FtpResponse {
454 code: 0,
455 message: "not connected".to_string(),
456 });
457 }
458
459 let resp = if let Ok(()) = self.send_command("QUIT") {
460 self.read_response().unwrap_or_else(|_| FtpResponse {
461 code: 221,
462 message: "Goodbye".to_string(),
463 })
464 } else {
465 FtpResponse {
466 code: 221,
467 message: "Goodbye".to_string(),
468 }
469 };
470
471 self.stream = None;
472 self.connected = false;
473 self.logged_in = false;
474 self.host = None;
475 self.user = None;
476 self.pwd = None;
477
478 Ok(resp)
479 }
480}
481
482fn parse_pasv_response(msg: &str) -> io::Result<(String, u16)> {
483 let start = msg
484 .find('(')
485 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid PASV response"))?;
486 let end = msg
487 .find(')')
488 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid PASV response"))?;
489
490 let nums: Vec<u16> = msg[start + 1..end]
491 .split(',')
492 .filter_map(|s| s.trim().parse().ok())
493 .collect();
494
495 if nums.len() != 6 {
496 return Err(io::Error::new(
497 io::ErrorKind::InvalidData,
498 "invalid PASV numbers",
499 ));
500 }
501
502 let ip = format!("{}.{}.{}.{}", nums[0], nums[1], nums[2], nums[3]);
503 let port = (nums[4] << 8) + nums[5];
504
505 Ok((ip, port))
506}
507
508#[derive(Debug, Default)]
510pub struct Zftp {
511 sessions: HashMap<String, FtpSession>,
512 current: Option<String>,
513}
514
515impl Zftp {
516 pub fn new() -> Self {
517 Self::default()
518 }
519
520 pub fn get_session(&self, name: Option<&str>) -> Option<&FtpSession> {
521 let key = name
522 .map(|s| s.to_string())
523 .or_else(|| self.current.clone())?;
524 self.sessions.get(&key)
525 }
526
527 pub fn get_session_mut(&mut self, name: Option<&str>) -> Option<&mut FtpSession> {
528 let key = name
529 .map(|s| s.to_string())
530 .or_else(|| self.current.clone())?;
531 self.sessions.get_mut(&key)
532 }
533
534 pub fn create_session(&mut self, name: &str) -> &mut FtpSession {
535 self.sessions
536 .entry(name.to_string())
537 .or_insert_with(|| FtpSession::new(name))
538 }
539
540 pub fn remove_session(&mut self, name: &str) -> Option<FtpSession> {
541 let sess = self.sessions.remove(name);
542 if self.current.as_deref() == Some(name) {
543 self.current = self.sessions.keys().next().cloned();
544 }
545 sess
546 }
547
548 pub fn set_current(&mut self, name: &str) -> bool {
549 if self.sessions.contains_key(name) {
550 self.current = Some(name.to_string());
551 true
552 } else {
553 false
554 }
555 }
556
557 pub fn current_name(&self) -> Option<&str> {
558 self.current.as_deref()
559 }
560
561 pub fn session_names(&self) -> Vec<&str> {
562 self.sessions.keys().map(|s| s.as_str()).collect()
563 }
564}
565
566pub fn builtin_zftp(args: &[&str], zftp: &mut Zftp) -> (i32, String) {
568 if args.is_empty() {
569 return (1, "zftp: subcommand required\n".to_string());
570 }
571
572 match args[0] {
573 "open" => {
574 if args.len() < 2 {
575 return (1, "zftp open: host required\n".to_string());
576 }
577
578 let host = args[1];
579 let port: Option<u16> = args.get(2).and_then(|s| s.parse().ok());
580
581 let session_name = zftp.current_name().unwrap_or("default").to_string();
582
583 let sess = zftp.create_session(&session_name);
584
585 match sess.connect(host, port) {
586 Ok(resp) => {
587 if resp.is_positive() {
588 zftp.set_current(&session_name);
589 (0, resp.message)
590 } else {
591 (1, resp.message)
592 }
593 }
594 Err(e) => (1, format!("zftp open: {}\n", e)),
595 }
596 }
597
598 "login" | "user" => {
599 if args.len() < 2 {
600 return (1, "zftp login: user required\n".to_string());
601 }
602
603 let user = args[1];
604 let pass = args.get(2).map(|s| *s);
605
606 let sess = match zftp.get_session_mut(None) {
607 Some(s) => s,
608 None => return (1, "zftp login: not connected\n".to_string()),
609 };
610
611 match sess.login(user, pass) {
612 Ok(resp) => {
613 if resp.is_positive_completion() {
614 (0, resp.message)
615 } else {
616 (1, resp.message)
617 }
618 }
619 Err(e) => (1, format!("zftp login: {}\n", e)),
620 }
621 }
622
623 "cd" => {
624 if args.len() < 2 {
625 return (1, "zftp cd: path required\n".to_string());
626 }
627
628 let sess = match zftp.get_session_mut(None) {
629 Some(s) => s,
630 None => return (1, "zftp cd: not connected\n".to_string()),
631 };
632
633 match sess.cd(args[1]) {
634 Ok(resp) => {
635 if resp.is_positive_completion() {
636 (0, resp.message)
637 } else {
638 (1, resp.message)
639 }
640 }
641 Err(e) => (1, format!("zftp cd: {}\n", e)),
642 }
643 }
644
645 "cdup" => {
646 let sess = match zftp.get_session_mut(None) {
647 Some(s) => s,
648 None => return (1, "zftp cdup: not connected\n".to_string()),
649 };
650
651 match sess.cdup() {
652 Ok(resp) => {
653 if resp.is_positive_completion() {
654 (0, resp.message)
655 } else {
656 (1, resp.message)
657 }
658 }
659 Err(e) => (1, format!("zftp cdup: {}\n", e)),
660 }
661 }
662
663 "pwd" => {
664 let sess = match zftp.get_session_mut(None) {
665 Some(s) => s,
666 None => return (1, "zftp pwd: not connected\n".to_string()),
667 };
668
669 match sess.pwd() {
670 Ok((resp, pwd)) => {
671 if let Some(p) = pwd {
672 (0, format!("{}\n", p))
673 } else {
674 (1, resp.message)
675 }
676 }
677 Err(e) => (1, format!("zftp pwd: {}\n", e)),
678 }
679 }
680
681 "dir" | "ls" => {
682 let path = args.get(1).map(|s| *s);
683 let use_nlst = args[0] == "ls";
684
685 let sess = match zftp.get_session_mut(None) {
686 Some(s) => s,
687 None => return (1, "zftp dir: not connected\n".to_string()),
688 };
689
690 let result = if use_nlst {
691 sess.nlst(path)
692 } else {
693 sess.list(path)
694 };
695
696 match result {
697 Ok((resp, lines)) => {
698 if resp.is_positive_completion() {
699 (0, lines.join("\n") + "\n")
700 } else {
701 (1, resp.message)
702 }
703 }
704 Err(e) => (1, format!("zftp dir: {}\n", e)),
705 }
706 }
707
708 "get" => {
709 if args.len() < 2 {
710 return (1, "zftp get: remote file required\n".to_string());
711 }
712
713 let remote = args[1];
714 let local = args.get(2).unwrap_or(&remote);
715
716 let sess = match zftp.get_session_mut(None) {
717 Some(s) => s,
718 None => return (1, "zftp get: not connected\n".to_string()),
719 };
720
721 match sess.get(remote, Path::new(local)) {
722 Ok(resp) => {
723 if resp.is_positive_completion() {
724 (0, String::new())
725 } else {
726 (1, resp.message)
727 }
728 }
729 Err(e) => (1, format!("zftp get: {}\n", e)),
730 }
731 }
732
733 "put" => {
734 if args.len() < 2 {
735 return (1, "zftp put: local file required\n".to_string());
736 }
737
738 let local = args[1];
739 let remote = args.get(2).unwrap_or(&local);
740
741 let sess = match zftp.get_session_mut(None) {
742 Some(s) => s,
743 None => return (1, "zftp put: not connected\n".to_string()),
744 };
745
746 match sess.put(Path::new(local), remote) {
747 Ok(resp) => {
748 if resp.is_positive_completion() {
749 (0, String::new())
750 } else {
751 (1, resp.message)
752 }
753 }
754 Err(e) => (1, format!("zftp put: {}\n", e)),
755 }
756 }
757
758 "delete" => {
759 if args.len() < 2 {
760 return (1, "zftp delete: file required\n".to_string());
761 }
762
763 let sess = match zftp.get_session_mut(None) {
764 Some(s) => s,
765 None => return (1, "zftp delete: not connected\n".to_string()),
766 };
767
768 match sess.delete(args[1]) {
769 Ok(resp) => {
770 if resp.is_positive_completion() {
771 (0, String::new())
772 } else {
773 (1, resp.message)
774 }
775 }
776 Err(e) => (1, format!("zftp delete: {}\n", e)),
777 }
778 }
779
780 "mkdir" => {
781 if args.len() < 2 {
782 return (1, "zftp mkdir: directory required\n".to_string());
783 }
784
785 let sess = match zftp.get_session_mut(None) {
786 Some(s) => s,
787 None => return (1, "zftp mkdir: not connected\n".to_string()),
788 };
789
790 match sess.mkdir(args[1]) {
791 Ok(resp) => {
792 if resp.is_positive_completion() {
793 (0, String::new())
794 } else {
795 (1, resp.message)
796 }
797 }
798 Err(e) => (1, format!("zftp mkdir: {}\n", e)),
799 }
800 }
801
802 "rmdir" => {
803 if args.len() < 2 {
804 return (1, "zftp rmdir: directory required\n".to_string());
805 }
806
807 let sess = match zftp.get_session_mut(None) {
808 Some(s) => s,
809 None => return (1, "zftp rmdir: not connected\n".to_string()),
810 };
811
812 match sess.rmdir(args[1]) {
813 Ok(resp) => {
814 if resp.is_positive_completion() {
815 (0, String::new())
816 } else {
817 (1, resp.message)
818 }
819 }
820 Err(e) => (1, format!("zftp rmdir: {}\n", e)),
821 }
822 }
823
824 "rename" => {
825 if args.len() < 3 {
826 return (1, "zftp rename: from and to required\n".to_string());
827 }
828
829 let sess = match zftp.get_session_mut(None) {
830 Some(s) => s,
831 None => return (1, "zftp rename: not connected\n".to_string()),
832 };
833
834 match sess.rename(args[1], args[2]) {
835 Ok(resp) => {
836 if resp.is_positive_completion() {
837 (0, String::new())
838 } else {
839 (1, resp.message)
840 }
841 }
842 Err(e) => (1, format!("zftp rename: {}\n", e)),
843 }
844 }
845
846 "type" | "ascii" | "binary" => {
847 let transfer_type = match args[0] {
848 "ascii" => TransferType::Ascii,
849 "binary" => TransferType::Binary,
850 "type" => {
851 if args.len() < 2 {
852 let sess = match zftp.get_session(None) {
853 Some(s) => s,
854 None => return (1, "zftp type: not connected\n".to_string()),
855 };
856 return (
857 0,
858 format!(
859 "{}\n",
860 if sess.transfer_type == TransferType::Ascii {
861 "ascii"
862 } else {
863 "binary"
864 }
865 ),
866 );
867 }
868 match args[1].to_lowercase().as_str() {
869 "a" | "ascii" => TransferType::Ascii,
870 "i" | "binary" | "image" => TransferType::Binary,
871 _ => return (1, format!("zftp type: unknown type {}\n", args[1])),
872 }
873 }
874 _ => unreachable!(),
875 };
876
877 let sess = match zftp.get_session_mut(None) {
878 Some(s) => s,
879 None => return (1, "zftp type: not connected\n".to_string()),
880 };
881
882 match sess.set_type(transfer_type) {
883 Ok(resp) => {
884 if resp.is_positive_completion() {
885 (0, String::new())
886 } else {
887 (1, resp.message)
888 }
889 }
890 Err(e) => (1, format!("zftp type: {}\n", e)),
891 }
892 }
893
894 "quote" => {
895 if args.len() < 2 {
896 return (1, "zftp quote: command required\n".to_string());
897 }
898
899 let cmd = args[1..].join(" ");
900
901 let sess = match zftp.get_session_mut(None) {
902 Some(s) => s,
903 None => return (1, "zftp quote: not connected\n".to_string()),
904 };
905
906 match sess.quote(&cmd) {
907 Ok(resp) => (if resp.is_positive() { 0 } else { 1 }, resp.message),
908 Err(e) => (1, format!("zftp quote: {}\n", e)),
909 }
910 }
911
912 "close" | "quit" => {
913 let sess = match zftp.get_session_mut(None) {
914 Some(s) => s,
915 None => return (0, String::new()),
916 };
917
918 match sess.close() {
919 Ok(_) => (0, String::new()),
920 Err(e) => (1, format!("zftp close: {}\n", e)),
921 }
922 }
923
924 "session" => {
925 if args.len() < 2 {
926 let names = zftp.session_names();
927 let current = zftp.current_name();
928 let mut out = String::new();
929 for name in names {
930 let marker = if Some(name) == current { "* " } else { " " };
931 out.push_str(&format!("{}{}\n", marker, name));
932 }
933 return (0, out);
934 }
935
936 let name = args[1];
937 if zftp.sessions.contains_key(name) {
938 zftp.set_current(name);
939 } else {
940 zftp.create_session(name);
941 zftp.set_current(name);
942 }
943 (0, String::new())
944 }
945
946 "rmsession" => {
947 if args.len() < 2 {
948 return (1, "zftp rmsession: session name required\n".to_string());
949 }
950
951 if zftp.remove_session(args[1]).is_some() {
952 (0, String::new())
953 } else {
954 (
955 1,
956 format!("zftp rmsession: session {} not found\n", args[1]),
957 )
958 }
959 }
960
961 "test" => {
962 let sess = zftp.get_session(None);
963 if sess.map(|s| s.connected).unwrap_or(false) {
964 (0, String::new())
965 } else {
966 (1, String::new())
967 }
968 }
969
970 _ => (1, format!("zftp: unknown subcommand {}\n", args[0])),
971 }
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977
978 #[test]
979 fn test_transfer_type() {
980 assert_eq!(TransferType::Ascii.as_str(), "A");
981 assert_eq!(TransferType::Binary.as_str(), "I");
982 }
983
984 #[test]
985 fn test_transfer_mode() {
986 assert_eq!(TransferMode::Stream.as_str(), "S");
987 assert_eq!(TransferMode::Block.as_str(), "B");
988 }
989
990 #[test]
991 fn test_ftp_response_positive() {
992 let resp = FtpResponse {
993 code: 200,
994 message: "OK".to_string(),
995 };
996 assert!(resp.is_positive());
997 assert!(resp.is_positive_completion());
998 assert!(!resp.is_negative());
999 }
1000
1001 #[test]
1002 fn test_ftp_response_intermediate() {
1003 let resp = FtpResponse {
1004 code: 331,
1005 message: "Password required".to_string(),
1006 };
1007 assert!(resp.is_positive());
1008 assert!(resp.is_positive_intermediate());
1009 assert!(!resp.is_positive_completion());
1010 }
1011
1012 #[test]
1013 fn test_ftp_response_negative() {
1014 let resp = FtpResponse {
1015 code: 550,
1016 message: "File not found".to_string(),
1017 };
1018 assert!(resp.is_negative());
1019 assert!(!resp.is_positive());
1020 }
1021
1022 #[test]
1023 fn test_ftp_session_new() {
1024 let sess = FtpSession::new("test");
1025 assert_eq!(sess.name, "test");
1026 assert!(!sess.connected);
1027 assert!(!sess.logged_in);
1028 }
1029
1030 #[test]
1031 fn test_parse_pasv_response() {
1032 let msg = "227 Entering Passive Mode (192,168,1,1,4,1)";
1033 let (ip, port) = parse_pasv_response(msg).unwrap();
1034 assert_eq!(ip, "192.168.1.1");
1035 assert_eq!(port, 1025);
1036 }
1037
1038 #[test]
1039 fn test_parse_pasv_response_invalid() {
1040 let msg = "invalid";
1041 assert!(parse_pasv_response(msg).is_err());
1042 }
1043
1044 #[test]
1045 fn test_zftp_new() {
1046 let zftp = Zftp::new();
1047 assert!(zftp.session_names().is_empty());
1048 }
1049
1050 #[test]
1051 fn test_zftp_create_session() {
1052 let mut zftp = Zftp::new();
1053 zftp.create_session("test");
1054 assert!(zftp.sessions.contains_key("test"));
1055 }
1056
1057 #[test]
1058 fn test_zftp_remove_session() {
1059 let mut zftp = Zftp::new();
1060 zftp.create_session("test");
1061 assert!(zftp.remove_session("test").is_some());
1062 assert!(zftp.remove_session("test").is_none());
1063 }
1064
1065 #[test]
1066 fn test_zftp_set_current() {
1067 let mut zftp = Zftp::new();
1068 zftp.create_session("test");
1069 assert!(zftp.set_current("test"));
1070 assert!(!zftp.set_current("nonexistent"));
1071 }
1072
1073 #[test]
1074 fn test_builtin_zftp_no_args() {
1075 let mut zftp = Zftp::new();
1076 let (status, _) = builtin_zftp(&[], &mut zftp);
1077 assert_eq!(status, 1);
1078 }
1079
1080 #[test]
1081 fn test_builtin_zftp_session() {
1082 let mut zftp = Zftp::new();
1083 let (status, _) = builtin_zftp(&["session", "test"], &mut zftp);
1084 assert_eq!(status, 0);
1085 assert!(zftp.sessions.contains_key("test"));
1086 }
1087
1088 #[test]
1089 fn test_builtin_zftp_test_not_connected() {
1090 let mut zftp = Zftp::new();
1091 let (status, _) = builtin_zftp(&["test"], &mut zftp);
1092 assert_eq!(status, 1);
1093 }
1094}