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/// Classify a returned artifact only after reading its nonempty identity marker.
463/// I/O failure is an unavailable verification result, never marker presence.
464pub 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    /// Find a binary in the project for testing
503    fn find_test_binary() -> Option<PathBuf> {
504        // Try release binary first, then debug
505        let candidates = [
506            "target/release/rch",
507            "target/debug/rch",
508            "target/release/rchd",
509            "target/debug/rchd",
510            "/bin/ls",  // Fallback to system binary
511            "/bin/cat", // Another fallback
512            "/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(), // Full hash may differ
573            code_hash: "xyz789abc".into(),           // Code hash matches
574            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(), // Different code hash
608            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, // Different size
643            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, // Different debug status
678        };
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        // Try to hash a text file which is not a valid binary
718        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        // BLAKE3 produces 64-character hex string
757        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        // Look for a common string that should exist in any binary
790        // ELF binaries contain "ELF", Mach-O binaries don't have that marker
791        // so we test for common strings that appear in most system binaries
792        #[cfg(target_os = "linux")]
793        let marker = "ELF";
794        #[cfg(target_os = "macos")]
795        let marker = "__TEXT"; // Mach-O segment name commonly found in binaries
796        #[cfg(target_os = "windows")]
797        let marker = "PE"; // PE header marker (though this test may be skipped on Windows)
798        #[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        // The marker should be found in the binary
810        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        // Look for a unique string that should NOT exist in any binary
829        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    // ===================================================================
861    // Empty / non-binary file edge cases
862    // ===================================================================
863
864    #[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        // ELF magic followed by truncated header
913        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    // ===================================================================
926    // BinaryHashResult equivalence edge cases
927    // ===================================================================
928
929    #[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(), // different full hash
941            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    // ===================================================================
999    // BinaryHashResult serialization
1000    // ===================================================================
1001
1002    #[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    // ===================================================================
1018    // Marker search edge cases
1019    // ===================================================================
1020
1021    #[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        // Empty marker is trivially contained (consistent with str::contains(""))
1034        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        // Very long marker that surely doesn't exist
1052        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    // ===================================================================
1061    // Hash determinism verification
1062    // ===================================================================
1063
1064    #[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        // BLAKE3 produces 64-char hex (256 bits)
1145        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    // --- canary verdict classification (bd-784xt) -------------------------
1158
1159    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        // Retained exclusive files: these byte fixtures test the reader, not a fleet.
1171        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        // Marker present + byte-equivalent -> clean pass regardless of toolchain.
1214        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); // even identical hashes fail if the marker is absent
1227        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        // The same compiler producing different code is a genuine miscompile.
1236        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        // The bd-784xt false-fail: a healthy worker whose rustc differs.
1247        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        // When parity cannot be determined, default to advisory PASS (do not
1259        // disable a possibly-healthy worker on a hash difference alone).
1260        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}