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
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use std::path::PathBuf;
466
467 fn init_test_logging() {
468 let _ = tracing_subscriber::fmt()
469 .with_test_writer()
470 .with_max_level(tracing::Level::INFO)
471 .try_init();
472 }
473
474 fn find_test_binary() -> Option<PathBuf> {
476 let candidates = [
478 "target/release/rch",
479 "target/debug/rch",
480 "target/release/rchd",
481 "target/debug/rchd",
482 "/bin/ls", "/bin/cat", "/usr/bin/ls",
485 ];
486
487 for candidate in candidates {
488 let path = PathBuf::from(candidate);
489 if path.exists() {
490 return Some(path);
491 }
492 }
493 None
494 }
495
496 #[test]
497 fn test_hash_same_binary_twice() {
498 init_test_logging();
499 info!("TEST START: test_hash_same_binary_twice");
500
501 let binary_path = match find_test_binary() {
502 Some(p) => p,
503 None => {
504 info!("SKIP: No test binary found");
505 return;
506 }
507 };
508
509 info!("INPUT: compute_binary_hash({:?}) twice", binary_path);
510
511 let hash1 = compute_binary_hash(&binary_path).unwrap();
512 let hash2 = compute_binary_hash(&binary_path).unwrap();
513
514 info!(
515 "RESULT: hash1.code_hash={}, hash2.code_hash={}",
516 &hash1.code_hash[..16],
517 &hash2.code_hash[..16]
518 );
519
520 assert_eq!(hash1.code_hash, hash2.code_hash, "Code hash should match");
521 assert_eq!(hash1.full_hash, hash2.full_hash, "Full hash should match");
522 assert_eq!(
523 hash1.text_section_size, hash2.text_section_size,
524 "Text section size should match"
525 );
526 assert_eq!(hash1.is_debug, hash2.is_debug, "Debug status should match");
527
528 info!("VERIFY: Same binary produces identical hashes");
529 info!("TEST PASS: test_hash_same_binary_twice");
530 }
531
532 #[test]
533 fn test_binaries_equivalent_matching() {
534 init_test_logging();
535 info!("TEST START: test_binaries_equivalent_matching");
536
537 let local = BinaryHashResult {
538 full_hash: "abc123def456".into(),
539 code_hash: "xyz789abc".into(),
540 text_section_size: 12345,
541 is_debug: false,
542 };
543 let remote = BinaryHashResult {
544 full_hash: "different_full_hash".into(), code_hash: "xyz789abc".into(), text_section_size: 12345,
547 is_debug: false,
548 };
549
550 info!(
551 "INPUT: local.code_hash={}, remote.code_hash={}",
552 local.code_hash, remote.code_hash
553 );
554
555 let result = binaries_equivalent(&local, &remote);
556 info!("RESULT: binaries_equivalent = {}", result);
557
558 assert!(
559 result,
560 "Binaries with matching code hash should be equivalent"
561 );
562 info!("VERIFY: Binaries with matching code hash are equivalent");
563 info!("TEST PASS: test_binaries_equivalent_matching");
564 }
565
566 #[test]
567 fn test_binaries_not_equivalent_different_code_hash() {
568 init_test_logging();
569 info!("TEST START: test_binaries_not_equivalent_different_code_hash");
570
571 let local = BinaryHashResult {
572 full_hash: "abc123".into(),
573 code_hash: "hash_v1".into(),
574 text_section_size: 12345,
575 is_debug: false,
576 };
577 let remote = BinaryHashResult {
578 full_hash: "abc123".into(),
579 code_hash: "hash_v2".into(), text_section_size: 12345,
581 is_debug: false,
582 };
583
584 info!(
585 "INPUT: local.code_hash={}, remote.code_hash={}",
586 local.code_hash, remote.code_hash
587 );
588
589 let result = binaries_equivalent(&local, &remote);
590 info!("RESULT: binaries_equivalent = {}", result);
591
592 assert!(
593 !result,
594 "Binaries with different code hash should not be equivalent"
595 );
596 info!("VERIFY: Different code hash makes binaries non-equivalent");
597 info!("TEST PASS: test_binaries_not_equivalent_different_code_hash");
598 }
599
600 #[test]
601 fn test_binaries_not_equivalent_different_size() {
602 init_test_logging();
603 info!("TEST START: test_binaries_not_equivalent_different_size");
604
605 let local = BinaryHashResult {
606 full_hash: "abc123".into(),
607 code_hash: "same_hash".into(),
608 text_section_size: 12345,
609 is_debug: false,
610 };
611 let remote = BinaryHashResult {
612 full_hash: "abc123".into(),
613 code_hash: "same_hash".into(),
614 text_section_size: 54321, is_debug: false,
616 };
617
618 info!(
619 "INPUT: local.text_size={}, remote.text_size={}",
620 local.text_section_size, remote.text_section_size
621 );
622
623 let result = binaries_equivalent(&local, &remote);
624 info!("RESULT: binaries_equivalent = {}", result);
625
626 assert!(
627 !result,
628 "Binaries with different text size should not be equivalent"
629 );
630 info!("VERIFY: Different text section size makes binaries non-equivalent");
631 info!("TEST PASS: test_binaries_not_equivalent_different_size");
632 }
633
634 #[test]
635 fn test_binaries_not_equivalent_different_debug_status() {
636 init_test_logging();
637 info!("TEST START: test_binaries_not_equivalent_different_debug_status");
638
639 let local = BinaryHashResult {
640 full_hash: "abc123".into(),
641 code_hash: "same_hash".into(),
642 text_section_size: 12345,
643 is_debug: false,
644 };
645 let remote = BinaryHashResult {
646 full_hash: "abc123".into(),
647 code_hash: "same_hash".into(),
648 text_section_size: 12345,
649 is_debug: true, };
651
652 info!(
653 "INPUT: local.is_debug={}, remote.is_debug={}",
654 local.is_debug, remote.is_debug
655 );
656
657 let result = binaries_equivalent(&local, &remote);
658 info!("RESULT: binaries_equivalent = {}", result);
659
660 assert!(
661 !result,
662 "Binaries with different debug status should not be equivalent"
663 );
664 info!("VERIFY: Different debug status makes binaries non-equivalent");
665 info!("TEST PASS: test_binaries_not_equivalent_different_debug_status");
666 }
667
668 #[test]
669 fn test_compute_binary_hash_nonexistent_file() {
670 init_test_logging();
671 info!("TEST START: test_compute_binary_hash_nonexistent_file");
672
673 let path = Path::new("/nonexistent/path/to/binary");
674 info!("INPUT: compute_binary_hash({:?})", path);
675
676 let result = compute_binary_hash(path);
677 info!("RESULT: is_err = {}", result.is_err());
678
679 assert!(result.is_err(), "Should fail for nonexistent file");
680 info!("VERIFY: Nonexistent file returns error");
681 info!("TEST PASS: test_compute_binary_hash_nonexistent_file");
682 }
683
684 #[test]
685 fn test_compute_binary_hash_invalid_file() {
686 init_test_logging();
687 info!("TEST START: test_compute_binary_hash_invalid_file");
688
689 let path = Path::new("Cargo.toml");
691 if !path.exists() {
692 info!("SKIP: Cargo.toml not found");
693 return;
694 }
695
696 info!("INPUT: compute_binary_hash({:?}) on text file", path);
697
698 let result = compute_binary_hash(path);
699 info!("RESULT: is_err = {}", result.is_err());
700
701 assert!(result.is_err(), "Should fail for non-binary file");
702 info!("VERIFY: Non-binary file returns error");
703 info!("TEST PASS: test_compute_binary_hash_invalid_file");
704 }
705
706 #[test]
707 fn test_binary_hash_result_fields() {
708 init_test_logging();
709 info!("TEST START: test_binary_hash_result_fields");
710
711 let binary_path = match find_test_binary() {
712 Some(p) => p,
713 None => {
714 info!("SKIP: No test binary found");
715 return;
716 }
717 };
718
719 info!("INPUT: compute_binary_hash({:?})", binary_path);
720
721 let result = compute_binary_hash(&binary_path).unwrap();
722
723 info!("RESULT: full_hash_len={}", result.full_hash.len());
724 info!("RESULT: code_hash_len={}", result.code_hash.len());
725 info!("RESULT: text_section_size={}", result.text_section_size);
726 info!("RESULT: is_debug={}", result.is_debug);
727
728 assert_eq!(
730 result.full_hash.len(),
731 64,
732 "Full hash should be 64 hex chars"
733 );
734 assert_eq!(
735 result.code_hash.len(),
736 64,
737 "Code hash should be 64 hex chars"
738 );
739 assert!(
740 result.text_section_size > 0,
741 "Text section should have content"
742 );
743
744 info!("VERIFY: All fields have valid values");
745 info!("TEST PASS: test_binary_hash_result_fields");
746 }
747
748 #[test]
749 fn test_binary_contains_marker_found() {
750 init_test_logging();
751 info!("TEST START: test_binary_contains_marker_found");
752
753 let binary_path = match find_test_binary() {
754 Some(p) => p,
755 None => {
756 info!("SKIP: No test binary found");
757 return;
758 }
759 };
760
761 #[cfg(target_os = "linux")]
765 let marker = "ELF";
766 #[cfg(target_os = "macos")]
767 let marker = "__TEXT"; #[cfg(target_os = "windows")]
769 let marker = "PE"; #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
771 let marker = "ELF";
772
773 info!(
774 "INPUT: binary_contains_marker({:?}, '{}')",
775 binary_path, marker
776 );
777
778 let result = binary_contains_marker(&binary_path, marker).unwrap();
779 info!("RESULT: contains_marker = {}", result);
780
781 assert!(result, "Binary should contain '{}' string", marker);
783 info!("VERIFY: Marker '{}' found in binary", marker);
784 info!("TEST PASS: test_binary_contains_marker_found");
785 }
786
787 #[test]
788 fn test_binary_contains_marker_not_found() {
789 init_test_logging();
790 info!("TEST START: test_binary_contains_marker_not_found");
791
792 let binary_path = match find_test_binary() {
793 Some(p) => p,
794 None => {
795 info!("SKIP: No test binary found");
796 return;
797 }
798 };
799
800 let marker = "RCH_TEST_MARKER_UNIQUE_12345_XYZ";
802 info!(
803 "INPUT: binary_contains_marker({:?}, '{}')",
804 binary_path, marker
805 );
806
807 let result = binary_contains_marker(&binary_path, marker).unwrap();
808 info!("RESULT: contains_marker = {}", result);
809
810 assert!(!result, "Binary should not contain made-up marker");
811 info!("VERIFY: Unique marker not found in binary");
812 info!("TEST PASS: test_binary_contains_marker_not_found");
813 }
814
815 #[test]
816 fn test_binary_contains_marker_nonexistent_file() {
817 init_test_logging();
818 info!("TEST START: test_binary_contains_marker_nonexistent_file");
819
820 let path = Path::new("/nonexistent/path/to/binary");
821 let marker = "test";
822 info!("INPUT: binary_contains_marker({:?}, '{}')", path, marker);
823
824 let result = binary_contains_marker(path, marker);
825 info!("RESULT: is_err = {}", result.is_err());
826
827 assert!(result.is_err(), "Should fail for nonexistent file");
828 info!("VERIFY: Nonexistent file returns error");
829 info!("TEST PASS: test_binary_contains_marker_nonexistent_file");
830 }
831
832 #[test]
837 fn test_compute_binary_hash_empty_file() {
838 init_test_logging();
839 info!("TEST START: test_compute_binary_hash_empty_file");
840
841 let dir = std::env::temp_dir().join("rch-binary-hash-test-empty");
842 let _ = std::fs::create_dir_all(&dir);
843 let empty_path = dir.join("empty_binary");
844 std::fs::write(&empty_path, b"").unwrap();
845
846 let result = compute_binary_hash(&empty_path);
847 assert!(
848 result.is_err(),
849 "empty file should fail binary hash (not a valid binary format)"
850 );
851
852 let _ = std::fs::remove_dir_all(&dir);
853 info!("TEST PASS: test_compute_binary_hash_empty_file");
854 }
855
856 #[test]
857 fn test_compute_binary_hash_text_file() {
858 init_test_logging();
859 info!("TEST START: test_compute_binary_hash_text_file");
860
861 let dir = std::env::temp_dir().join("rch-binary-hash-test-text");
862 let _ = std::fs::create_dir_all(&dir);
863 let text_path = dir.join("not_a_binary.txt");
864 std::fs::write(&text_path, b"Hello, this is not a binary file.").unwrap();
865
866 let result = compute_binary_hash(&text_path);
867 assert!(
868 result.is_err(),
869 "text file should fail binary hash computation"
870 );
871
872 let _ = std::fs::remove_dir_all(&dir);
873 info!("TEST PASS: test_compute_binary_hash_text_file");
874 }
875
876 #[test]
877 fn test_compute_binary_hash_truncated_elf_header() {
878 init_test_logging();
879 info!("TEST START: test_compute_binary_hash_truncated_elf_header");
880
881 let dir = std::env::temp_dir().join("rch-binary-hash-test-truncated");
882 let _ = std::fs::create_dir_all(&dir);
883 let trunc_path = dir.join("truncated_elf");
884 std::fs::write(&trunc_path, b"\x7fELF\x02\x01\x01\x00").unwrap();
886
887 let result = compute_binary_hash(&trunc_path);
888 assert!(
889 result.is_err(),
890 "truncated ELF should fail binary hash computation"
891 );
892
893 let _ = std::fs::remove_dir_all(&dir);
894 info!("TEST PASS: test_compute_binary_hash_truncated_elf_header");
895 }
896
897 #[test]
902 fn test_binaries_equivalent_ignores_full_hash() {
903 init_test_logging();
904
905 let result1 = BinaryHashResult {
906 full_hash: "aaaa".to_string(),
907 code_hash: "same_code".to_string(),
908 text_section_size: 1024,
909 is_debug: false,
910 };
911 let result2 = BinaryHashResult {
912 full_hash: "bbbb".to_string(), code_hash: "same_code".to_string(),
914 text_section_size: 1024,
915 is_debug: false,
916 };
917
918 assert!(
919 binaries_equivalent(&result1, &result2),
920 "equivalence should only consider code_hash, text_section_size, is_debug"
921 );
922 }
923
924 #[test]
925 fn test_binaries_not_equivalent_debug_vs_release() {
926 init_test_logging();
927
928 let debug = BinaryHashResult {
929 full_hash: "a".to_string(),
930 code_hash: "same".to_string(),
931 text_section_size: 1024,
932 is_debug: true,
933 };
934 let release = BinaryHashResult {
935 full_hash: "a".to_string(),
936 code_hash: "same".to_string(),
937 text_section_size: 1024,
938 is_debug: false,
939 };
940
941 assert!(
942 !binaries_equivalent(&debug, &release),
943 "debug vs release should not be equivalent"
944 );
945 }
946
947 #[test]
948 fn test_binaries_not_equivalent_different_text_size() {
949 init_test_logging();
950
951 let small = BinaryHashResult {
952 full_hash: "a".to_string(),
953 code_hash: "same".to_string(),
954 text_section_size: 1024,
955 is_debug: false,
956 };
957 let large = BinaryHashResult {
958 full_hash: "a".to_string(),
959 code_hash: "same".to_string(),
960 text_section_size: 2048,
961 is_debug: false,
962 };
963
964 assert!(
965 !binaries_equivalent(&small, &large),
966 "different text section sizes should not be equivalent"
967 );
968 }
969
970 #[test]
975 fn test_binary_hash_result_serialization_round_trip() {
976 let result = BinaryHashResult {
977 full_hash: "abc123def456".to_string(),
978 code_hash: "789ghi012".to_string(),
979 text_section_size: 65536,
980 is_debug: true,
981 };
982
983 let json = serde_json::to_string(&result).expect("should serialize");
984 let deserialized: BinaryHashResult =
985 serde_json::from_str(&json).expect("should deserialize");
986 assert_eq!(result, deserialized);
987 }
988
989 #[test]
994 fn test_binary_contains_marker_empty_marker() {
995 init_test_logging();
996
997 let binary_path = match find_test_binary() {
998 Some(p) => p,
999 None => {
1000 info!("SKIP: No test binary found");
1001 return;
1002 }
1003 };
1004
1005 let result = binary_contains_marker(&binary_path, "");
1007 assert!(result.is_ok(), "empty marker should not panic");
1008 assert!(result.unwrap(), "empty marker should be trivially found");
1009 }
1010
1011 #[test]
1012 fn test_binary_contains_marker_very_long_marker() {
1013 init_test_logging();
1014
1015 let binary_path = match find_test_binary() {
1016 Some(p) => p,
1017 None => {
1018 info!("SKIP: No test binary found");
1019 return;
1020 }
1021 };
1022
1023 let marker = "X".repeat(1024);
1025 let result = binary_contains_marker(&binary_path, &marker).unwrap();
1026 assert!(
1027 !result,
1028 "very long marker should not be found in any binary"
1029 );
1030 }
1031
1032 #[test]
1037 fn test_full_hash_determinism() {
1038 init_test_logging();
1039
1040 let binary_path = match find_test_binary() {
1041 Some(p) => p,
1042 None => return,
1043 };
1044
1045 let result1 = compute_binary_hash(&binary_path).unwrap();
1046 let result2 = compute_binary_hash(&binary_path).unwrap();
1047
1048 assert_eq!(
1049 result1.full_hash, result2.full_hash,
1050 "full_hash must be deterministic"
1051 );
1052 assert_eq!(
1053 result1.code_hash, result2.code_hash,
1054 "code_hash must be deterministic"
1055 );
1056 assert_eq!(
1057 result1.text_section_size, result2.text_section_size,
1058 "text_section_size must be deterministic"
1059 );
1060 assert_eq!(
1061 result1.is_debug, result2.is_debug,
1062 "is_debug must be deterministic"
1063 );
1064 }
1065
1066 #[test]
1067 fn test_code_hash_differs_from_full_hash() {
1068 init_test_logging();
1069
1070 let binary_path = match find_test_binary() {
1071 Some(p) => p,
1072 None => return,
1073 };
1074
1075 let result = compute_binary_hash(&binary_path).unwrap();
1076 assert_ne!(
1077 result.code_hash, result.full_hash,
1078 "code_hash should differ from full_hash (unless binary has only code sections)"
1079 );
1080 }
1081
1082 #[test]
1083 fn test_text_section_size_nonzero_for_real_binary() {
1084 init_test_logging();
1085
1086 let binary_path = match find_test_binary() {
1087 Some(p) => p,
1088 None => return,
1089 };
1090
1091 let result = compute_binary_hash(&binary_path).unwrap();
1092 assert!(
1093 result.text_section_size > 0,
1094 "real binary should have non-zero text section"
1095 );
1096 }
1097
1098 #[test]
1099 fn test_hash_format_is_hex_string() {
1100 init_test_logging();
1101
1102 let binary_path = match find_test_binary() {
1103 Some(p) => p,
1104 None => return,
1105 };
1106
1107 let result = compute_binary_hash(&binary_path).unwrap();
1108 assert!(
1109 result.full_hash.chars().all(|c| c.is_ascii_hexdigit()),
1110 "full_hash should be hex string"
1111 );
1112 assert!(
1113 result.code_hash.chars().all(|c| c.is_ascii_hexdigit()),
1114 "code_hash should be hex string"
1115 );
1116 assert_eq!(
1118 result.full_hash.len(),
1119 64,
1120 "BLAKE3 hash should be 64 hex chars"
1121 );
1122 assert_eq!(
1123 result.code_hash.len(),
1124 64,
1125 "BLAKE3 hash should be 64 hex chars"
1126 );
1127 }
1128
1129 fn hash(code: &str, text: u64) -> BinaryHashResult {
1132 BinaryHashResult {
1133 full_hash: format!("full-{code}"),
1134 code_hash: code.to_string(),
1135 text_section_size: text,
1136 is_debug: false,
1137 }
1138 }
1139
1140 #[test]
1141 fn canary_equivalent_binaries_are_reproduced() {
1142 let a = hash("aaaa", 100);
1143 let b = hash("aaaa", 100);
1144 for tc in [Some(true), Some(false), None] {
1146 let v = classify_canary(&a, &b, true, tc);
1147 assert_eq!(v, CanaryVerdict::Reproduced);
1148 assert!(v.passed());
1149 assert!(!v.is_advisory());
1150 assert_eq!(v.reason_code(), None);
1151 }
1152 }
1153
1154 #[test]
1155 fn canary_missing_marker_is_corruption_fail() {
1156 let a = hash("aaaa", 100);
1157 let b = hash("aaaa", 100); let v = classify_canary(&a, &b, false, Some(true));
1159 assert_eq!(v, CanaryVerdict::MissingMarker);
1160 assert!(!v.passed());
1161 assert_eq!(v.reason_code(), Some("RCH-I014"));
1162 }
1163
1164 #[test]
1165 fn canary_mismatch_on_identical_toolchain_is_miscompile_fail() {
1166 let local = hash("aaaa", 100);
1168 let remote = hash("bbbb", 99);
1169 let v = classify_canary(&local, &remote, true, Some(true));
1170 assert_eq!(v, CanaryVerdict::Miscompile);
1171 assert!(!v.passed());
1172 assert_eq!(v.reason_code(), Some("RCH-E203"));
1173 }
1174
1175 #[test]
1176 fn canary_mismatch_on_differing_toolchain_is_advisory_drift_pass() {
1177 let local = hash("22472b9ad970e533", 251331);
1179 let remote = hash("64c365ebc866d769", 251187);
1180 let v = classify_canary(&local, &remote, true, Some(false));
1181 assert_eq!(v, CanaryVerdict::ToolchainDrift);
1182 assert!(v.passed(), "a healthy heterogeneous worker must not fail");
1183 assert!(v.is_advisory());
1184 assert_eq!(v.reason_code(), Some("RCH-I018"));
1185 }
1186
1187 #[test]
1188 fn canary_mismatch_with_unknown_toolchain_defaults_to_advisory_pass() {
1189 let local = hash("aaaa", 100);
1192 let remote = hash("bbbb", 99);
1193 let v = classify_canary(&local, &remote, true, None);
1194 assert_eq!(v, CanaryVerdict::ToolchainDrift);
1195 assert!(v.passed());
1196 }
1197
1198 #[test]
1199 fn canary_verdict_tokens_and_serde_are_stable() {
1200 assert_eq!(CanaryVerdict::Reproduced.as_str(), "reproduced");
1201 assert_eq!(CanaryVerdict::ToolchainDrift.as_str(), "toolchain_drift");
1202 assert_eq!(CanaryVerdict::Miscompile.as_str(), "miscompile");
1203 assert_eq!(CanaryVerdict::MissingMarker.as_str(), "missing_marker");
1204 let json = serde_json::to_string(&CanaryVerdict::ToolchainDrift).unwrap();
1205 assert_eq!(json, "\"toolchain_drift\"");
1206 let back: CanaryVerdict = serde_json::from_str(&json).unwrap();
1207 assert_eq!(back, CanaryVerdict::ToolchainDrift);
1208 }
1209}