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}