pushwire-core 0.1.1

Shared types and codecs for push-wire multiplexed push protocol
Documentation
use sha2::{Digest, Sha256};
use thiserror::Error;

/// Delta against a base cursor for content payloads.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PayloadDelta {
    pub base_cursor: u64,
    pub ops: Vec<DeltaOp>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeltaOp {
    Replace { sha: String, content: String },
}

#[derive(Debug, PartialEq, Eq)]
pub enum DeltaApplyResult {
    Applied { sha: String, content: String },
    NeedsFullSync,
}

#[derive(Debug, Error, PartialEq, Eq)]
pub enum DeltaError {
    #[error("base cursor mismatch")]
    BaseMismatch,
}

/// Compute a naive delta that replaces the tree when the content differs.
pub fn compute_delta(base_cursor: u64, current: &str, next: &str) -> PayloadDelta {
    if current == next {
        PayloadDelta {
            base_cursor,
            ops: Vec::new(),
        }
    } else {
        PayloadDelta {
            base_cursor,
            ops: vec![DeltaOp::Replace {
                sha: sha256(next),
                content: next.to_string(),
            }],
        }
    }
}

/// Apply a delta on the client. If the base cursor does not match or the delta
/// is empty, it signals full sync.
pub fn apply_delta(
    current_cursor: u64,
    _current_sha: &str,
    delta: PayloadDelta,
) -> Result<DeltaApplyResult, DeltaError> {
    if delta.base_cursor != current_cursor {
        return Err(DeltaError::BaseMismatch);
    }

    if delta.ops.is_empty() {
        return Ok(DeltaApplyResult::NeedsFullSync);
    }

    match &delta.ops[0] {
        DeltaOp::Replace { sha, content } => {
            if sha256(content) != *sha {
                Ok(DeltaApplyResult::NeedsFullSync)
            } else {
                Ok(DeltaApplyResult::Applied {
                    sha: sha.clone(),
                    content: content.clone(),
                })
            }
        }
    }
}

fn sha256(body: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(body.as_bytes());
    format!("{:x}", hasher.finalize())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn computes_replace_when_different() {
        let delta = compute_delta(10, "old", "new");
        assert_eq!(delta.base_cursor, 10);
        assert_eq!(delta.ops.len(), 1);
        match &delta.ops[0] {
            DeltaOp::Replace { content, .. } => assert_eq!(content, "new"),
        }
    }

    #[test]
    fn no_ops_when_same() {
        let delta = compute_delta(5, "same", "same");
        assert!(delta.ops.is_empty());
    }

    #[test]
    fn apply_delta_success() {
        let next = "new tree";
        let delta = compute_delta(3, "old", next);
        let res = apply_delta(3, "ignored", delta).unwrap();
        match res {
            DeltaApplyResult::Applied { sha, content } => {
                assert_eq!(content, next);
                assert_eq!(sha, sha256(next));
            }
            _ => panic!("expected applied"),
        }
    }

    #[test]
    fn apply_delta_detects_corruption() {
        let mut delta = compute_delta(2, "old", "good");
        let DeltaOp::Replace { content, .. } = &mut delta.ops[0];
        *content = "tampered".to_string();
        let res = apply_delta(2, "ignored", delta).unwrap();
        assert!(matches!(res, DeltaApplyResult::NeedsFullSync));
    }

    #[test]
    fn base_cursor_mismatch_errors() {
        let delta = compute_delta(1, "a", "b");
        let err = apply_delta(2, "ignored", delta).unwrap_err();
        assert_eq!(err, DeltaError::BaseMismatch);
    }
}