Skip to main content

sc/sync/
hash.rs

1//! Content hashing for sync operations.
2//!
3//! This module provides SHA256-based content hashing for change detection.
4//! By hashing the serialized JSON of a record, we can detect changes without
5//! comparing every field.
6
7use serde::Serialize;
8use sha2::{Digest, Sha256};
9
10/// Compute a SHA256 hash of a serializable value.
11///
12/// The value is first serialized to JSON, then hashed. This provides a
13/// deterministic fingerprint of the content that can be used for:
14/// - Detecting if a record has changed since last export
15/// - Comparing local vs external records during import
16///
17/// # Panics
18///
19/// Panics if the value cannot be serialized to JSON. This should never happen
20/// for our data types which are all serializable.
21///
22/// # Example
23///
24/// ```ignore
25/// let session = Session { id: "sess_1".into(), ... };
26/// let hash = content_hash(&session);
27/// // hash is something like "a1b2c3d4..."
28/// ```
29#[must_use]
30pub fn content_hash<T: Serialize>(value: &T) -> String {
31    let json = serde_json::to_string(value).expect("serialization should not fail");
32    let mut hasher = Sha256::new();
33    hasher.update(json.as_bytes());
34    format!("{:x}", hasher.finalize())
35}
36
37/// Check if an entity has changed since last export.
38///
39/// Returns `true` if:
40/// - There is no stored hash (never exported)
41/// - The current hash differs from the stored hash
42///
43/// Returns `false` if the hashes match (no change).
44#[must_use]
45pub fn has_changed(current_hash: &str, stored_hash: Option<&str>) -> bool {
46    stored_hash.map_or(true, |h| h != current_hash)
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use serde::Serialize;
53
54    #[derive(Serialize)]
55    struct TestRecord {
56        id: String,
57        value: i32,
58    }
59
60    #[test]
61    fn test_content_hash_deterministic() {
62        let record = TestRecord {
63            id: "test_1".into(),
64            value: 42,
65        };
66
67        let hash1 = content_hash(&record);
68        let hash2 = content_hash(&record);
69
70        assert_eq!(hash1, hash2);
71        assert_eq!(hash1.len(), 64); // SHA256 produces 64 hex chars
72    }
73
74    #[test]
75    fn test_content_hash_changes_with_content() {
76        let record1 = TestRecord {
77            id: "test_1".into(),
78            value: 42,
79        };
80        let record2 = TestRecord {
81            id: "test_1".into(),
82            value: 43, // Different value
83        };
84
85        let hash1 = content_hash(&record1);
86        let hash2 = content_hash(&record2);
87
88        assert_ne!(hash1, hash2);
89    }
90
91    #[test]
92    fn test_has_changed_no_stored_hash() {
93        assert!(has_changed("abc123", None));
94    }
95
96    #[test]
97    fn test_has_changed_different_hash() {
98        assert!(has_changed("abc123", Some("xyz789")));
99    }
100
101    #[test]
102    fn test_has_changed_same_hash() {
103        assert!(!has_changed("abc123", Some("abc123")));
104    }
105}