Skip to content

Commit

Permalink
feat: Support MLST and MLSD
Browse files Browse the repository at this point in the history
  • Loading branch information
veeso committed May 18, 2024
1 parent 55f4dc1 commit 701071d
Show file tree
Hide file tree
Showing 8 changed files with 257 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions suppaftp-cli/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: ") {
Expand Down
10 changes: 10 additions & 0 deletions suppaftp-cli/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ pub enum Command {
List(Option<String>),
Login,
Mdtm(String),
Mlsd(Option<String>),
Mlst(Option<String>),
Mkdir(String),
Mode(Mode),
Noop,
Expand Down Expand Up @@ -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)),
Expand Down
5 changes: 5 additions & 0 deletions suppaftp-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> Get modification time for `file`");
println!("MKDIR <dir> 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 <PASSIVE|EXTPASSIVE|ACTIVE> Set mode");
println!("NOOP Ping server");
println!("OPTS <feature-name> [feature-value] Set a feature on the server (e.g. OPTS UTF8 ON)");
Expand Down Expand Up @@ -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),
Expand Down
27 changes: 27 additions & 0 deletions suppaftp/src/async_ftp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>> {
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<Vec<String>> {
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<S: AsRef<str>>(&mut self, pathname: S) -> FtpResult<NaiveDateTime> {
debug!("Getting modification time for {}", pathname.as_ref());
Expand Down
28 changes: 27 additions & 1 deletion suppaftp/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ pub enum Command {
List(Option<String>),
/// 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<String>),
/// Get details of an individual file or directory at specified path
Mlst(Option<String>),
/// Make directory
Mkd(String),
/// Get the list of file names at specified path. If path is not provided list entries at current working directory
Expand Down Expand Up @@ -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}"))
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(),
Expand Down
127 changes: 127 additions & 0 deletions suppaftp/src/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, ParseError> {
let tokens = line.split(';').collect::<Vec<&str>>();
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::<usize>().map_err(|_| ParseError::BadSize)?;
}
"modify" => {
f.modified = Self::parse_mlsx_time(value)?;
}
"unix.uid" => {
f.uid = value.parse::<u32>().ok();
}
"unix.gid" => {
f.gid = value.parse::<u32>().ok();
}
"unix.mode" => {
if value.len() != 3 {
return Err(ParseError::SyntaxError);
}
let chars = value.chars().collect::<Vec<char>>();
// convert to nums
let modes = chars
.iter()
.map(|c| c.to_digit(8).unwrap_or(0))
.collect::<Vec<u32>>();

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<Self, ParseError> {
Expand Down Expand Up @@ -359,6 +436,17 @@ impl File {
(filename, symlink)
}

/// Convert MLSD time to System Time
fn parse_mlsx_time(tm: &str) -> Result<SystemTime, ParseError> {
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)
Expand Down Expand Up @@ -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);
Expand Down
43 changes: 42 additions & 1 deletion suppaftp/src/sync_ftp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>> {
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<String> {
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<S: AsRef<str>>(&mut self, pathname: S) -> FtpResult<NaiveDateTime> {
debug!("Getting modification time for {}", pathname.as_ref());
Expand Down Expand Up @@ -746,6 +772,15 @@ where

/// Retrieve single line response
fn read_response_in(&mut self, expected_code: &[Status]) -> FtpResult<Response> {
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<usize>,
) -> FtpResult<Response> {
let mut line = Vec::new();
self.read_line(&mut line)?;

Expand All @@ -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)
Expand Down

0 comments on commit 701071d

Please sign in to comment.