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