remote-fs 0.1.2

Unified async file operations over SMB, FTP, and SFTP
Documentation
use std::path::{Path, PathBuf};
use std::time::Duration;

use async_trait::async_trait;
use smb2::{ClientConfig, DirectoryEntry, FileInfo, SmbClient as RawSmb, Tree};

use crate::error::{Error, Result};
use crate::traits::RemoteFileSystem;
use crate::types::{FileEntry, FileMeta, SmbConfig};

/// SMB remote file system client (backed by `smb2`).
pub struct SmbClient {
    config: SmbConfig,
    client: Option<RawSmb>,
    tree: Option<Tree>,
}

impl SmbClient {
    pub fn new(config: SmbConfig) -> Self {
        Self {
            config,
            client: None,
            tree: None,
        }
    }

    fn addr(&self) -> String {
        match self.config.port {
            Some(port) => format!("{}:{}", self.config.host, port),
            None => format!("{}:445", self.config.host),
        }
    }

    fn require_parts(&mut self) -> Result<(&mut RawSmb, &mut Tree)> {
        match (&mut self.client, &mut self.tree) {
            (Some(client), Some(tree)) => Ok((client, tree)),
            _ => Err(Error::NotConnected),
        }
    }

    fn entry_to_file(base: &str, entry: &DirectoryEntry) -> FileEntry {
        let name = entry.name.clone();
        let path = if base.is_empty() || base == "/" || base == "." {
            PathBuf::from(&name)
        } else {
            PathBuf::from(base.trim_end_matches(['/', '\\'])).join(&name)
        };
        let is_dir = entry.is_directory;
        let _ = &entry.modified;
        let _ = &entry.created;
        FileEntry {
            path,
            name,
            is_dir,
            is_file: !is_dir,
            is_symlink: false,
            size: entry.size,
            modified: None,
        }
    }

    fn info_to_meta(path: &str, info: &FileInfo) -> FileMeta {
        let _ = &info.modified;
        let _ = &info.created;
        let _ = &info.accessed;
        FileMeta {
            path: PathBuf::from(path),
            is_dir: info.is_directory,
            is_file: !info.is_directory,
            is_symlink: false,
            size: info.size,
            modified: None,
        }
    }
}

fn normalize_smb_path(path: &str) -> String {
    path.trim_matches(['/', '\\']).replace('/', "\\")
}

#[async_trait]
impl RemoteFileSystem for SmbClient {
    async fn connect(&mut self) -> Result<()> {
        let mut client = RawSmb::connect(ClientConfig {
            addr: self.addr(),
            timeout: Duration::from_secs(30),
            username: self.config.username.clone(),
            password: self.config.password.clone(),
            domain: self.config.workgroup.clone().unwrap_or_default(),
            auto_reconnect: true,
            compression: true,
            dfs_enabled: false,
            dfs_target_overrides: Default::default(),
        })
        .await?;
        let tree = client.connect_share(&self.config.share).await?;
        self.client = Some(client);
        self.tree = Some(tree);
        Ok(())
    }

    async fn disconnect(&mut self) -> Result<()> {
        if let (Some(client), Some(tree)) = (self.client.as_mut(), self.tree.take()) {
            let _ = client.disconnect_share(&tree).await;
        }
        self.client = None;
        Ok(())
    }

    fn is_connected(&self) -> bool {
        self.client.is_some() && self.tree.is_some()
    }

    async fn list(&mut self, path: &str) -> Result<Vec<FileEntry>> {
        let norm = normalize_smb_path(path);
        let (client, tree) = self.require_parts()?;
        let entries = client.list_directory(tree, &norm).await?;
        Ok(entries
            .iter()
            .filter(|e| e.name != "." && e.name != "..")
            .map(|e| Self::entry_to_file(path, e))
            .collect())
    }

    async fn metadata(&mut self, path: &str) -> Result<FileMeta> {
        let norm = normalize_smb_path(path);
        let (client, tree) = self.require_parts()?;
        let st = client.stat(tree, &norm).await?;
        Ok(Self::info_to_meta(path, &st))
    }

    async fn exists(&mut self, path: &str) -> Result<bool> {
        match self.metadata(path).await {
            Ok(_) => Ok(true),
            Err(_) => Ok(false),
        }
    }

    async fn mkdir(&mut self, path: &str) -> Result<()> {
        let norm = normalize_smb_path(path);
        let (client, tree) = self.require_parts()?;
        client.create_directory(tree, &norm).await?;
        Ok(())
    }

    async fn mkdir_all(&mut self, path: &str) -> Result<()> {
        let norm = normalize_smb_path(path);
        if norm.is_empty() {
            return Ok(());
        }
        let mut current = String::new();
        for part in norm.split('\\').filter(|p| !p.is_empty()) {
            if current.is_empty() {
                current = part.to_string();
            } else {
                current = format!("{current}\\{part}");
            }
            if !self.exists(&current).await? {
                self.mkdir(&current).await?;
            }
        }
        Ok(())
    }

    async fn rmdir(&mut self, path: &str) -> Result<()> {
        let norm = normalize_smb_path(path);
        let (client, tree) = self.require_parts()?;
        client.delete_directory(tree, &norm).await?;
        Ok(())
    }

    async fn remove_file(&mut self, path: &str) -> Result<()> {
        let norm = normalize_smb_path(path);
        let (client, tree) = self.require_parts()?;
        client.delete_file(tree, &norm).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().replace('/', "\\");
                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<()> {
        let from = normalize_smb_path(from);
        let to = normalize_smb_path(to);
        let (client, tree) = self.require_parts()?;
        client.rename(tree, &from, &to).await?;
        Ok(())
    }

    async fn read(&mut self, path: &str) -> Result<Vec<u8>> {
        let norm = normalize_smb_path(path);
        let (client, tree) = self.require_parts()?;
        Ok(client.read_file(tree, &norm).await?)
    }

    async fn write(&mut self, path: &str, data: &[u8]) -> Result<()> {
        let norm = normalize_smb_path(path);
        let (client, tree) = self.require_parts()?;
        client.write_file(tree, &norm, 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
    }
}