1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct VerificationResult {
35 pub success: bool,
37 pub local_hash: BinaryHashResult,
39 pub remote_hash: BinaryHashResult,
41 pub local_build_ms: u64,
43 pub rsync_up_ms: u64,
45 pub compilation_ms: u64,
47 pub rsync_down_ms: u64,
49 pub total_ms: u64,
51 pub error: Option<String>,
53 pub test_marker: String,
55 pub verdict: String,
61}
62
63#[derive(Debug, Clone)]
65pub struct RemoteCompilationTest {
66 pub worker: WorkerConfig,
68 pub test_project: PathBuf,
70 pub timeout: Duration,
72 pub ssh_options: SshOptions,
74 pub release_mode: bool,
76 pub binary_name: Option<String>,
78 pub remote_base: String,
80 pub remote_path_suffix: String,
82}
83
84fn resolve_cargo_binary() -> std::ffi::OsString {
93 resolve_cargo_binary_from(std::env::var_os("CARGO"), std::env::var_os("HOME"))
94}
95
96fn 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
117fn 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 pub fn new(worker: WorkerConfig, test_project: PathBuf) -> Self {
186 Self {
187 worker,
188 test_project,
189 ..Default::default()
190 }
191 }
192
193 pub fn with_timeout(mut self, timeout: Duration) -> Self {
195 self.timeout = timeout;
196 self
197 }
198
199 pub fn with_ssh_options(mut self, options: SshOptions) -> Self {
201 self.ssh_options = options;
202 self
203 }
204
205 pub fn with_release_mode(mut self, release: bool) -> Self {
207 self.release_mode = release;
208 self
209 }
210
211 pub fn with_binary_name(mut self, name: impl Into<String>) -> Self {
213 self.binary_name = Some(name.into());
214 self
215 }
216
217 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 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 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 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 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 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 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 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 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 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 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 fn get_binary_name(&self) -> String {
393 if let Some(name) = self.binary_name.clone() {
394 return name;
395 }
396 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 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 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 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 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") .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 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 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 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 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 let session_id = Uuid::new_v4().simple().to_string();
595 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 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 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 async fn rsync_from_worker(&self) -> Result<()> {
675 let profile = if self.release_mode {
676 "release"
677 } else {
678 "debug"
679 };
680
681 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 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 assert_eq!(
839 resolve_cargo_binary_from(Some(OsString::from("/opt/cargo")), None),
840 OsString::from("/opt/cargo")
841 );
842
843 assert_eq!(
845 resolve_cargo_binary_from(Some(OsString::new()), None),
846 OsString::from("cargo")
847 );
848
849 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 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 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 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 #[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 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 #[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 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 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 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 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 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 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 #[test]
1531 fn cargo_home_base_prelude_does_not_hardcode_tmp() {
1532 let prelude = remote_cargo_home_base_prelude();
1533 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 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 assert!(
1548 !prelude.contains("/tmp/rch"),
1549 "must not bake a /tmp path: {prelude}"
1550 );
1551 }
1552
1553 #[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 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 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 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 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 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 assert!(result.total_ms >= sum - 100); logger.pass();
1642 }
1643
1644 #[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]
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 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]
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 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 #[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 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 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 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 assert!(!ssh_invocations.is_empty());
1785 assert!(!rsync_invocations.is_empty());
1786
1787 clear_mock_overrides();
1788 logger.pass();
1789 }
1790
1791 #[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 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 #[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 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 #[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 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 assert!(result.is_err());
1934
1935 clear_mock_overrides();
1936 logger.pass();
1937 }
1938
1939 #[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 let rsync_invocations = global_rsync_invocations_snapshot();
1980 assert!(!rsync_invocations.is_empty());
1981
1982 clear_mock_overrides();
1983 logger.pass();
1984 }
1985
1986 #[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 #[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 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 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 clear_mock_overrides();
2152
2153 set_mock_enabled_override(Some(false));
2155 assert!(!is_mock_enabled());
2156
2157 set_mock_enabled_override(Some(true));
2159 assert!(is_mock_enabled());
2160
2161 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}