Skip to content

Commit 7147e17

Browse files
authored
Merge pull request #77 from logannye/rosalind/index-receipt-chain-verify
feat: `index` receipt + `chain verify` — a rooted, offline-verifiable provenance DAG
2 parents ce4ad6d + 3491c45 commit 7147e17

6 files changed

Lines changed: 1897 additions & 0 deletions

File tree

crates/receipt/src/chain.rs

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
//! Pure, `std`-only provenance-DAG walk over a set of run receipts. No filesystem, no
2+
//! htslib — so it is unit-testable and wasm-portable (the future `chain confirm` board /
3+
//! browser verifier reuse it). A node is a receipt (id = its claim `content_hash`); an
4+
//! edge is one input operand, classified by whether its content hash is produced by
5+
//! another node in the set.
6+
7+
use std::collections::HashMap;
8+
9+
use crate::{FileHash, RunManifest};
10+
11+
/// Input operand flags that MUST resolve to a producing node. An unresolved one is a
12+
/// broken chain (a missing/mismatched upstream receipt). Everything else (reads,
13+
/// alignments, reference FASTA) is an external source: integrity-verified by its
14+
/// recorded hash, never a chain failure.
15+
const EXPECTED_INTERNAL: &[&str] = &["--index"];
16+
17+
/// How one input operand resolved against the receipt set.
18+
#[derive(Debug, Clone, PartialEq, Eq)]
19+
pub enum EdgeStatus {
20+
/// The input hash equals some node's output hash — an internal provenance edge.
21+
Resolved { parent_id: String },
22+
/// Unresolved, but an expected-external operand (reference/alignments/reads).
23+
External,
24+
/// Unresolved AND an expected-internal operand (`--index`) — breaks the chain.
25+
Broken,
26+
}
27+
28+
/// One classified input operand of one node.
29+
#[derive(Debug, Clone, PartialEq, Eq)]
30+
pub struct ChainEdge {
31+
pub child_id: String,
32+
pub child_subcommand: String,
33+
pub flag: String,
34+
pub input_blake3: String,
35+
pub status: EdgeStatus,
36+
}
37+
38+
/// A receipt as a DAG node.
39+
#[derive(Debug, Clone, PartialEq, Eq)]
40+
pub struct ChainNode {
41+
pub id: String,
42+
pub subcommand: String,
43+
/// `Some(true/false)` if a claim self-hash is recorded and matches/mismatches;
44+
/// `None` for a pre-self-hash receipt.
45+
pub self_hash: Option<bool>,
46+
}
47+
48+
/// The result of walking the set: nodes, classified edges, and the overall verdict.
49+
#[derive(Debug, Clone)]
50+
pub struct ChainReport {
51+
pub nodes: Vec<ChainNode>,
52+
pub edges: Vec<ChainEdge>,
53+
/// `true` iff no node fails its self-hash AND no edge is `Broken`.
54+
pub intact: bool,
55+
}
56+
57+
impl ChainReport {
58+
/// A compact, dependency-free JSON summary for `--json` / a scheduler.
59+
pub fn to_json(&self) -> String {
60+
let resolved = self
61+
.edges
62+
.iter()
63+
.filter(|e| matches!(e.status, EdgeStatus::Resolved { .. }))
64+
.count();
65+
let external = self
66+
.edges
67+
.iter()
68+
.filter(|e| matches!(e.status, EdgeStatus::External))
69+
.count();
70+
let broken = self
71+
.edges
72+
.iter()
73+
.filter(|e| matches!(e.status, EdgeStatus::Broken))
74+
.count();
75+
format!(
76+
"{{\"intact\":{},\"nodes\":{},\"edges_resolved\":{},\"edges_external\":{},\"edges_broken\":{}}}",
77+
self.intact,
78+
self.nodes.len(),
79+
resolved,
80+
external,
81+
broken
82+
)
83+
}
84+
}
85+
86+
/// Recover `(flag, input_blake3)` pairs from a recorded `command` string: each
87+
/// `@in:<hash>` token is preceded by its operand flag.
88+
fn input_operands(command: &str) -> Vec<(String, String)> {
89+
let toks: Vec<&str> = command.split(' ').collect();
90+
let mut out = Vec::new();
91+
for (i, t) in toks.iter().enumerate() {
92+
if let Some(h) = t.strip_prefix("@in:") {
93+
let flag = if i > 0 { toks[i - 1] } else { "?" };
94+
out.push((flag.to_string(), h.to_string()));
95+
}
96+
}
97+
out
98+
}
99+
100+
/// Operands for a node: from its recorded `command` when present (carries the flags),
101+
/// else a flag-less fallback over `inputs[]` (every input treated as external).
102+
fn operands_for(m: &RunManifest) -> Vec<(String, String)> {
103+
match m.params.get("command") {
104+
Some(c) if !c.is_empty() => input_operands(c),
105+
_ => m
106+
.inputs
107+
.iter()
108+
.map(|f: &FileHash| ("?".to_string(), f.blake3.clone()))
109+
.collect(),
110+
}
111+
}
112+
113+
/// Walk the receipt set as a provenance DAG. Pure: no I/O.
114+
pub fn walk_chain(receipts: &[RunManifest]) -> ChainReport {
115+
let ids: Vec<String> = receipts.iter().map(|m| m.content_hash()).collect();
116+
117+
// output content hash -> producing node id (first producer wins).
118+
let mut producer: HashMap<String, String> = HashMap::new();
119+
for (m, id) in receipts.iter().zip(&ids) {
120+
for o in &m.outputs {
121+
producer
122+
.entry(o.blake3.clone())
123+
.or_insert_with(|| id.clone());
124+
}
125+
}
126+
127+
let nodes: Vec<ChainNode> = receipts
128+
.iter()
129+
.zip(&ids)
130+
.map(|(m, id)| ChainNode {
131+
id: id.clone(),
132+
subcommand: m.subcommand.clone(),
133+
self_hash: m.self_hash_ok(),
134+
})
135+
.collect();
136+
137+
let mut edges = Vec::new();
138+
for (m, id) in receipts.iter().zip(&ids) {
139+
for (flag, hash) in operands_for(m) {
140+
let status = if let Some(parent) = producer.get(&hash) {
141+
EdgeStatus::Resolved {
142+
parent_id: parent.clone(),
143+
}
144+
} else if EXPECTED_INTERNAL.contains(&flag.as_str()) {
145+
EdgeStatus::Broken
146+
} else {
147+
EdgeStatus::External
148+
};
149+
edges.push(ChainEdge {
150+
child_id: id.clone(),
151+
child_subcommand: m.subcommand.clone(),
152+
flag,
153+
input_blake3: hash,
154+
status,
155+
});
156+
}
157+
}
158+
159+
let tampered = nodes.iter().any(|n| n.self_hash == Some(false));
160+
let broken = edges.iter().any(|e| matches!(e.status, EdgeStatus::Broken));
161+
ChainReport {
162+
nodes,
163+
edges,
164+
intact: !tampered && !broken,
165+
}
166+
}
167+
168+
#[cfg(test)]
169+
mod tests {
170+
use super::*;
171+
use crate::{FileHash, RunManifest};
172+
173+
/// Build a finalized manifest with a recorded `command`, inputs, and outputs.
174+
fn mk(
175+
sub: &str,
176+
command: &str,
177+
inputs: &[(&str, &str)],
178+
outputs: &[(&str, &str)],
179+
) -> RunManifest {
180+
let mut m = RunManifest::new(sub);
181+
m.params.insert("command".to_string(), command.to_string());
182+
m.inputs = inputs
183+
.iter()
184+
.map(|(p, h)| FileHash {
185+
path: p.to_string(),
186+
blake3: h.to_string(),
187+
})
188+
.collect();
189+
m.outputs = outputs
190+
.iter()
191+
.map(|(p, h)| FileHash {
192+
path: p.to_string(),
193+
blake3: h.to_string(),
194+
})
195+
.collect();
196+
m.finalize();
197+
m
198+
}
199+
200+
fn index_node() -> RunManifest {
201+
mk(
202+
"index",
203+
"index --reference @in:rh --output @out:ih",
204+
&[("ref.fa", "rh")],
205+
&[("ref.idx", "ih")],
206+
)
207+
}
208+
209+
fn variants_node(index_hash: &str) -> RunManifest {
210+
mk(
211+
"variants",
212+
&format!("variants --index @in:{index_hash} --alignments @in:bh -o @out:vh"),
213+
&[("ref.idx", index_hash), ("s.bam", "bh")],
214+
&[("calls.vcf", "vh")],
215+
)
216+
}
217+
218+
#[test]
219+
fn resolves_the_index_edge_and_marks_external_inputs() {
220+
let report = walk_chain(&[index_node(), variants_node("ih")]);
221+
assert!(report.intact, "a complete chain is intact");
222+
// The --index edge resolves; --alignments (bh) and --reference (rh) are external.
223+
let resolved = report
224+
.edges
225+
.iter()
226+
.filter(|e| matches!(e.status, EdgeStatus::Resolved { .. }))
227+
.count();
228+
let external = report
229+
.edges
230+
.iter()
231+
.filter(|e| matches!(e.status, EdgeStatus::External))
232+
.count();
233+
assert_eq!(resolved, 1, "exactly the --index edge resolves");
234+
assert_eq!(
235+
external, 2,
236+
"--alignments and --reference are external sources"
237+
);
238+
assert_eq!(
239+
report.to_json(),
240+
"{\"intact\":true,\"nodes\":2,\"edges_resolved\":1,\"edges_external\":2,\"edges_broken\":0}"
241+
);
242+
}
243+
244+
#[test]
245+
fn an_unresolved_index_input_is_broken() {
246+
// variants references an --index hash no node produces.
247+
let report = walk_chain(&[index_node(), variants_node("MISSING")]);
248+
assert!(!report.intact, "a dangling --index edge breaks the chain");
249+
assert!(report
250+
.edges
251+
.iter()
252+
.any(|e| e.flag == "--index" && e.status == EdgeStatus::Broken));
253+
}
254+
255+
#[test]
256+
fn an_unresolved_external_input_does_not_break_the_chain() {
257+
// The index node alone: its --reference (rh) resolves to nothing, but --reference
258+
// is an external source, so the chain is still intact.
259+
let report = walk_chain(&[index_node()]);
260+
assert!(
261+
report.intact,
262+
"an unresolved external source is integrity-only, not broken"
263+
);
264+
assert!(report
265+
.edges
266+
.iter()
267+
.any(|e| e.flag == "--reference" && e.status == EdgeStatus::External));
268+
}
269+
270+
#[test]
271+
fn a_tampered_node_breaks_the_chain() {
272+
let mut tampered = index_node();
273+
// Edit a claim field AFTER finalize → the recorded manifest_blake3 no longer matches.
274+
tampered
275+
.params
276+
.insert("total_bp".to_string(), "999999".to_string());
277+
let report = walk_chain(&[tampered, variants_node("ih")]);
278+
assert!(!report.intact, "a self-hash mismatch breaks the chain");
279+
assert!(report.nodes.iter().any(|n| n.self_hash == Some(false)));
280+
}
281+
}

crates/receipt/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ use std::sync::OnceLock;
2424
mod command;
2525
pub use command::CommandCapture;
2626

27+
mod chain;
28+
pub use chain::{walk_chain, ChainEdge, ChainNode, ChainReport, EdgeStatus};
29+
2730
mod repro;
2831
pub use repro::{ReproOutput, ReproReceipt};
2932

0 commit comments

Comments
 (0)