1use anyhow::{Context, Result, anyhow};
8use blake3::Hasher;
9use object::read::macho::{FatArch, MachOFatFile32, MachOFatFile64};
10use object::{Architecture, Object, ObjectSection};
11use serde::{Deserialize, Serialize};
12use std::fs::File;
13use std::io::{BufReader, Read};
14use std::path::Path;
15use tracing::info;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct BinaryHashResult {
20 pub full_hash: String,
22 pub code_hash: String,
24 pub text_section_size: u64,
26 pub is_debug: bool,
28}
29
30fn compute_full_hash(path: &Path) -> Result<String> {
35 let file = File::open(path).with_context(|| format!("Failed to open binary: {:?}", path))?;
36 let mut reader = BufReader::new(file);
37 let mut hasher = Hasher::new();
38 let mut buffer = [0u8; 65536];
39
40 loop {
41 let bytes_read = reader.read(&mut buffer)?;
42 if bytes_read == 0 {
43 break;
44 }
45 hasher.update(&buffer[..bytes_read]);
46 }
47
48 Ok(hasher.finalize().to_hex().to_string())
49}
50
51#[cfg(target_arch = "x86_64")]
53fn native_architecture() -> Architecture {
54 Architecture::X86_64
55}
56
57#[cfg(target_arch = "aarch64")]
58fn native_architecture() -> Architecture {
59 Architecture::Aarch64
60}
61
62#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
63fn native_architecture() -> Architecture {
64 Architecture::Unknown
65}
66
67fn try_extract_from_fat<'a, F: FatArch>(
69 data: &'a [u8],
70 arches: &[F],
71 native_arch: Architecture,
72) -> Option<&'a [u8]> {
73 for arch in arches {
75 if arch.architecture() == native_arch
76 && let Ok(slice) = arch.data(data)
77 {
78 info!("Extracted {:?} slice from fat binary", native_arch);
79 return Some(slice);
80 }
81 }
82
83 if let Some(arch) = arches.first()
85 && let Ok(slice) = arch.data(data)
86 {
87 info!("Using first available slice from fat binary");
88 return Some(slice);
89 }
90
91 None
92}
93
94fn extract_binary_slice(data: &[u8]) -> Result<&[u8]> {
102 if let Ok(fat) = MachOFatFile32::parse(data) {
104 let arches = fat.arches();
105 if arches.is_empty() {
106 return Err(anyhow!("Fat binary contains no architecture slices"));
107 }
108
109 let native_arch = native_architecture();
110 if let Some(slice) = try_extract_from_fat(data, arches, native_arch) {
111 return Ok(slice);
112 }
113 return Err(anyhow!("Failed to extract any valid slice from fat binary"));
114 }
115
116 if let Ok(fat) = MachOFatFile64::parse(data) {
118 let arches = fat.arches();
119 if arches.is_empty() {
120 return Err(anyhow!("Fat64 binary contains no architecture slices"));
121 }
122
123 let native_arch = native_architecture();
124 if let Some(slice) = try_extract_from_fat(data, arches, native_arch) {
125 return Ok(slice);
126 }
127 return Err(anyhow!(
128 "Failed to extract any valid slice from fat64 binary"
129 ));
130 }
131
132 Ok(data)
134}
135
136fn is_code_section(name: &str) -> bool {
140 name == ".text"
142 || name == ".rodata"
143 || name.starts_with(".text.")
144 || name.starts_with(".rodata.")
145 || name == "__text"
147 || name == "__const"
148 || name == "__cstring"
149 || name == "__stubs"
150 || name == "__stub_helper"
151}
152
153fn compute_code_hash(data: &[u8]) -> Result<String> {
162 let binary_data = extract_binary_slice(data)?;
164 let file = object::File::parse(binary_data).context("Failed to parse binary format")?;
165 let mut hasher = Hasher::new();
166 let mut sections_hashed = 0;
167
168 for section in file.sections() {
170 let name = section.name().unwrap_or("");
171
172 if is_code_section(name)
174 && let Ok(section_data) = section.data()
175 {
176 hasher.update(section_data);
177 sections_hashed += 1;
178 }
179 }
180
181 if sections_hashed == 0 {
182 return Err(anyhow!("No code sections found in binary"));
183 }
184
185 Ok(hasher.finalize().to_hex().to_string())
186}
187
188fn is_text_section(name: &str) -> bool {
190 name == ".text" || name == "__text"
192}
193
194fn is_debug_section(name: &str) -> bool {
196 name.starts_with(".debug")
198 || name.starts_with("__debug")
200 || name == "__DWARF"
201}
202
203fn extract_metadata(data: &[u8]) -> Result<(u64, bool)> {
209 let binary_data = extract_binary_slice(data)?;
211 let file = object::File::parse(binary_data).context("Failed to parse binary for metadata")?;
212
213 let text_size: u64 = file
214 .sections()
215 .filter(|s| is_text_section(s.name().unwrap_or("")))
216 .map(|s| s.size())
217 .sum();
218
219 let has_debug = file
220 .sections()
221 .any(|s| is_debug_section(s.name().unwrap_or("")));
222
223 Ok((text_size, has_debug))
224}
225
226pub fn compute_binary_hash(path: &Path) -> Result<BinaryHashResult> {
241 info!("Computing binary hash for {:?}", path);
242
243 let full_hash = compute_full_hash(path)?;
245 info!("Full hash computed: {}", &full_hash[..16]);
246
247 let data = std::fs::read(path).with_context(|| format!("Failed to read binary: {:?}", path))?;
249
250 let code_hash = compute_code_hash(&data)?;
252 info!("Code hash computed: {}", &code_hash[..16]);
253
254 let (text_section_size, is_debug) = extract_metadata(&data)?;
256 info!(
257 "Metadata: text_size={}, is_debug={}",
258 text_section_size, is_debug
259 );
260
261 Ok(BinaryHashResult {
262 full_hash,
263 code_hash,
264 text_section_size,
265 is_debug,
266 })
267}
268
269pub fn binary_contains_marker(path: &Path, marker: &str) -> Result<bool> {
281 info!("Searching for marker '{}' in {:?}", marker, path);
282
283 if marker.is_empty() {
285 return Ok(true);
286 }
287
288 let data = std::fs::read(path).with_context(|| format!("Failed to read binary: {:?}", path))?;
289
290 let marker_bytes = marker.as_bytes();
293 let contains = data
294 .windows(marker_bytes.len())
295 .any(|window| window == marker_bytes);
296
297 info!("Marker search result: found={}", contains);
298 Ok(contains)
299}
300
301pub fn binaries_equivalent(local: &BinaryHashResult, remote: &BinaryHashResult) -> bool {
318 if local.code_hash != remote.code_hash {
320 info!(
321 "Code hash mismatch: local={}, remote={}",
322 &local.code_hash[..local.code_hash.len().min(16)],
323 &remote.code_hash[..remote.code_hash.len().min(16)]
324 );
325 return false;
326 }
327
328 if local.text_section_size != remote.text_section_size {
330 info!(
331 "Text section size mismatch: local={}, remote={}",
332 local.text_section_size, remote.text_section_size
333 );
334 return false;
335 }
336
337 if local.is_debug != remote.is_debug {
339 info!(
340 "Debug status mismatch: local={}, remote={}",
341 local.is_debug, remote.is_debug
342 );
343 return false;
344 }
345
346 true
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "snake_case")]
364pub enum CanaryVerdict {
365 Reproduced,
367 ToolchainDrift,
371 Miscompile,
375 MissingMarker,
378}
379
380impl CanaryVerdict {
381 #[must_use]
384 pub const fn passed(self) -> bool {
385 matches!(self, Self::Reproduced | Self::ToolchainDrift)
386 }
387
388 #[must_use]
391 pub const fn is_advisory(self) -> bool {
392 matches!(self, Self::ToolchainDrift)
393 }
394
395 #[must_use]
397 pub const fn as_str(self) -> &'static str {
398 match self {
399 Self::Reproduced => "reproduced",
400 Self::ToolchainDrift => "toolchain_drift",
401 Self::Miscompile => "miscompile",
402 Self::MissingMarker => "missing_marker",
403 }
404 }
405
406 #[must_use]
414 pub const fn reason_code(self) -> Option<&'static str> {
415 match self {
416 Self::Reproduced => None,
417 Self::ToolchainDrift => Some("RCH-I018"),
418 Self::Miscompile => Some("RCH-E203"),
419 Self::MissingMarker => Some("RCH-I014"),
420 }
421 }
422}
423
424#[must_use]
441pub fn classify_canary(
442 local: &BinaryHashResult,
443 remote: &BinaryHashResult,
444 marker_present: bool,
445 toolchains_match: Option<bool>,
446) -> CanaryVerdict {
447 if !marker_present {
448 return CanaryVerdict::MissingMarker;
449 }
450 if binaries_equivalent(local, remote) {
451 return CanaryVerdict::Reproduced;
452 }
453 match toolchains_match {
457 Some(true) => CanaryVerdict::Miscompile,
458 Some(false) | None => CanaryVerdict::ToolchainDrift,
459 }
460}
461
462pub fn classify_returned_canary(
465 path: &Path,
466 marker: &str,
467 local: &BinaryHashResult,
468 remote: &BinaryHashResult,
469 toolchains_match: Option<bool>,
470) -> Result<CanaryVerdict> {
471 if marker.is_empty() {
472 return Err(anyhow!(
473 "Cannot verify a canary with an empty identity marker"
474 ));
475 }
476 let marker_present = binary_contains_marker(path, marker).with_context(|| {
477 format!(
478 "Failed to verify returned canary identity: {}",
479 path.display()
480 )
481 })?;
482 Ok(classify_canary(
483 local,
484 remote,
485 marker_present,
486 toolchains_match,
487 ))
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use std::path::PathBuf;
494
495 fn init_test_logging() {
496 let _ = tracing_subscriber::fmt()
497 .with_test_writer()
498 .with_max_level(tracing::Level::INFO)
499 .try_init();
500 }
501
502 fn find_test_binary() -> Option<PathBuf> {
504 let candidates = [
506 "target/release/rch",
507 "target/debug/rch",
508 "target/release/rchd",
509 "target/debug/rchd",
510 "/bin/ls", "/bin/cat", "/usr/bin/ls",
513 ];
514
515 for candidate in candidates {
516 let path = PathBuf::from(candidate);
517 if path.exists() {
518 return Some(path);
519 }
520 }
521 None
522 }
523
524 #[test]
525 fn test_hash_same_binary_twice() {
526 init_test_logging();
527 info!("TEST START: test_hash_same_binary_twice");
528
529 let binary_path = match find_test_binary() {
530 Some(p) => p,
531 None => {
532 info!("SKIP: No test binary found");
533 return;
534 }
535 };
536
537 info!("INPUT: compute_binary_hash({:?}) twice", binary_path);
538
539 let hash1 = compute_binary_hash(&binary_path).unwrap();
540 let hash2 = compute_binary_hash(&binary_path).unwrap();
541
542 info!(
543 "RESULT: hash1.code_hash={}, hash2.code_hash={}",
544 &hash1.code_hash[..16],
545 &hash2.code_hash[..16]
546 );
547
548 assert_eq!(hash1.code_hash, hash2.code_hash, "Code hash should match");
549 assert_eq!(hash1.full_hash, hash2.full_hash, "Full hash should match");
550 assert_eq!(
551 hash1.text_section_size, hash2.text_section_size,
552 "Text section size should match"
553 );
554 assert_eq!(hash1.is_debug, hash2.is_debug, "Debug status should match");
555
556 info!("VERIFY: Same binary produces identical hashes");
557 info!("TEST PASS: test_hash_same_binary_twice");
558 }
559
560 #[test]
561 fn test_binaries_equivalent_matching() {
562 init_test_logging();
563 info!("TEST START: test_binaries_equivalent_matching");
564
565 let local = BinaryHashResult {
566 full_hash: "abc123def456".into(),
567 code_hash: "xyz789abc".into(),
568 text_section_size: 12345,
569 is_debug: false,
570 };
571 let remote = BinaryHashResult {
572 full_hash: "different_full_hash".into(), code_hash: "xyz789abc".into(), text_section_size: 12345,
575 is_debug: false,
576 };
577
578 info!(
579 "INPUT: local.code_hash={}, remote.code_hash={}",
580 local.code_hash, remote.code_hash
581 );
582
583 let result = binaries_equivalent(&local, &remote);
584 info!("RESULT: binaries_equivalent = {}", result);
585
586 assert!(
587 result,
588 "Binaries with matching code hash should be equivalent"
589 );
590 info!("VERIFY: Binaries with matching code hash are equivalent");
591 info!("TEST PASS: test_binaries_equivalent_matching");
592 }
593
594 #[test]
595 fn test_binaries_not_equivalent_different_code_hash() {
596 init_test_logging();
597 info!("TEST START: test_binaries_not_equivalent_different_code_hash");
598
599 let local = BinaryHashResult {
600 full_hash: "abc123".into(),
601 code_hash: "hash_v1".into(),
602 text_section_size: 12345,
603 is_debug: false,
604 };
605 let remote = BinaryHashResult {
606 full_hash: "abc123".into(),
607 code_hash: "hash_v2".into(), text_section_size: 12345,
609 is_debug: false,
610 };
611
612 info!(
613 "INPUT: local.code_hash={}, remote.code_hash={}",
614 local.code_hash, remote.code_hash
615 );
616
617 let result = binaries_equivalent(&local, &remote);
618 info!("RESULT: binaries_equivalent = {}", result);
619
620 assert!(
621 !result,
622 "Binaries with different code hash should not be equivalent"
623 );
624 info!("VERIFY: Different code hash makes binaries non-equivalent");
625 info!("TEST PASS: test_binaries_not_equivalent_different_code_hash");
626 }
627
628 #[test]
629 fn test_binaries_not_equivalent_different_size() {
630 init_test_logging();
631 info!("TEST START: test_binaries_not_equivalent_different_size");
632
633 let local = BinaryHashResult {
634 full_hash: "abc123".into(),
635 code_hash: "same_hash".into(),
636 text_section_size: 12345,
637 is_debug: false,
638 };
639 let remote = BinaryHashResult {
640 full_hash: "abc123".into(),
641 code_hash: "same_hash".into(),
642 text_section_size: 54321, is_debug: false,
644 };
645
646 info!(
647 "INPUT: local.text_size={}, remote.text_size={}",
648 local.text_section_size, remote.text_section_size
649 );
650
651 let result = binaries_equivalent(&local, &remote);
652 info!("RESULT: binaries_equivalent = {}", result);
653
654 assert!(
655 !result,
656 "Binaries with different text size should not be equivalent"
657 );
658 info!("VERIFY: Different text section size makes binaries non-equivalent");
659 info!("TEST PASS: test_binaries_not_equivalent_different_size");
660 }
661
662 #[test]
663 fn test_binaries_not_equivalent_different_debug_status() {
664 init_test_logging();
665 info!("TEST START: test_binaries_not_equivalent_different_debug_status");
666
667 let local = BinaryHashResult {
668 full_hash: "abc123".into(),
669 code_hash: "same_hash".into(),
670 text_section_size: 12345,
671 is_debug: false,
672 };
673 let remote = BinaryHashResult {
674 full_hash: "abc123".into(),
675 code_hash: "same_hash".into(),
676 text_section_size: 12345,
677 is_debug: true, };
679
680 info!(
681 "INPUT: local.is_debug={}, remote.is_debug={}",
682 local.is_debug, remote.is_debug
683 );
684
685 let result = binaries_equivalent(&local, &remote);
686 info!("RESULT: binaries_equivalent = {}", result);
687
688 assert!(
689 !result,
690 "Binaries with different debug status should not be equivalent"
691 );
692 info!("VERIFY: Different debug status makes binaries non-equivalent");
693 info!("TEST PASS: test_binaries_not_equivalent_different_debug_status");
694 }
695
696 #[test]
697 fn test_compute_binary_hash_nonexistent_file() {
698 init_test_logging();
699 info!("TEST START: test_compute_binary_hash_nonexistent_file");
700
701 let path = Path::new("/nonexistent/path/to/binary");
702 info!("INPUT: compute_binary_hash({:?})", path);
703
704 let result = compute_binary_hash(path);
705 info!("RESULT: is_err = {}", result.is_err());
706
707 assert!(result.is_err(), "Should fail for nonexistent file");
708 info!("VERIFY: Nonexistent file returns error");
709 info!("TEST PASS: test_compute_binary_hash_nonexistent_file");
710 }
711
712 #[test]
713 fn test_compute_binary_hash_invalid_file() {
714 init_test_logging();
715 info!("TEST START: test_compute_binary_hash_invalid_file");
716
717 let path = Path::new("Cargo.toml");
719 if !path.exists() {
720 info!("SKIP: Cargo.toml not found");
721 return;
722 }
723
724 info!("INPUT: compute_binary_hash({:?}) on text file", path);
725
726 let result = compute_binary_hash(path);
727 info!("RESULT: is_err = {}", result.is_err());
728
729 assert!(result.is_err(), "Should fail for non-binary file");
730 info!("VERIFY: Non-binary file returns error");
731 info!("TEST PASS: test_compute_binary_hash_invalid_file");
732 }
733
734 #[test]
735 fn test_binary_hash_result_fields() {
736 init_test_logging();
737 info!("TEST START: test_binary_hash_result_fields");
738
739 let binary_path = match find_test_binary() {
740 Some(p) => p,
741 None => {
742 info!("SKIP: No test binary found");
743 return;
744 }
745 };
746
747 info!("INPUT: compute_binary_hash({:?})", binary_path);
748
749 let result = compute_binary_hash(&binary_path).unwrap();
750
751 info!("RESULT: full_hash_len={}", result.full_hash.len());
752 info!("RESULT: code_hash_len={}", result.code_hash.len());
753 info!("RESULT: text_section_size={}", result.text_section_size);
754 info!("RESULT: is_debug={}", result.is_debug);
755
756 assert_eq!(
758 result.full_hash.len(),
759 64,
760 "Full hash should be 64 hex chars"
761 );
762 assert_eq!(
763 result.code_hash.len(),
764 64,
765 "Code hash should be 64 hex chars"
766 );
767 assert!(
768 result.text_section_size > 0,
769 "Text section should have content"
770 );
771
772 info!("VERIFY: All fields have valid values");
773 info!("TEST PASS: test_binary_hash_result_fields");
774 }
775
776 #[test]
777 fn test_binary_contains_marker_found() {
778 init_test_logging();
779 info!("TEST START: test_binary_contains_marker_found");
780
781 let binary_path = match find_test_binary() {
782 Some(p) => p,
783 None => {
784 info!("SKIP: No test binary found");
785 return;
786 }
787 };
788
789 #[cfg(target_os = "linux")]
793 let marker = "ELF";
794 #[cfg(target_os = "macos")]
795 let marker = "__TEXT"; #[cfg(target_os = "windows")]
797 let marker = "PE"; #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
799 let marker = "ELF";
800
801 info!(
802 "INPUT: binary_contains_marker({:?}, '{}')",
803 binary_path, marker
804 );
805
806 let result = binary_contains_marker(&binary_path, marker).unwrap();
807 info!("RESULT: contains_marker = {}", result);
808
809 assert!(result, "Binary should contain '{}' string", marker);
811 info!("VERIFY: Marker '{}' found in binary", marker);
812 info!("TEST PASS: test_binary_contains_marker_found");
813 }
814
815 #[test]
816 fn test_binary_contains_marker_not_found() {
817 init_test_logging();
818 info!("TEST START: test_binary_contains_marker_not_found");
819
820 let binary_path = match find_test_binary() {
821 Some(p) => p,
822 None => {
823 info!("SKIP: No test binary found");
824 return;
825 }
826 };
827
828 let marker = "RCH_TEST_MARKER_UNIQUE_12345_XYZ";
830 info!(
831 "INPUT: binary_contains_marker({:?}, '{}')",
832 binary_path, marker
833 );
834
835 let result = binary_contains_marker(&binary_path, marker).unwrap();
836 info!("RESULT: contains_marker = {}", result);
837
838 assert!(!result, "Binary should not contain made-up marker");
839 info!("VERIFY: Unique marker not found in binary");
840 info!("TEST PASS: test_binary_contains_marker_not_found");
841 }
842
843 #[test]
844 fn test_binary_contains_marker_nonexistent_file() {
845 init_test_logging();
846 info!("TEST START: test_binary_contains_marker_nonexistent_file");
847
848 let path = Path::new("/nonexistent/path/to/binary");
849 let marker = "test";
850 info!("INPUT: binary_contains_marker({:?}, '{}')", path, marker);
851
852 let result = binary_contains_marker(path, marker);
853 info!("RESULT: is_err = {}", result.is_err());
854
855 assert!(result.is_err(), "Should fail for nonexistent file");
856 info!("VERIFY: Nonexistent file returns error");
857 info!("TEST PASS: test_binary_contains_marker_nonexistent_file");
858 }
859
860 #[test]
865 fn test_compute_binary_hash_empty_file() {
866 init_test_logging();
867 info!("TEST START: test_compute_binary_hash_empty_file");
868
869 let dir = std::env::temp_dir().join("rch-binary-hash-test-empty");
870 let _ = std::fs::create_dir_all(&dir);
871 let empty_path = dir.join("empty_binary");
872 std::fs::write(&empty_path, b"").unwrap();
873
874 let result = compute_binary_hash(&empty_path);
875 assert!(
876 result.is_err(),
877 "empty file should fail binary hash (not a valid binary format)"
878 );
879
880 let _ = std::fs::remove_dir_all(&dir);
881 info!("TEST PASS: test_compute_binary_hash_empty_file");
882 }
883
884 #[test]
885 fn test_compute_binary_hash_text_file() {
886 init_test_logging();
887 info!("TEST START: test_compute_binary_hash_text_file");
888
889 let dir = std::env::temp_dir().join("rch-binary-hash-test-text");
890 let _ = std::fs::create_dir_all(&dir);
891 let text_path = dir.join("not_a_binary.txt");
892 std::fs::write(&text_path, b"Hello, this is not a binary file.").unwrap();
893
894 let result = compute_binary_hash(&text_path);
895 assert!(
896 result.is_err(),
897 "text file should fail binary hash computation"
898 );
899
900 let _ = std::fs::remove_dir_all(&dir);
901 info!("TEST PASS: test_compute_binary_hash_text_file");
902 }
903
904 #[test]
905 fn test_compute_binary_hash_truncated_elf_header() {
906 init_test_logging();
907 info!("TEST START: test_compute_binary_hash_truncated_elf_header");
908
909 let dir = std::env::temp_dir().join("rch-binary-hash-test-truncated");
910 let _ = std::fs::create_dir_all(&dir);
911 let trunc_path = dir.join("truncated_elf");
912 std::fs::write(&trunc_path, b"\x7fELF\x02\x01\x01\x00").unwrap();
914
915 let result = compute_binary_hash(&trunc_path);
916 assert!(
917 result.is_err(),
918 "truncated ELF should fail binary hash computation"
919 );
920
921 let _ = std::fs::remove_dir_all(&dir);
922 info!("TEST PASS: test_compute_binary_hash_truncated_elf_header");
923 }
924
925 #[test]
930 fn test_binaries_equivalent_ignores_full_hash() {
931 init_test_logging();
932
933 let result1 = BinaryHashResult {
934 full_hash: "aaaa".to_string(),
935 code_hash: "same_code".to_string(),
936 text_section_size: 1024,
937 is_debug: false,
938 };
939 let result2 = BinaryHashResult {
940 full_hash: "bbbb".to_string(), code_hash: "same_code".to_string(),
942 text_section_size: 1024,
943 is_debug: false,
944 };
945
946 assert!(
947 binaries_equivalent(&result1, &result2),
948 "equivalence should only consider code_hash, text_section_size, is_debug"
949 );
950 }
951
952 #[test]
953 fn test_binaries_not_equivalent_debug_vs_release() {
954 init_test_logging();
955
956 let debug = BinaryHashResult {
957 full_hash: "a".to_string(),
958 code_hash: "same".to_string(),
959 text_section_size: 1024,
960 is_debug: true,
961 };
962 let release = BinaryHashResult {
963 full_hash: "a".to_string(),
964 code_hash: "same".to_string(),
965 text_section_size: 1024,
966 is_debug: false,
967 };
968
969 assert!(
970 !binaries_equivalent(&debug, &release),
971 "debug vs release should not be equivalent"
972 );
973 }
974
975 #[test]
976 fn test_binaries_not_equivalent_different_text_size() {
977 init_test_logging();
978
979 let small = BinaryHashResult {
980 full_hash: "a".to_string(),
981 code_hash: "same".to_string(),
982 text_section_size: 1024,
983 is_debug: false,
984 };
985 let large = BinaryHashResult {
986 full_hash: "a".to_string(),
987 code_hash: "same".to_string(),
988 text_section_size: 2048,
989 is_debug: false,
990 };
991
992 assert!(
993 !binaries_equivalent(&small, &large),
994 "different text section sizes should not be equivalent"
995 );
996 }
997
998 #[test]
1003 fn test_binary_hash_result_serialization_round_trip() {
1004 let result = BinaryHashResult {
1005 full_hash: "abc123def456".to_string(),
1006 code_hash: "789ghi012".to_string(),
1007 text_section_size: 65536,
1008 is_debug: true,
1009 };
1010
1011 let json = serde_json::to_string(&result).expect("should serialize");
1012 let deserialized: BinaryHashResult =
1013 serde_json::from_str(&json).expect("should deserialize");
1014 assert_eq!(result, deserialized);
1015 }
1016
1017 #[test]
1022 fn test_binary_contains_marker_empty_marker() {
1023 init_test_logging();
1024
1025 let binary_path = match find_test_binary() {
1026 Some(p) => p,
1027 None => {
1028 info!("SKIP: No test binary found");
1029 return;
1030 }
1031 };
1032
1033 let result = binary_contains_marker(&binary_path, "");
1035 assert!(result.is_ok(), "empty marker should not panic");
1036 assert!(result.unwrap(), "empty marker should be trivially found");
1037 }
1038
1039 #[test]
1040 fn test_binary_contains_marker_very_long_marker() {
1041 init_test_logging();
1042
1043 let binary_path = match find_test_binary() {
1044 Some(p) => p,
1045 None => {
1046 info!("SKIP: No test binary found");
1047 return;
1048 }
1049 };
1050
1051 let marker = "X".repeat(1024);
1053 let result = binary_contains_marker(&binary_path, &marker).unwrap();
1054 assert!(
1055 !result,
1056 "very long marker should not be found in any binary"
1057 );
1058 }
1059
1060 #[test]
1065 fn test_full_hash_determinism() {
1066 init_test_logging();
1067
1068 let binary_path = match find_test_binary() {
1069 Some(p) => p,
1070 None => return,
1071 };
1072
1073 let result1 = compute_binary_hash(&binary_path).unwrap();
1074 let result2 = compute_binary_hash(&binary_path).unwrap();
1075
1076 assert_eq!(
1077 result1.full_hash, result2.full_hash,
1078 "full_hash must be deterministic"
1079 );
1080 assert_eq!(
1081 result1.code_hash, result2.code_hash,
1082 "code_hash must be deterministic"
1083 );
1084 assert_eq!(
1085 result1.text_section_size, result2.text_section_size,
1086 "text_section_size must be deterministic"
1087 );
1088 assert_eq!(
1089 result1.is_debug, result2.is_debug,
1090 "is_debug must be deterministic"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_code_hash_differs_from_full_hash() {
1096 init_test_logging();
1097
1098 let binary_path = match find_test_binary() {
1099 Some(p) => p,
1100 None => return,
1101 };
1102
1103 let result = compute_binary_hash(&binary_path).unwrap();
1104 assert_ne!(
1105 result.code_hash, result.full_hash,
1106 "code_hash should differ from full_hash (unless binary has only code sections)"
1107 );
1108 }
1109
1110 #[test]
1111 fn test_text_section_size_nonzero_for_real_binary() {
1112 init_test_logging();
1113
1114 let binary_path = match find_test_binary() {
1115 Some(p) => p,
1116 None => return,
1117 };
1118
1119 let result = compute_binary_hash(&binary_path).unwrap();
1120 assert!(
1121 result.text_section_size > 0,
1122 "real binary should have non-zero text section"
1123 );
1124 }
1125
1126 #[test]
1127 fn test_hash_format_is_hex_string() {
1128 init_test_logging();
1129
1130 let binary_path = match find_test_binary() {
1131 Some(p) => p,
1132 None => return,
1133 };
1134
1135 let result = compute_binary_hash(&binary_path).unwrap();
1136 assert!(
1137 result.full_hash.chars().all(|c| c.is_ascii_hexdigit()),
1138 "full_hash should be hex string"
1139 );
1140 assert!(
1141 result.code_hash.chars().all(|c| c.is_ascii_hexdigit()),
1142 "code_hash should be hex string"
1143 );
1144 assert_eq!(
1146 result.full_hash.len(),
1147 64,
1148 "BLAKE3 hash should be 64 hex chars"
1149 );
1150 assert_eq!(
1151 result.code_hash.len(),
1152 64,
1153 "BLAKE3 hash should be 64 hex chars"
1154 );
1155 }
1156
1157 fn hash(code: &str, text: u64) -> BinaryHashResult {
1160 BinaryHashResult {
1161 full_hash: format!("full-{code}"),
1162 code_hash: code.to_string(),
1163 text_section_size: text,
1164 is_debug: false,
1165 }
1166 }
1167
1168 #[test]
1169 fn returned_canary_requires_readable_nonempty_identity() {
1170 let root = std::env::temp_dir().join(format!("rch-marker-{}", uuid::Uuid::new_v4()));
1172 std::fs::create_dir(&root).unwrap();
1173 let artifact = root.join("artifact");
1174 let bytes = b"prefix RCH_TEST_unique suffix";
1175 let mut file = std::fs::OpenOptions::new()
1176 .write(true)
1177 .create_new(true)
1178 .open(&artifact)
1179 .unwrap();
1180 std::io::Write::write_all(&mut file, bytes).unwrap();
1181 drop(file);
1182 let local = hash("aaaa", 100);
1183 for remote in [hash("aaaa", 100), hash("bbbb", 99)] {
1184 let verdict =
1185 classify_returned_canary(&artifact, "RCH_TEST_unique", &local, &remote, None)
1186 .unwrap();
1187 assert!(verdict.passed());
1188 assert_eq!(verdict.is_advisory(), remote.code_hash != local.code_hash);
1189 assert_eq!(
1190 classify_returned_canary(&artifact, "absent", &local, &remote, None,).unwrap(),
1191 CanaryVerdict::MissingMarker
1192 );
1193 assert!(classify_returned_canary(&artifact, "", &local, &remote, None,).is_err());
1194 for unreadable in [root.clone(), root.join("never-created")] {
1195 let error =
1196 classify_returned_canary(&unreadable, "RCH_TEST_unique", &local, &remote, None)
1197 .unwrap_err();
1198 assert!(
1199 error
1200 .chain()
1201 .any(|cause| cause.downcast_ref::<std::io::Error>().is_some())
1202 );
1203 }
1204 }
1205 assert_eq!(std::fs::read(&artifact).unwrap(), bytes);
1206 assert!(!root.join("never-created").exists());
1207 }
1208
1209 #[test]
1210 fn canary_equivalent_binaries_are_reproduced() {
1211 let a = hash("aaaa", 100);
1212 let b = hash("aaaa", 100);
1213 for tc in [Some(true), Some(false), None] {
1215 let v = classify_canary(&a, &b, true, tc);
1216 assert_eq!(v, CanaryVerdict::Reproduced);
1217 assert!(v.passed());
1218 assert!(!v.is_advisory());
1219 assert_eq!(v.reason_code(), None);
1220 }
1221 }
1222
1223 #[test]
1224 fn canary_missing_marker_is_corruption_fail() {
1225 let a = hash("aaaa", 100);
1226 let b = hash("aaaa", 100); let v = classify_canary(&a, &b, false, Some(true));
1228 assert_eq!(v, CanaryVerdict::MissingMarker);
1229 assert!(!v.passed());
1230 assert_eq!(v.reason_code(), Some("RCH-I014"));
1231 }
1232
1233 #[test]
1234 fn canary_mismatch_on_identical_toolchain_is_miscompile_fail() {
1235 let local = hash("aaaa", 100);
1237 let remote = hash("bbbb", 99);
1238 let v = classify_canary(&local, &remote, true, Some(true));
1239 assert_eq!(v, CanaryVerdict::Miscompile);
1240 assert!(!v.passed());
1241 assert_eq!(v.reason_code(), Some("RCH-E203"));
1242 }
1243
1244 #[test]
1245 fn canary_mismatch_on_differing_toolchain_is_advisory_drift_pass() {
1246 let local = hash("22472b9ad970e533", 251331);
1248 let remote = hash("64c365ebc866d769", 251187);
1249 let v = classify_canary(&local, &remote, true, Some(false));
1250 assert_eq!(v, CanaryVerdict::ToolchainDrift);
1251 assert!(v.passed(), "a healthy heterogeneous worker must not fail");
1252 assert!(v.is_advisory());
1253 assert_eq!(v.reason_code(), Some("RCH-I018"));
1254 }
1255
1256 #[test]
1257 fn canary_mismatch_with_unknown_toolchain_defaults_to_advisory_pass() {
1258 let local = hash("aaaa", 100);
1261 let remote = hash("bbbb", 99);
1262 let v = classify_canary(&local, &remote, true, None);
1263 assert_eq!(v, CanaryVerdict::ToolchainDrift);
1264 assert!(v.passed());
1265 }
1266
1267 #[test]
1268 fn canary_verdict_tokens_and_serde_are_stable() {
1269 assert_eq!(CanaryVerdict::Reproduced.as_str(), "reproduced");
1270 assert_eq!(CanaryVerdict::ToolchainDrift.as_str(), "toolchain_drift");
1271 assert_eq!(CanaryVerdict::Miscompile.as_str(), "miscompile");
1272 assert_eq!(CanaryVerdict::MissingMarker.as_str(), "missing_marker");
1273 let json = serde_json::to_string(&CanaryVerdict::ToolchainDrift).unwrap();
1274 assert_eq!(json, "\"toolchain_drift\"");
1275 let back: CanaryVerdict = serde_json::from_str(&json).unwrap();
1276 assert_eq!(back, CanaryVerdict::ToolchainDrift);
1277 }
1278}