Skip to main content

rch_common/
remote_compilation.rs

1//! Remote Compilation Verification via SSH.
2//!
3//! This module provides infrastructure for verifying that remote compilation
4//! actually works correctly by:
5//! 1. Applying a test change to make the binary unique
6//! 2. Building locally for a reference hash
7//! 3. Syncing source to worker via rsync
8//! 4. Building on the remote worker
9//! 5. Syncing artifacts back
10//! 6. Comparing binary hashes to verify correctness
11
12use crate::binary_hash::{
13    BinaryHashResult, CanaryVerdict, classify_returned_canary, compute_binary_hash,
14};
15use crate::gc_roots::{remote_cargo_home_base_prelude, remote_cargo_home_expr};
16use crate::mock::{self, MockConfig, MockRsync, MockRsyncConfig, MockSshClient};
17use crate::rsync_flavor::{RsyncFlavor, configure_rsync_remote_args, resolve_rsync_cached};
18use crate::ssh::{SshClient, SshOptions};
19use crate::test_change::{TestChangeGuard, TestCodeChange};
20use crate::types::WorkerConfig;
21use anyhow::{Context, Result, bail};
22use serde::{Deserialize, Serialize};
23use shell_escape::escape;
24use std::borrow::Cow;
25use std::path::{Path, PathBuf};
26use std::process::Stdio;
27use std::time::{Duration, Instant};
28use tokio::process::Command;
29use tracing::{debug, info, warn};
30use uuid::Uuid;
31
32/// Result of a remote compilation verification test.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct VerificationResult {
35    /// Whether the verification succeeded (hashes match).
36    pub success: bool,
37    /// Hash result from local build.
38    pub local_hash: BinaryHashResult,
39    /// Hash result from remote build.
40    pub remote_hash: BinaryHashResult,
41    /// Time spent on local compilation in milliseconds.
42    pub local_build_ms: u64,
43    /// Time spent on rsync upload in milliseconds.
44    pub rsync_up_ms: u64,
45    /// Time spent on remote compilation in milliseconds.
46    pub compilation_ms: u64,
47    /// Time spent on rsync download in milliseconds.
48    pub rsync_down_ms: u64,
49    /// Total time for the entire verification in milliseconds.
50    pub total_ms: u64,
51    /// Error message if verification failed.
52    pub error: Option<String>,
53    /// The test marker ID that was embedded in the binary.
54    pub test_marker: String,
55    /// The canary verdict token (bd-784xt): `reproduced` (byte-equivalent),
56    /// `toolchain_drift` (bytes differ but the worker is healthy — advisory PASS),
57    /// `miscompile` (same-toolchain byte mismatch — FAIL), or `missing_marker`
58    /// (the build is corrupt / not ours — FAIL). [`Self::success`] is true for the
59    /// first two. See [`crate::binary_hash::CanaryVerdict`].
60    pub verdict: String,
61}
62
63/// Configuration for a remote compilation test.
64#[derive(Debug, Clone)]
65pub struct RemoteCompilationTest {
66    /// Worker to test compilation on.
67    pub worker: WorkerConfig,
68    /// Path to the test project (must be a Rust project with src/main.rs or src/lib.rs).
69    pub test_project: PathBuf,
70    /// Timeout for the entire test operation.
71    pub timeout: Duration,
72    /// SSH options for connections.
73    pub ssh_options: SshOptions,
74    /// Whether to use release mode for builds.
75    pub release_mode: bool,
76    /// Binary name to verify (defaults to project directory name).
77    pub binary_name: Option<String>,
78    /// Remote base directory for builds.
79    pub remote_base: String,
80    /// Unique suffix appended to the remote project directory for this test run.
81    pub remote_path_suffix: String,
82}
83
84/// Resolve the `cargo` executable for the LOCAL reference build.
85///
86/// The daemon runs with a minimal, systemd-style `PATH` (e.g.
87/// `/usr/local/bin:/usr/bin:...`) that does NOT include `~/.cargo/bin`, so a bare
88/// `Command::new("cargo")` fails with ENOENT and silently wedges the self-test
89/// canary. Resolve robustly: honor an explicit `CARGO` override, then the rustup
90/// default at `$HOME/.cargo/bin/cargo`, and only fall back to a bare `cargo`
91/// (PATH lookup) when neither is present.
92fn resolve_cargo_binary() -> std::ffi::OsString {
93    resolve_cargo_binary_from(std::env::var_os("CARGO"), std::env::var_os("HOME"))
94}
95
96/// Pure core of [`resolve_cargo_binary`]: pick the cargo executable from an
97/// explicit `CARGO` override and a `HOME` directory. Split out so the resolution
98/// order is unit-testable without mutating process-global env vars.
99fn resolve_cargo_binary_from(
100    cargo_override: Option<std::ffi::OsString>,
101    home: Option<std::ffi::OsString>,
102) -> std::ffi::OsString {
103    if let Some(explicit) = cargo_override
104        && !explicit.is_empty()
105    {
106        return explicit;
107    }
108    if let Some(home) = home {
109        let candidate = PathBuf::from(home).join(".cargo").join("bin").join("cargo");
110        if candidate.is_file() {
111            return candidate.into_os_string();
112        }
113    }
114    std::ffi::OsString::from("cargo")
115}
116
117/// Read the Cargo package name from `<project>/Cargo.toml` (the `[package] name`
118/// field). Cargo names the built binary after the package (not the directory),
119/// so the verification hash step must resolve the artifact by package name.
120/// Returns `None` if the manifest is absent/unparseable or has no package name.
121fn cargo_package_name(project: &Path) -> Option<String> {
122    let manifest = std::fs::read_to_string(project.join("Cargo.toml")).ok()?;
123    let table = toml::from_str::<toml::Table>(&manifest).ok()?;
124    table
125        .get("package")
126        .and_then(toml::Value::as_table)
127        .and_then(|pkg| pkg.get("name"))
128        .and_then(toml::Value::as_str)
129        .map(ToString::to_string)
130}
131
132fn use_mock_transport(worker: &WorkerConfig) -> bool {
133    mock::is_mock_enabled() || mock::is_mock_worker(worker)
134}
135
136fn sanitize_remote_path_component(component: &str, fallback: &str) -> String {
137    let sanitized = component
138        .chars()
139        .map(|ch| {
140            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
141                ch
142            } else {
143                '-'
144            }
145        })
146        .collect::<String>();
147    let trimmed = sanitized.trim_matches('-');
148    if trimmed.is_empty() {
149        fallback.to_string()
150    } else {
151        trimmed.to_string()
152    }
153}
154
155fn sanitize_remote_path_suffix(suffix: &str) -> String {
156    sanitize_remote_path_component(suffix, "run")
157}
158
159fn join_remote_path(base: &str, child: &str) -> String {
160    let base = base.trim_end_matches('/');
161    if base.is_empty() {
162        format!("/{child}")
163    } else {
164        format!("{base}/{child}")
165    }
166}
167
168impl Default for RemoteCompilationTest {
169    fn default() -> Self {
170        Self {
171            worker: WorkerConfig::default(),
172            test_project: PathBuf::new(),
173            timeout: Duration::from_secs(300),
174            ssh_options: SshOptions::default(),
175            release_mode: true,
176            binary_name: None,
177            remote_base: "/tmp/rch_self_test".to_string(),
178            remote_path_suffix: format!("run-{}", Uuid::new_v4()),
179        }
180    }
181}
182
183impl RemoteCompilationTest {
184    /// Create a new remote compilation test.
185    pub fn new(worker: WorkerConfig, test_project: PathBuf) -> Self {
186        Self {
187            worker,
188            test_project,
189            ..Default::default()
190        }
191    }
192
193    /// Set the timeout for the test.
194    pub fn with_timeout(mut self, timeout: Duration) -> Self {
195        self.timeout = timeout;
196        self
197    }
198
199    /// Set SSH options.
200    pub fn with_ssh_options(mut self, options: SshOptions) -> Self {
201        self.ssh_options = options;
202        self
203    }
204
205    /// Set release mode (default: true).
206    pub fn with_release_mode(mut self, release: bool) -> Self {
207        self.release_mode = release;
208        self
209    }
210
211    /// Set the binary name to verify.
212    pub fn with_binary_name(mut self, name: impl Into<String>) -> Self {
213        self.binary_name = Some(name.into());
214        self
215    }
216
217    /// Set the remote project path suffix.
218    pub fn with_remote_path_suffix(mut self, suffix: impl AsRef<str>) -> Self {
219        self.remote_path_suffix = sanitize_remote_path_suffix(suffix.as_ref());
220        self
221    }
222
223    /// Run the remote compilation verification test.
224    ///
225    /// This performs the full pipeline:
226    /// 1. Apply test change to make binary unique
227    /// 2. Build locally for reference hash
228    /// 3. rsync source to worker
229    /// 4. Build on worker
230    /// 5. rsync artifacts back
231    /// 6. Compare hashes
232    pub async fn run(&self) -> Result<VerificationResult> {
233        let start = Instant::now();
234
235        info!(
236            "Starting remote compilation verification for {:?} on worker {}",
237            self.test_project, self.worker.id
238        );
239
240        // 1. Apply test change to make binary unique
241        let change = TestCodeChange::for_main_rs(&self.test_project)
242            .or_else(|_| TestCodeChange::for_lib_rs(&self.test_project))
243            .context("Failed to create test change - no src/main.rs or src/lib.rs found")?;
244        let _guard = TestChangeGuard::new(change.clone())?;
245        let test_marker = change.change_id.clone();
246
247        info!("Applied test marker: {}", test_marker);
248
249        // 2. Build locally first
250        info!("Building locally for reference hash");
251        let local_build_start = Instant::now();
252        self.build_local().await.context("Local build failed")?;
253        let local_build_ms = local_build_start.elapsed().as_millis() as u64;
254        info!("Local build complete in {}ms", local_build_ms);
255
256        let local_binary_path = self.local_binary_path();
257        let local_hash = compute_binary_hash(&local_binary_path)
258            .with_context(|| format!("Failed to hash local binary: {:?}", local_binary_path))?;
259        info!("Local hash: {}", &local_hash.code_hash[..16]);
260
261        // 3. rsync up to worker
262        info!("Syncing source to worker {}", self.worker.id);
263        let rsync_up_start = Instant::now();
264        self.rsync_to_worker()
265            .await
266            .context("rsync to worker failed")?;
267        let rsync_up_ms = rsync_up_start.elapsed().as_millis() as u64;
268        info!("rsync upload complete in {}ms", rsync_up_ms);
269
270        // 4. Build on worker
271        info!("Building on remote worker");
272        let compile_start = Instant::now();
273        self.build_remote().await.context("Remote build failed")?;
274        let compilation_ms = compile_start.elapsed().as_millis() as u64;
275        info!("Remote build complete in {}ms", compilation_ms);
276
277        // 5. rsync back
278        info!("Syncing artifacts from worker");
279        let rsync_down_start = Instant::now();
280        self.rsync_from_worker()
281            .await
282            .context("rsync from worker failed")?;
283        let rsync_down_ms = rsync_down_start.elapsed().as_millis() as u64;
284        info!("rsync download complete in {}ms", rsync_down_ms);
285
286        // 6. Compute remote binary hash
287        let remote_binary_path = self.remote_binary_path();
288        let remote_hash = compute_binary_hash(&remote_binary_path)
289            .with_context(|| format!("Failed to hash remote binary: {:?}", remote_binary_path))?;
290        info!("Remote hash: {}", &remote_hash.code_hash[..16]);
291
292        // 7. Classify the canary verdict (bd-784xt). Byte-for-byte identity to the
293        // orchestrator's reference build is ADVISORY: a healthy worker whose rustc
294        // nightly differs legitimately produces different codegen, so a strict
295        // equality check false-failed healthy workers on a heterogeneous fleet.
296        // The injected marker proves the binary is the build we asked for (catches
297        // corruption / a stale artifact); a byte mismatch on a *confirmed-
298        // identical* toolchain is a genuine miscompile. Toolchain parity is not
299        // probed in this path, so a mismatch with the marker present resolves to
300        // advisory drift (PASS) rather than a false failure.
301        let verdict = classify_returned_canary(
302            &remote_binary_path,
303            &test_marker,
304            &local_hash,
305            &remote_hash,
306            None,
307        )?;
308        let success = verdict.passed();
309        let total_ms = start.elapsed().as_millis() as u64;
310
311        let local_short = &local_hash.code_hash[..local_hash.code_hash.len().min(16)];
312        let remote_short = &remote_hash.code_hash[..remote_hash.code_hash.len().min(16)];
313        let error = if success {
314            if verdict.is_advisory() {
315                info!(
316                    "Verification PASSED (advisory {}): worker healthy; codegen differs from the \
317                     orchestrator toolchain (local={local_short} remote={remote_short})",
318                    verdict.reason_code().unwrap_or("RCH-I018"),
319                );
320            } else {
321                info!("Verification PASSED: remote build reproduced the reference binary");
322            }
323            None
324        } else {
325            let code = verdict.reason_code().unwrap_or("RCH-E203");
326            let msg = if verdict == CanaryVerdict::MissingMarker {
327                format!(
328                    "[{code}] canary missing test marker '{test_marker}' — remote build is corrupt or not ours"
329                )
330            } else {
331                format!(
332                    "[{code}] canary {} — local={local_short} remote={remote_short}",
333                    verdict.as_str(),
334                )
335            };
336            warn!("Verification FAILED: {}", msg);
337            Some(msg)
338        };
339
340        Ok(VerificationResult {
341            success,
342            local_hash,
343            remote_hash,
344            local_build_ms,
345            rsync_up_ms,
346            compilation_ms,
347            rsync_down_ms,
348            total_ms,
349            error,
350            test_marker,
351            verdict: verdict.as_str().to_string(),
352        })
353    }
354
355    /// Get the path to the local binary after build.
356    fn local_binary_path(&self) -> PathBuf {
357        let binary_name = self.get_binary_name();
358        let profile = if self.release_mode {
359            "release"
360        } else {
361            "debug"
362        };
363        self.test_project
364            .join("target")
365            .join(profile)
366            .join(binary_name)
367    }
368
369    /// Get the path where remote binary will be stored locally after retrieval.
370    fn remote_binary_path(&self) -> PathBuf {
371        let binary_name = self.get_binary_name();
372        let profile = if self.release_mode {
373            "release"
374        } else {
375            "debug"
376        };
377        self.test_project
378            .join("target")
379            .join(format!("{}_remote", profile))
380            .join(binary_name)
381    }
382
383    /// Get the binary name to verify.
384    ///
385    /// Prefers an explicit override, then the actual Cargo package name from the
386    /// project's `Cargo.toml` (cargo names the binary after the PACKAGE, with `-`
387    /// normalized to `_`), and only falls back to the directory name. The
388    /// directory-name heuristic is wrong whenever the dir and package names
389    /// differ — e.g. the self-test fixture lives in a `project/` dir but its
390    /// package is `rch_self_test`, so the produced binary is `rch_self_test`, not
391    /// `project`; reading Cargo.toml makes the hash step find the real artifact.
392    fn get_binary_name(&self) -> String {
393        if let Some(name) = self.binary_name.clone() {
394            return name;
395        }
396        // An empty project path is not a real project root: `cargo_package_name`
397        // would join "Cargo.toml" against an empty path and read `<cwd>/Cargo.toml`,
398        // misattributing the binary to whatever package the process happens to run
399        // in. Only consult the manifest for a real path; otherwise fall through to
400        // the file-name / "unknown" fallback.
401        if !self.test_project.as_os_str().is_empty()
402            && let Some(pkg) = cargo_package_name(&self.test_project)
403        {
404            return pkg.replace('-', "_");
405        }
406        self.test_project
407            .file_name()
408            .and_then(|n| n.to_str())
409            .unwrap_or("unknown")
410            .replace('-', "_")
411    }
412
413    /// Get the remote project path on the worker.
414    fn remote_project_path(&self) -> String {
415        let project_name = self
416            .test_project
417            .file_name()
418            .and_then(|n| n.to_str())
419            .map(|n| sanitize_remote_path_component(n, "self_test"))
420            .unwrap_or_else(|| "self_test".to_string());
421        let remote_path_suffix = sanitize_remote_path_suffix(&self.remote_path_suffix);
422        let project_dir = format!("{project_name}-{remote_path_suffix}");
423        join_remote_path(&self.remote_base, &project_dir)
424    }
425
426    /// Get the remote artifact source for rsync retrieval.
427    fn remote_artifact_source(&self, profile: &str, flavor: RsyncFlavor) -> String {
428        let remote_path = self.remote_project_path();
429        let remote_target = format!("{}/target/{profile}/", remote_path.trim_end_matches('/'));
430        format!(
431            "{}@{}:{}",
432            self.worker.user,
433            self.worker.host,
434            flavor.remote_path_arg(&remote_target)
435        )
436    }
437
438    /// Build the isolated remote CARGO_HOME shell *expression* for a single
439    /// build attempt. The temp base is resolved on the worker at execution time
440    /// (see [`remote_cargo_home_base_prelude`]), honoring `$TMPDIR` / `/data/tmp`
441    /// rather than hardcoding `/tmp`, so large caches stay off a tmpfs `/tmp`.
442    /// The returned string references the prelude's shell variable and must be
443    /// used in a `$VAR`-expanding (double-quoted) context.
444    fn remote_cargo_home_path(&self, session_id: &str) -> String {
445        let worker_id = sanitize_remote_path_component(self.worker.id.as_str(), "worker");
446        let session_id = sanitize_remote_path_component(session_id, "session");
447        remote_cargo_home_expr(&format!("{worker_id}-{session_id}"))
448    }
449
450    /// Build the project locally.
451    async fn build_local(&self) -> Result<()> {
452        let mut cmd = Command::new(resolve_cargo_binary());
453        cmd.arg("build");
454
455        if self.release_mode {
456            cmd.arg("--release");
457        }
458
459        cmd.current_dir(&self.test_project)
460            .env("CARGO_INCREMENTAL", "0") // Disable incremental for reproducibility
461            // Pin the output to the test project's own `target/` so the produced
462            // binary lands exactly where `local_binary_path()` reads it. The
463            // daemon inherits a process-wide `CARGO_TARGET_DIR` (it redirects its
464            // OWN builds to a shared cache); left unoverridden it sends the canary
465            // binary there instead, and the subsequent local-hash step fails.
466            .env("CARGO_TARGET_DIR", self.test_project.join("target"))
467            .stdout(Stdio::piped())
468            .stderr(Stdio::piped());
469
470        debug!(
471            "Running local build: cargo build {}",
472            if self.release_mode { "--release" } else { "" }
473        );
474
475        let output = cmd
476            .output()
477            .await
478            .context("Failed to execute cargo build")?;
479
480        if !output.status.success() {
481            let stderr = String::from_utf8_lossy(&output.stderr);
482            bail!("Local build failed: {}", stderr);
483        }
484
485        Ok(())
486    }
487
488    /// Sync source files to the worker via rsync.
489    async fn rsync_to_worker(&self) -> Result<()> {
490        let remote_path = self.remote_project_path();
491        let escaped_remote_path = escape(Cow::from(&remote_path));
492
493        if use_mock_transport(&self.worker) {
494            let mut client = MockSshClient::new(self.worker.clone(), MockConfig::from_env());
495            client.connect().await?;
496            let mkdir_cmd = format!("mkdir -p -- {}", escaped_remote_path);
497            let mkdir_result = client.execute(&mkdir_cmd).await?;
498            client.disconnect().await?;
499
500            if !mkdir_result.success() {
501                bail!("Failed to create remote directory: {}", mkdir_result.stderr);
502            }
503
504            let rsync = MockRsync::new(MockRsyncConfig::from_env());
505            let destination = format!(
506                "{}@{}:{}",
507                self.worker.user,
508                self.worker.host,
509                RsyncFlavor::Unknown.remote_path_arg(&remote_path)
510            );
511            rsync
512                .sync_to_remote(&self.test_project.display().to_string(), &destination, &[])
513                .await?;
514            return Ok(());
515        }
516
517        // First ensure remote directory exists
518        let mut client = SshClient::new(self.worker.clone(), self.ssh_options.clone());
519        client.connect().await?;
520        let mkdir_cmd = format!("mkdir -p -- {}", escaped_remote_path);
521        // Always disconnect, even on error/timeout — see the build path below: a
522        // dropped RemoteChild is not reaped while the ControlMaster is alive, so
523        // `?` before disconnect() would leak the remote process.
524        let mkdir_result = client.execute(&mkdir_cmd).await;
525        if let Err(e) = client.disconnect().await {
526            warn!(
527                "Failed to disconnect from {} after remote mkdir: {}",
528                self.worker.id, e
529            );
530        }
531        let mkdir_result = mkdir_result?;
532
533        if !mkdir_result.success() {
534            bail!("Failed to create remote directory: {}", mkdir_result.stderr);
535        }
536
537        let resolved = resolve_rsync_cached(None)?;
538        let destination = format!(
539            "{}@{}:{}",
540            self.worker.user,
541            self.worker.host,
542            resolved.flavor.remote_path_arg(&remote_path)
543        );
544
545        let identity_file = shellexpand::tilde(&self.worker.identity_file);
546
547        let mut cmd = Command::new(&resolved.path);
548        configure_rsync_remote_args(cmd.as_std_mut());
549        cmd.arg("-az")
550            .arg("--rsync-path")
551            .arg(
552                resolved
553                    .flavor
554                    .remote_path_command("rsync".to_owned(), &remote_path),
555            )
556            .arg("--no-owner")
557            .arg("--no-group")
558            .arg("--delete")
559            .arg("--exclude")
560            .arg("target/")
561            .arg("--exclude")
562            .arg(".git/")
563            .arg("-e")
564            .arg(format!(
565                "ssh {} -o StrictHostKeyChecking=accept-new -o BatchMode=yes",
566                crate::ssh_utils::identity_shell_args(identity_file.as_ref())
567            ))
568            .arg(format!("{}/", self.test_project.display()))
569            .arg(&destination)
570            .stdout(Stdio::piped())
571            .stderr(Stdio::piped());
572
573        debug!(
574            "Running rsync to worker: {:?}",
575            cmd.as_std().get_args().collect::<Vec<_>>()
576        );
577
578        let output = cmd.output().await.context("Failed to execute rsync")?;
579
580        if !output.status.success() {
581            let stderr = String::from_utf8_lossy(&output.stderr);
582            bail!("rsync to worker failed: {}", stderr);
583        }
584
585        Ok(())
586    }
587
588    /// Build the project on the remote worker.
589    async fn build_remote(&self) -> Result<()> {
590        let remote_path = self.remote_project_path();
591        let escaped_remote_path = escape(Cow::from(&remote_path));
592
593        // Generate a unique cargo home per build attempt to prevent cache lock contention.
594        let session_id = Uuid::new_v4().simple().to_string();
595        // `cargo_home` is a shell *expression* (`${RCH_CH_BASE}/rch-cargo-home-…`)
596        // resolved on the worker by the prelude below — double-quote it (not
597        // shell-escape) so `$RCH_CH_BASE` expands; its suffix is charset-safe.
598        let cargo_home = self.remote_cargo_home_path(&session_id);
599        let quoted_cargo_home = format!("\"{cargo_home}\"");
600        let cargo_target_dir = format!("{}/target", remote_path);
601        let escaped_cargo_target_dir = escape(Cow::from(&cargo_target_dir));
602        let cargo_home_base_prelude = remote_cargo_home_base_prelude();
603
604        let cargo_args = if self.release_mode {
605            "cargo build --release"
606        } else {
607            "cargo build"
608        };
609        // Preserve the cargo status after removing the isolated cache.
610        let build_cmd = format!(
611            "{cargo_home_base_prelude}; mkdir -p -- {} {} && cd {} && CARGO_HOME={} CARGO_TARGET_DIR={} CARGO_INCREMENTAL=0 {}; status=$?; rm -rf -- {}; exit $status",
612            quoted_cargo_home,
613            escaped_cargo_target_dir,
614            escaped_remote_path,
615            quoted_cargo_home,
616            escaped_cargo_target_dir,
617            cargo_args,
618            quoted_cargo_home
619        );
620
621        debug!(
622            "Running remote build with isolated cargo cache: {}",
623            build_cmd
624        );
625
626        if use_mock_transport(&self.worker) {
627            let mut client = MockSshClient::new(self.worker.clone(), MockConfig::from_env());
628            client.connect().await?;
629            let result = client.execute(&build_cmd).await?;
630            client.disconnect().await?;
631
632            if !result.success() {
633                bail!(
634                    "Remote build failed (exit {}): {}",
635                    result.exit_code,
636                    result.stderr
637                );
638            }
639
640            return Ok(());
641        }
642
643        let mut client = SshClient::new(self.worker.clone(), self.ssh_options.clone());
644        client.connect().await?;
645        // Capture the result and ALWAYS disconnect, even on error/timeout. On an
646        // execute() timeout the inner RemoteChild is dropped, but dropping it does
647        // NOT kill the remote process while the SSH ControlMaster is alive — only
648        // closing the session (disconnect) reaps it. Propagating `?` straight from
649        // execute() would skip disconnect() and leak a timed-out `cargo build`
650        // that keeps burning worker CPU and holding cache/target locks until sshd
651        // reaps it. The disconnect error is best-effort and must not mask the
652        // primary build error.
653        let result = client.execute(&build_cmd).await;
654        if let Err(e) = client.disconnect().await {
655            warn!(
656                "Failed to disconnect from {} after remote build: {}",
657                self.worker.id, e
658            );
659        }
660        let result = result?;
661
662        if !result.success() {
663            bail!(
664                "Remote build failed (exit {}): {}",
665                result.exit_code,
666                result.stderr
667            );
668        }
669
670        Ok(())
671    }
672
673    /// Sync build artifacts from the worker via rsync.
674    async fn rsync_from_worker(&self) -> Result<()> {
675        let profile = if self.release_mode {
676            "release"
677        } else {
678            "debug"
679        };
680
681        // Create local destination directory for remote artifacts
682        let local_dest = self
683            .test_project
684            .join("target")
685            .join(format!("{}_remote", profile));
686        std::fs::create_dir_all(&local_dest)
687            .with_context(|| format!("Failed to create directory: {:?}", local_dest))?;
688
689        if use_mock_transport(&self.worker) {
690            let rsync = MockRsync::new(MockRsyncConfig::from_env());
691            let remote_target = self.remote_artifact_source(profile, RsyncFlavor::Unknown);
692            rsync
693                .retrieve_artifacts(&remote_target, &local_dest.display().to_string(), &[])
694                .await?;
695            return Ok(());
696        }
697
698        let resolved = resolve_rsync_cached(None)?;
699        let remote_target = self.remote_artifact_source(profile, resolved.flavor);
700        let raw_target = format!(
701            "{}/target/{profile}/",
702            self.remote_project_path().trim_end_matches('/')
703        );
704
705        let identity_file = shellexpand::tilde(&self.worker.identity_file);
706
707        let mut cmd = Command::new(&resolved.path);
708        configure_rsync_remote_args(cmd.as_std_mut());
709        cmd.arg("-az")
710            .arg("--rsync-path")
711            .arg(
712                resolved
713                    .flavor
714                    .remote_path_command("rsync".to_owned(), &raw_target),
715            )
716            .arg("--no-owner")
717            .arg("--no-group")
718            .arg("-e")
719            .arg(format!(
720                "ssh {} -o StrictHostKeyChecking=accept-new -o BatchMode=yes",
721                crate::ssh_utils::identity_shell_args(identity_file.as_ref())
722            ))
723            .arg(&remote_target)
724            .arg(format!("{}/", local_dest.display()))
725            .stdout(Stdio::piped())
726            .stderr(Stdio::piped());
727
728        debug!(
729            "Running rsync from worker: {:?}",
730            cmd.as_std().get_args().collect::<Vec<_>>()
731        );
732
733        let output = cmd.output().await.context("Failed to retrieve artifacts")?;
734
735        if !output.status.success() {
736            let stderr = String::from_utf8_lossy(&output.stderr);
737            bail!("rsync from worker failed: {}", stderr);
738        }
739
740        Ok(())
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use crate::gc_roots::RCH_CARGO_HOME_BASE_VAR;
748    use crate::mock::{
749        MockConfig, MockRsyncConfig, clear_global_invocations, clear_mock_overrides,
750        global_rsync_invocations_snapshot, global_ssh_invocations_snapshot, is_mock_enabled,
751        set_mock_enabled_override, set_mock_rsync_config_override, set_mock_ssh_config_override,
752    };
753    use crate::test_guard;
754    use crate::testing::{TestLogger, TestPhase};
755    use crate::types::WorkerId;
756
757    #[test]
758    fn returned_canary_requires_readable_nonempty_marker_and_preserves_drift_policy() {
759        use std::io::Write;
760
761        // Marker/classifier integration only, not compiled-worker proof.
762        let retained = tempfile::tempdir().unwrap().keep();
763        let present = retained.join("marker-present.bin");
764        let absent = retained.join("marker-absent.bin");
765        let missing = retained.join("never-created.bin");
766        let directory = retained.join("not-a-binary");
767        let marker = "rch-returned-canary-test-marker";
768        let present_bytes = format!("prefix\0{marker}\0suffix").into_bytes();
769        let absent_bytes = b"prefix\0different-marker\0suffix";
770        std::fs::File::create_new(&present)
771            .unwrap()
772            .write_all(&present_bytes)
773            .unwrap();
774        std::fs::File::create_new(&absent)
775            .unwrap()
776            .write_all(absent_bytes)
777            .unwrap();
778        std::fs::create_dir(&directory).unwrap();
779
780        let local = BinaryHashResult {
781            full_hash: "supplied-local-full-hash".to_owned(),
782            code_hash: "supplied-local-code-hash".to_owned(),
783            text_section_size: 100,
784            is_debug: false,
785        };
786        for drift in [false, true] {
787            let mut remote = local.clone();
788            if drift {
789                remote.full_hash = "supplied-remote-full-hash".to_owned();
790                remote.code_hash = "supplied-remote-code-hash".to_owned();
791            }
792            let verdict =
793                classify_returned_canary(&present, marker, &local, &remote, None).unwrap();
794            assert_eq!(
795                verdict,
796                if drift {
797                    CanaryVerdict::ToolchainDrift
798                } else {
799                    CanaryVerdict::Reproduced
800                }
801            );
802            assert!(verdict.passed());
803            assert_eq!(verdict.is_advisory(), drift);
804
805            let verdict = classify_returned_canary(&absent, marker, &local, &remote, None).unwrap();
806            assert_eq!(verdict, CanaryVerdict::MissingMarker);
807            assert!(!verdict.passed());
808
809            for artifact in [&missing, &directory] {
810                let error =
811                    classify_returned_canary(artifact, marker, &local, &remote, None).unwrap_err();
812                assert!(format!("{error:#}").contains("Failed to verify returned canary identity"));
813                assert!(format!("{error:#}").contains("Failed to read binary"));
814                let cause = error
815                    .chain()
816                    .find_map(|cause| cause.downcast_ref::<std::io::Error>())
817                    .expect("the real artifact read error must remain in the chain");
818                if artifact == &missing {
819                    assert_eq!(cause.kind(), std::io::ErrorKind::NotFound);
820                }
821            }
822            let error = classify_returned_canary(&present, "", &local, &remote, None).unwrap_err();
823            assert!(error.to_string().contains("empty identity marker"));
824        }
825        assert_eq!(std::fs::read(&present).unwrap(), present_bytes);
826        assert_eq!(std::fs::read(&absent).unwrap(), absent_bytes);
827        assert!(!missing.exists());
828        assert!(directory.is_dir());
829        assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 0);
830        assert_eq!(std::fs::read_dir(retained).unwrap().count(), 3);
831    }
832
833    #[test]
834    fn resolve_cargo_binary_prefers_override_then_rustup_then_path() {
835        use std::ffi::OsString;
836
837        // An explicit CARGO override always wins.
838        assert_eq!(
839            resolve_cargo_binary_from(Some(OsString::from("/opt/cargo")), None),
840            OsString::from("/opt/cargo")
841        );
842
843        // An empty override is ignored (falls through to HOME / PATH).
844        assert_eq!(
845            resolve_cargo_binary_from(Some(OsString::new()), None),
846            OsString::from("cargo")
847        );
848
849        // With no override and no rustup cargo present under HOME, fall back to a
850        // bare `cargo` (PATH lookup).
851        let empty_home = tempfile::tempdir().unwrap();
852        assert_eq!(
853            resolve_cargo_binary_from(None, Some(empty_home.path().as_os_str().to_os_string())),
854            OsString::from("cargo")
855        );
856
857        // When `$HOME/.cargo/bin/cargo` exists (the rustup default the daemon's
858        // minimal PATH omits), resolve to it absolutely.
859        let home = tempfile::tempdir().unwrap();
860        let cargo_bin = home.path().join(".cargo").join("bin");
861        std::fs::create_dir_all(&cargo_bin).unwrap();
862        let cargo_path = cargo_bin.join("cargo");
863        std::fs::write(&cargo_path, b"#!/bin/sh\n").unwrap();
864        assert_eq!(
865            resolve_cargo_binary_from(None, Some(home.path().as_os_str().to_os_string())),
866            cargo_path.into_os_string()
867        );
868    }
869
870    #[test]
871    fn cargo_package_name_reads_package_not_directory() {
872        // The self-test fixture's real shape: a `project/` dir whose package is
873        // `rch_self_test` — cargo names the binary after the package, so reading
874        // the directory name (`project`) would look for a non-existent artifact.
875        let dir = tempfile::tempdir().unwrap();
876        let proj = dir.path().join("project");
877        std::fs::create_dir_all(&proj).unwrap();
878        std::fs::write(
879            proj.join("Cargo.toml"),
880            "[package]\nname = \"rch_self_test\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n",
881        )
882        .unwrap();
883        assert_eq!(cargo_package_name(&proj).as_deref(), Some("rch_self_test"));
884
885        // No manifest -> None (caller falls back to the directory name).
886        let empty = dir.path().join("empty");
887        std::fs::create_dir_all(&empty).unwrap();
888        assert_eq!(cargo_package_name(&empty), None);
889    }
890
891    fn init_test_logging() {
892        let _ = tracing_subscriber::fmt()
893            .with_test_writer()
894            .with_max_level(tracing::Level::DEBUG)
895            .try_init();
896    }
897
898    // ========================
899    // VerificationResult tests
900    // ========================
901
902    #[test]
903    fn verification_result_serializes_roundtrip() {
904        let _guard = test_guard!();
905        let logger = TestLogger::for_test("verification_result_serializes_roundtrip");
906
907        let result = VerificationResult {
908            success: true,
909            local_hash: BinaryHashResult {
910                full_hash: "abc123".to_string(),
911                code_hash: "def456".to_string(),
912                text_section_size: 12345,
913                is_debug: false,
914            },
915            remote_hash: BinaryHashResult {
916                full_hash: "abc123".to_string(),
917                code_hash: "def456".to_string(),
918                text_section_size: 12345,
919                is_debug: false,
920            },
921            local_build_ms: 1200,
922            rsync_up_ms: 100,
923            compilation_ms: 5000,
924            rsync_down_ms: 200,
925            total_ms: 5300,
926            error: None,
927            test_marker: "RCH_TEST_12345".to_string(),
928            verdict: "reproduced".to_string(),
929        };
930
931        logger.log(TestPhase::Execute, "Serializing VerificationResult to JSON");
932        let json = serde_json::to_string(&result).unwrap();
933        logger.log_with_data(
934            TestPhase::Execute,
935            "Serialized result",
936            serde_json::json!({ "json_len": json.len() }),
937        );
938
939        let restored: VerificationResult = serde_json::from_str(&json).unwrap();
940
941        logger.log(TestPhase::Verify, "Checking restored fields");
942        assert!(restored.success);
943        assert_eq!(restored.rsync_up_ms, 100);
944        assert_eq!(restored.compilation_ms, 5000);
945        assert_eq!(restored.test_marker, "RCH_TEST_12345");
946
947        logger.pass();
948    }
949
950    #[test]
951    fn verification_result_with_error() {
952        let _guard = test_guard!();
953        let logger = TestLogger::for_test("verification_result_with_error");
954
955        let result = VerificationResult {
956            success: false,
957            local_hash: BinaryHashResult {
958                full_hash: "abc".to_string(),
959                code_hash: "local_hash".to_string(),
960                text_section_size: 1000,
961                is_debug: false,
962            },
963            remote_hash: BinaryHashResult {
964                full_hash: "xyz".to_string(),
965                code_hash: "remote_hash".to_string(),
966                text_section_size: 1000,
967                is_debug: false,
968            },
969            local_build_ms: 900,
970            rsync_up_ms: 50,
971            compilation_ms: 3000,
972            rsync_down_ms: 50,
973            total_ms: 3100,
974            error: Some("Binary hash mismatch".to_string()),
975            test_marker: "RCH_TEST_99999".to_string(),
976            verdict: "reproduced".to_string(),
977        };
978
979        logger.log(TestPhase::Verify, "Checking failed verification result");
980        assert!(!result.success);
981        assert!(result.error.is_some());
982        assert_eq!(result.error.as_ref().unwrap(), "Binary hash mismatch");
983
984        logger.log_with_data(
985            TestPhase::Verify,
986            "Error captured correctly",
987            serde_json::json!({ "error": result.error }),
988        );
989        logger.pass();
990    }
991
992    #[test]
993    fn verification_result_serializes_with_all_fields() {
994        let _guard = test_guard!();
995        let logger = TestLogger::for_test("verification_result_serializes_with_all_fields");
996
997        let result = VerificationResult {
998            success: false,
999            local_hash: BinaryHashResult {
1000                full_hash: "full_local".to_string(),
1001                code_hash: "code_local".to_string(),
1002                text_section_size: 5000,
1003                is_debug: true,
1004            },
1005            remote_hash: BinaryHashResult {
1006                full_hash: "full_remote".to_string(),
1007                code_hash: "code_remote".to_string(),
1008                text_section_size: 5001,
1009                is_debug: true,
1010            },
1011            local_build_ms: 2500,
1012            rsync_up_ms: 300,
1013            compilation_ms: 15000,
1014            rsync_down_ms: 400,
1015            total_ms: 18200,
1016            error: Some("Size mismatch: 5000 vs 5001".to_string()),
1017            test_marker: "RCH_FULL_TEST".to_string(),
1018            verdict: "reproduced".to_string(),
1019        };
1020
1021        logger.log(TestPhase::Execute, "Serializing result with all fields");
1022        let json = serde_json::to_string_pretty(&result).unwrap();
1023
1024        // Verify all fields are present in serialization
1025        assert!(json.contains("\"success\": false"));
1026        assert!(json.contains("\"local_build_ms\": 2500"));
1027        assert!(json.contains("\"rsync_up_ms\": 300"));
1028        assert!(json.contains("\"compilation_ms\": 15000"));
1029        assert!(json.contains("\"rsync_down_ms\": 400"));
1030        assert!(json.contains("\"total_ms\": 18200"));
1031        assert!(json.contains("RCH_FULL_TEST"));
1032        assert!(json.contains("Size mismatch"));
1033
1034        logger.log_with_data(
1035            TestPhase::Verify,
1036            "All fields serialized",
1037            serde_json::json!({ "fields_checked": 8 }),
1038        );
1039        logger.pass();
1040    }
1041
1042    // ========================
1043    // RemoteCompilationTest tests
1044    // ========================
1045
1046    #[test]
1047    fn remote_compilation_test_default_values() {
1048        let _guard = test_guard!();
1049        let logger = TestLogger::for_test("remote_compilation_test_default_values");
1050
1051        let test = RemoteCompilationTest::default();
1052
1053        logger.log(TestPhase::Verify, "Checking default values");
1054        assert_eq!(test.timeout, Duration::from_secs(300));
1055        assert!(test.release_mode);
1056        assert!(test.binary_name.is_none());
1057        assert_eq!(test.remote_base, "/tmp/rch_self_test");
1058        assert!(test.remote_path_suffix.starts_with("run-"));
1059
1060        logger.log_with_data(
1061            TestPhase::Verify,
1062            "Default values correct",
1063            serde_json::json!({
1064                "timeout_secs": 300,
1065                "release_mode": true,
1066                "remote_base": "/tmp/rch_self_test",
1067                "remote_path_suffix": &test.remote_path_suffix
1068            }),
1069        );
1070        logger.pass();
1071    }
1072
1073    #[test]
1074    fn remote_compilation_test_builder_pattern() {
1075        let _guard = test_guard!();
1076        let logger = TestLogger::for_test("remote_compilation_test_builder_pattern");
1077
1078        let worker = WorkerConfig {
1079            id: WorkerId::new("test-worker"),
1080            host: "worker.example.com".to_string(),
1081            user: "builder".to_string(),
1082            identity_file: "~/.ssh/id_rsa".to_string(),
1083            total_slots: 4,
1084            ..Default::default()
1085        };
1086
1087        logger.log(TestPhase::Execute, "Building test with custom options");
1088        let test = RemoteCompilationTest::new(worker.clone(), PathBuf::from("/tmp/project"))
1089            .with_timeout(Duration::from_secs(120))
1090            .with_release_mode(false)
1091            .with_binary_name("my_binary");
1092
1093        logger.log(TestPhase::Verify, "Checking builder-set values");
1094        assert_eq!(test.timeout, Duration::from_secs(120));
1095        assert!(!test.release_mode);
1096        assert_eq!(test.binary_name, Some("my_binary".to_string()));
1097        assert_eq!(test.worker.id.as_str(), "test-worker");
1098
1099        logger.pass();
1100    }
1101
1102    #[test]
1103    fn remote_compilation_test_with_ssh_options() {
1104        let _guard = test_guard!();
1105        let logger = TestLogger::for_test("remote_compilation_test_with_ssh_options");
1106
1107        let worker = WorkerConfig {
1108            id: WorkerId::new("w1"),
1109            host: "host".to_string(),
1110            user: "user".to_string(),
1111            identity_file: "~/.ssh/id_rsa".to_string(),
1112            total_slots: 1,
1113            ..Default::default()
1114        };
1115
1116        let ssh_opts = SshOptions {
1117            connect_timeout: Duration::from_secs(30),
1118            command_timeout: Duration::from_secs(600),
1119            ..Default::default()
1120        };
1121
1122        logger.log(TestPhase::Execute, "Creating test with custom SSH options");
1123        let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/project"))
1124            .with_ssh_options(ssh_opts);
1125
1126        logger.log(TestPhase::Verify, "Checking SSH options");
1127        assert_eq!(test.ssh_options.connect_timeout, Duration::from_secs(30));
1128        assert_eq!(test.ssh_options.command_timeout, Duration::from_secs(600));
1129
1130        logger.pass();
1131    }
1132
1133    #[test]
1134    fn remote_compilation_test_binary_path_release() {
1135        let _guard = test_guard!();
1136        let logger = TestLogger::for_test("remote_compilation_test_binary_path_release");
1137
1138        let worker = WorkerConfig {
1139            id: WorkerId::new("w1"),
1140            host: "host".to_string(),
1141            user: "user".to_string(),
1142            identity_file: "~/.ssh/id_rsa".to_string(),
1143            total_slots: 1,
1144            ..Default::default()
1145        };
1146
1147        let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/my-project"))
1148            .with_release_mode(true)
1149            .with_binary_name("my_binary");
1150
1151        let local_path = test.local_binary_path();
1152        let remote_path = test.remote_binary_path();
1153
1154        logger.log_with_data(
1155            TestPhase::Execute,
1156            "Computed binary paths",
1157            serde_json::json!({
1158                "local": local_path.to_string_lossy(),
1159                "remote": remote_path.to_string_lossy()
1160            }),
1161        );
1162
1163        assert!(
1164            local_path
1165                .to_string_lossy()
1166                .contains("target/release/my_binary")
1167        );
1168        assert!(
1169            remote_path
1170                .to_string_lossy()
1171                .contains("target/release_remote/my_binary")
1172        );
1173
1174        logger.pass();
1175    }
1176
1177    #[test]
1178    fn remote_compilation_test_binary_path_debug() {
1179        let _guard = test_guard!();
1180        let logger = TestLogger::for_test("remote_compilation_test_binary_path_debug");
1181
1182        let worker = WorkerConfig {
1183            id: WorkerId::new("w1"),
1184            host: "host".to_string(),
1185            user: "user".to_string(),
1186            identity_file: "~/.ssh/id_rsa".to_string(),
1187            total_slots: 1,
1188            ..Default::default()
1189        };
1190
1191        let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/my-project"))
1192            .with_release_mode(false)
1193            .with_binary_name("my_binary");
1194
1195        let local_path = test.local_binary_path();
1196        let remote_path = test.remote_binary_path();
1197
1198        logger.log_with_data(
1199            TestPhase::Execute,
1200            "Computed debug binary paths",
1201            serde_json::json!({
1202                "local": local_path.to_string_lossy(),
1203                "remote": remote_path.to_string_lossy()
1204            }),
1205        );
1206
1207        assert!(
1208            local_path
1209                .to_string_lossy()
1210                .contains("target/debug/my_binary")
1211        );
1212        assert!(
1213            remote_path
1214                .to_string_lossy()
1215                .contains("target/debug_remote/my_binary")
1216        );
1217
1218        logger.pass();
1219    }
1220
1221    #[test]
1222    fn remote_compilation_test_infers_binary_name() {
1223        let _guard = test_guard!();
1224        let logger = TestLogger::for_test("remote_compilation_test_infers_binary_name");
1225
1226        let worker = WorkerConfig {
1227            id: WorkerId::new("w1"),
1228            host: "host".to_string(),
1229            user: "user".to_string(),
1230            identity_file: "~/.ssh/id_rsa".to_string(),
1231            total_slots: 1,
1232            ..Default::default()
1233        };
1234
1235        // Test with hyphenated project name (should convert to underscore)
1236        let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/my-cool-project"));
1237
1238        let binary_name = test.get_binary_name();
1239        logger.log_with_data(
1240            TestPhase::Execute,
1241            "Inferred binary name",
1242            serde_json::json!({ "binary_name": &binary_name }),
1243        );
1244
1245        assert_eq!(binary_name, "my_cool_project");
1246
1247        logger.pass();
1248    }
1249
1250    #[test]
1251    fn remote_compilation_test_infers_binary_name_edge_cases() {
1252        let _guard = test_guard!();
1253        let logger = TestLogger::for_test("remote_compilation_test_infers_binary_name_edge_cases");
1254
1255        let worker = WorkerConfig {
1256            id: WorkerId::new("w1"),
1257            host: "host".to_string(),
1258            user: "user".to_string(),
1259            identity_file: "~/.ssh/id_rsa".to_string(),
1260            total_slots: 1,
1261            ..Default::default()
1262        };
1263
1264        // Test with multiple hyphens
1265        let test1 =
1266            RemoteCompilationTest::new(worker.clone(), PathBuf::from("/a/b/my-very-cool-project"));
1267        assert_eq!(test1.get_binary_name(), "my_very_cool_project");
1268
1269        // Test with underscores (should remain unchanged)
1270        let test2 =
1271            RemoteCompilationTest::new(worker.clone(), PathBuf::from("/a/b/my_project_name"));
1272        assert_eq!(test2.get_binary_name(), "my_project_name");
1273
1274        // Test with mixed
1275        let test3 = RemoteCompilationTest::new(worker.clone(), PathBuf::from("/a/b/my-project_v2"));
1276        assert_eq!(test3.get_binary_name(), "my_project_v2");
1277
1278        // Test with explicit binary name (should override)
1279        let test4 = RemoteCompilationTest::new(worker.clone(), PathBuf::from("/a/b/my-project"))
1280            .with_binary_name("custom");
1281        assert_eq!(test4.get_binary_name(), "custom");
1282
1283        logger.log_with_data(
1284            TestPhase::Verify,
1285            "All edge cases handled",
1286            serde_json::json!({ "cases_tested": 4 }),
1287        );
1288        logger.pass();
1289    }
1290
1291    #[test]
1292    fn remote_compilation_test_remote_project_path() {
1293        let _guard = test_guard!();
1294        let logger = TestLogger::for_test("remote_compilation_test_remote_project_path");
1295
1296        let worker = WorkerConfig {
1297            id: WorkerId::new("w1"),
1298            host: "host".to_string(),
1299            user: "user".to_string(),
1300            identity_file: "~/.ssh/id_rsa".to_string(),
1301            total_slots: 1,
1302            ..Default::default()
1303        };
1304
1305        let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/test-project"));
1306
1307        let remote_path = test.remote_project_path();
1308        logger.log_with_data(
1309            TestPhase::Execute,
1310            "Computed remote path",
1311            serde_json::json!({ "remote_path": &remote_path }),
1312        );
1313
1314        assert!(
1315            remote_path.starts_with("/tmp/rch_self_test/test-project-run-"),
1316            "remote path should include a unique run suffix: {remote_path}"
1317        );
1318
1319        logger.pass();
1320    }
1321
1322    #[test]
1323    fn remote_compilation_test_custom_remote_path_suffix() {
1324        let _guard = test_guard!();
1325        let logger = TestLogger::for_test("remote_compilation_test_custom_remote_path_suffix");
1326
1327        let worker = WorkerConfig {
1328            id: WorkerId::new("w1"),
1329            host: "host".to_string(),
1330            user: "user".to_string(),
1331            identity_file: "~/.ssh/id_rsa".to_string(),
1332            total_slots: 1,
1333            ..Default::default()
1334        };
1335
1336        let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/test-project"))
1337            .with_remote_path_suffix("run/42 attempt 1");
1338
1339        let remote_path = test.remote_project_path();
1340        logger.log_with_data(
1341            TestPhase::Execute,
1342            "Computed remote path with custom suffix",
1343            serde_json::json!({ "remote_path": &remote_path }),
1344        );
1345
1346        assert_eq!(
1347            remote_path,
1348            "/tmp/rch_self_test/test-project-run-42-attempt-1"
1349        );
1350
1351        logger.pass();
1352    }
1353
1354    #[test]
1355    fn remote_compilation_test_remote_path_sanitizes_public_suffix_field() {
1356        let _guard = test_guard!();
1357        let logger = TestLogger::for_test(
1358            "remote_compilation_test_remote_path_sanitizes_public_suffix_field",
1359        );
1360
1361        let worker = WorkerConfig {
1362            id: WorkerId::new("w1"),
1363            host: "host".to_string(),
1364            user: "user".to_string(),
1365            identity_file: "~/.ssh/id_rsa".to_string(),
1366            total_slots: 1,
1367            ..Default::default()
1368        };
1369
1370        let mut test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/test-project"));
1371        test.remote_path_suffix = "../unsafe path".to_string();
1372
1373        let remote_path = test.remote_project_path();
1374        logger.log_with_data(
1375            TestPhase::Execute,
1376            "Computed remote path with directly-mutated suffix",
1377            serde_json::json!({ "remote_path": &remote_path }),
1378        );
1379
1380        assert_eq!(
1381            remote_path,
1382            "/tmp/rch_self_test/test-project-..-unsafe-path"
1383        );
1384
1385        logger.pass();
1386    }
1387
1388    #[test]
1389    fn remote_compilation_test_instances_do_not_share_remote_paths() {
1390        let _guard = test_guard!();
1391        let logger =
1392            TestLogger::for_test("remote_compilation_test_instances_do_not_share_remote_paths");
1393
1394        let worker = WorkerConfig {
1395            id: WorkerId::new("w1"),
1396            host: "host".to_string(),
1397            user: "user".to_string(),
1398            identity_file: "~/.ssh/id_rsa".to_string(),
1399            total_slots: 1,
1400            ..Default::default()
1401        };
1402
1403        let first = RemoteCompilationTest::new(worker.clone(), PathBuf::from("/home/user/project"));
1404        let second = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/project"));
1405
1406        let first_path = first.remote_project_path();
1407        let second_path = second.remote_project_path();
1408        logger.log_with_data(
1409            TestPhase::Verify,
1410            "Computed independent remote paths",
1411            serde_json::json!({
1412                "first_path": &first_path,
1413                "second_path": &second_path
1414            }),
1415        );
1416
1417        assert_ne!(first_path, second_path);
1418
1419        logger.pass();
1420    }
1421
1422    #[test]
1423    fn remote_compilation_test_with_custom_remote_base() {
1424        let _guard = test_guard!();
1425        let logger = TestLogger::for_test("remote_compilation_test_with_custom_remote_base");
1426
1427        let worker = WorkerConfig {
1428            id: WorkerId::new("w1"),
1429            host: "host".to_string(),
1430            user: "user".to_string(),
1431            identity_file: "~/.ssh/id_rsa".to_string(),
1432            total_slots: 1,
1433            ..Default::default()
1434        };
1435
1436        let mut test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/project"))
1437            .with_remote_path_suffix("custom-suffix");
1438        test.remote_base = "/custom/build/dir/".to_string();
1439
1440        let remote_path = test.remote_project_path();
1441        logger.log_with_data(
1442            TestPhase::Execute,
1443            "Computed remote path with custom base",
1444            serde_json::json!({
1445                "remote_base": "/custom/build/dir/",
1446                "remote_path": &remote_path
1447            }),
1448        );
1449
1450        assert_eq!(remote_path, "/custom/build/dir/project-custom-suffix");
1451
1452        logger.pass();
1453    }
1454
1455    #[test]
1456    fn remote_compilation_test_artifact_source_uses_rsync_argument_mode() {
1457        let _guard = test_guard!();
1458        let logger = TestLogger::for_test("remote_compilation_test_artifact_source_argument_mode");
1459
1460        let worker = WorkerConfig {
1461            id: WorkerId::new("w1"),
1462            host: "host".to_string(),
1463            user: "user".to_string(),
1464            identity_file: "~/.ssh/id_rsa".to_string(),
1465            total_slots: 1,
1466            ..Default::default()
1467        };
1468
1469        let mut test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/project"))
1470            .with_remote_path_suffix("run-1");
1471        for base in ["/tmp/plain", "/tmp/p q", "/tmp/x:y"] {
1472            test.remote_base = base.to_string();
1473            let target = format!("{base}/project-run-1/target/release/");
1474            assert_eq!(
1475                test.remote_artifact_source("release", RsyncFlavor::Unknown),
1476                format!("user@host:{target}")
1477            );
1478            let legacy = RsyncFlavor::OpenRsync { protocol: Some(29) };
1479            let expected = if base == "/tmp/plain" {
1480                target
1481            } else {
1482                format!("'{target}'")
1483            };
1484            assert_eq!(
1485                test.remote_artifact_source("release", legacy),
1486                format!("user@host:{expected}")
1487            );
1488        }
1489
1490        logger.pass();
1491    }
1492
1493    #[test]
1494    fn remote_compilation_test_cargo_home_sanitizes_worker_id() {
1495        let _guard = test_guard!();
1496        let logger = TestLogger::for_test("remote_compilation_test_cargo_home_sanitizes_worker_id");
1497
1498        let worker = WorkerConfig {
1499            id: WorkerId::new("worker/one with spaces"),
1500            host: "host".to_string(),
1501            user: "user".to_string(),
1502            identity_file: "~/.ssh/id_rsa".to_string(),
1503            total_slots: 1,
1504            ..Default::default()
1505        };
1506
1507        let test = RemoteCompilationTest::new(worker, PathBuf::from("/home/user/project"));
1508        let cargo_home = test.remote_cargo_home_path("run/7 with spaces");
1509        logger.log_with_data(
1510            TestPhase::Verify,
1511            "Computed sanitized cargo home",
1512            serde_json::json!({ "cargo_home": &cargo_home }),
1513        );
1514
1515        // The worker_id/session_id are still sanitized into the basename, but the
1516        // base is now a worker-resolved shell variable (honoring $TMPDIR /
1517        // /data/tmp) instead of a hardcoded /tmp.
1518        assert_eq!(
1519            cargo_home,
1520            "${RCH_CH_BASE}/rch-cargo-home-worker-one-with-spaces-run-7-with-spaces"
1521        );
1522
1523        logger.pass();
1524    }
1525
1526    /// The prelude must never bake in `/tmp`: it resolves the staging base on the
1527    /// worker from `$TMPDIR` → `/data/tmp` → `/tmp` at execution time. rchd runs
1528    /// under systemd without PAM's `/etc/environment`, so this explicit resolution
1529    /// (not a client-baked path) is what keeps caches off a tmpfs `/tmp`.
1530    #[test]
1531    fn cargo_home_base_prelude_does_not_hardcode_tmp() {
1532        let prelude = remote_cargo_home_base_prelude();
1533        // Sets the documented variable, references $TMPDIR and /data/tmp.
1534        assert!(prelude.contains(RCH_CARGO_HOME_BASE_VAR), "{prelude}");
1535        assert!(
1536            prelude.contains("$TMPDIR") || prelude.contains("${TMPDIR"),
1537            "{prelude}"
1538        );
1539        assert!(prelude.contains("/data/tmp"), "{prelude}");
1540        // The guards must inspect the shell variable's value, not the literal
1541        // string "RCH_CH_BASE"; otherwise the prelude falls through to /tmp.
1542        assert!(prelude.contains("[ -n \"${RCH_CH_BASE}\" ]"), "{prelude}");
1543        assert!(prelude.contains("[ -d \"${RCH_CH_BASE}\" ]"), "{prelude}");
1544        assert!(!prelude.contains("[ -n \"RCH_CH_BASE\" ]"), "{prelude}");
1545        assert!(!prelude.contains("[ -d \"RCH_CH_BASE\" ]"), "{prelude}");
1546        // /tmp only as the last-resort fallback, never as the leading base.
1547        assert!(
1548            !prelude.contains("/tmp/rch"),
1549            "must not bake a /tmp path: {prelude}"
1550        );
1551    }
1552
1553    /// End-to-end behavioral check of the prelude's resolution order, executed
1554    /// under POSIX `sh` so a regression in the snippet is actually caught.
1555    #[cfg(unix)]
1556    #[test]
1557    fn cargo_home_base_prelude_resolves_tmpdir_first_then_data_tmp() {
1558        use std::process::Command;
1559        use tempfile::tempdir;
1560
1561        let prelude = remote_cargo_home_base_prelude();
1562        let var = RCH_CARGO_HOME_BASE_VAR;
1563        // Run the prelude then echo the resolved base, under controlled $TMPDIR.
1564        let run = |tmpdir_env: Option<&str>| -> String {
1565            let mut cmd = Command::new("sh");
1566            cmd.arg("-c")
1567                .arg(format!("{prelude}; printf '%s' \"${var}\""));
1568            cmd.env_remove("TMPDIR");
1569            if let Some(v) = tmpdir_env {
1570                cmd.env("TMPDIR", v);
1571            }
1572            let out = cmd.output().expect("run prelude under sh");
1573            String::from_utf8_lossy(&out.stdout).into_owned()
1574        };
1575
1576        // $TMPDIR set to a real dir wins.
1577        let dir = tempdir().expect("tempdir");
1578        let real = dir.path().to_str().unwrap();
1579        assert_eq!(run(Some(real)), real, "a real $TMPDIR must be honored");
1580
1581        // $TMPDIR unset → /data/tmp if it exists, else /tmp. Either is acceptable
1582        // and proves we never depend on $TMPDIR being present (the systemd case).
1583        let resolved = run(None);
1584        assert!(
1585            resolved == "/data/tmp" || resolved == "/tmp",
1586            "unset $TMPDIR must fall back to /data/tmp or /tmp, got {resolved:?}"
1587        );
1588
1589        // A bogus $TMPDIR (not a directory) must be rejected in favor of the
1590        // existing fallback, never used blindly.
1591        let bogus = run(Some("/nonexistent-rch-tmpdir-xyz"));
1592        assert!(
1593            bogus == "/data/tmp" || bogus == "/tmp",
1594            "a non-directory $TMPDIR must be rejected, got {bogus:?}"
1595        );
1596    }
1597
1598    #[test]
1599    fn verification_result_timing_fields() {
1600        let _guard = test_guard!();
1601        let logger = TestLogger::for_test("verification_result_timing_fields");
1602
1603        let result = VerificationResult {
1604            success: true,
1605            local_hash: BinaryHashResult {
1606                full_hash: "a".to_string(),
1607                code_hash: "b".to_string(),
1608                text_section_size: 100,
1609                is_debug: false,
1610            },
1611            remote_hash: BinaryHashResult {
1612                full_hash: "a".to_string(),
1613                code_hash: "b".to_string(),
1614                text_section_size: 100,
1615                is_debug: false,
1616            },
1617            local_build_ms: 1200,
1618            rsync_up_ms: 150,
1619            compilation_ms: 8000,
1620            rsync_down_ms: 200,
1621            total_ms: 8500,
1622            error: None,
1623            test_marker: "RCH_TEST_1".to_string(),
1624            verdict: "reproduced".to_string(),
1625        };
1626
1627        // Verify timing breakdown makes sense
1628        let sum = result.rsync_up_ms + result.compilation_ms + result.rsync_down_ms;
1629        logger.log_with_data(
1630            TestPhase::Verify,
1631            "Checking timing consistency",
1632            serde_json::json!({
1633                "sum_of_phases": sum,
1634                "total_reported": result.total_ms
1635            }),
1636        );
1637
1638        // Total should be >= sum (may include overhead)
1639        assert!(result.total_ms >= sum - 100); // Allow some timing variance
1640
1641        logger.pass();
1642    }
1643
1644    // ========================
1645    // use_mock_transport tests
1646    // ========================
1647
1648    #[test]
1649    fn use_mock_transport_with_mock_host() {
1650        let _guard = test_guard!();
1651        let logger = TestLogger::for_test("use_mock_transport_with_mock_host");
1652
1653        let worker = WorkerConfig {
1654            id: WorkerId::new("mock-worker"),
1655            host: "mock://localhost".to_string(),
1656            user: "user".to_string(),
1657            identity_file: "~/.ssh/id".to_string(),
1658            total_slots: 4,
1659            ..Default::default()
1660        };
1661
1662        logger.log(TestPhase::Execute, "Testing mock:// host detection");
1663        assert!(use_mock_transport(&worker));
1664
1665        logger.pass();
1666    }
1667
1668    /// Test use_mock_transport with real host and mock disabled.
1669    /// Uses global mock state - run with `cargo test -- --test-threads=1`.
1670    #[test]
1671    #[ignore = "uses global mock state; run with --test-threads=1"]
1672    fn use_mock_transport_with_real_host() {
1673        let logger = TestLogger::for_test("use_mock_transport_with_real_host");
1674
1675        // Ensure mock mode is disabled
1676        set_mock_enabled_override(Some(false));
1677
1678        let worker = WorkerConfig {
1679            id: WorkerId::new("real-worker"),
1680            host: "192.168.1.100".to_string(),
1681            user: "user".to_string(),
1682            identity_file: "~/.ssh/id".to_string(),
1683            total_slots: 4,
1684            ..Default::default()
1685        };
1686
1687        logger.log(TestPhase::Execute, "Testing real host (mock disabled)");
1688        assert!(!use_mock_transport(&worker));
1689
1690        clear_mock_overrides();
1691        logger.pass();
1692    }
1693
1694    /// Test use_mock_transport with real host but global mock override.
1695    /// Uses global mock state - run with `cargo test -- --test-threads=1`.
1696    #[test]
1697    #[ignore = "uses global mock state; run with --test-threads=1"]
1698    fn use_mock_transport_with_global_override() {
1699        let logger = TestLogger::for_test("use_mock_transport_with_global_override");
1700
1701        // Enable mock mode globally
1702        set_mock_enabled_override(Some(true));
1703
1704        let worker = WorkerConfig {
1705            id: WorkerId::new("real-worker"),
1706            host: "192.168.1.100".to_string(),
1707            user: "user".to_string(),
1708            identity_file: "~/.ssh/id".to_string(),
1709            total_slots: 4,
1710            ..Default::default()
1711        };
1712
1713        logger.log(
1714            TestPhase::Execute,
1715            "Testing real host with global mock override",
1716        );
1717        assert!(use_mock_transport(&worker));
1718
1719        clear_mock_overrides();
1720        logger.pass();
1721    }
1722
1723    // ========================
1724    // Async transport tests (using mock)
1725    // These tests use global mock state and must be run with --test-threads=1
1726    // ========================
1727
1728    /// Test rsync_to_worker with mock transport succeeds.
1729    #[tokio::test]
1730    #[ignore = "uses global mock state; run with --test-threads=1"]
1731    async fn rsync_to_worker_mock_success() {
1732        init_test_logging();
1733        let logger = TestLogger::for_test("rsync_to_worker_mock_success");
1734
1735        // Enable mock mode
1736        set_mock_enabled_override(Some(true));
1737        set_mock_ssh_config_override(Some(MockConfig::success()));
1738        set_mock_rsync_config_override(Some(MockRsyncConfig::success()));
1739        clear_global_invocations();
1740
1741        let worker = WorkerConfig {
1742            id: WorkerId::new("mock-worker"),
1743            host: "mock://localhost".to_string(),
1744            user: "testuser".to_string(),
1745            identity_file: "~/.ssh/mock_key".to_string(),
1746            total_slots: 4,
1747            ..Default::default()
1748        };
1749
1750        // Create a temporary directory for testing
1751        let temp_dir = tempfile::tempdir().unwrap();
1752        let test_project = temp_dir.path().to_path_buf();
1753
1754        let test = RemoteCompilationTest::new(worker, test_project);
1755
1756        logger.log(
1757            TestPhase::Execute,
1758            "Calling rsync_to_worker with mock transport",
1759        );
1760        let result = test.rsync_to_worker().await;
1761
1762        logger.log_with_data(
1763            TestPhase::Verify,
1764            "Checking rsync result",
1765            serde_json::json!({ "success": result.is_ok() }),
1766        );
1767
1768        assert!(result.is_ok());
1769
1770        // Verify invocations were recorded
1771        let ssh_invocations = global_ssh_invocations_snapshot();
1772        let rsync_invocations = global_rsync_invocations_snapshot();
1773
1774        logger.log_with_data(
1775            TestPhase::Verify,
1776            "Mock invocations recorded",
1777            serde_json::json!({
1778                "ssh_calls": ssh_invocations.len(),
1779                "rsync_calls": rsync_invocations.len()
1780            }),
1781        );
1782
1783        // Should have SSH calls (connect, mkdir, disconnect) and rsync call
1784        assert!(!ssh_invocations.is_empty());
1785        assert!(!rsync_invocations.is_empty());
1786
1787        clear_mock_overrides();
1788        logger.pass();
1789    }
1790
1791    /// Test rsync_to_worker fails when SSH connection fails.
1792    #[tokio::test]
1793    #[ignore = "uses global mock state; run with --test-threads=1"]
1794    async fn rsync_to_worker_mock_ssh_failure() {
1795        init_test_logging();
1796        let logger = TestLogger::for_test("rsync_to_worker_mock_ssh_failure");
1797
1798        // Enable mock mode with SSH connection failure
1799        set_mock_enabled_override(Some(true));
1800        set_mock_ssh_config_override(Some(MockConfig::connection_failure()));
1801        clear_global_invocations();
1802
1803        let worker = WorkerConfig {
1804            id: WorkerId::new("mock-worker"),
1805            host: "mock://localhost".to_string(),
1806            user: "testuser".to_string(),
1807            identity_file: "~/.ssh/mock_key".to_string(),
1808            total_slots: 4,
1809            ..Default::default()
1810        };
1811
1812        let temp_dir = tempfile::tempdir().unwrap();
1813        let test_project = temp_dir.path().to_path_buf();
1814
1815        let test = RemoteCompilationTest::new(worker, test_project);
1816
1817        logger.log(
1818            TestPhase::Execute,
1819            "Calling rsync_to_worker with failing SSH",
1820        );
1821        let result = test.rsync_to_worker().await;
1822
1823        logger.log_with_data(
1824            TestPhase::Verify,
1825            "Checking failure result",
1826            serde_json::json!({
1827                "is_err": result.is_err(),
1828                "error": result.as_ref().err().map(|e| e.to_string())
1829            }),
1830        );
1831
1832        assert!(result.is_err());
1833        let err_msg = result.unwrap_err().to_string();
1834        assert!(err_msg.contains("Connection failed") || err_msg.contains("failed"));
1835
1836        clear_mock_overrides();
1837        logger.pass();
1838    }
1839
1840    /// Test build_remote with mock transport succeeds.
1841    #[tokio::test]
1842    #[ignore = "uses global mock state; run with --test-threads=1"]
1843    async fn build_remote_mock_success() {
1844        init_test_logging();
1845        let logger = TestLogger::for_test("build_remote_mock_success");
1846
1847        set_mock_enabled_override(Some(true));
1848        set_mock_ssh_config_override(Some(MockConfig::success()));
1849        clear_global_invocations();
1850
1851        let worker = WorkerConfig {
1852            id: WorkerId::new("mock-worker"),
1853            host: "mock://localhost".to_string(),
1854            user: "testuser".to_string(),
1855            identity_file: "~/.ssh/mock_key".to_string(),
1856            total_slots: 4,
1857            ..Default::default()
1858        };
1859
1860        let temp_dir = tempfile::tempdir().unwrap();
1861        let test_project = temp_dir.path().to_path_buf();
1862
1863        let test = RemoteCompilationTest::new(worker, test_project);
1864
1865        logger.log(
1866            TestPhase::Execute,
1867            "Calling build_remote with mock transport",
1868        );
1869        let result = test.build_remote().await;
1870
1871        logger.log_with_data(
1872            TestPhase::Verify,
1873            "Checking build result",
1874            serde_json::json!({ "success": result.is_ok() }),
1875        );
1876
1877        assert!(result.is_ok());
1878
1879        // Verify SSH execution happened
1880        let ssh_invocations = global_ssh_invocations_snapshot();
1881        assert!(ssh_invocations.iter().any(|inv| {
1882            inv.command
1883                .as_ref()
1884                .is_some_and(|c| c.contains("cargo build"))
1885        }));
1886
1887        clear_mock_overrides();
1888        logger.pass();
1889    }
1890
1891    /// Test build_remote fails when remote command fails.
1892    #[tokio::test]
1893    #[ignore = "uses global mock state; run with --test-threads=1"]
1894    async fn build_remote_mock_command_failure() {
1895        init_test_logging();
1896        let logger = TestLogger::for_test("build_remote_mock_command_failure");
1897
1898        // Configure mock to fail command execution
1899        set_mock_enabled_override(Some(true));
1900        set_mock_ssh_config_override(Some(MockConfig::command_failure(1, "compilation error")));
1901        clear_global_invocations();
1902
1903        let worker = WorkerConfig {
1904            id: WorkerId::new("mock-worker"),
1905            host: "mock://localhost".to_string(),
1906            user: "testuser".to_string(),
1907            identity_file: "~/.ssh/mock_key".to_string(),
1908            total_slots: 4,
1909            ..Default::default()
1910        };
1911
1912        let temp_dir = tempfile::tempdir().unwrap();
1913        let test_project = temp_dir.path().to_path_buf();
1914
1915        let test = RemoteCompilationTest::new(worker, test_project);
1916
1917        logger.log(
1918            TestPhase::Execute,
1919            "Calling build_remote with failing command",
1920        );
1921        let result = test.build_remote().await;
1922
1923        logger.log_with_data(
1924            TestPhase::Verify,
1925            "Checking failure",
1926            serde_json::json!({
1927                "is_err": result.is_err(),
1928                "error": result.as_ref().err().map(|e| e.to_string())
1929            }),
1930        );
1931
1932        // Note: command_failure sets fail_execute=true, which returns Err
1933        assert!(result.is_err());
1934
1935        clear_mock_overrides();
1936        logger.pass();
1937    }
1938
1939    /// Test rsync_from_worker with mock transport succeeds.
1940    #[tokio::test]
1941    #[ignore = "uses global mock state; run with --test-threads=1"]
1942    async fn rsync_from_worker_mock_success() {
1943        init_test_logging();
1944        let logger = TestLogger::for_test("rsync_from_worker_mock_success");
1945
1946        set_mock_enabled_override(Some(true));
1947        set_mock_rsync_config_override(Some(MockRsyncConfig::success()));
1948        clear_global_invocations();
1949
1950        let worker = WorkerConfig {
1951            id: WorkerId::new("mock-worker"),
1952            host: "mock://localhost".to_string(),
1953            user: "testuser".to_string(),
1954            identity_file: "~/.ssh/mock_key".to_string(),
1955            total_slots: 4,
1956            ..Default::default()
1957        };
1958
1959        let temp_dir = tempfile::tempdir().unwrap();
1960        let test_project = temp_dir.path().to_path_buf();
1961
1962        let test = RemoteCompilationTest::new(worker, test_project);
1963
1964        logger.log(
1965            TestPhase::Execute,
1966            "Calling rsync_from_worker with mock transport",
1967        );
1968        let result = test.rsync_from_worker().await;
1969
1970        logger.log_with_data(
1971            TestPhase::Verify,
1972            "Checking rsync result",
1973            serde_json::json!({ "success": result.is_ok() }),
1974        );
1975
1976        assert!(result.is_ok());
1977
1978        // Verify artifact retrieval was called
1979        let rsync_invocations = global_rsync_invocations_snapshot();
1980        assert!(!rsync_invocations.is_empty());
1981
1982        clear_mock_overrides();
1983        logger.pass();
1984    }
1985
1986    /// Test rsync_from_worker fails when artifact retrieval fails.
1987    #[tokio::test]
1988    #[ignore = "uses global mock state; run with --test-threads=1"]
1989    async fn rsync_from_worker_mock_artifact_failure() {
1990        init_test_logging();
1991        let logger = TestLogger::for_test("rsync_from_worker_mock_artifact_failure");
1992
1993        set_mock_enabled_override(Some(true));
1994        set_mock_rsync_config_override(Some(MockRsyncConfig::artifact_failure()));
1995        clear_global_invocations();
1996
1997        let worker = WorkerConfig {
1998            id: WorkerId::new("mock-worker"),
1999            host: "mock://localhost".to_string(),
2000            user: "testuser".to_string(),
2001            identity_file: "~/.ssh/mock_key".to_string(),
2002            total_slots: 4,
2003            ..Default::default()
2004        };
2005
2006        let temp_dir = tempfile::tempdir().unwrap();
2007        let test_project = temp_dir.path().to_path_buf();
2008
2009        let test = RemoteCompilationTest::new(worker, test_project);
2010
2011        logger.log(
2012            TestPhase::Execute,
2013            "Calling rsync_from_worker with failing artifacts",
2014        );
2015        let result = test.rsync_from_worker().await;
2016
2017        logger.log_with_data(
2018            TestPhase::Verify,
2019            "Checking failure",
2020            serde_json::json!({
2021                "is_err": result.is_err(),
2022                "error": result.as_ref().err().map(|e| e.to_string())
2023            }),
2024        );
2025
2026        assert!(result.is_err());
2027
2028        clear_mock_overrides();
2029        logger.pass();
2030    }
2031
2032    // ========================
2033    // Edge case tests
2034    // ========================
2035
2036    #[test]
2037    fn remote_compilation_test_empty_project_path() {
2038        let _guard = test_guard!();
2039        let logger = TestLogger::for_test("remote_compilation_test_empty_project_path");
2040
2041        let worker = WorkerConfig {
2042            id: WorkerId::new("w1"),
2043            host: "host".to_string(),
2044            user: "user".to_string(),
2045            identity_file: "~/.ssh/id_rsa".to_string(),
2046            total_slots: 1,
2047            ..Default::default()
2048        };
2049
2050        // Empty path should still work (uses "unknown" as fallback)
2051        let test = RemoteCompilationTest::new(worker, PathBuf::new());
2052
2053        let binary_name = test.get_binary_name();
2054        logger.log_with_data(
2055            TestPhase::Execute,
2056            "Binary name for empty path",
2057            serde_json::json!({ "binary_name": &binary_name }),
2058        );
2059
2060        // Should fall back to "unknown"
2061        assert_eq!(binary_name, "unknown");
2062
2063        logger.pass();
2064    }
2065
2066    #[test]
2067    fn verification_result_zero_timings() {
2068        let _guard = test_guard!();
2069        let logger = TestLogger::for_test("verification_result_zero_timings");
2070
2071        let result = VerificationResult {
2072            success: true,
2073            local_hash: BinaryHashResult {
2074                full_hash: "h".to_string(),
2075                code_hash: "c".to_string(),
2076                text_section_size: 0,
2077                is_debug: false,
2078            },
2079            remote_hash: BinaryHashResult {
2080                full_hash: "h".to_string(),
2081                code_hash: "c".to_string(),
2082                text_section_size: 0,
2083                is_debug: false,
2084            },
2085            local_build_ms: 0,
2086            rsync_up_ms: 0,
2087            compilation_ms: 0,
2088            rsync_down_ms: 0,
2089            total_ms: 0,
2090            error: None,
2091            test_marker: "ZERO".to_string(),
2092            verdict: "reproduced".to_string(),
2093        };
2094
2095        logger.log(TestPhase::Execute, "Serializing zero-timing result");
2096        let json = serde_json::to_string(&result).unwrap();
2097        let restored: VerificationResult = serde_json::from_str(&json).unwrap();
2098
2099        assert_eq!(restored.local_build_ms, 0);
2100        assert_eq!(restored.total_ms, 0);
2101        assert!(restored.success);
2102
2103        logger.pass();
2104    }
2105
2106    #[test]
2107    fn verification_result_max_timings() {
2108        let _guard = test_guard!();
2109        let logger = TestLogger::for_test("verification_result_max_timings");
2110
2111        let result = VerificationResult {
2112            success: true,
2113            local_hash: BinaryHashResult {
2114                full_hash: "h".to_string(),
2115                code_hash: "c".to_string(),
2116                text_section_size: u64::MAX,
2117                is_debug: false,
2118            },
2119            remote_hash: BinaryHashResult {
2120                full_hash: "h".to_string(),
2121                code_hash: "c".to_string(),
2122                text_section_size: u64::MAX,
2123                is_debug: false,
2124            },
2125            local_build_ms: u64::MAX,
2126            rsync_up_ms: u64::MAX,
2127            compilation_ms: u64::MAX,
2128            rsync_down_ms: u64::MAX,
2129            total_ms: u64::MAX,
2130            error: None,
2131            test_marker: "MAX".to_string(),
2132            verdict: "reproduced".to_string(),
2133        };
2134
2135        logger.log(TestPhase::Execute, "Serializing max-timing result");
2136        let json = serde_json::to_string(&result).unwrap();
2137        let restored: VerificationResult = serde_json::from_str(&json).unwrap();
2138
2139        assert_eq!(restored.total_ms, u64::MAX);
2140        assert!(restored.success);
2141
2142        logger.pass();
2143    }
2144
2145    #[test]
2146    fn is_mock_enabled_respects_overrides() {
2147        let _guard = test_guard!();
2148        let logger = TestLogger::for_test("is_mock_enabled_respects_overrides");
2149
2150        // Start with clean state
2151        clear_mock_overrides();
2152
2153        // Without override, depends on env (we'll set it false)
2154        set_mock_enabled_override(Some(false));
2155        assert!(!is_mock_enabled());
2156
2157        // With override true
2158        set_mock_enabled_override(Some(true));
2159        assert!(is_mock_enabled());
2160
2161        // Clear and test again
2162        clear_mock_overrides();
2163        set_mock_enabled_override(Some(false));
2164        assert!(!is_mock_enabled());
2165
2166        clear_mock_overrides();
2167        logger.pass();
2168    }
2169}