use std::path::Path; use anyhow::{Context, Result, bail}; use russh_sftp::client::SftpSession; use tokio::io::AsyncWriteExt; use walkdir::WalkDir; use crate::ssh::{Session, runtime}; pub struct Stats { pub files: u64, pub bytes: u64, } impl std::fmt::Display for Stats { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{} file(s), {}", self.files, human_size(self.bytes)) } } fn human_size(bytes: u64) -> String { const KIB: f64 = 1024.0; const MIB: f64 = 1024.0 * 1024.0; let b = bytes as f64; if b >= MIB { format!("{:.1} MiB", b / MIB) } else if b >= KIB { format!("{:.1} KiB", b / KIB) } else { format!("{bytes} B") } } pub fn upload(session: &mut Session, source: &Path, target: &str) -> Result { runtime().block_on(async { if source.is_dir() { upload_dir(session, source, target).await } else if source.is_file() { upload_file(session, source, target).await } else { bail!( "sync source '{}' does not exist (relative paths are resolved against project workdir)", source.display() ); } }) } async fn open_sftp(session: &mut Session) -> Result { let channel = session .handle() .channel_open_session() .await .context("failed to open SFTP channel")?; channel .request_subsystem(true, "sftp") .await .context("remote host has no SFTP subsystem")?; let sftp = SftpSession::new(channel.into_stream()) .await .context("SFTP session init failed")?; Ok(sftp) } async fn mkdir_if_missing(sftp: &SftpSession, path: &str) -> Result<()> { match sftp.metadata(path).await { Ok(meta) if meta.is_dir() => return Ok(()), Ok(_) => bail!("remote path exists but is not a directory: {path}"), Err(_) => {} } sftp.create_dir(path) .await .with_context(|| format!("cannot create remote directory {path}")) } fn join_remote(base: &str, rel: &str) -> String { let rel = rel.replace('\\', "/"); if rel.is_empty() { base.trim_end_matches('/').to_string() } else { format!("{}/{}", base.trim_end_matches('/'), rel) } } async fn upload_dir(session: &mut Session, source: &Path, target: &str) -> Result { let sftp = open_sftp(session).await?; mkdir_if_missing(&sftp, target).await?; let mut stats = Stats { files: 0, bytes: 0 }; for entry in WalkDir::new(source) { let entry = entry.with_context(|| format!("cannot read local dir {}", source.display()))?; let rel = entry .path() .strip_prefix(source) .expect("walkdir entry is inside source"); let remote_path = join_remote(target, &rel.to_string_lossy()); if entry.file_type().is_dir() { if !rel.as_os_str().is_empty() { mkdir_if_missing(&sftp, &remote_path).await?; } } else if entry.file_type().is_file() { write_remote_file(&sftp, entry.path(), &remote_path).await?; stats.files += 1; stats.bytes += std::fs::metadata(entry.path()) .map(|m| m.len()) .unwrap_or(0); } } Ok(stats) } async fn upload_file(session: &mut Session, source: &Path, target: &str) -> Result { let sftp = open_sftp(session).await?; let dest_is_dir = sftp .metadata(target) .await .map(|meta| meta.is_dir()) .unwrap_or(false); let dest = if dest_is_dir { let name = source .file_name() .with_context(|| format!("cannot determine file name of {}", source.display()))?; join_remote(target, &name.to_string_lossy()) } else { target.to_string() }; write_remote_file(&sftp, source, &dest).await?; let size = std::fs::metadata(source).map(|m| m.len()).unwrap_or(0); Ok(Stats { files: 1, bytes: size, }) } async fn write_remote_file(sftp: &SftpSession, local: &Path, remote_path: &str) -> Result<()> { let data = std::fs::read(local).with_context(|| format!("cannot read {}", local.display()))?; let mut remote_file = sftp .create(remote_path) .await .with_context(|| format!("cannot create remote file {remote_path}"))?; remote_file .write_all(&data) .await .with_context(|| format!("upload interrupted: {remote_path}"))?; remote_file .shutdown() .await .with_context(|| format!("cannot finish writing {remote_path}"))?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn join_remote_handles_slashes_and_edges() { assert_eq!(join_remote("/var/www/pages", "dist"), "/var/www/pages/dist"); assert_eq!( join_remote("/var/www/pages/", "a/b.js"), "/var/www/pages/a/b.js" ); assert_eq!(join_remote("/var/www/pages", ""), "/var/www/pages"); assert_eq!( join_remote("/var/www/pages", "sub\\file.css"), "/var/www/pages/sub/file.css" ); } #[test] fn human_size_formats() { assert_eq!(human_size(0), "0 B"); assert_eq!(human_size(512), "512 B"); assert_eq!(human_size(2048), "2.0 KiB"); assert_eq!(human_size(3 * 1024 * 1024), "3.0 MiB"); } }