add differential harness and empty-invocation test
- SHA
9c5dcc17660e57947f9ce80e41bd2299fb621ee1- Parents
-
e4af513 - Tree
3d59d69
9c5dcc1
9c5dcc17660e57947f9ce80e41bd2299fb621ee1e4af513
3d59d69| Status | File | + | - |
|---|---|---|---|
| A |
tests/common/harness.rs
|
96 | 0 |
| A |
tests/common/mod.rs
|
4 | 0 |
| A |
tests/diff_harness_finds_critical.rs
|
30 | 0 |
| A |
tests/diff_harness_sanity.rs
|
18 | 0 |
| A |
tests/reader_empty.rs
|
28 | 0 |
tests/common/harness.rsadded@@ -0,0 +1,96 @@ | ||
| 1 | +//! Differential harness: compare afs-ld output against Apple `ld` output. | |
| 2 | +//! | |
| 3 | +//! Sprint 0 lands the diffing surface. The `link_both` function that actually | |
| 4 | +//! shells out to both linkers arrives once afs-ld can produce a real binary | |
| 5 | +//! (Sprint 18). Until then, tests exercise `diff_macho` directly against | |
| 6 | +//! synthesized byte slices. | |
| 7 | + | |
| 8 | +#![allow(dead_code)] | |
| 9 | + | |
| 10 | +use std::path::PathBuf; | |
| 11 | + | |
| 12 | +pub struct LinkCase { | |
| 13 | + pub name: &'static str, | |
| 14 | + pub inputs: Vec<PathBuf>, | |
| 15 | + pub args: Vec<String>, | |
| 16 | +} | |
| 17 | + | |
| 18 | +pub struct LinkOutputs { | |
| 19 | + pub ours: Vec<u8>, | |
| 20 | + pub theirs: Vec<u8>, | |
| 21 | +} | |
| 22 | + | |
| 23 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 24 | +pub enum DiffCategory { | |
| 25 | + /// A diff we expect: UUID bytes, timestamps, hash-backed temp paths, etc. | |
| 26 | + Tolerated(&'static str), | |
| 27 | + /// Anything else. Fails the parity test. | |
| 28 | + Critical, | |
| 29 | +} | |
| 30 | + | |
| 31 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 32 | +pub struct DiffChunk { | |
| 33 | + pub offset: usize, | |
| 34 | + pub len: usize, | |
| 35 | + pub reason: String, | |
| 36 | + pub category: DiffCategory, | |
| 37 | +} | |
| 38 | + | |
| 39 | +#[derive(Debug, Default)] | |
| 40 | +pub struct DiffReport { | |
| 41 | + pub tolerated: Vec<DiffChunk>, | |
| 42 | + pub critical: Vec<DiffChunk>, | |
| 43 | +} | |
| 44 | + | |
| 45 | +impl DiffReport { | |
| 46 | + pub fn is_clean(&self) -> bool { | |
| 47 | + self.critical.is_empty() | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// Byte-level diff between two Mach-O images. Sprint 0 treats every byte diff | |
| 52 | +/// as Critical; later sprints layer in the tolerated-diff predicates (UUID, | |
| 53 | +/// timestamp, code-signature hashes, string-table suffix-dedup variance). | |
| 54 | +pub fn diff_macho(ours: &[u8], theirs: &[u8]) -> DiffReport { | |
| 55 | + let mut report = DiffReport::default(); | |
| 56 | + | |
| 57 | + if ours.len() != theirs.len() { | |
| 58 | + report.critical.push(DiffChunk { | |
| 59 | + offset: 0, | |
| 60 | + len: ours.len().max(theirs.len()), | |
| 61 | + reason: format!( | |
| 62 | + "total size differs: ours = {}, theirs = {}", | |
| 63 | + ours.len(), | |
| 64 | + theirs.len() | |
| 65 | + ), | |
| 66 | + category: DiffCategory::Critical, | |
| 67 | + }); | |
| 68 | + return report; | |
| 69 | + } | |
| 70 | + | |
| 71 | + let mut i = 0; | |
| 72 | + while i < ours.len() { | |
| 73 | + if ours[i] != theirs[i] { | |
| 74 | + let start = i; | |
| 75 | + while i < ours.len() && ours[i] != theirs[i] { | |
| 76 | + i += 1; | |
| 77 | + } | |
| 78 | + report.critical.push(DiffChunk { | |
| 79 | + offset: start, | |
| 80 | + len: i - start, | |
| 81 | + reason: format!("{} byte(s) differ starting at 0x{start:x}", i - start), | |
| 82 | + category: DiffCategory::Critical, | |
| 83 | + }); | |
| 84 | + } else { | |
| 85 | + i += 1; | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + report | |
| 90 | +} | |
| 91 | + | |
| 92 | +/// Placeholder for the full linker-spawning contract. Sprint 18 wires this to | |
| 93 | +/// real invocations of afs-ld and the system `ld` via `xcrun -f ld`. | |
| 94 | +pub fn link_both(_case: &LinkCase) -> LinkOutputs { | |
| 95 | + panic!("link_both is not implemented until Sprint 18 (hello-world milestone)"); | |
| 96 | +} | |
tests/common/mod.rsadded@@ -0,0 +1,4 @@ | ||
| 1 | +//! Shared test infrastructure. Integration tests that need the harness | |
| 2 | +//! declare `mod common;` and then use `common::harness::*`. | |
| 3 | + | |
| 4 | +pub mod harness; | |
tests/diff_harness_finds_critical.rsadded@@ -0,0 +1,30 @@ | ||
| 1 | +//! Two byte slices that differ in a non-tolerated region must surface as | |
| 2 | +//! critical diffs. Proves the harness actually notices regressions — otherwise | |
| 3 | +//! every later differential test becomes a silent false-negative generator. | |
| 4 | + | |
| 5 | +mod common; | |
| 6 | + | |
| 7 | +use common::harness::diff_macho; | |
| 8 | + | |
| 9 | +#[test] | |
| 10 | +fn differing_bytes_surface_as_critical() { | |
| 11 | + let ours = b"hello world".to_vec(); | |
| 12 | + let mut theirs = ours.clone(); | |
| 13 | + theirs[6] = b'W'; // capitalize the W in "world" | |
| 14 | + | |
| 15 | + let report = diff_macho(&ours, &theirs); | |
| 16 | + assert!(!report.is_clean(), "harness missed a real byte difference"); | |
| 17 | + assert_eq!(report.critical.len(), 1); | |
| 18 | + let chunk = &report.critical[0]; | |
| 19 | + assert_eq!(chunk.offset, 6); | |
| 20 | + assert_eq!(chunk.len, 1); | |
| 21 | +} | |
| 22 | + | |
| 23 | +#[test] | |
| 24 | +fn size_mismatch_is_critical() { | |
| 25 | + let ours = b"short".to_vec(); | |
| 26 | + let theirs = b"considerably longer bytes".to_vec(); | |
| 27 | + let report = diff_macho(&ours, &theirs); | |
| 28 | + assert!(!report.is_clean()); | |
| 29 | + assert!(report.critical[0].reason.contains("size differs")); | |
| 30 | +} | |
tests/diff_harness_sanity.rsadded@@ -0,0 +1,18 @@ | ||
| 1 | +//! Two identical byte slices must produce zero critical diffs. | |
| 2 | + | |
| 3 | +mod common; | |
| 4 | + | |
| 5 | +use common::harness::diff_macho; | |
| 6 | + | |
| 7 | +#[test] | |
| 8 | +fn identical_inputs_produce_zero_critical_diffs() { | |
| 9 | + let a = b"same bytes everywhere".to_vec(); | |
| 10 | + let b = a.clone(); | |
| 11 | + let report = diff_macho(&a, &b); | |
| 12 | + assert!( | |
| 13 | + report.is_clean(), | |
| 14 | + "identical inputs produced critical diffs: {:#?}", | |
| 15 | + report.critical | |
| 16 | + ); | |
| 17 | + assert!(report.tolerated.is_empty()); | |
| 18 | +} | |
tests/reader_empty.rsadded@@ -0,0 +1,28 @@ | ||
| 1 | +//! Running afs-ld with no inputs must produce the exact diagnostic line | |
| 2 | +//! `afs-ld: error: no input files` on stderr and exit with code 2. | |
| 3 | +//! | |
| 4 | +//! The CLI surface grows over the next sprints but this invariant is the | |
| 5 | +//! baseline every later change must preserve. | |
| 6 | + | |
| 7 | +use std::process::Command; | |
| 8 | + | |
| 9 | +#[test] | |
| 10 | +fn empty_invocation_produces_no_input_files_diagnostic() { | |
| 11 | + let exe = env!("CARGO_BIN_EXE_afs-ld"); | |
| 12 | + let out = Command::new(exe) | |
| 13 | + .output() | |
| 14 | + .expect("afs-ld binary should exist and be executable"); | |
| 15 | + | |
| 16 | + assert!(!out.status.success(), "afs-ld should fail on empty input"); | |
| 17 | + assert_eq!( | |
| 18 | + out.status.code(), | |
| 19 | + Some(2), | |
| 20 | + "empty-input exit code must be 2 (CLI misuse / missing input)" | |
| 21 | + ); | |
| 22 | + | |
| 23 | + let stderr = String::from_utf8_lossy(&out.stderr); | |
| 24 | + assert!( | |
| 25 | + stderr.contains("afs-ld: error: no input files"), | |
| 26 | + "expected the `no input files` diagnostic on stderr; got: {stderr}" | |
| 27 | + ); | |
| 28 | +} | |