Skip to main content

pushwire_core/
delta.rs

1use sha2::{Digest, Sha256};
2use thiserror::Error;
3
4/// Delta against a base cursor for content payloads.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct PayloadDelta {
7    pub base_cursor: u64,
8    pub ops: Vec<DeltaOp>,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum DeltaOp {
13    Replace { sha: String, content: String },
14}
15
16#[derive(Debug, PartialEq, Eq)]
17pub enum DeltaApplyResult {
18    Applied { sha: String, content: String },
19    NeedsFullSync,
20}
21
22#[derive(Debug, Error, PartialEq, Eq)]
23pub enum DeltaError {
24    #[error("base cursor mismatch")]
25    BaseMismatch,
26}
27
28/// Compute a naive delta that replaces the tree when the content differs.
29pub fn compute_delta(base_cursor: u64, current: &str, next: &str) -> PayloadDelta {
30    if current == next {
31        PayloadDelta {
32            base_cursor,
33            ops: Vec::new(),
34        }
35    } else {
36        PayloadDelta {
37            base_cursor,
38            ops: vec![DeltaOp::Replace {
39                sha: sha256(next),
40                content: next.to_string(),
41            }],
42        }
43    }
44}
45
46/// Apply a delta on the client. If the base cursor does not match or the delta
47/// is empty, it signals full sync.
48pub fn apply_delta(
49    current_cursor: u64,
50    _current_sha: &str,
51    delta: PayloadDelta,
52) -> Result<DeltaApplyResult, DeltaError> {
53    if delta.base_cursor != current_cursor {
54        return Err(DeltaError::BaseMismatch);
55    }
56
57    if delta.ops.is_empty() {
58        return Ok(DeltaApplyResult::NeedsFullSync);
59    }
60
61    match &delta.ops[0] {
62        DeltaOp::Replace { sha, content } => {
63            if sha256(content) != *sha {
64                Ok(DeltaApplyResult::NeedsFullSync)
65            } else {
66                Ok(DeltaApplyResult::Applied {
67                    sha: sha.clone(),
68                    content: content.clone(),
69                })
70            }
71        }
72    }
73}
74
75fn sha256(body: &str) -> String {
76    let mut hasher = Sha256::new();
77    hasher.update(body.as_bytes());
78    format!("{:x}", hasher.finalize())
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn computes_replace_when_different() {
87        let delta = compute_delta(10, "old", "new");
88        assert_eq!(delta.base_cursor, 10);
89        assert_eq!(delta.ops.len(), 1);
90        match &delta.ops[0] {
91            DeltaOp::Replace { content, .. } => assert_eq!(content, "new"),
92        }
93    }
94
95    #[test]
96    fn no_ops_when_same() {
97        let delta = compute_delta(5, "same", "same");
98        assert!(delta.ops.is_empty());
99    }
100
101    #[test]
102    fn apply_delta_success() {
103        let next = "new tree";
104        let delta = compute_delta(3, "old", next);
105        let res = apply_delta(3, "ignored", delta).unwrap();
106        match res {
107            DeltaApplyResult::Applied { sha, content } => {
108                assert_eq!(content, next);
109                assert_eq!(sha, sha256(next));
110            }
111            _ => panic!("expected applied"),
112        }
113    }
114
115    #[test]
116    fn apply_delta_detects_corruption() {
117        let mut delta = compute_delta(2, "old", "good");
118        let DeltaOp::Replace { content, .. } = &mut delta.ops[0];
119        *content = "tampered".to_string();
120        let res = apply_delta(2, "ignored", delta).unwrap();
121        assert!(matches!(res, DeltaApplyResult::NeedsFullSync));
122    }
123
124    #[test]
125    fn base_cursor_mismatch_errors() {
126        let delta = compute_delta(1, "a", "b");
127        let err = apply_delta(2, "ignored", delta).unwrap_err();
128        assert_eq!(err, DeltaError::BaseMismatch);
129    }
130}