Skip to content

Commit 3491c45

Browse files
logannyeclaude
andcommitted
test(chain): tamper -> CHAIN BROKEN (exit 5); BAM input -> external
Adds the failure-mode integration gates and applies fmt + the sort_by_key clippy fix. Full suite green; clippy -D warnings + fmt --check clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7cfa7d8 commit 3491c45

3 files changed

Lines changed: 94 additions & 7 deletions

File tree

crates/receipt/src/chain.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ pub fn walk_chain(receipts: &[RunManifest]) -> ChainReport {
118118
let mut producer: HashMap<String, String> = HashMap::new();
119119
for (m, id) in receipts.iter().zip(&ids) {
120120
for o in &m.outputs {
121-
producer.entry(o.blake3.clone()).or_insert_with(|| id.clone());
121+
producer
122+
.entry(o.blake3.clone())
123+
.or_insert_with(|| id.clone());
122124
}
123125
}
124126

@@ -229,7 +231,10 @@ mod tests {
229231
.filter(|e| matches!(e.status, EdgeStatus::External))
230232
.count();
231233
assert_eq!(resolved, 1, "exactly the --index edge resolves");
232-
assert_eq!(external, 2, "--alignments and --reference are external sources");
234+
assert_eq!(
235+
external, 2,
236+
"--alignments and --reference are external sources"
237+
);
233238
assert_eq!(
234239
report.to_json(),
235240
"{\"intact\":true,\"nodes\":2,\"edges_resolved\":1,\"edges_external\":2,\"edges_broken\":0}"

src/main.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -754,9 +754,10 @@ fn run_index(reference: PathBuf, output: PathBuf, memory_budget_mb: Option<u64>)
754754
cmd.record_into(&mut manifest);
755755
// `reference_blake3` is the in-memory NORMALIZED sequence hash (a "what genome"
756756
// id, stable across FASTA reformatting) — informational, NOT the chain edge.
757-
manifest
758-
.params
759-
.insert("reference_blake3".to_string(), blake3_hex(&reference_blake3));
757+
manifest.params.insert(
758+
"reference_blake3".to_string(),
759+
blake3_hex(&reference_blake3),
760+
);
760761
manifest
761762
.params
762763
.insert("total_bp".to_string(), total_bp.to_string());
@@ -1064,7 +1065,7 @@ fn run_chain_verify(dir: PathBuf, json: bool) -> Result<()> {
10641065
bail!("no receipts (*.manifest.json) found in {}", dir.display());
10651066
}
10661067
// Stable order (independent of read_dir) so the report is deterministic.
1067-
receipts.sort_by(|a, b| a.content_hash().cmp(&b.content_hash()));
1068+
receipts.sort_by_key(|m| m.content_hash());
10681069

10691070
let report = walk_chain(&receipts);
10701071

@@ -1093,7 +1094,10 @@ fn run_chain_verify(dir: PathBuf, json: bool) -> Result<()> {
10931094
match &e.status {
10941095
EdgeStatus::Resolved { parent_id } => {
10951096
let p = sub.get(parent_id.as_str()).copied().unwrap_or("?");
1096-
println!("edge: {} {}--> {p} [resolved]", e.child_subcommand, e.flag);
1097+
println!(
1098+
"edge: {} {}--> {p} [resolved]",
1099+
e.child_subcommand, e.flag
1100+
);
10971101
}
10981102
EdgeStatus::External => {
10991103
println!(

tests/chain.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,81 @@ fn chain_verify_is_intact_on_a_real_index_variants_chain() {
214214

215215
std::fs::remove_dir_all(&d).ok();
216216
}
217+
218+
#[test]
219+
fn chain_verify_breaks_when_the_index_receipt_is_tampered() {
220+
let d = tmpdir();
221+
let seq = "ACGTACGTACGTACGTACGTACGTACGTACGT";
222+
let fa = write_fasta(&d, "chr1", seq);
223+
let fq = write_fastq(&d, seq, &[0, 0, 8], 16);
224+
let (idx, bam) = build_index_and_sorted_bam(&d, &fa, &fq);
225+
let vcf = d.join("calls.vcf");
226+
assert!(run(&[
227+
"variants",
228+
"--index",
229+
idx.to_str().unwrap(),
230+
"--alignments",
231+
bam.to_str().unwrap(),
232+
"-o",
233+
vcf.to_str().unwrap(),
234+
])
235+
.status
236+
.success());
237+
238+
// Tamper with the index receipt's claim: flip a digit of total_bp without
239+
// recomputing manifest_blake3 → the node self-hash must fail.
240+
let mpath = d.join("ref.idx.manifest.json");
241+
let text = std::fs::read_to_string(&mpath).unwrap();
242+
let tampered = text.replacen("\"total_bp\":\"", "\"total_bp\":\"9", 1);
243+
assert_ne!(
244+
text, tampered,
245+
"the receipt must contain a total_bp field to tamper"
246+
);
247+
std::fs::write(&mpath, tampered).unwrap();
248+
249+
let out = run(&["chain", "verify", d.to_str().unwrap()]);
250+
assert_eq!(out.status.code(), Some(5), "a tampered node must exit 5");
251+
assert!(
252+
String::from_utf8_lossy(&out.stdout).contains("CHAIN BROKEN"),
253+
"stdout: {}",
254+
String::from_utf8_lossy(&out.stdout)
255+
);
256+
257+
std::fs::remove_dir_all(&d).ok();
258+
}
259+
260+
#[test]
261+
fn chain_verify_reports_the_bam_input_as_external_not_a_failure() {
262+
let d = tmpdir();
263+
let seq = "ACGTACGTACGTACGTACGTACGTACGTACGT";
264+
let fa = write_fasta(&d, "chr1", seq);
265+
let fq = write_fastq(&d, seq, &[0, 0, 8], 16);
266+
let (idx, bam) = build_index_and_sorted_bam(&d, &fa, &fq);
267+
let vcf = d.join("calls.vcf");
268+
assert!(run(&[
269+
"variants",
270+
"--index",
271+
idx.to_str().unwrap(),
272+
"--alignments",
273+
bam.to_str().unwrap(),
274+
"-o",
275+
vcf.to_str().unwrap(),
276+
])
277+
.status
278+
.success());
279+
280+
let out = run(&["chain", "verify", d.to_str().unwrap()]);
281+
let stdout = String::from_utf8_lossy(&out.stdout);
282+
// The BAM alignments input has no producing receipt → external/integrity-only,
283+
// and it must NOT fail the chain.
284+
assert!(
285+
out.status.success(),
286+
"external inputs must not break the chain: {stdout}"
287+
);
288+
assert!(
289+
stdout.contains("--alignments--> (external) [integrity-only]"),
290+
"the BAM edge must be reported external: {stdout}"
291+
);
292+
293+
std::fs::remove_dir_all(&d).ok();
294+
}

0 commit comments

Comments
 (0)