diff --git a/Cargo.lock b/Cargo.lock index 7186b75..f3a779e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,6 +156,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "blake2" @@ -379,6 +382,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-bigint" version = "0.7.5" @@ -464,6 +473,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -797,6 +820,18 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "group" version = "0.14.0" @@ -808,6 +843,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.17.1" @@ -902,7 +943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -979,6 +1020,15 @@ dependencies = [ "libc", ] +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -1162,6 +1212,19 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "password-hash" version = "0.6.1" @@ -1336,6 +1399,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -1474,6 +1546,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "russh-sftp" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de67aace74530a29086db0671fa200c470a58eb380081f28ad512ffb0c5356b" +dependencies = [ + "bitflags", + "bytes", + "chrono", + "dashmap", + "gloo-timers", + "log", + "serde", + "serde_bytes", + "thiserror", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + [[package]] name = "russh-util" version = "0.52.0" @@ -1520,6 +1612,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scrypt" version = "0.12.0" @@ -1562,6 +1660,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -1672,6 +1780,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "socket2" version = "0.6.5" @@ -1851,6 +1965,20 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "1.1.4+spec-1.1.0" @@ -2234,6 +2362,7 @@ dependencies = [ "colored", "home", "russh", + "russh-sftp", "serde", "ssh2-config", "tokio", diff --git a/Cargo.toml b/Cargo.toml index f39f944..a62b8a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ clap = { version = "4.6.6", features = ["derive"] } colored = "3.1.1" home = "0.5.12" russh = { version = "0.62.7", default-features = false, features = ["ring", "rsa", "flate2"] } +russh-sftp = "2.4.0" serde = { version = "1.0.229", features = ["derive"] } ssh2-config = "0.7.2" tokio = { version = "1.53.1", features = ["rt", "time", "io-util"] } diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 7bb4bee..110fd0f 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -109,10 +109,12 @@ commands не пустые для секций, которые присутст ### Этап 4 — SFTP-синхронизация -1. src/sync.rs: открытие SFTP-сесси поверх установленной SSH-сессии. -2. Одиночный файл: sftp.open/sendfile -> запись в target. -3. Рекурсивная папка: walkdir по source; для каждой подпапки удалённый mkdir - с игнорированием ошибки "already exists" (аналог mkdir -p); файлы поверх. +1. src/sync.rs: открытие SFTP-сессии поверх установленной SSH-сессии + (subsystem sfp через russh + крейт russh-sftp, см. ADR-006). +2. Одиночный файл: remote create/truncate -> запись -> shutdown; если target — + существующий каталог, файл кладётся внутрь с локальным именем. +3. Рекурсивная папка: walkdir по source; для каждой подпапки mkdir если её ещё нет + (проверка metadata, аналог mkdir -p); файлы поверх. 4. Лог: `[SFTP] transferring dist/ -> /var/www/node-app ... Done!`, счётчик файлов/байт. diff --git a/src/main.rs b/src/main.rs index efa4387..83b30a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod config; mod local; mod ssh; +mod sync; use std::process::ExitCode; @@ -93,10 +94,10 @@ fn run(cli: &cli::Cli) -> Result<()> { session = Some(ssh::connect(&resolved)?); } - if !project.sync.is_empty() { - println!("stage 4 not implemented yet: SFTP file transfer"); - } if let Some(session) = session.as_mut() { + for rule in &project.sync { + sync::upload(session, &rule.source, &rule.target.to_string_lossy())?; + } for cmd in remote_cmds { session.exec(cmd)?; } diff --git a/src/ssh.rs b/src/ssh.rs index 36c2c68..45bec8f 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -121,7 +121,7 @@ fn query_ssh_config(alias: &str) -> HostConfig { } } -struct ClientHandler { +pub(crate) struct ClientHandler { host: String, port: u16, } @@ -173,7 +173,13 @@ pub struct Session { handle: Handle, } -fn runtime() -> &'static tokio::runtime::Runtime { +impl Session { + pub(crate) fn handle(&mut self) -> &mut Handle { + &mut self.handle + } +} + +pub(crate) fn runtime() -> &'static tokio::runtime::Runtime { static RT: OnceLock = OnceLock::new(); RT.get_or_init(|| { tokio::runtime::Builder::new_current_thread() diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..a18dbab --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,178 @@ +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 { + println!("[SFTP] transferring {} -> {} ...", source.display(), target); + let 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", source.display()); + } + })?; + println!("[SFTP] ... Done! ({stats})"); + Ok(stats) +} + +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"); + } +}