Skip to main content

rush_sync_server/sync/
transport.rs

1use crate::core::prelude::*;
2use crate::sync::profiles::RemoteProfile;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5
6const DEFAULT_EXCLUDES: &[&str] = &[".git", ".rss", "target", ".DS_Store"];
7
8#[derive(Debug)]
9struct ProcessResult {
10    stdout: String,
11    stderr: String,
12    status_code: i32,
13}
14
15pub fn test_connection(profile: &RemoteProfile) -> Result<String> {
16    ensure_tool_available("ssh", "-V")?;
17
18    let mut args = ssh_base_args(profile);
19    args.push(profile.ssh_target());
20    args.push("echo rush-sync-remote-ok".to_string());
21
22    let output = run_process("ssh", &args, false)?;
23    let merged = format!("{}\n{}", output.stdout, output.stderr);
24
25    if merged.contains("rush-sync-remote-ok") {
26        Ok(format!(
27            "Remote '{}' reachable via SSH ({}:{})",
28            profile.ssh_target(),
29            profile.host,
30            profile.port
31        ))
32    } else {
33        Err(AppError::Validation(format!(
34            "SSH connection failed for {}:{}.\nOutput: {}",
35            profile.host,
36            profile.port,
37            if merged.trim().is_empty() {
38                "no output".to_string()
39            } else {
40                merged.trim().to_string()
41            }
42        )))
43    }
44}
45
46pub fn run_remote_command(profile: &RemoteProfile, command: &str) -> Result<String> {
47    ensure_tool_available("ssh", "-V")?;
48
49    let mut args = ssh_base_args(profile);
50    args.push(profile.ssh_target());
51    args.push(command.to_string());
52
53    let output = run_process("ssh", &args, false)?;
54    Ok(format_process_output("Remote command executed", &output))
55}
56
57pub fn sync_push(
58    profile: &RemoteProfile,
59    local_path: &Path,
60    delete: bool,
61    dry_run: bool,
62) -> Result<String> {
63    if !local_path.exists() {
64        return Err(AppError::Validation(format!(
65            "Local path '{}' does not exist",
66            local_path.display()
67        )));
68    }
69
70    ensure_remote_directory(profile)?;
71
72    if tool_available("rsync", "--version") {
73        sync_push_rsync(profile, local_path, delete, dry_run)
74    } else {
75        if dry_run {
76            return Err(AppError::Validation(
77                "Dry-run is only supported with rsync (rsync not found in PATH)".to_string(),
78            ));
79        }
80        sync_push_scp(profile, local_path)
81    }
82}
83
84pub fn sync_pull(
85    profile: &RemoteProfile,
86    local_path: &Path,
87    delete: bool,
88    dry_run: bool,
89) -> Result<String> {
90    ensure_local_directory(local_path)?;
91
92    if tool_available("rsync", "--version") {
93        sync_pull_rsync(profile, local_path, delete, dry_run)
94    } else {
95        if dry_run {
96            return Err(AppError::Validation(
97                "Dry-run is only supported with rsync (rsync not found in PATH)".to_string(),
98            ));
99        }
100        sync_pull_scp(profile, local_path)
101    }
102}
103
104pub fn restart_service(profile: &RemoteProfile, service_name: &str) -> Result<String> {
105    let name = service_name.trim();
106    if name.is_empty() {
107        return Err(AppError::Validation(
108            "Service name cannot be empty".to_string(),
109        ));
110    }
111    if !name
112        .chars()
113        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '@')
114    {
115        return Err(AppError::Validation(
116            "Service name contains invalid characters (allowed: a-z, 0-9, - _ . @)".to_string(),
117        ));
118    }
119
120    let cmd = format!(
121        "sudo systemctl restart {} && sudo systemctl status {} --no-pager --lines=5",
122        shell_quote(service_name),
123        shell_quote(service_name)
124    );
125
126    run_remote_command(profile, &cmd)
127}
128
129pub fn git_pull(profile: &RemoteProfile, branch: Option<&str>) -> Result<String> {
130    let branch = branch.unwrap_or("main");
131    if !branch
132        .chars()
133        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '/' || c == '.')
134    {
135        return Err(AppError::Validation(
136            "Branch name contains invalid characters".to_string(),
137        ));
138    }
139    let cmd = format!(
140        "cd {} && git fetch --all && git pull --ff-only origin {}",
141        shell_quote(&profile.remote_path),
142        shell_quote(branch)
143    );
144    run_remote_command(profile, &cmd)
145}
146
147fn sync_push_rsync(
148    profile: &RemoteProfile,
149    local_path: &Path,
150    delete: bool,
151    dry_run: bool,
152) -> Result<String> {
153    ensure_tool_available("rsync", "--version")?;
154
155    let mut args = Vec::<String>::new();
156    args.push("-az".to_string());
157    args.push("--human-readable".to_string());
158
159    for exclude in DEFAULT_EXCLUDES {
160        args.push("--exclude".to_string());
161        args.push((*exclude).to_string());
162    }
163
164    if delete {
165        args.push("--delete".to_string());
166    }
167
168    if dry_run {
169        args.push("--dry-run".to_string());
170    }
171
172    args.push("-e".to_string());
173    args.push(rsync_ssh_transport(profile));
174    args.push(rsync_source_arg(local_path));
175    args.push(format!(
176        "{}:{}/",
177        profile.ssh_target(),
178        escape_remote_path(&profile.remote_path)
179    ));
180
181    let output = run_process("rsync", &args, false)?;
182    Ok(format!(
183        "{}\n{}",
184        if dry_run {
185            "Sync push dry-run via rsync"
186        } else {
187            "Sync push completed via rsync"
188        },
189        format_process_output("rsync", &output)
190    ))
191}
192
193fn sync_pull_rsync(
194    profile: &RemoteProfile,
195    local_path: &Path,
196    delete: bool,
197    dry_run: bool,
198) -> Result<String> {
199    ensure_tool_available("rsync", "--version")?;
200
201    let mut args = Vec::<String>::new();
202    args.push("-az".to_string());
203    args.push("--human-readable".to_string());
204
205    if delete {
206        args.push("--delete".to_string());
207    }
208
209    if dry_run {
210        args.push("--dry-run".to_string());
211    }
212
213    args.push("-e".to_string());
214    args.push(rsync_ssh_transport(profile));
215    args.push(format!(
216        "{}:{}/",
217        profile.ssh_target(),
218        escape_remote_path(&profile.remote_path)
219    ));
220    args.push(rsync_source_arg(local_path));
221
222    let output = run_process("rsync", &args, false)?;
223    Ok(format!(
224        "{}\n{}",
225        if dry_run {
226            "Sync pull dry-run via rsync"
227        } else {
228            "Sync pull completed via rsync"
229        },
230        format_process_output("rsync", &output)
231    ))
232}
233
234fn sync_push_scp(profile: &RemoteProfile, local_path: &Path) -> Result<String> {
235    ensure_tool_available("scp", "-V")?;
236
237    let mut args = scp_base_args(profile);
238    if local_path.is_dir() {
239        args.push("-r".to_string());
240    }
241    args.push(scp_source_arg(local_path));
242    args.push(format!(
243        "{}:{}",
244        profile.ssh_target(),
245        escape_remote_path(&profile.remote_path)
246    ));
247
248    let output = run_process("scp", &args, false)?;
249    Ok(format!(
250        "{}\n{}",
251        "Sync push completed via scp fallback",
252        format_process_output("scp", &output)
253    ))
254}
255
256fn sync_pull_scp(profile: &RemoteProfile, local_path: &Path) -> Result<String> {
257    ensure_tool_available("scp", "-V")?;
258
259    let mut args = scp_base_args(profile);
260    args.push("-r".to_string());
261    args.push(format!(
262        "{}:{}/.",
263        profile.ssh_target(),
264        escape_remote_path(&profile.remote_path)
265    ));
266    args.push(local_path.display().to_string());
267
268    let output = run_process("scp", &args, false)?;
269    Ok(format!(
270        "{}\n{}",
271        "Sync pull completed via scp fallback",
272        format_process_output("scp", &output)
273    ))
274}
275
276fn ensure_remote_directory(profile: &RemoteProfile) -> Result<()> {
277    let cmd = format!("mkdir -p {}", shell_quote(&profile.remote_path));
278    let _ = run_remote_command(profile, &cmd)?;
279    Ok(())
280}
281
282fn ensure_local_directory(path: &Path) -> Result<()> {
283    std::fs::create_dir_all(path).map_err(AppError::Io)
284}
285
286fn base_connection_args(profile: &RemoteProfile, port_flag: &str) -> Vec<String> {
287    let mut args = Vec::new();
288    args.push(port_flag.to_string());
289    args.push(profile.port.to_string());
290
291    if let Some(identity) = expanded_identity(profile) {
292        args.push("-i".to_string());
293        args.push(identity.display().to_string());
294    }
295
296    args.push("-o".to_string());
297    args.push("BatchMode=yes".to_string());
298    args.push("-o".to_string());
299    args.push("ConnectTimeout=30".to_string());
300    args
301}
302
303fn ssh_base_args(profile: &RemoteProfile) -> Vec<String> {
304    base_connection_args(profile, "-p")
305}
306
307fn scp_base_args(profile: &RemoteProfile) -> Vec<String> {
308    base_connection_args(profile, "-P")
309}
310
311fn rsync_ssh_transport(profile: &RemoteProfile) -> String {
312    let mut transport = format!("ssh -p {} -o BatchMode=yes -o ConnectTimeout=30", profile.port);
313    if let Some(identity) = expanded_identity(profile) {
314        transport.push_str(&format!(
315            " -i {}",
316            shell_quote(&identity.display().to_string())
317        ));
318    }
319    transport
320}
321
322fn expanded_identity(profile: &RemoteProfile) -> Option<PathBuf> {
323    profile
324        .identity_file
325        .as_ref()
326        .map(|path| expand_tilde(path.trim()))
327}
328
329fn expand_tilde(path: &str) -> PathBuf {
330    if path == "~" {
331        if let Ok(home) = std::env::var("HOME") {
332            return PathBuf::from(home);
333        }
334        return PathBuf::from(path);
335    }
336
337    if let Some(suffix) = path.strip_prefix("~/") {
338        if let Ok(home) = std::env::var("HOME") {
339            return PathBuf::from(home).join(suffix);
340        }
341    }
342
343    PathBuf::from(path)
344}
345
346fn rsync_source_arg(path: &Path) -> String {
347    if path.is_dir() {
348        format!("{}/", path.display())
349    } else {
350        path.display().to_string()
351    }
352}
353
354fn scp_source_arg(path: &Path) -> String {
355    if path.is_dir() {
356        format!("{}/.", path.display())
357    } else {
358        path.display().to_string()
359    }
360}
361
362fn escape_remote_path(path: &str) -> String {
363    path.replace(' ', "\\ ")
364}
365
366fn tool_available(tool: &str, version_arg: &str) -> bool {
367    Command::new(tool)
368        .arg(version_arg)
369        .stdout(Stdio::null())
370        .stderr(Stdio::null())
371        .status()
372        .is_ok()
373}
374
375fn ensure_tool_available(tool: &str, version_arg: &str) -> Result<()> {
376    if tool_available(tool, version_arg) {
377        Ok(())
378    } else {
379        Err(AppError::Validation(format!(
380            "Required tool '{}' was not found in PATH",
381            tool
382        )))
383    }
384}
385
386fn run_process(binary: &str, args: &[String], allow_non_zero: bool) -> Result<ProcessResult> {
387    let output = Command::new(binary)
388        .args(args)
389        .output()
390        .map_err(|e| match e.kind() {
391            std::io::ErrorKind::NotFound => {
392                AppError::Validation(format!("Command '{}' not found. Install it first.", binary))
393            }
394            _ => AppError::Io(e),
395        })?;
396
397    let result = ProcessResult {
398        stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
399        stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
400        status_code: output.status.code().unwrap_or(-1),
401    };
402
403    if !allow_non_zero && !output.status.success() {
404        return Err(AppError::Validation(format!(
405            "Command '{}' failed with exit code {}: {}",
406            binary,
407            result.status_code,
408            if result.stderr.is_empty() {
409                "No error output".to_string()
410            } else {
411                result.stderr.clone()
412            }
413        )));
414    }
415
416    Ok(result)
417}
418
419fn shell_quote(value: &str) -> String {
420    let escaped = value.replace('\'', "'\"'\"'");
421    format!("'{}'", escaped)
422}
423
424fn format_process_output(prefix: &str, result: &ProcessResult) -> String {
425    let mut output = format!("{} (exit={})", prefix, result.status_code);
426    if !result.stdout.is_empty() {
427        output.push_str(&format!("\nstdout:\n{}", result.stdout));
428    }
429    if !result.stderr.is_empty() {
430        output.push_str(&format!("\nstderr:\n{}", result.stderr));
431    }
432    output
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn shell_quote_simple() {
441        assert_eq!(shell_quote("hello"), "'hello'");
442    }
443
444    #[test]
445    fn shell_quote_with_single_quotes() {
446        assert_eq!(shell_quote("it's"), "'it'\"'\"'s'");
447    }
448
449    #[test]
450    fn shell_quote_empty() {
451        assert_eq!(shell_quote(""), "''");
452    }
453
454    #[test]
455    fn expand_tilde_home() {
456        let result = expand_tilde("~/test");
457        assert!(result.to_str().unwrap().ends_with("/test"));
458        assert!(!result.to_str().unwrap().starts_with("~"));
459    }
460
461    #[test]
462    fn expand_tilde_no_tilde() {
463        assert_eq!(expand_tilde("/absolute/path"), PathBuf::from("/absolute/path"));
464    }
465
466    #[test]
467    fn escape_remote_path_spaces() {
468        assert_eq!(escape_remote_path("/my path/dir"), "/my\\ path/dir");
469    }
470
471    #[test]
472    fn escape_remote_path_no_spaces() {
473        assert_eq!(escape_remote_path("/opt/app"), "/opt/app");
474    }
475
476    #[test]
477    fn service_name_rejects_injection() {
478        let profile =
479            RemoteProfile::new("u".into(), "h".into(), "/opt".into(), 22, None).unwrap();
480        let res = restart_service(&profile, "foo;rm -rf /");
481        assert!(res.is_err());
482    }
483
484    #[test]
485    fn git_branch_rejects_injection() {
486        let profile =
487            RemoteProfile::new("u".into(), "h".into(), "/opt".into(), 22, None).unwrap();
488        let res = git_pull(&profile, Some("main;rm -rf /"));
489        assert!(res.is_err());
490    }
491
492    #[test]
493    fn rsync_source_arg_dir_trailing_slash() {
494        let tmp = std::env::temp_dir();
495        assert!(rsync_source_arg(&tmp).ends_with('/'));
496    }
497}