Skip to main content

rch_common/
binary_hash.rs

1//! Binary hash computation utility for verifying remote compilation correctness.
2//!
3//! This module provides deterministic hashing of compiled binaries by focusing on
4//! code sections (.text, .rodata) while ignoring non-deterministic metadata like
5//! timestamps and paths.
6
7use 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/// Result of computing hashes for a binary file.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct BinaryHashResult {
20    /// Hash of the entire binary file (includes non-deterministic elements).
21    pub full_hash: String,
22    /// Hash of only code sections (.text, .rodata) - deterministic across builds.
23    pub code_hash: String,
24    /// Size of the .text section in bytes.
25    pub text_section_size: u64,
26    /// Whether the binary contains debug information.
27    pub is_debug: bool,
28}
29
30/// Compute hash of the entire binary file using BLAKE3.
31///
32/// This hash includes all non-deterministic elements (timestamps, paths, etc.)
33/// and will differ between builds even of identical source code.
34fn 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/// Get the current architecture for selecting the right slice from a fat binary.
52#[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
67/// Try to extract a slice from a fat binary using the given arch list.
68fn try_extract_from_fat<'a, F: FatArch>(
69    data: &'a [u8],
70    arches: &[F],
71    native_arch: Architecture,
72) -> Option<&'a [u8]> {
73    // First try to find a slice matching our native architecture
74    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    // No exact match found, use the first valid slice
84    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
94/// Extract the appropriate binary slice from a fat/universal binary.
95///
96/// macOS universal binaries (fat binaries) contain multiple architecture slices.
97/// This function extracts the slice matching the current native architecture,
98/// or the first available slice if no exact match is found.
99///
100/// For non-fat binaries, returns the original data unchanged.
101fn extract_binary_slice(data: &[u8]) -> Result<&[u8]> {
102    // Try parsing as 32-bit fat binary first (more common)
103    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    // Try parsing as 64-bit fat binary
117    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    // Not a fat binary, return as-is
133    Ok(data)
134}
135
136/// Check if a section name represents code or read-only data.
137///
138/// Supports both ELF (.text, .rodata) and Mach-O (__text, __const) section names.
139fn is_code_section(name: &str) -> bool {
140    // ELF section names
141    name == ".text"
142        || name == ".rodata"
143        || name.starts_with(".text.")
144        || name.starts_with(".rodata.")
145        // Mach-O section names (lowercase, within __TEXT segment)
146        || name == "__text"
147        || name == "__const"
148        || name == "__cstring"
149        || name == "__stubs"
150        || name == "__stub_helper"
151}
152
153/// Compute hash of code sections only using BLAKE3.
154///
155/// This hash focuses on executable code and read-only data sections,
156/// ignoring non-deterministic metadata. This provides a more reliable
157/// comparison between builds of the same source code.
158///
159/// Supports both ELF (Linux) and Mach-O (macOS) binary formats,
160/// including macOS universal (fat) binaries.
161fn compute_code_hash(data: &[u8]) -> Result<String> {
162    // Handle fat/universal binaries by extracting the appropriate slice
163    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    // Hash only executable code and read-only data sections
169    for section in file.sections() {
170        let name = section.name().unwrap_or("");
171
172        // Include code sections from both ELF and Mach-O formats
173        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
188/// Check if a section name represents the main text/code section.
189fn is_text_section(name: &str) -> bool {
190    // ELF and Mach-O text sections
191    name == ".text" || name == "__text"
192}
193
194/// Check if a section name represents debug information.
195fn is_debug_section(name: &str) -> bool {
196    // ELF debug sections
197    name.starts_with(".debug")
198        // Mach-O debug sections (DWARF)
199        || name.starts_with("__debug")
200        || name == "__DWARF"
201}
202
203/// Extract metadata about the binary.
204///
205/// Returns (text_section_size, has_debug_info).
206/// Supports both ELF (Linux) and Mach-O (macOS) binary formats,
207/// including macOS universal (fat) binaries.
208fn extract_metadata(data: &[u8]) -> Result<(u64, bool)> {
209    // Handle fat/universal binaries by extracting the appropriate slice
210    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
226/// Compute comprehensive hash information for a binary file.
227///
228/// This function returns both a full file hash and a deterministic code hash
229/// that can be used to verify remote compilation correctness.
230///
231/// # Arguments
232/// * `path` - Path to the binary file
233///
234/// # Returns
235/// A `BinaryHashResult` containing:
236/// - `full_hash`: Complete file hash (non-deterministic)
237/// - `code_hash`: Hash of code sections only (deterministic)
238/// - `text_section_size`: Size of executable code
239/// - `is_debug`: Whether debug symbols are present
240pub fn compute_binary_hash(path: &Path) -> Result<BinaryHashResult> {
241    info!("Computing binary hash for {:?}", path);
242
243    // Compute full file hash
244    let full_hash = compute_full_hash(path)?;
245    info!("Full hash computed: {}", &full_hash[..16]);
246
247    // Read file for section analysis
248    let data = std::fs::read(path).with_context(|| format!("Failed to read binary: {:?}", path))?;
249
250    // Compute code-only hash
251    let code_hash = compute_code_hash(&data)?;
252    info!("Code hash computed: {}", &code_hash[..16]);
253
254    // Extract metadata
255    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
269/// Check if a binary contains a specific marker string.
270///
271/// This is used to verify that the remote worker actually compiled the code
272/// by checking for a unique test marker that was added to the source.
273///
274/// # Arguments
275/// * `path` - Path to the binary file
276/// * `marker` - The marker string to search for
277///
278/// # Returns
279/// `true` if the marker is found in the binary's string data
280pub fn binary_contains_marker(path: &Path, marker: &str) -> Result<bool> {
281    info!("Searching for marker '{}' in {:?}", marker, path);
282
283    // Empty marker is trivially contained in any file (consistent with str::contains(""))
284    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    // Search for the marker in the raw binary data
291    // The marker should appear as a string literal in .rodata or similar sections
292    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
301/// Compare two binaries for functional equivalence.
302///
303/// Two binaries are considered equivalent if they have:
304/// - Matching code hashes (same executable code)
305/// - Matching text section sizes
306/// - Matching debug status
307///
308/// The full hash may differ due to timestamps and paths, so it is not used
309/// for equivalence checking.
310///
311/// # Arguments
312/// * `local` - Hash result from local build
313/// * `remote` - Hash result from remote build
314///
315/// # Returns
316/// `true` if the binaries are functionally equivalent
317pub fn binaries_equivalent(local: &BinaryHashResult, remote: &BinaryHashResult) -> bool {
318    // Code hash must match exactly
319    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    // Text section size should match
329    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    // Debug status should match
338    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/// The verdict of a self-test canary build, distinguishing a healthy worker whose
350/// toolchain merely differs from the orchestrator's (an advisory PASS) from a
351/// genuine miscompile or corruption (a FAIL).
352///
353/// Background (bd-784xt): the original canary criterion was pure bit-for-bit
354/// equivalence (`binaries_equivalent`) between a LOCAL reference build on the
355/// orchestrator and the REMOTE build on the worker. On a heterogeneous fleet a
356/// worker whose `rustc` nightly differs even slightly produces different codegen,
357/// so a perfectly healthy worker was marked FAILED and disabled. This classifier
358/// makes byte-identity advisory when toolchains differ (or parity is unknown),
359/// while still catching the two real failures: a build that does not contain the
360/// injected marker (corruption / wrong binary), and a byte mismatch on a
361/// *confirmed-identical* toolchain (a genuine miscompile).
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "snake_case")]
364pub enum CanaryVerdict {
365    /// Marker present and binaries byte-equivalent — strong reproducibility. PASS.
366    Reproduced,
367    /// Marker present, binaries differ, but the worker's toolchain differs from
368    /// (or could not be confirmed equal to) the orchestrator's — legitimate
369    /// codegen drift. Advisory PASS.
370    ToolchainDrift,
371    /// Marker present, binaries differ, and the toolchains are *confirmed
372    /// identical* — the same compiler produced different code, a genuine
373    /// miscompile. FAIL.
374    Miscompile,
375    /// The remote binary does not contain the injected test marker — the build
376    /// is not the one we asked for, or it is corrupt. FAIL.
377    MissingMarker,
378}
379
380impl CanaryVerdict {
381    /// Whether this verdict counts as a passing self-test (the worker stays
382    /// schedulable). [`Self::ToolchainDrift`] passes with an advisory.
383    #[must_use]
384    pub const fn passed(self) -> bool {
385        matches!(self, Self::Reproduced | Self::ToolchainDrift)
386    }
387
388    /// Whether this verdict is an advisory (a pass that nonetheless warrants a
389    /// note in the trace), as opposed to a clean pass or a hard fail.
390    #[must_use]
391    pub const fn is_advisory(self) -> bool {
392        matches!(self, Self::ToolchainDrift)
393    }
394
395    /// Stable lowercase token for logs / JSONL (matches the serde representation).
396    #[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    /// A distinct incident reason-code token for the non-clean verdicts, so
407    /// toolchain drift is never conflated with a real mismatch:
408    /// - `RCH-I018` toolchain drift (advisory; [`crate::incident::IncidentReasonCode::ToolchainDrift`]),
409    /// - `RCH-I014` artifact miss (the marker-absent corruption case),
410    /// - `RCH-E203` worker self-test failed (a genuine same-toolchain miscompile).
411    ///
412    /// `None` for [`Self::Reproduced`] (a clean pass carries no reason).
413    #[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/// Classify a self-test canary outcome (bd-784xt). Pure and total, so the verdict
425/// logic is unit-tested without a live daemon.
426///
427/// Inputs:
428/// - `local` / `remote`: the orchestrator and worker binary hashes,
429/// - `marker_present`: whether the remote binary contains the injected test
430///   marker (proving it is the build we asked for, not a stale/corrupt artifact),
431/// - `toolchains_match`: `Some(true)` if the worker's `rustc` is confirmed equal
432///   to the orchestrator's, `Some(false)` if confirmed different, `None` if parity
433///   could not be determined.
434///
435/// Verdict precedence:
436/// 1. marker absent -> [`CanaryVerdict::MissingMarker`] (corruption — FAIL),
437/// 2. byte-equivalent -> [`CanaryVerdict::Reproduced`] (PASS),
438/// 3. byte mismatch + toolchains confirmed identical -> [`CanaryVerdict::Miscompile`] (FAIL),
439/// 4. byte mismatch + toolchains differ/unknown -> [`CanaryVerdict::ToolchainDrift`] (advisory PASS).
440#[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    // Binaries differ but the marker is present. Only a *confirmed-identical*
454    // toolchain makes a mismatch a genuine miscompile; differing or unknown
455    // toolchains legitimately diverge (the bd-784xt false-fail).
456    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    /// Find a binary in the project for testing
475    fn find_test_binary() -> Option<PathBuf> {
476        // Try release binary first, then debug
477        let candidates = [
478            "target/release/rch",
479            "target/debug/rch",
480            "target/release/rchd",
481            "target/debug/rchd",
482            "/bin/ls",  // Fallback to system binary
483            "/bin/cat", // Another fallback
484            "/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(), // Full hash may differ
545            code_hash: "xyz789abc".into(),           // Code hash matches
546            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(), // Different code hash
580            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, // Different size
615            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, // Different debug status
650        };
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        // Try to hash a text file which is not a valid binary
690        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        // BLAKE3 produces 64-character hex string
729        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        // Look for a common string that should exist in any binary
762        // ELF binaries contain "ELF", Mach-O binaries don't have that marker
763        // so we test for common strings that appear in most system binaries
764        #[cfg(target_os = "linux")]
765        let marker = "ELF";
766        #[cfg(target_os = "macos")]
767        let marker = "__TEXT"; // Mach-O segment name commonly found in binaries
768        #[cfg(target_os = "windows")]
769        let marker = "PE"; // PE header marker (though this test may be skipped on Windows)
770        #[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        // The marker should be found in the binary
782        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        // Look for a unique string that should NOT exist in any binary
801        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    // ===================================================================
833    // Empty / non-binary file edge cases
834    // ===================================================================
835
836    #[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        // ELF magic followed by truncated header
885        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    // ===================================================================
898    // BinaryHashResult equivalence edge cases
899    // ===================================================================
900
901    #[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(), // different full hash
913            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    // ===================================================================
971    // BinaryHashResult serialization
972    // ===================================================================
973
974    #[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    // ===================================================================
990    // Marker search edge cases
991    // ===================================================================
992
993    #[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        // Empty marker is trivially contained (consistent with str::contains(""))
1006        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        // Very long marker that surely doesn't exist
1024        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    // ===================================================================
1033    // Hash determinism verification
1034    // ===================================================================
1035
1036    #[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        // BLAKE3 produces 64-char hex (256 bits)
1117        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    // --- canary verdict classification (bd-784xt) -------------------------
1130
1131    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        // Marker present + byte-equivalent -> clean pass regardless of toolchain.
1145        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); // even identical hashes fail if the marker is absent
1158        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        // The same compiler producing different code is a genuine miscompile.
1167        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        // The bd-784xt false-fail: a healthy worker whose rustc differs.
1178        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        // When parity cannot be determined, default to advisory PASS (do not
1190        // disable a possibly-healthy worker on a hash difference alone).
1191        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}