use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_ssh2_tokio::client::{AuthMethod, Client as SshClient, ServerCheckMethod};
use async_trait::async_trait;
use russh_sftp::client::SftpSession;
use russh_sftp::protocol::FileType;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::error::{Error, Result};
use crate::traits::RemoteFileSystem;
use crate::types::{FileEntry, FileMeta, SftpConfig};
pub struct SftpClient {
config: SftpConfig,
ssh: Option<SshClient>,
sftp: Option<SftpSession>,
}
impl SftpClient {
pub fn new(config: SftpConfig) -> Self {
Self {
config,
ssh: None,
sftp: None,
}
}
fn auth_method(&self) -> Result<AuthMethod> {
if let Some(ref password) = self.config.password {
Ok(AuthMethod::with_password(password))
} else if let Some(ref key) = self.config.private_key_path {
Ok(AuthMethod::with_key_file(key, None))
} else {
Err(Error::Auth(
"SFTP requires password or private key".into(),
))
}
}
fn sftp(&self) -> Result<&SftpSession> {
self.sftp.as_ref().ok_or(Error::NotConnected)
}
async fn open_sftp(ssh: &SshClient) -> Result<SftpSession> {
let channel = ssh.get_channel().await?;
channel.request_subsystem(true, "sftp").await.map_err(|e| {
Error::Protocol(format!("failed to request sftp subsystem: {e}"))
})?;
let sftp = SftpSession::new(channel.into_stream()).await?;
Ok(sftp)
}
}
fn file_type_flags(ft: &FileType) -> (bool, bool, bool) {
match ft {
FileType::Dir => (true, false, false),
FileType::Symlink => (false, false, true),
FileType::File => (false, true, false),
_ => (false, true, false),
}
}
fn mtime_to_system(mtime: Option<u32>) -> Option<SystemTime> {
mtime.map(|secs| UNIX_EPOCH + Duration::from_secs(secs as u64))
}
#[async_trait]
impl RemoteFileSystem for SftpClient {
async fn connect(&mut self) -> Result<()> {
let auth = self.auth_method()?;
let ssh = SshClient::connect(
(self.config.host.as_str(), self.config.port),
&self.config.username,
auth,
ServerCheckMethod::NoCheck,
)
.await?;
let sftp = Self::open_sftp(&ssh).await?;
self.ssh = Some(ssh);
self.sftp = Some(sftp);
Ok(())
}
async fn disconnect(&mut self) -> Result<()> {
if let Some(sftp) = self.sftp.take() {
let _ = sftp.close().await;
}
self.ssh = None;
Ok(())
}
fn is_connected(&self) -> bool {
self.ssh.is_some() && self.sftp.is_some()
}
async fn list(&mut self, path: &str) -> Result<Vec<FileEntry>> {
let sftp = self.sftp()?;
let mut entries = Vec::new();
let mut dir = sftp.read_dir(path.to_string()).await?;
while let Some(entry) = dir.next() {
let name = entry.file_name();
if name == "." || name == ".." {
continue;
}
let meta = entry.metadata();
let (is_dir, is_file, is_symlink) = file_type_flags(&meta.file_type());
let child = if path.ends_with('/') {
format!("{path}{name}")
} else if path.is_empty() {
name.clone()
} else {
format!("{path}/{name}")
};
entries.push(FileEntry {
path: PathBuf::from(child),
name,
is_dir,
is_file,
is_symlink,
size: meta.size.unwrap_or(0),
modified: mtime_to_system(meta.mtime),
});
}
Ok(entries)
}
async fn metadata(&mut self, path: &str) -> Result<FileMeta> {
let sftp = self.sftp()?;
let meta = sftp.metadata(path.to_string()).await?;
let (is_dir, is_file, is_symlink) = file_type_flags(&meta.file_type());
Ok(FileMeta {
path: PathBuf::from(path),
is_dir,
is_file,
is_symlink,
size: meta.size.unwrap_or(0),
modified: mtime_to_system(meta.mtime),
})
}
async fn exists(&mut self, path: &str) -> Result<bool> {
Ok(self.sftp()?.try_exists(path.to_string()).await?)
}
async fn mkdir(&mut self, path: &str) -> Result<()> {
self.sftp()?.create_dir(path.to_string()).await?;
Ok(())
}
async fn mkdir_all(&mut self, path: &str) -> Result<()> {
let path = path.trim_matches('/');
if path.is_empty() {
return Ok(());
}
let mut current = String::new();
for part in path.split('/').filter(|p| !p.is_empty()) {
current = if current.is_empty() {
format!("/{part}")
} else {
format!("{current}/{part}")
};
if !self.exists(¤t).await? {
self.mkdir(¤t).await?;
}
}
Ok(())
}
async fn rmdir(&mut self, path: &str) -> Result<()> {
self.sftp()?.remove_dir(path.to_string()).await?;
Ok(())
}
async fn remove_file(&mut self, path: &str) -> Result<()> {
self.sftp()?.remove_file(path.to_string()).await?;
Ok(())
}
async fn remove(&mut self, path: &str) -> Result<()> {
let meta = self.metadata(path).await?;
if meta.is_dir {
let children = self.list(path).await?;
for child in children {
let child_path = child.path.to_string_lossy().to_string();
Box::pin(self.remove(&child_path)).await?;
}
self.rmdir(path).await
} else {
self.remove_file(path).await
}
}
async fn rename(&mut self, from: &str, to: &str) -> Result<()> {
self.sftp()?
.rename(from.to_string(), to.to_string())
.await?;
Ok(())
}
async fn read(&mut self, path: &str) -> Result<Vec<u8>> {
Ok(self.sftp()?.read(path.to_string()).await?)
}
async fn write(&mut self, path: &str, data: &[u8]) -> Result<()> {
self.sftp()?.write(path.to_string(), data).await?;
Ok(())
}
async fn download(&mut self, remote: &str, local: &Path) -> Result<()> {
let data = self.read(remote).await?;
tokio::fs::write(local, data).await?;
Ok(())
}
async fn upload(&mut self, local: &Path, remote: &str) -> Result<()> {
let data = tokio::fs::read(local).await?;
self.write(remote, &data).await
}
}
#[allow(dead_code)]
async fn copy_async_read_to_vec<R: AsyncReadExt + Unpin>(mut reader: R) -> Result<Vec<u8>> {
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await?;
Ok(buf)
}
#[allow(dead_code)]
async fn copy_slice_to_async_write<W: AsyncWriteExt + Unpin>(
mut writer: W,
data: &[u8],
) -> Result<()> {
writer.write_all(data).await?;
writer.flush().await?;
Ok(())
}