|
| 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 | +} |
0 commit comments