Skip to main content

rch_common/
remote_verification.rs

1//! Remote compilation verification via SSH.
2//!
3//! This module implements SSH-based remote compilation verification that tests
4//! the full RCH pipeline by building code on a remote worker and verifying
5//! the output through binary hash comparison.
6
7use anyhow::{Context, Result};
8use shell_escape::escape;
9use std::borrow::Cow;
10use std::path::{Path, PathBuf};
11use std::time::{Duration, Instant};
12use tokio::process::Command;
13use tracing::{error, info, warn};
14use uuid::Uuid;
15
16use crate::binary_hash::{BinaryHashResult, binaries_equivalent, compute_binary_hash};
17use crate::rsync_flavor::{configure_rsync_remote_args, resolve_rsync_cached};
18use crate::test_change::{TestChangeGuard, TestCodeChange};
19use crate::types::WorkerConfig;
20
21/// Configuration for remote verification testing.
22/// Extends WorkerConfig with verification-specific settings.
23#[derive(Debug, Clone)]
24pub struct VerificationWorkerConfig {
25    /// Unique identifier for the worker.
26    pub id: String,
27    /// SSH host string (e.g., "user@host" or just "host").
28    pub ssh_host: String,
29    /// SSH identity file path (optional).
30    pub identity_file: Option<PathBuf>,
31    /// Remote directory for builds.
32    pub build_dir: PathBuf,
33}
34
35impl VerificationWorkerConfig {
36    /// Create a verification config from an existing WorkerConfig.
37    pub fn from_worker_config(config: &WorkerConfig, build_dir: PathBuf) -> Self {
38        Self {
39            id: config.id.to_string(),
40            ssh_host: format!("{}@{}", config.user, config.host),
41            identity_file: Some(PathBuf::from(&config.identity_file)),
42            build_dir,
43        }
44    }
45}
46
47/// Result of a remote compilation verification test.
48#[derive(Debug, Clone)]
49pub struct VerificationResult {
50    /// Whether the verification succeeded (hashes match).
51    pub success: bool,
52    /// Hash result from the local build.
53    pub local_hash: BinaryHashResult,
54    /// Hash result from the remote build.
55    pub remote_hash: BinaryHashResult,
56    /// Time spent syncing files to the worker (ms).
57    pub rsync_up_ms: u64,
58    /// Time spent compiling on the worker (ms).
59    pub compilation_ms: u64,
60    /// Time spent syncing artifacts back (ms).
61    pub rsync_down_ms: u64,
62    /// Total test duration (ms).
63    pub total_ms: u64,
64    /// Error message if verification failed.
65    pub error: Option<String>,
66}
67
68impl VerificationResult {
69    /// Get the speedup factor (local time / remote time).
70    /// Returns None if either time is zero.
71    pub fn speedup_factor(&self, local_compilation_ms: u64) -> Option<f64> {
72        if self.compilation_ms == 0 || local_compilation_ms == 0 {
73            return None;
74        }
75        Some(local_compilation_ms as f64 / self.compilation_ms as f64)
76    }
77}
78
79/// Remote compilation test configuration and executor.
80pub struct RemoteCompilationTest {
81    /// Worker configuration.
82    pub worker: VerificationWorkerConfig,
83    /// Path to the test project.
84    pub test_project: PathBuf,
85    /// Timeout for the entire test.
86    pub timeout: Duration,
87    /// Unique suffix appended to the remote project directory for this test run.
88    remote_path_suffix: String,
89    /// Local compilation time for comparison (ms).
90    local_compilation_ms: Option<u64>,
91}
92
93fn sanitize_remote_path_component(component: &str, fallback: &str) -> String {
94    let sanitized = component
95        .chars()
96        .map(|ch| {
97            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
98                ch
99            } else {
100                '-'
101            }
102        })
103        .collect::<String>();
104    let trimmed = sanitized.trim_matches('-');
105    if trimmed.is_empty() {
106        fallback.to_string()
107    } else {
108        trimmed.to_string()
109    }
110}
111
112fn sanitize_remote_path_suffix(suffix: &str) -> String {
113    sanitize_remote_path_component(suffix, "run")
114}
115
116fn shell_escape_path(path: &Path) -> String {
117    shell_escape_path_str(&path.to_string_lossy())
118}
119
120fn shell_escape_path_str(path: &str) -> String {
121    escape(Cow::from(path)).into_owned()
122}
123
124impl RemoteCompilationTest {
125    /// Create a new remote compilation test.
126    pub fn new(worker: VerificationWorkerConfig, test_project: PathBuf) -> Self {
127        Self {
128            worker,
129            test_project,
130            timeout: Duration::from_secs(120),
131            remote_path_suffix: format!("run-{}", Uuid::new_v4()),
132            local_compilation_ms: None,
133        }
134    }
135
136    /// Set the timeout for the test.
137    pub fn with_timeout(mut self, timeout: Duration) -> Self {
138        self.timeout = timeout;
139        self
140    }
141
142    /// Set the remote project path suffix.
143    pub fn with_remote_path_suffix(mut self, suffix: impl AsRef<str>) -> Self {
144        self.remote_path_suffix = sanitize_remote_path_suffix(suffix.as_ref());
145        self
146    }
147
148    /// Get the path to the local binary after building.
149    fn local_binary_path(&self) -> PathBuf {
150        // Assuming this is a Rust project with a binary of the same name as the directory
151        let project_name = self
152            .test_project
153            .file_name()
154            .and_then(|n| n.to_str())
155            .unwrap_or("test_project");
156        self.test_project.join("target/release").join(project_name)
157    }
158
159    /// Get the path to the remote binary after syncing back.
160    fn remote_binary_path(&self) -> PathBuf {
161        let project_name = self
162            .test_project
163            .file_name()
164            .and_then(|n| n.to_str())
165            .unwrap_or("test_project");
166        self.test_project
167            .join("target/release_remote")
168            .join(project_name)
169    }
170
171    /// Get the remote project path on the worker.
172    fn remote_project_path(&self) -> PathBuf {
173        let project_name = self
174            .test_project
175            .file_name()
176            .and_then(|n| n.to_str())
177            .map(|n| sanitize_remote_path_component(n, "test_project"))
178            .unwrap_or_else(|| "test_project".to_string());
179        let remote_path_suffix = sanitize_remote_path_suffix(&self.remote_path_suffix);
180        self.worker
181            .build_dir
182            .join("self_test")
183            .join(format!("{project_name}-{remote_path_suffix}"))
184    }
185
186    /// Build SSH args with optional identity file.
187    fn ssh_args(&self) -> Vec<String> {
188        let mut args = vec!["-o".to_string(), "BatchMode=yes".to_string()];
189        if let Some(ref identity) = self.worker.identity_file {
190            args.push("-i".to_string());
191            args.push(identity.to_string_lossy().to_string());
192            if let Some(opts) = crate::ssh_utils::identities_only_args(&identity.to_string_lossy())
193            {
194                args.extend(opts.iter().map(|opt| (*opt).to_string()));
195            }
196        }
197        args.push(self.worker.ssh_host.clone());
198        args
199    }
200
201    /// Build rsync args with optional identity file.
202    fn rsync_ssh_option(&self) -> Option<String> {
203        self.worker
204            .identity_file
205            .as_ref()
206            .map(|identity| format!("ssh -o BatchMode=yes -i {}", shell_escape_path(identity)))
207    }
208
209    /// Run the full remote compilation verification test.
210    pub async fn run(&mut self) -> Result<VerificationResult> {
211        let start = Instant::now();
212        info!(
213            "Starting remote compilation verification for worker {}",
214            self.worker.id
215        );
216
217        // 1. Apply test change to make binary unique
218        let change = TestCodeChange::for_main_rs(&self.test_project)
219            .context("Failed to create test code change")?;
220        let guard = TestChangeGuard::new(change).context("Failed to apply test change")?;
221        info!("Applied test change: {}", guard.change_id());
222
223        // 2. Build locally first
224        info!("Building locally for reference hash");
225        let local_build_start = Instant::now();
226        self.build_local().await.context("Local build failed")?;
227        let local_compilation_ms = local_build_start.elapsed().as_millis() as u64;
228        self.local_compilation_ms = Some(local_compilation_ms);
229
230        let local_hash = compute_binary_hash(&self.local_binary_path())
231            .context("Failed to compute local binary hash")?;
232        info!(
233            "Local build complete in {}ms: hash={}",
234            local_compilation_ms,
235            &local_hash.code_hash[..16]
236        );
237
238        // 3. rsync up to worker
239        info!("Syncing source to worker {}", self.worker.id);
240        let rsync_up_start = Instant::now();
241        self.rsync_to_worker()
242            .await
243            .context("Failed to rsync to worker")?;
244        let rsync_up_ms = rsync_up_start.elapsed().as_millis() as u64;
245        info!("Source synced to worker in {}ms", rsync_up_ms);
246
247        // 4. Build on worker
248        info!("Building on remote worker");
249        let compile_start = Instant::now();
250        self.build_remote().await.context("Remote build failed")?;
251        let compilation_ms = compile_start.elapsed().as_millis() as u64;
252        info!("Remote build complete in {}ms", compilation_ms);
253
254        // 5. rsync back
255        info!("Syncing artifacts from worker");
256        let rsync_down_start = Instant::now();
257        self.rsync_from_worker()
258            .await
259            .context("Failed to rsync from worker")?;
260        let rsync_down_ms = rsync_down_start.elapsed().as_millis() as u64;
261        info!("Artifacts synced back in {}ms", rsync_down_ms);
262
263        // 6. Compute remote binary hash
264        let remote_hash = compute_binary_hash(&self.remote_binary_path())
265            .context("Failed to compute remote binary hash")?;
266        info!("Remote binary hash: {}", &remote_hash.code_hash[..16]);
267
268        // 7. Compare
269        let success = binaries_equivalent(&local_hash, &remote_hash);
270        let total_ms = start.elapsed().as_millis() as u64;
271
272        let error = if success {
273            info!("Verification PASSED: local and remote hashes match");
274            None
275        } else {
276            let msg = format!(
277                "Binary hashes do not match: local={}, remote={}",
278                &local_hash.code_hash[..16],
279                &remote_hash.code_hash[..16]
280            );
281            error!("Verification FAILED: {}", msg);
282            Some(msg)
283        };
284
285        // Guard will auto-revert on drop
286        drop(guard);
287
288        Ok(VerificationResult {
289            success,
290            local_hash,
291            remote_hash,
292            rsync_up_ms,
293            compilation_ms,
294            rsync_down_ms,
295            total_ms,
296            error,
297        })
298    }
299
300    /// Build the project locally.
301    async fn build_local(&self) -> Result<()> {
302        info!("Running: cargo build --release in {:?}", self.test_project);
303
304        let output = Command::new("cargo")
305            .args(["build", "--release"])
306            .current_dir(&self.test_project)
307            .env("CARGO_INCREMENTAL", "0")
308            .output()
309            .await
310            .context("Failed to execute cargo build")?;
311
312        if !output.status.success() {
313            let stderr = String::from_utf8_lossy(&output.stderr);
314            anyhow::bail!("Local build failed: {}", stderr);
315        }
316        Ok(())
317    }
318
319    /// Sync source files to the remote worker.
320    async fn rsync_to_worker(&self) -> Result<()> {
321        let remote_project = self.remote_project_path();
322        let escaped_remote_project = shell_escape_path(&remote_project);
323        let mkdir_cmd = format!("mkdir -p -- {escaped_remote_project}");
324
325        let mut mkdir = Command::new("ssh");
326        for arg in self.ssh_args() {
327            mkdir.arg(arg);
328        }
329        mkdir.arg(&mkdir_cmd);
330
331        let output = mkdir
332            .output()
333            .await
334            .context("Failed to create remote project directory")?;
335
336        if !output.status.success() {
337            let stderr = String::from_utf8_lossy(&output.stderr);
338            anyhow::bail!("Remote directory creation failed: {}", stderr);
339        }
340
341        let resolved = resolve_rsync_cached(None)?;
342        let remote_project_with_slash = format!("{}/", remote_project.display());
343        let remote_path = format!(
344            "{}:{}",
345            self.worker.ssh_host,
346            resolved.flavor.remote_path_arg(&remote_project_with_slash)
347        );
348
349        let mut cmd = Command::new(&resolved.path);
350        configure_rsync_remote_args(cmd.as_std_mut());
351        cmd.arg("--rsync-path").arg(
352            resolved
353                .flavor
354                .remote_path_command("rsync".to_owned(), &remote_project_with_slash),
355        );
356        cmd.args([
357            "-az",
358            "--no-owner",
359            "--no-group",
360            "--delete",
361            "--exclude",
362            "target/",
363            "--exclude",
364            ".git/",
365        ]);
366
367        if let Some(ssh_option) = self.rsync_ssh_option() {
368            cmd.args(["-e", &ssh_option]);
369        }
370
371        cmd.arg(format!("{}/", self.test_project.display()));
372        cmd.arg(&remote_path);
373
374        info!("Running rsync to {}", remote_path);
375        let output = cmd.output().await.context("Failed to execute rsync")?;
376
377        if !output.status.success() {
378            let stderr = String::from_utf8_lossy(&output.stderr);
379            anyhow::bail!("rsync to worker failed: {}", stderr);
380        }
381        Ok(())
382    }
383
384    /// Build the project on the remote worker.
385    async fn build_remote(&self) -> Result<()> {
386        let remote_project = self.remote_project_path();
387        let build_cmd = format!(
388            "cd {} && cargo build --release",
389            shell_escape_path(&remote_project)
390        );
391
392        let mut cmd = Command::new("ssh");
393        for arg in self.ssh_args() {
394            cmd.arg(arg);
395        }
396        cmd.arg(&build_cmd);
397
398        info!(
399            "Running remote build: ssh {} '{}'",
400            self.worker.ssh_host, build_cmd
401        );
402        let output = cmd
403            .output()
404            .await
405            .context("Failed to execute remote build")?;
406
407        if !output.status.success() {
408            let stderr = String::from_utf8_lossy(&output.stderr);
409            anyhow::bail!("Remote build failed: {}", stderr);
410        }
411        Ok(())
412    }
413
414    /// Sync artifacts back from the remote worker.
415    async fn rsync_from_worker(&self) -> Result<()> {
416        let resolved = resolve_rsync_cached(None)?;
417        let remote_project = self.remote_project_path();
418        let remote_release_dir = remote_project.join("target/release");
419        let remote_release_with_slash = format!("{}/", remote_release_dir.display());
420        let remote_target = format!(
421            "{}:{}",
422            self.worker.ssh_host,
423            resolved.flavor.remote_path_arg(&remote_release_with_slash)
424        );
425
426        let local_target = self.test_project.join("target/release_remote/");
427        std::fs::create_dir_all(&local_target)
428            .context("Failed to create local target directory")?;
429
430        let mut cmd = Command::new(&resolved.path);
431        configure_rsync_remote_args(cmd.as_std_mut());
432        cmd.arg("--rsync-path").arg(
433            resolved
434                .flavor
435                .remote_path_command("rsync".to_owned(), &remote_release_with_slash),
436        );
437        cmd.args(["-az", "--no-owner", "--no-group"]);
438
439        if let Some(ssh_option) = self.rsync_ssh_option() {
440            cmd.args(["-e", &ssh_option]);
441        }
442
443        cmd.arg(&remote_target);
444        cmd.arg(format!("{}/", local_target.display()));
445
446        info!("Running rsync from {}", remote_target);
447        let output = cmd.output().await.context("Failed to execute rsync")?;
448
449        if !output.status.success() {
450            let stderr = String::from_utf8_lossy(&output.stderr);
451            anyhow::bail!("rsync from worker failed: {}", stderr);
452        }
453        Ok(())
454    }
455
456    /// Clean up remote build artifacts.
457    pub async fn cleanup_remote(&self) -> Result<()> {
458        let remote_project = self.remote_project_path();
459        let cleanup_cmd = format!("rm -rf -- {}", shell_escape_path(&remote_project));
460
461        let mut cmd = Command::new("ssh");
462        for arg in self.ssh_args() {
463            cmd.arg(arg);
464        }
465        cmd.arg(&cleanup_cmd);
466
467        info!("Cleaning up remote: {}", cleanup_cmd);
468        let output = cmd
469            .output()
470            .await
471            .context("Failed to execute remote cleanup")?;
472
473        if !output.status.success() {
474            let stderr = String::from_utf8_lossy(&output.stderr);
475            warn!("Remote cleanup failed (non-fatal): {}", stderr);
476        }
477        Ok(())
478    }
479}
480
481#[cfg(test)]
482mod basic_tests {
483    use super::*;
484    use crate::types::{WorkerConfig, WorkerId};
485
486    fn sample_worker_config() -> WorkerConfig {
487        WorkerConfig {
488            id: WorkerId::new("worker-1"),
489            host: "example.com".to_string(),
490            user: "builder".to_string(),
491            identity_file: "/tmp/id_rsa".to_string(),
492            total_slots: 8,
493            priority: 100,
494            tags: Vec::new(),
495            tools: Vec::new(),
496        }
497    }
498
499    #[test]
500    fn test_verification_worker_config_from_worker_config() {
501        let worker = sample_worker_config();
502        let build_dir = PathBuf::from("/tmp/rch-build");
503        let verification = VerificationWorkerConfig::from_worker_config(&worker, build_dir.clone());
504
505        assert_eq!(verification.id, "worker-1");
506        assert_eq!(verification.ssh_host, "builder@example.com");
507        assert_eq!(
508            verification.identity_file,
509            Some(PathBuf::from("/tmp/id_rsa"))
510        );
511        assert_eq!(verification.build_dir, build_dir);
512    }
513
514    #[test]
515    fn test_remote_compilation_paths() {
516        let verification = VerificationWorkerConfig {
517            id: "worker-1".to_string(),
518            ssh_host: "builder@example.com".to_string(),
519            identity_file: Some(PathBuf::from("/tmp/id_rsa")),
520            build_dir: PathBuf::from("/tmp/rch-builds"),
521        };
522        let test_project = PathBuf::from("/tmp/test-project");
523        let test = RemoteCompilationTest::new(verification, test_project.clone())
524            .with_remote_path_suffix("run-1");
525
526        assert_eq!(
527            test.local_binary_path(),
528            test_project.join("target/release/test-project")
529        );
530        assert_eq!(
531            test.remote_binary_path(),
532            test_project.join("target/release_remote/test-project")
533        );
534        assert_eq!(
535            test.remote_project_path(),
536            PathBuf::from("/tmp/rch-builds/self_test/test-project-run-1")
537        );
538    }
539
540    #[test]
541    fn test_remote_compilation_paths_are_isolated_by_default() {
542        let verification = VerificationWorkerConfig {
543            id: "worker-1".to_string(),
544            ssh_host: "builder@example.com".to_string(),
545            identity_file: Some(PathBuf::from("/tmp/id_rsa")),
546            build_dir: PathBuf::from("/tmp/rch-builds"),
547        };
548        let test_project = PathBuf::from("/tmp/test-project");
549        let first = RemoteCompilationTest::new(verification.clone(), test_project.clone());
550        let second = RemoteCompilationTest::new(verification, test_project);
551
552        assert_ne!(first.remote_project_path(), second.remote_project_path());
553        let first_path = first.remote_project_path().display().to_string();
554        assert!(
555            first_path.starts_with("/tmp/rch-builds/self_test/test-project-run-"),
556            "remote path should include an isolated run suffix: {first_path}"
557        );
558    }
559
560    #[test]
561    fn test_remote_project_path_sanitizes_project_and_suffix() {
562        let verification = VerificationWorkerConfig {
563            id: "worker-1".to_string(),
564            ssh_host: "builder@example.com".to_string(),
565            identity_file: Some(PathBuf::from("/tmp/id_rsa")),
566            build_dir: PathBuf::from("/tmp/rch-builds"),
567        };
568        let test_project = PathBuf::from("/tmp/project with spaces");
569        let test = RemoteCompilationTest::new(verification, test_project)
570            .with_remote_path_suffix("../attempt 1");
571
572        assert_eq!(
573            test.remote_project_path(),
574            PathBuf::from("/tmp/rch-builds/self_test/project-with-spaces-..-attempt-1")
575        );
576    }
577
578    #[test]
579    fn test_ssh_args_with_identity() {
580        let verification = VerificationWorkerConfig {
581            id: "worker-1".to_string(),
582            ssh_host: "builder@example.com".to_string(),
583            identity_file: Some(PathBuf::from("/tmp/key.pem")),
584            build_dir: PathBuf::from("/tmp/rch-builds"),
585        };
586        let test = RemoteCompilationTest::new(verification, PathBuf::from("/tmp/project"));
587        let args = test.ssh_args();
588
589        assert!(args.contains(&"-i".to_string()));
590        assert!(args.contains(&"/tmp/key.pem".to_string()));
591        assert_eq!(args.last(), Some(&"builder@example.com".to_string()));
592    }
593
594    #[test]
595    fn test_ssh_args_without_identity() {
596        let verification = VerificationWorkerConfig {
597            id: "worker-1".to_string(),
598            ssh_host: "builder@example.com".to_string(),
599            identity_file: None,
600            build_dir: PathBuf::from("/tmp/rch-builds"),
601        };
602        let test = RemoteCompilationTest::new(verification, PathBuf::from("/tmp/project"));
603        let args = test.ssh_args();
604
605        assert!(!args.contains(&"-i".to_string()));
606        assert_eq!(args.last(), Some(&"builder@example.com".to_string()));
607    }
608
609    #[test]
610    fn test_rsync_ssh_option() {
611        let verification = VerificationWorkerConfig {
612            id: "worker-1".to_string(),
613            ssh_host: "builder@example.com".to_string(),
614            identity_file: Some(PathBuf::from("/tmp/key.pem")),
615            build_dir: PathBuf::from("/tmp/rch-builds"),
616        };
617        let test = RemoteCompilationTest::new(verification, PathBuf::from("/tmp/project"));
618
619        assert_eq!(
620            test.rsync_ssh_option(),
621            Some("ssh -o BatchMode=yes -i /tmp/key.pem".to_string())
622        );
623    }
624
625    #[test]
626    fn test_rsync_ssh_option_quotes_identity_with_spaces() {
627        let verification = VerificationWorkerConfig {
628            id: "worker-1".to_string(),
629            ssh_host: "builder@example.com".to_string(),
630            identity_file: Some(PathBuf::from("/tmp/key files/key.pem")),
631            build_dir: PathBuf::from("/tmp/rch-builds"),
632        };
633        let test = RemoteCompilationTest::new(verification, PathBuf::from("/tmp/project"));
634
635        assert_eq!(
636            test.rsync_ssh_option(),
637            Some("ssh -o BatchMode=yes -i '/tmp/key files/key.pem'".to_string())
638        );
639    }
640
641    fn dummy_hash() -> BinaryHashResult {
642        BinaryHashResult {
643            full_hash: "full".to_string(),
644            code_hash: "code".to_string(),
645            text_section_size: 123,
646            is_debug: false,
647        }
648    }
649
650    #[test]
651    fn test_speedup_factor() {
652        let result = VerificationResult {
653            success: true,
654            local_hash: dummy_hash(),
655            remote_hash: dummy_hash(),
656            rsync_up_ms: 10,
657            compilation_ms: 500,
658            rsync_down_ms: 10,
659            total_ms: 520,
660            error: None,
661        };
662
663        assert_eq!(result.speedup_factor(1000), Some(2.0));
664        assert_eq!(result.speedup_factor(0), None);
665
666        let zero_remote = VerificationResult {
667            compilation_ms: 0,
668            ..result
669        };
670        assert_eq!(zero_remote.speedup_factor(1000), None);
671    }
672}
673
674/// Quick verification that tests basic SSH connectivity to a worker.
675pub async fn verify_ssh_connectivity(worker: &VerificationWorkerConfig) -> Result<bool> {
676    let mut cmd = Command::new("ssh");
677    cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]);
678    if let Some(ref identity) = worker.identity_file {
679        cmd.args(["-i", &identity.to_string_lossy()]);
680    }
681    cmd.arg(&worker.ssh_host);
682    cmd.arg("echo ok");
683
684    let output = cmd
685        .output()
686        .await
687        .context("Failed to execute SSH connectivity test")?;
688
689    Ok(output.status.success())
690}
691
692/// Quick verification that rsync is available on the worker.
693pub async fn verify_rsync_available(worker: &VerificationWorkerConfig) -> Result<bool> {
694    let mut cmd = Command::new("ssh");
695    cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]);
696    if let Some(ref identity) = worker.identity_file {
697        cmd.args(["-i", &identity.to_string_lossy()]);
698    }
699    cmd.arg(&worker.ssh_host);
700    cmd.arg("which rsync");
701
702    let output = cmd
703        .output()
704        .await
705        .context("Failed to check rsync availability")?;
706
707    Ok(output.status.success())
708}
709
710/// Quick verification that cargo is available on the worker.
711pub async fn verify_cargo_available(worker: &VerificationWorkerConfig) -> Result<bool> {
712    let mut cmd = Command::new("ssh");
713    cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]);
714    if let Some(ref identity) = worker.identity_file {
715        cmd.args(["-i", &identity.to_string_lossy()]);
716    }
717    cmd.arg(&worker.ssh_host);
718    cmd.arg("which cargo");
719
720    let output = cmd
721        .output()
722        .await
723        .context("Failed to check cargo availability")?;
724
725    Ok(output.status.success())
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    fn init_test_logging() {
733        let _ = tracing_subscriber::fmt()
734            .with_test_writer()
735            .with_max_level(tracing::Level::INFO)
736            .try_init();
737    }
738
739    #[test]
740    fn test_verification_worker_config_creation() {
741        init_test_logging();
742        info!("TEST START: test_verification_worker_config_creation");
743
744        let worker = VerificationWorkerConfig {
745            id: "test-worker".to_string(),
746            ssh_host: "user@192.168.1.100".to_string(),
747            identity_file: Some(PathBuf::from("/home/user/.ssh/id_rsa")),
748            build_dir: PathBuf::from("/tmp/rch_builds"),
749        };
750
751        info!(
752            "INPUT: VerificationWorkerConfig with id={}, host={}",
753            worker.id, worker.ssh_host
754        );
755
756        assert_eq!(worker.id, "test-worker");
757        assert_eq!(worker.ssh_host, "user@192.168.1.100");
758
759        info!("VERIFY: VerificationWorkerConfig created successfully");
760        info!("TEST PASS: test_verification_worker_config_creation");
761    }
762
763    #[test]
764    fn test_remote_compilation_test_creation() {
765        init_test_logging();
766        info!("TEST START: test_remote_compilation_test_creation");
767
768        let worker = VerificationWorkerConfig {
769            id: "test-worker".to_string(),
770            ssh_host: "localhost".to_string(),
771            identity_file: None,
772            build_dir: PathBuf::from("/tmp/rch_builds"),
773        };
774
775        let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/test_project"))
776            .with_timeout(Duration::from_secs(60));
777
778        info!("INPUT: RemoteCompilationTest with timeout=60s");
779
780        assert_eq!(test.timeout, Duration::from_secs(60));
781        assert_eq!(test.test_project, PathBuf::from("/tmp/test_project"));
782
783        info!("VERIFY: RemoteCompilationTest created with correct settings");
784        info!("TEST PASS: test_remote_compilation_test_creation");
785    }
786
787    #[test]
788    fn test_verification_result_speedup() {
789        init_test_logging();
790        info!("TEST START: test_verification_result_speedup");
791
792        let result = VerificationResult {
793            success: true,
794            local_hash: BinaryHashResult {
795                full_hash: "abc".to_string(),
796                code_hash: "xyz".to_string(),
797                text_section_size: 1000,
798                is_debug: false,
799            },
800            remote_hash: BinaryHashResult {
801                full_hash: "abc".to_string(),
802                code_hash: "xyz".to_string(),
803                text_section_size: 1000,
804                is_debug: false,
805            },
806            rsync_up_ms: 100,
807            compilation_ms: 5000,
808            rsync_down_ms: 50,
809            total_ms: 5150,
810            error: None,
811        };
812
813        let speedup = result.speedup_factor(10000);
814        info!("INPUT: local=10000ms, remote=5000ms");
815        info!("RESULT: speedup_factor={:?}", speedup);
816
817        assert!(speedup.is_some());
818        assert!((speedup.unwrap() - 2.0).abs() < 0.01);
819
820        info!("VERIFY: Speedup calculated correctly (2x)");
821        info!("TEST PASS: test_verification_result_speedup");
822    }
823
824    #[test]
825    fn test_local_binary_path() {
826        init_test_logging();
827        info!("TEST START: test_local_binary_path");
828
829        let worker = VerificationWorkerConfig {
830            id: "test".to_string(),
831            ssh_host: "localhost".to_string(),
832            identity_file: None,
833            build_dir: PathBuf::from("/tmp"),
834        };
835
836        let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/my_project"));
837        let path = test.local_binary_path();
838
839        info!("INPUT: test_project=/tmp/my_project");
840        info!("RESULT: local_binary_path={:?}", path);
841
842        assert_eq!(
843            path,
844            PathBuf::from("/tmp/my_project/target/release/my_project")
845        );
846
847        info!("VERIFY: Local binary path constructed correctly");
848        info!("TEST PASS: test_local_binary_path");
849    }
850
851    #[test]
852    fn test_remote_binary_path() {
853        init_test_logging();
854        info!("TEST START: test_remote_binary_path");
855
856        let worker = VerificationWorkerConfig {
857            id: "test".to_string(),
858            ssh_host: "localhost".to_string(),
859            identity_file: None,
860            build_dir: PathBuf::from("/tmp"),
861        };
862
863        let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/my_project"));
864        let path = test.remote_binary_path();
865
866        info!("INPUT: test_project=/tmp/my_project");
867        info!("RESULT: remote_binary_path={:?}", path);
868
869        assert_eq!(
870            path,
871            PathBuf::from("/tmp/my_project/target/release_remote/my_project")
872        );
873
874        info!("VERIFY: Remote binary path constructed correctly");
875        info!("TEST PASS: test_remote_binary_path");
876    }
877
878    #[test]
879    fn test_ssh_args_with_identity() {
880        init_test_logging();
881        info!("TEST START: test_ssh_args_with_identity");
882
883        let worker = VerificationWorkerConfig {
884            id: "test".to_string(),
885            ssh_host: "user@host.example.com".to_string(),
886            identity_file: Some(PathBuf::from("/home/user/.ssh/mykey")),
887            build_dir: PathBuf::from("/tmp"),
888        };
889
890        let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/project"));
891        let args = test.ssh_args();
892
893        info!("INPUT: identity_file=/home/user/.ssh/mykey");
894        info!("RESULT: ssh_args={:?}", args);
895
896        assert!(args.contains(&"-i".to_string()));
897        assert!(args.contains(&"/home/user/.ssh/mykey".to_string()));
898        assert!(args.contains(&"user@host.example.com".to_string()));
899
900        info!("VERIFY: SSH args include identity file");
901        info!("TEST PASS: test_ssh_args_with_identity");
902    }
903
904    #[test]
905    fn test_ssh_args_without_identity() {
906        init_test_logging();
907        info!("TEST START: test_ssh_args_without_identity");
908
909        let worker = VerificationWorkerConfig {
910            id: "test".to_string(),
911            ssh_host: "user@host".to_string(),
912            identity_file: None,
913            build_dir: PathBuf::from("/tmp"),
914        };
915
916        let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/project"));
917        let args = test.ssh_args();
918
919        info!("INPUT: identity_file=None");
920        info!("RESULT: ssh_args={:?}", args);
921
922        assert!(!args.contains(&"-i".to_string()));
923        assert!(args.contains(&"user@host".to_string()));
924
925        info!("VERIFY: SSH args work without identity file");
926        info!("TEST PASS: test_ssh_args_without_identity");
927    }
928}