remote-fs 0.1.2

Unified async file operations over SMB, FTP, and SFTP
Documentation
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};

/// SFTP remote file system client (backed by `async-ssh2-tokio` + `russh-sftp`).
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(&current).await? {
                self.mkdir(&current).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(())
}