Skip to main content

hyphae_engine/proof/
verify.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::{
4    fs::{File, OpenOptions},
5    io::{Read, Write},
6    path::Path,
7    time::Instant,
8};
9
10use hyphae_query::{Record, execute};
11use hyphae_storage::load_snapshot;
12
13use super::{
14    ProofAnchor, ProofError, ProvenOperation, ProvenResult, ResultProof, VerificationLimits,
15    VerificationReport, decode_proof, encode_proof,
16};
17use crate::decode_document;
18
19/// Writes a canonical result proof to a new file and synchronizes it.
20///
21/// Existing paths are never replaced.
22///
23/// # Errors
24///
25/// Returns a proof encoding, path, create, write, or synchronization error.
26pub fn write_result_proof(path: impl AsRef<Path>, proof: &ResultProof) -> Result<(), ProofError> {
27    let encoded = encode_proof(proof)?;
28    let mut file = OpenOptions::new().create_new(true).write(true).open(path)?;
29    file.write_all(&encoded)?;
30    file.sync_all()?;
31    Ok(())
32}
33
34/// Reads and verifies one canonical result-proof file under a byte limit.
35///
36/// # Errors
37///
38/// Returns an I/O, resource-limit, framing, canonicality, checksum, or digest
39/// error.
40pub fn read_result_proof(
41    path: impl AsRef<Path>,
42    maximum_bytes: u64,
43) -> Result<ResultProof, ProofError> {
44    let mut file = File::open(path)?;
45    let metadata_length = file.metadata()?.len();
46    if metadata_length > maximum_bytes {
47        return Err(ProofError::ProofLimitExceeded {
48            actual: metadata_length,
49            maximum: maximum_bytes,
50        });
51    }
52    let capacity = usize::try_from(metadata_length).map_err(|_| ProofError::LengthOverflow)?;
53    let mut encoded = Vec::with_capacity(capacity);
54    file.read_to_end(&mut encoded)?;
55    let actual = u64::try_from(encoded.len()).map_err(|_| ProofError::LengthOverflow)?;
56    if actual > maximum_bytes {
57        return Err(ProofError::ProofLimitExceeded {
58            actual,
59            maximum: maximum_bytes,
60        });
61    }
62    if actual != metadata_length {
63        return Err(ProofError::Invalid {
64            reason: "proof changed while being read",
65        });
66    }
67    decode_proof(&encoded)
68}
69
70/// Verifies a result proof completely offline against a trusted anchor and
71/// canonical snapshot witness.
72///
73/// # Errors
74///
75/// Returns an error for any proof or snapshot corruption, wrong anchor,
76/// resource exhaustion, document failure, timeout, or replay mismatch. No
77/// partial result is accepted.
78pub fn verify_result_proof(
79    proof_path: impl AsRef<Path>,
80    snapshot_path: impl AsRef<Path>,
81    expected_anchor_digest: [u8; 32],
82    limits: &VerificationLimits,
83) -> Result<VerificationReport, ProofError> {
84    let started = Instant::now();
85    let proof = read_result_proof(proof_path, limits.proof_bytes)?;
86    check_timeout(started, limits)?;
87
88    let anchor_digest = proof.anchor_digest();
89    if anchor_digest != expected_anchor_digest {
90        return Err(ProofError::AnchorMismatch);
91    }
92
93    let snapshot = load_snapshot(snapshot_path, &limits.snapshot)?;
94    check_timeout(started, limits)?;
95    if ProofAnchor::from_snapshot(&snapshot.info) != *proof.anchor() {
96        return Err(ProofError::SnapshotAnchorMismatch);
97    }
98
99    let mut records = Vec::with_capacity(snapshot.entries.len());
100    for entry in snapshot.entries {
101        check_timeout(started, limits)?;
102        records.push(Record {
103            key: entry.key,
104            value: decode_document(&entry.value)?,
105        });
106    }
107
108    let verified_result = match (proof.operation(), proof.result()) {
109        (ProvenOperation::Get { key }, ProvenResult::Get(expected)) => {
110            let actual = records
111                .binary_search_by(|record| record.key.as_slice().cmp(key))
112                .ok()
113                .map(|index| records[index].clone());
114            if &actual != expected {
115                return Err(ProofError::ReexecutionMismatch);
116            }
117            ProvenResult::Get(actual)
118        }
119        (ProvenOperation::Query(query), ProvenResult::Query(expected)) => {
120            let elapsed = started.elapsed();
121            let remaining = limits
122                .timeout
123                .checked_sub(elapsed)
124                .ok_or(ProofError::TimedOut)?;
125            if remaining.is_zero() {
126                return Err(ProofError::TimedOut);
127            }
128            let query_limits = hyphae_query::ExecutionLimits {
129                timeout: remaining.min(limits.query.timeout),
130                ..limits.query.clone()
131            };
132            let actual = execute(&[records.as_slice()], query, &query_limits)?;
133            if &actual != expected {
134                return Err(ProofError::ReexecutionMismatch);
135            }
136            ProvenResult::Query(actual)
137        }
138        _ => return Err(ProofError::OperationResultMismatch),
139    };
140    check_timeout(started, limits)?;
141
142    Ok(VerificationReport {
143        anchor: proof.anchor().clone(),
144        anchor_digest,
145        proof_digest: proof.proof_digest(),
146        result: verified_result,
147    })
148}
149
150fn check_timeout(started: Instant, limits: &VerificationLimits) -> Result<(), ProofError> {
151    if started.elapsed() >= limits.timeout {
152        Err(ProofError::TimedOut)
153    } else {
154        Ok(())
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use std::{collections::BTreeMap, error::Error, fs, path::PathBuf};
161
162    use hyphae_query::{ExecutionLimits, Filter, Query, Record, Value};
163    use uuid::Uuid;
164
165    use super::{VerificationLimits, verify_result_proof, write_result_proof};
166    use crate::{HyphaeEngine, ProofError, ProvenResult};
167
168    struct TestDirectory {
169        path: PathBuf,
170    }
171
172    impl TestDirectory {
173        fn create() -> Result<Self, Box<dyn Error>> {
174            let path = std::env::temp_dir()
175                .join(format!("hyphae-proof-rehashed-tamper-{}", Uuid::now_v7()));
176            fs::create_dir_all(&path)?;
177            Ok(Self { path })
178        }
179    }
180
181    impl Drop for TestDirectory {
182        fn drop(&mut self) {
183            let _ignored = fs::remove_dir_all(&self.path);
184        }
185    }
186
187    #[test]
188    fn self_consistently_rehashed_result_edits_are_rejected() -> Result<(), Box<dyn Error>> {
189        let temporary = TestDirectory::create()?;
190        let mut opened = HyphaeEngine::open(temporary.path.join("data"))?;
191        opened.engine.put_records(
192            Uuid::now_v7(),
193            &[record(b"a", 1), record(b"b", 2), record(b"c", 3)],
194        )?;
195        let artifact = opened.engine.query_with_proof(
196            &Query {
197                filter: Filter::MatchAll,
198                sort: Vec::new(),
199                cursor: None,
200                limit: 3,
201                aggregation: None,
202            },
203            &ExecutionLimits::default(),
204        )?;
205
206        for (name, mutation) in [
207            ("delete", 1_u8),
208            ("insert", 2_u8),
209            ("reorder", 3_u8),
210            ("edit", 4_u8),
211        ] {
212            let mut forged = artifact.proof.clone();
213            let ProvenResult::Query(result) = &mut forged.result else {
214                return Err(ProofError::OperationResultMismatch.into());
215            };
216            match mutation {
217                1 => {
218                    result.rows.remove(0);
219                }
220                2 => result.rows.push(result.rows[0].clone()),
221                3 => result.rows.swap(0, 1),
222                4 => result.rows[0].value = Value::Integer(999),
223                _ => return Err(ProofError::OperationResultMismatch.into()),
224            }
225            let proof_path = temporary.path.join(format!("{name}.hyproof"));
226            write_result_proof(&proof_path, &forged)?;
227            assert!(matches!(
228                verify_result_proof(
229                    &proof_path,
230                    &artifact.snapshot.path,
231                    artifact.proof.anchor_digest(),
232                    &VerificationLimits::default(),
233                ),
234                Err(ProofError::ReexecutionMismatch)
235            ));
236        }
237        Ok(())
238    }
239
240    fn record(key: &[u8], score: i64) -> Record {
241        Record::new(
242            key,
243            Value::Object(BTreeMap::from([(
244                "score".to_owned(),
245                Value::Integer(score),
246            )])),
247        )
248    }
249}