1use std::borrow::Cow;
31use std::path::{Path, PathBuf};
32use std::process::Command;
33use std::time::{Duration, Instant};
34
35use anyhow::{Context, Result, anyhow};
36use shell_escape::escape;
37use tracing::{debug, error, info, warn};
38use uuid::Uuid;
39
40use crate::binary_hash::{BinaryHashResult, binaries_equivalent, compute_binary_hash};
41use crate::test_change::{TestChangeGuard, TestCodeChange};
42use crate::types::WorkerConfig;
43
44#[derive(Debug, Clone)]
46pub struct VerificationConfig {
47 pub timeout: Duration,
49 pub build_timeout: Duration,
51 pub release_mode: bool,
53 pub cargo_flags: Vec<String>,
55 pub rsync_compression: u32,
57 pub exclude_patterns: Vec<String>,
59 pub clean_before_build: bool,
61 pub remote_base_path: PathBuf,
63}
64
65impl Default for VerificationConfig {
66 fn default() -> Self {
67 Self {
68 timeout: Duration::from_secs(300),
69 build_timeout: Duration::from_secs(180),
70 release_mode: false,
71 cargo_flags: vec![],
72 rsync_compression: 3,
73 exclude_patterns: vec![
74 "target/".to_string(),
75 ".git/".to_string(),
76 "node_modules/".to_string(),
77 ],
78 clean_before_build: false,
79 remote_base_path: PathBuf::from("/tmp/rch_verify"),
80 }
81 }
82}
83
84#[derive(Debug, Clone)]
86pub struct VerificationResult {
87 pub success: bool,
89 pub local_hash: Option<BinaryHashResult>,
91 pub remote_hash: Option<BinaryHashResult>,
93 pub rsync_up_ms: u64,
95 pub compilation_ms: u64,
97 pub rsync_down_ms: u64,
99 pub total_ms: u64,
101 pub bytes_up: u64,
103 pub bytes_down: u64,
105 pub error: Option<String>,
107 pub change_id: String,
109 pub marker_verified: bool,
111}
112
113impl VerificationResult {
114 pub fn failed(error: impl Into<String>, elapsed_ms: u64, change_id: String) -> Self {
116 Self {
117 success: false,
118 local_hash: None,
119 remote_hash: None,
120 rsync_up_ms: 0,
121 compilation_ms: 0,
122 rsync_down_ms: 0,
123 total_ms: elapsed_ms,
124 bytes_up: 0,
125 bytes_down: 0,
126 error: Some(error.into()),
127 change_id,
128 marker_verified: false,
129 }
130 }
131}
132
133pub struct RemoteCompilationTest {
141 worker: WorkerConfig,
143 project_path: PathBuf,
145 config: VerificationConfig,
147 remote_path_suffix: String,
149}
150
151fn sanitize_remote_path_component(component: &str, fallback: &str) -> String {
152 let sanitized = component
153 .chars()
154 .map(|ch| {
155 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
156 ch
157 } else {
158 '-'
159 }
160 })
161 .collect::<String>();
162 let trimmed = sanitized.trim_matches('-');
163 if trimmed.is_empty() {
164 fallback.to_string()
165 } else {
166 trimmed.to_string()
167 }
168}
169
170fn sanitize_remote_path_suffix(suffix: &str) -> String {
171 sanitize_remote_path_component(suffix, "run")
172}
173
174fn shell_escape_path(path: &Path) -> String {
175 escape(path.to_string_lossy()).into_owned()
176}
177
178fn shell_escape_str(value: &str) -> String {
179 escape(Cow::from(value)).into_owned()
180}
181
182impl RemoteCompilationTest {
183 pub fn new(
190 worker: WorkerConfig,
191 project_path: impl Into<PathBuf>,
192 config: VerificationConfig,
193 ) -> Self {
194 Self {
195 worker,
196 project_path: project_path.into(),
197 config,
198 remote_path_suffix: format!("run-{}", Uuid::new_v4()),
199 }
200 }
201
202 pub fn with_remote_path_suffix(mut self, suffix: impl AsRef<str>) -> Self {
204 self.remote_path_suffix = sanitize_remote_path_suffix(suffix.as_ref());
205 self
206 }
207
208 pub fn run(&self) -> Result<VerificationResult> {
218 let start = Instant::now();
219 info!(
220 "Starting remote compilation verification for {:?} on {}",
221 self.project_path, self.worker.id
222 );
223
224 let change = TestCodeChange::for_main_rs(&self.project_path)
226 .with_context(|| "Failed to create test change")?;
227 let change_id = change.change_id.clone();
228 let guard = TestChangeGuard::new(change).with_context(|| "Failed to apply test change")?;
229
230 info!("Applied test change: {}", guard.change_id());
231
232 info!("Building locally for reference hash");
234 let local_build_start = Instant::now();
235 if let Err(e) = self.build_local() {
236 return Ok(VerificationResult::failed(
237 format!("Local build failed: {}", e),
238 start.elapsed().as_millis() as u64,
239 change_id,
240 ));
241 }
242 let local_build_ms = local_build_start.elapsed().as_millis() as u64;
243 debug!("Local build completed in {}ms", local_build_ms);
244
245 let local_binary = self.binary_path();
247 let local_hash = compute_binary_hash(&local_binary)
248 .with_context(|| format!("Failed to hash local binary: {:?}", local_binary))?;
249 info!(
250 "Local build hash: {} (code_hash: {})",
251 &local_hash.full_hash[..16],
252 &local_hash.code_hash[..16]
253 );
254
255 let local_marker_ok = guard.verify_in_binary(&local_binary)?;
257 if !local_marker_ok {
258 return Ok(VerificationResult::failed(
259 "Test marker not found in local binary",
260 start.elapsed().as_millis() as u64,
261 change_id,
262 ));
263 }
264 info!("Test marker verified in local binary");
265
266 info!("Syncing source to worker {}", self.worker.id);
268 let rsync_up_start = Instant::now();
269 let bytes_up = match self.rsync_to_worker() {
270 Ok(bytes) => bytes,
271 Err(e) => {
272 return Ok(VerificationResult::failed(
273 format!("rsync to worker failed: {}", e),
274 start.elapsed().as_millis() as u64,
275 change_id,
276 ));
277 }
278 };
279 let rsync_up_ms = rsync_up_start.elapsed().as_millis() as u64;
280 info!("Synced {} bytes in {}ms", bytes_up, rsync_up_ms);
281
282 info!("Building on worker {}", self.worker.id);
284 let compilation_start = Instant::now();
285 if let Err(e) = self.build_remote() {
286 return Ok(VerificationResult::failed(
287 format!("Remote build failed: {}", e),
288 start.elapsed().as_millis() as u64,
289 change_id,
290 ));
291 }
292 let compilation_ms = compilation_start.elapsed().as_millis() as u64;
293 info!("Remote build completed in {}ms", compilation_ms);
294
295 info!("Syncing artifacts from worker");
297 let rsync_down_start = Instant::now();
298 let bytes_down = match self.rsync_from_worker() {
299 Ok(bytes) => bytes,
300 Err(e) => {
301 return Ok(VerificationResult::failed(
302 format!("rsync from worker failed: {}", e),
303 start.elapsed().as_millis() as u64,
304 change_id,
305 ));
306 }
307 };
308 let rsync_down_ms = rsync_down_start.elapsed().as_millis() as u64;
309 info!("Retrieved {} bytes in {}ms", bytes_down, rsync_down_ms);
310
311 let remote_binary = self.remote_binary_path_local();
313 let remote_hash = match compute_binary_hash(&remote_binary) {
314 Ok(h) => h,
315 Err(e) => {
316 return Ok(VerificationResult::failed(
317 format!("Failed to hash remote binary: {}", e),
318 start.elapsed().as_millis() as u64,
319 change_id,
320 ));
321 }
322 };
323 info!(
324 "Remote build hash: {} (code_hash: {})",
325 &remote_hash.full_hash[..16],
326 &remote_hash.code_hash[..16]
327 );
328
329 let marker_verified = guard.verify_in_binary(&remote_binary)?;
331 if !marker_verified {
332 warn!("Test marker not found in remote binary");
333 }
334
335 let success = binaries_equivalent(&local_hash, &remote_hash);
337 let total_ms = start.elapsed().as_millis() as u64;
338
339 if success {
340 info!(
341 "Verification PASSED: code hashes match (total: {}ms)",
342 total_ms
343 );
344 } else {
345 error!(
346 "Verification FAILED: code hashes differ (local={}, remote={})",
347 &local_hash.code_hash[..16],
348 &remote_hash.code_hash[..16]
349 );
350 }
351
352 Ok(VerificationResult {
354 success,
355 local_hash: Some(local_hash),
356 remote_hash: Some(remote_hash),
357 rsync_up_ms,
358 compilation_ms,
359 rsync_down_ms,
360 total_ms,
361 bytes_up,
362 bytes_down,
363 error: if success {
364 None
365 } else {
366 Some("Code hashes do not match".to_string())
367 },
368 change_id,
369 marker_verified,
370 })
371 }
372
373 fn build_local(&self) -> Result<()> {
375 let mut cmd = Command::new("cargo");
376 cmd.arg("build");
377 if self.config.release_mode {
378 cmd.arg("--release");
379 }
380 for flag in &self.config.cargo_flags {
381 cmd.arg(flag);
382 }
383 cmd.current_dir(&self.project_path);
384
385 debug!("Running local build: {:?}", cmd);
386 let output = cmd.output().context("Failed to execute cargo build")?;
387
388 if !output.status.success() {
389 let stderr = String::from_utf8_lossy(&output.stderr);
390 return Err(anyhow!("cargo build failed: {}", stderr));
391 }
392
393 Ok(())
394 }
395
396 fn build_remote(&self) -> Result<()> {
398 let build_cmd = if self.config.release_mode {
399 "cargo build --release"
400 } else {
401 "cargo build"
402 };
403
404 let ssh_cmd = self.remote_build_command(build_cmd);
405
406 let identity_file = shellexpand::tilde(&self.worker.identity_file).to_string();
407 let mut cmd = Command::new("ssh");
408 cmd.args([
409 "-i",
410 &identity_file,
411 "-o",
412 "StrictHostKeyChecking=accept-new",
413 "-o",
414 "BatchMode=yes",
415 &format!("{}@{}", self.worker.user, self.worker.host),
416 &ssh_cmd,
417 ]);
418
419 debug!("Running remote build via SSH: {:?}", cmd);
420 let output = cmd.output().context("Failed to execute SSH command")?;
421
422 if !output.status.success() {
423 let stderr = String::from_utf8_lossy(&output.stderr);
424 return Err(anyhow!("Remote build failed: {}", stderr));
425 }
426
427 Ok(())
428 }
429
430 fn rsync_to_worker(&self) -> Result<u64> {
432 let remote_path = self.remote_project_path();
433 let identity_file = shellexpand::tilde(&self.worker.identity_file).to_string();
434
435 let escaped_remote_path = shell_escape_path(&remote_path);
437 let mkdir_cmd = format!("mkdir -p -- {}", escaped_remote_path);
438 let mut mkdir = Command::new("ssh");
439 mkdir.args([
440 "-i",
441 &identity_file,
442 "-o",
443 "StrictHostKeyChecking=accept-new",
444 "-o",
445 "BatchMode=yes",
446 &format!("{}@{}", self.worker.user, self.worker.host),
447 &mkdir_cmd,
448 ]);
449 let mkdir_output = mkdir
450 .output()
451 .context("Failed to create remote directory")?;
452 if !mkdir_output.status.success() {
453 let stderr = String::from_utf8_lossy(&mkdir_output.stderr);
454 return Err(anyhow!("remote directory creation failed: {}", stderr));
455 }
456
457 let mut cmd = Command::new("rsync");
459 cmd.args([
460 "-az",
461 "--no-owner",
462 "--no-group",
463 "--compress-level",
464 &self.config.rsync_compression.to_string(),
465 "--delete",
466 "-e",
467 &self.rsync_ssh_command(&identity_file),
468 ]);
469
470 for pattern in &self.config.exclude_patterns {
472 cmd.args(["--exclude", pattern]);
473 }
474
475 let src = format!("{}/", self.project_path.display());
477 let dest = format!(
478 "{}@{}:{}",
479 self.worker.user, self.worker.host, escaped_remote_path
480 );
481 cmd.args([&src, &dest]);
482
483 debug!("Running rsync to worker: {:?}", cmd);
484 let output = cmd.output().context("Failed to execute rsync")?;
485
486 if !output.status.success() {
487 let stderr = String::from_utf8_lossy(&output.stderr);
488 return Err(anyhow!("rsync to worker failed: {}", stderr));
489 }
490
491 let stdout = String::from_utf8_lossy(&output.stdout);
493 let bytes = parse_rsync_bytes_transferred(&stdout);
494 Ok(bytes)
495 }
496
497 fn rsync_from_worker(&self) -> Result<u64> {
499 let remote_path = self.remote_project_path();
500 let identity_file = shellexpand::tilde(&self.worker.identity_file).to_string();
501
502 let local_artifact_dir = self.project_path.join("target_remote");
504 std::fs::create_dir_all(&local_artifact_dir)?;
505
506 let profile = if self.config.release_mode {
508 "release"
509 } else {
510 "debug"
511 };
512
513 let mut cmd = Command::new("rsync");
514 cmd.args([
515 "-az",
516 "--no-owner",
517 "--no-group",
518 "--compress-level",
519 &self.config.rsync_compression.to_string(),
520 "-e",
521 &self.rsync_ssh_command(&identity_file),
522 ]);
523
524 let remote_target_dir = remote_path.join("target").join(profile);
526 let remote_target_dir_with_slash = format!("{}/", remote_target_dir.display());
527 let remote_target = format!(
528 "{}@{}:{}",
529 self.worker.user,
530 self.worker.host,
531 shell_escape_str(&remote_target_dir_with_slash)
532 );
533 let local_target = format!("{}/", local_artifact_dir.display());
534 cmd.args([&remote_target, &local_target]);
535
536 debug!("Running rsync from worker: {:?}", cmd);
537 let output = cmd.output().context("Failed to execute rsync")?;
538
539 if !output.status.success() {
540 let stderr = String::from_utf8_lossy(&output.stderr);
541 return Err(anyhow!("rsync from worker failed: {}", stderr));
542 }
543
544 let stdout = String::from_utf8_lossy(&output.stdout);
546 let bytes = parse_rsync_bytes_transferred(&stdout);
547 Ok(bytes)
548 }
549
550 fn binary_path(&self) -> PathBuf {
552 let profile = if self.config.release_mode {
553 "release"
554 } else {
555 "debug"
556 };
557
558 let binary_name = self.get_binary_name().unwrap_or_else(|| "main".to_string());
560 self.project_path
561 .join("target")
562 .join(profile)
563 .join(&binary_name)
564 }
565
566 fn remote_binary_path_local(&self) -> PathBuf {
568 let binary_name = self.get_binary_name().unwrap_or_else(|| "main".to_string());
569 self.project_path.join("target_remote").join(&binary_name)
570 }
571
572 fn remote_project_path(&self) -> PathBuf {
574 let project_name = self
575 .project_path
576 .file_name()
577 .and_then(|n| n.to_str())
578 .map(|name| sanitize_remote_path_component(name, "project"))
579 .unwrap_or_else(|| "project".to_string());
580 let remote_path_suffix = sanitize_remote_path_suffix(&self.remote_path_suffix);
581
582 self.config
583 .remote_base_path
584 .join(format!("{project_name}-{remote_path_suffix}"))
585 }
586
587 fn remote_cargo_command(&self, build_cmd: &str) -> String {
589 let mut parts = vec![build_cmd.to_string()];
590 parts.extend(
591 self.config
592 .cargo_flags
593 .iter()
594 .map(|flag| shell_escape_str(flag)),
595 );
596 parts.join(" ")
597 }
598
599 fn remote_build_command(&self, build_cmd: &str) -> String {
601 let remote_path = self.remote_project_path();
602 let cargo_cmd = self.remote_cargo_command(build_cmd);
603 if self.config.clean_before_build {
604 format!(
605 "cd {} && cargo clean && {}",
606 shell_escape_path(&remote_path),
607 cargo_cmd
608 )
609 } else {
610 format!("cd {} && {}", shell_escape_path(&remote_path), cargo_cmd)
611 }
612 }
613
614 fn rsync_ssh_command(&self, identity_file: &str) -> String {
616 format!(
617 "ssh -i {} -o StrictHostKeyChecking=accept-new -o BatchMode=yes",
618 shell_escape_str(identity_file)
619 )
620 }
621
622 fn get_binary_name(&self) -> Option<String> {
624 let cargo_toml = self.project_path.join("Cargo.toml");
625 let content = std::fs::read_to_string(&cargo_toml).ok()?;
626
627 for line in content.lines() {
629 let line = line.trim();
630 if line.starts_with("name")
631 && line.contains('=')
632 && let Some(name) = line.split('=').nth(1)
633 {
634 let name = name.trim().trim_matches('"');
635 return Some(name.to_string());
636 }
637 }
638 None
639 }
640}
641
642fn parse_rsync_bytes_transferred(output: &str) -> u64 {
644 for line in output.lines() {
647 if line.contains("bytes") {
648 let nums: Vec<u64> = line
650 .split_whitespace()
651 .filter_map(|w| w.replace(',', "").parse().ok())
652 .collect();
653 if !nums.is_empty() {
654 return nums.iter().sum();
655 }
656 }
657 }
658 0
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 fn init_test_logging() {
666 let _ = tracing_subscriber::fmt()
667 .with_test_writer()
668 .with_max_level(tracing::Level::DEBUG)
669 .try_init();
670 }
671
672 #[test]
673 fn test_verification_config_default() {
674 init_test_logging();
675 info!("TEST START: test_verification_config_default");
676
677 let config = VerificationConfig::default();
678
679 info!("RESULT: timeout={:?}", config.timeout);
680 info!("RESULT: release_mode={}", config.release_mode);
681 info!("RESULT: rsync_compression={}", config.rsync_compression);
682
683 assert_eq!(config.timeout, Duration::from_secs(300));
684 assert!(!config.release_mode);
685 assert_eq!(config.rsync_compression, 3);
686 assert!(config.exclude_patterns.contains(&"target/".to_string()));
687
688 info!("TEST PASS: test_verification_config_default");
689 }
690
691 #[test]
692 fn test_verification_result_failed() {
693 init_test_logging();
694 info!("TEST START: test_verification_result_failed");
695
696 let result = VerificationResult::failed("Test error", 1000, "RCH_TEST_123".to_string());
697
698 info!("RESULT: success={}", result.success);
699 info!("RESULT: error={:?}", result.error);
700
701 assert!(!result.success);
702 assert_eq!(result.error, Some("Test error".to_string()));
703 assert_eq!(result.total_ms, 1000);
704 assert_eq!(result.change_id, "RCH_TEST_123");
705
706 info!("TEST PASS: test_verification_result_failed");
707 }
708
709 #[test]
710 fn test_parse_rsync_bytes() {
711 init_test_logging();
712 info!("TEST START: test_parse_rsync_bytes");
713
714 let output = "sent 12,345 bytes received 678 bytes 8,682.00 bytes/sec";
715 let bytes = parse_rsync_bytes_transferred(output);
716
717 info!("INPUT: {:?}", output);
718 info!("RESULT: bytes={}", bytes);
719
720 assert!(bytes > 0);
721
722 info!("TEST PASS: test_parse_rsync_bytes");
723 }
724
725 #[test]
726 fn test_parse_rsync_bytes_empty() {
727 init_test_logging();
728 info!("TEST START: test_parse_rsync_bytes_empty");
729
730 let output = "";
731 let bytes = parse_rsync_bytes_transferred(output);
732
733 info!("INPUT: empty string");
734 info!("RESULT: bytes={}", bytes);
735
736 assert_eq!(bytes, 0);
737
738 info!("TEST PASS: test_parse_rsync_bytes_empty");
739 }
740
741 #[test]
742 fn test_remote_compilation_test_paths() {
743 init_test_logging();
744 info!("TEST START: test_remote_compilation_test_paths");
745
746 let config = VerificationConfig::default();
747 let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
748 .with_remote_path_suffix("run-1");
749
750 let remote_path = test.remote_project_path();
751 info!("RESULT: remote_path={:?}", remote_path);
752 assert_eq!(
753 remote_path,
754 PathBuf::from("/tmp/rch_verify/test-project-run-1")
755 );
756
757 info!("TEST PASS: test_remote_compilation_test_paths");
758 }
759
760 #[test]
761 fn test_remote_compilation_paths_are_isolated_by_default() {
762 init_test_logging();
763 info!("TEST START: test_remote_compilation_paths_are_isolated_by_default");
764
765 let config = VerificationConfig::default();
766 let first = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config.clone());
767 let second = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config);
768
769 let first_remote_path = first.remote_project_path();
770 let second_remote_path = second.remote_project_path();
771
772 info!("RESULT: first_remote_path={:?}", first_remote_path);
773 info!("RESULT: second_remote_path={:?}", second_remote_path);
774
775 assert_ne!(first_remote_path, second_remote_path);
776 assert!(
777 first_remote_path
778 .to_string_lossy()
779 .starts_with("/tmp/rch_verify/test-project-run-")
780 );
781 assert!(
782 second_remote_path
783 .to_string_lossy()
784 .starts_with("/tmp/rch_verify/test-project-run-")
785 );
786
787 info!("TEST PASS: test_remote_compilation_paths_are_isolated_by_default");
788 }
789
790 #[test]
791 fn test_remote_project_path_sanitizes_project_and_suffix() {
792 init_test_logging();
793 info!("TEST START: test_remote_project_path_sanitizes_project_and_suffix");
794
795 let config = VerificationConfig::default();
796 let test = RemoteCompilationTest::new(test_worker(), "/tmp/project with spaces", config)
797 .with_remote_path_suffix("../attempt 1");
798
799 let remote_path = test.remote_project_path();
800 info!("RESULT: remote_path={:?}", remote_path);
801 assert_eq!(
802 remote_path,
803 PathBuf::from("/tmp/rch_verify/project-with-spaces-..-attempt-1")
804 );
805
806 info!("TEST PASS: test_remote_project_path_sanitizes_project_and_suffix");
807 }
808
809 #[test]
810 fn test_remote_build_command_includes_cargo_flags_and_clean() {
811 init_test_logging();
812 info!("TEST START: test_remote_build_command_includes_cargo_flags_and_clean");
813
814 let config = VerificationConfig {
815 release_mode: true,
816 cargo_flags: vec!["--features".to_string(), "foo bar".to_string()],
817 clean_before_build: true,
818 ..VerificationConfig::default()
819 };
820 let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
821 .with_remote_path_suffix("run-1");
822
823 let command = test.remote_build_command("cargo build --release");
824 info!("RESULT: command={}", command);
825 assert_eq!(
826 command,
827 "cd /tmp/rch_verify/test-project-run-1 && cargo clean && cargo build --release --features 'foo bar'"
828 );
829
830 info!("TEST PASS: test_remote_build_command_includes_cargo_flags_and_clean");
831 }
832
833 #[test]
834 fn test_remote_build_command_quotes_remote_path() {
835 init_test_logging();
836 info!("TEST START: test_remote_build_command_quotes_remote_path");
837
838 let config = VerificationConfig {
839 remote_base_path: PathBuf::from("/tmp/rch verify"),
840 ..VerificationConfig::default()
841 };
842 let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
843 .with_remote_path_suffix("run-1");
844
845 let command = test.remote_build_command("cargo build");
846 info!("RESULT: command={}", command);
847 assert_eq!(
848 command,
849 "cd '/tmp/rch verify/test-project-run-1' && cargo build"
850 );
851
852 info!("TEST PASS: test_remote_build_command_quotes_remote_path");
853 }
854
855 #[test]
856 fn test_rsync_ssh_command_quotes_identity_path() {
857 init_test_logging();
858 info!("TEST START: test_rsync_ssh_command_quotes_identity_path");
859
860 let config = VerificationConfig::default();
861 let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
862 .with_remote_path_suffix("run-1");
863
864 let command = test.rsync_ssh_command("/tmp/key files/id_ed25519");
865 info!("RESULT: command={}", command);
866 assert_eq!(
867 command,
868 "ssh -i '/tmp/key files/id_ed25519' -o StrictHostKeyChecking=accept-new -o BatchMode=yes"
869 );
870
871 info!("TEST PASS: test_rsync_ssh_command_quotes_identity_path");
872 }
873
874 fn test_worker() -> WorkerConfig {
875 WorkerConfig {
876 id: crate::types::WorkerId::new("test-worker"),
877 host: "localhost".to_string(),
878 user: "testuser".to_string(),
879 identity_file: "~/.ssh/id_rsa".to_string(),
880 total_slots: 4,
881 priority: 100,
882 tags: vec![],
883 tools: Vec::new(),
884 }
885 }
886}