diff --git a/CHANGELOG.md b/CHANGELOG.md index d55cbfd..0753fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Released on 18/05/2024 - [Issue 70](https://github.com/veeso/suppaftp/issues/70): **SITE** Command - [Issue 75](https://github.com/veeso/suppaftp/issues/75): Public access to `connect_with_stream` +- [Issue 76](https://github.com/veeso/suppaftp/issues/76): Support for **MLST** and **MLSD** - [PR 78](https://github.com/veeso/suppaftp/pull/78): Async SSL file uploads not properly closing - `custom_command`: added `custom_command` function to perform custom commands diff --git a/suppaftp-cli/src/actions.rs b/suppaftp-cli/src/actions.rs index 95c26c6..e7f73ec 100644 --- a/suppaftp-cli/src/actions.rs +++ b/suppaftp-cli/src/actions.rs @@ -117,6 +117,24 @@ pub fn list(ftp: &mut FtpStream, p: Option<&str>) { } } +pub fn mlst(ftp: &mut FtpStream, p: Option<&str>) { + match ftp.mlst(p) { + Ok(file) => { + println!("{file}"); + } + Err(err) => eprintln!("MLST error: {err}"), + } +} + +pub fn mlsd(ftp: &mut FtpStream, p: Option<&str>) { + match ftp.mlsd(p) { + Ok(files) => { + files.iter().for_each(|f| println!("{f}")); + } + Err(err) => eprintln!("MLSD error: {err}"), + } +} + pub fn login(ftp: &mut FtpStream) { // Read username let username: String = match rpassword::prompt_password("Username: ") { diff --git a/suppaftp-cli/src/command.rs b/suppaftp-cli/src/command.rs index bca4379..43be43f 100644 --- a/suppaftp-cli/src/command.rs +++ b/suppaftp-cli/src/command.rs @@ -13,6 +13,8 @@ pub enum Command { List(Option), Login, Mdtm(String), + Mlsd(Option), + Mlst(Option), Mkdir(String), Mode(Mode), Noop, @@ -74,6 +76,14 @@ impl FromStr for Command { Some(file) => Ok(Self::Mkdir(file.to_string())), None => Err("Missing `file` field"), }, + "MLSD" => match args.next() { + Some(dir) => Ok(Self::Mlsd(Some(dir.to_string()))), + None => Ok(Self::Mlsd(None)), + }, + "MLST" => match args.next() { + Some(dir) => Ok(Self::Mlst(Some(dir.to_string()))), + None => Ok(Self::Mlst(None)), + }, "MODE" => match args.next() { Some("ACTIVE") => Ok(Self::Mode(Mode::Active)), Some("EXTPASSIVE") => Ok(Self::Mode(Mode::ExtendedPassive)), diff --git a/suppaftp-cli/src/main.rs b/suppaftp-cli/src/main.rs index 31b6161..52b3cc6 100644 --- a/suppaftp-cli/src/main.rs +++ b/suppaftp-cli/src/main.rs @@ -34,6 +34,9 @@ fn usage() { println!("LIST [dir] List files. If directory is not provided, current directory is used"); println!("LOGIN Login to remote"); println!("MDTM Get modification time for `file`"); + println!("MKDIR Create directory"); + println!("MLSD [dir] List files in a machine-readable format. If directory is not provided, current directory is used"); + println!("MLST [dir] List files in a machine-readable format. If directory is not provided, current directory is used"); println!("MODE Set mode"); println!("NOOP Ping server"); println!("OPTS [feature-value] Set a feature on the server (e.g. OPTS UTF8 ON)"); @@ -135,6 +138,8 @@ fn perform_connected(ftp: &mut FtpStream, command: Command) { Command::Feat => feat(ftp), Command::Login => login(ftp), Command::Mdtm(p) => mdtm(ftp, p.as_str()), + Command::Mlsd(p) => mlsd(ftp, p.as_deref()), + Command::Mlst(p) => mlst(ftp, p.as_deref()), Command::Mkdir(p) => mkdir(ftp, p.as_str()), Command::Mode(m) => set_mode(ftp, m), Command::Noop => noop(ftp), diff --git a/suppaftp/src/async_ftp/mod.rs b/suppaftp/src/async_ftp/mod.rs index 8c956ba..b9167b9 100644 --- a/suppaftp/src/async_ftp/mod.rs +++ b/suppaftp/src/async_ftp/mod.rs @@ -590,6 +590,33 @@ where .await } + /// Execute `MLSD` command which returns the machine-processable listing of a directory. + /// If `pathname` is omited then the list of files in the current directory will be + pub async fn mlsd(&mut self, pathname: Option<&str>) -> FtpResult> { + debug!( + "Reading {} directory content", + pathname.unwrap_or("working") + ); + + self.stream_lines( + Command::Mlsd(pathname.map(|x| x.to_string())), + Status::AboutToSend, + ) + .await + } + + /// Execute `MLST` command which returns the machine-processable listing of a file. + /// If `pathname` is omited then the list of files in the current directory will be + pub async fn mlst(&mut self, pathname: Option<&str>) -> FtpResult> { + debug!("Reading {} path information", pathname.unwrap_or("working")); + + self.stream_lines( + Command::Mlst(pathname.map(|x| x.to_string())), + Status::AboutToSend, + ) + .await + } + /// Retrieves the modification time of the file at `pathname` if it exists. pub async fn mdtm>(&mut self, pathname: S) -> FtpResult { debug!("Getting modification time for {}", pathname.as_ref()); diff --git a/suppaftp/src/command.rs b/suppaftp/src/command.rs index 0f2624f..3474221 100644 --- a/suppaftp/src/command.rs +++ b/suppaftp/src/command.rs @@ -39,6 +39,10 @@ pub enum Command { List(Option), /// Get modification time for file at specified path Mdtm(String), + /// Get the list of directories at specified path. If path is not provided list directories at current working directory + Mlsd(Option), + /// Get details of an individual file or directory at specified path + Mlst(Option), /// Make directory Mkd(String), /// Get the list of file names at specified path. If path is not provided list entries at current working directory @@ -132,6 +136,14 @@ impl fmt::Display for Command { .unwrap_or_else(|| "LIST".to_string()), Self::Mdtm(p) => format!("MDTM {p}"), Self::Mkd(p) => format!("MKD {p}"), + Self::Mlsd(p) => p + .as_deref() + .map(|x| format!("MLSD {x}")) + .unwrap_or_else(|| "MLSD".to_string()), + Self::Mlst(p) => p + .as_deref() + .map(|x| format!("MLST {x}")) + .unwrap_or_else(|| "MLST".to_string()), Self::Nlst(p) => p .as_deref() .map(|x| format!("NLST {x}")) @@ -249,6 +261,20 @@ mod test { Command::Mkd(String::from("/tmp")).to_string().as_str(), "MKD /tmp\r\n" ); + assert_eq!( + Command::Mlsd(Some(String::from("/tmp"))) + .to_string() + .as_str(), + "MLSD /tmp\r\n" + ); + assert_eq!(Command::Mlsd(None).to_string().as_str(), "MLSD\r\n"); + assert_eq!( + Command::Mlst(Some(String::from("/tmp"))) + .to_string() + .as_str(), + "MLST /tmp\r\n" + ); + assert_eq!(Command::Mlst(None).to_string().as_str(), "MLST\r\n"); assert_eq!( Command::Nlst(Some(String::from("/tmp"))) .to_string() @@ -316,7 +342,7 @@ mod test { Command::Site(String::from("chmod 755 a.txt")) .to_string() .as_str(), - "SITE chmopd 755 a.txt\r\n" + "SITE chmod 755 a.txt\r\n" ); assert_eq!( Command::Size(String::from("a.txt")).to_string().as_str(), diff --git a/suppaftp/src/list.rs b/suppaftp/src/list.rs index 4937fef..e4bbe28 100644 --- a/suppaftp/src/list.rs +++ b/suppaftp/src/list.rs @@ -187,6 +187,83 @@ impl File { // -- parsers + /// Parse an output line from a MLSD or MLST command + pub fn from_mlsx_line(line: &str) -> Result { + let tokens = line.split(';').collect::>(); + if tokens.is_empty() { + return Err(ParseError::SyntaxError); + } + let mut f = File { + name: String::default(), + file_type: FileType::File, + size: 0, + modified: SystemTime::UNIX_EPOCH, + uid: None, + gid: None, + posix_pex: ( + PosixPex::from(0o7), + PosixPex::from(0o7), + PosixPex::from(0o7), + ), + }; + for token in tokens.iter() { + let mut parts = token.split('='); + let key = match parts.next() { + Some(k) => k, + None => continue, + }; + let value = match parts.next() { + Some(v) => v, + None => continue, + }; + match key.to_lowercase().as_str() { + "type" => { + f.file_type = match value.to_lowercase().as_str() { + "dir" => FileType::Directory, + "file" => FileType::File, + "link" => FileType::Symlink(PathBuf::default()), + _ => return Err(ParseError::SyntaxError), + }; + } + "size" => { + f.size = value.parse::().map_err(|_| ParseError::BadSize)?; + } + "modify" => { + f.modified = Self::parse_mlsx_time(value)?; + } + "unix.uid" => { + f.uid = value.parse::().ok(); + } + "unix.gid" => { + f.gid = value.parse::().ok(); + } + "unix.mode" => { + if value.len() != 3 { + return Err(ParseError::SyntaxError); + } + let chars = value.chars().collect::>(); + // convert to nums + let modes = chars + .iter() + .map(|c| c.to_digit(8).unwrap_or(0)) + .collect::>(); + + f.posix_pex = ( + PosixPex::from(modes[0] as u8), + PosixPex::from(modes[1] as u8), + PosixPex::from(modes[2] as u8), + ); + } + _ => continue, + } + } + + // get name + f.name = tokens.last().unwrap().trim_start().to_string(); + + Ok(f) + } + /// Parse a POSIX LIST output line and if it is valid, return a `File` instance. /// In case of error a `ParseError` is returned pub fn from_posix_line(line: &str) -> Result { @@ -359,6 +436,17 @@ impl File { (filename, symlink) } + /// Convert MLSD time to System Time + fn parse_mlsx_time(tm: &str) -> Result { + NaiveDateTime::parse_from_str(tm, "%Y%m%d%H%M%S") + .map(|dt| { + SystemTime::UNIX_EPOCH + .checked_add(Duration::from_secs(dt.timestamp() as u64)) + .unwrap_or(SystemTime::UNIX_EPOCH) + }) + .map_err(|_| ParseError::InvalidDate) + } + /// Convert ls syntax time to System Time /// ls time has two possible syntax: /// 1. if year is current: %b %d %H:%M (e.g. Nov 5 13:46) @@ -860,6 +948,45 @@ mod test { assert!(File::parse_dostime("04-08-14").is_err()); } + #[test] + fn test_parse_mlsx_line() { + let file = File::from_mlsx_line("type=file;size=8192;modify=20181105163248; omar.txt") + .ok() + .unwrap(); + + assert_eq!(file.name(), "omar.txt"); + assert_eq!(file.size, 8192); + assert!(file.is_file()); + assert_eq!(file.gid, None); + assert_eq!(file.uid, None); + assert_eq!(file.can_read(PosixPexQuery::Owner), true); + assert_eq!(file.can_write(PosixPexQuery::Owner), true); + assert_eq!(file.can_execute(PosixPexQuery::Owner), true); + assert_eq!(file.can_read(PosixPexQuery::Group), true); + assert_eq!(file.can_write(PosixPexQuery::Group), true); + assert_eq!(file.can_execute(PosixPexQuery::Group), true); + assert_eq!(file.can_read(PosixPexQuery::Others), true); + assert_eq!(file.can_write(PosixPexQuery::Others), true); + assert_eq!(file.can_execute(PosixPexQuery::Others), true); + + let file = File::from_mlsx_line("type=dir;size=4096;modify=20181105163248; docs") + .ok() + .unwrap(); + + assert_eq!(file.name(), "docs"); + assert!(file.is_directory()); + + let file = File::from_mlsx_line( + "type=file;size=4096;modify=20181105163248;unix.mode=644; omar.txt", + ) + .ok() + .unwrap(); + assert_eq!( + file.posix_pex, + (PosixPex::from(6), PosixPex::from(4), PosixPex::from(4)) + ); + } + #[test] fn file_type() { assert_eq!(FileType::Directory.is_directory(), true); diff --git a/suppaftp/src/sync_ftp/mod.rs b/suppaftp/src/sync_ftp/mod.rs index 000207b..d061e3e 100644 --- a/suppaftp/src/sync_ftp/mod.rs +++ b/suppaftp/src/sync_ftp/mod.rs @@ -598,6 +598,32 @@ where ) } + /// Execute `MLSD` command which returns the machine-processable listing of a directory. + /// If `pathname` is omited then the list of files in the current directory will be + pub fn mlsd(&mut self, pathname: Option<&str>) -> FtpResult> { + debug!( + "Reading {} directory content", + pathname.unwrap_or("working") + ); + + self.stream_lines( + Command::Mlsd(pathname.map(|x| x.to_string())), + Status::AboutToSend, + ) + } + + /// Execute `MLST` command which returns the machine-processable listing of a file. + /// If `pathname` is omited then the list of files in the current directory will be + pub fn mlst(&mut self, pathname: Option<&str>) -> FtpResult { + debug!("Reading {} path information", pathname.unwrap_or("working")); + + self.perform(Command::Mlst(pathname.map(|x| x.to_string())))?; + let response = self.read_response_in_at(&[Status::RequestedFileActionOk], Some(0))?; + + // trim newline and space + Ok(String::from_utf8_lossy(&response.body).trim().to_string()) + } + /// Retrieves the modification time of the file at `pathname` if it exists. pub fn mdtm>(&mut self, pathname: S) -> FtpResult { debug!("Getting modification time for {}", pathname.as_ref()); @@ -746,6 +772,15 @@ where /// Retrieve single line response fn read_response_in(&mut self, expected_code: &[Status]) -> FtpResult { + self.read_response_in_at(expected_code, None) + } + + /// Retrieve single line response + fn read_response_in_at( + &mut self, + expected_code: &[Status], + at_line: Option, + ) -> FtpResult { let mut line = Vec::new(); self.read_line(&mut line)?; @@ -769,13 +804,19 @@ where expected }; trace!("CC IN: {:?}", line); + let mut line_num = 0; + let mut body = None; while line.len() < 5 || (line[0..4] != expected && line[0..4] != alt_expected) { line.clear(); self.read_line(&mut line)?; + if Some(line_num) == at_line { + body = Some(line.clone()); + } + line_num += 1; trace!("CC IN: {:?}", line); } - let response: Response = Response::new(code, line); + let response: Response = Response::new(code, body.unwrap_or(line)); // Return Ok or error with response if expected_code.iter().any(|ec| code == *ec) { Ok(response)