Files
xboct-deploy/src/sync.rs
T

179 lines
5.4 KiB
Rust

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<Stats> {
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<SftpSession> {
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<Stats> {
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<Stats> {
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");
}
}