Skip to main content

zsh/
zftp.rs

1//! ZFTP module - port of Modules/zftp.c
2//!
3//! Provides a builtin FTP client for zsh.
4
5use 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/// FTP transfer type
12#[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/// FTP transfer mode
28#[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/// FTP response
44#[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/// FTP session state
69#[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    /// Connect to FTP server — DNS resolution on background thread to avoid hangs
162    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        // DNS on background thread
168        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    /// Login to FTP server
204    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    /// Set transfer type
229    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    /// Change directory
239    pub fn cd(&mut self, path: &str) -> io::Result<FtpResponse> {
240        self.send_command(&format!("CWD {}", path))?;
241        self.read_response()
242    }
243
244    /// Change to parent directory
245    pub fn cdup(&mut self) -> io::Result<FtpResponse> {
246        self.send_command("CDUP")?;
247        self.read_response()
248    }
249
250    /// Get current directory
251    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    /// List directory
273    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    /// List filenames only
301    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    /// Download a file
348    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    /// Upload a file
372    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    /// Delete a file
397    pub fn delete(&mut self, path: &str) -> io::Result<FtpResponse> {
398        self.send_command(&format!("DELE {}", path))?;
399        self.read_response()
400    }
401
402    /// Make directory
403    pub fn mkdir(&mut self, path: &str) -> io::Result<FtpResponse> {
404        self.send_command(&format!("MKD {}", path))?;
405        self.read_response()
406    }
407
408    /// Remove directory
409    pub fn rmdir(&mut self, path: &str) -> io::Result<FtpResponse> {
410        self.send_command(&format!("RMD {}", path))?;
411        self.read_response()
412    }
413
414    /// Rename file
415    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    /// Get file size
428    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    /// Send raw command
445    pub fn quote(&mut self, cmd: &str) -> io::Result<FtpResponse> {
446        self.send_command(cmd)?;
447        self.read_response()
448    }
449
450    /// Close connection
451    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/// FTP sessions manager
509#[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
566/// Execute zftp builtin
567pub 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}