Skip to main content

driven/streaming/
etag_negotiator.rs

1//! ETag Negotiator
2//!
3//! HTTP-style cache validation for rule synchronization.
4
5use crate::binary::checksum::compute_blake3;
6
7/// Negotiation result
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum NegotiationResult {
10    /// Content is fresh, no update needed
11    Fresh,
12    /// Content is stale, full update needed
13    Stale,
14    /// Content can be patched (delta update)
15    Patch,
16}
17
18/// ETag for content versioning
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub struct ETag {
21    /// Strong ETag (content hash)
22    strong: [u8; 16],
23    /// Weak ETag (version number)
24    weak: u64,
25}
26
27impl ETag {
28    /// Create from content
29    pub fn from_content(content: &[u8], version: u64) -> Self {
30        Self {
31            strong: compute_blake3(content),
32            weak: version,
33        }
34    }
35
36    /// Create a weak ETag (version only)
37    pub fn weak(version: u64) -> Self {
38        Self {
39            strong: [0; 16],
40            weak: version,
41        }
42    }
43
44    /// Get strong hash
45    pub fn strong(&self) -> &[u8; 16] {
46        &self.strong
47    }
48
49    /// Get weak version
50    pub fn weak_version(&self) -> u64 {
51        self.weak
52    }
53
54    /// Check if ETags match strongly
55    pub fn strong_match(&self, other: &ETag) -> bool {
56        self.strong != [0; 16] && self.strong == other.strong
57    }
58
59    /// Check if ETags match weakly (version only)
60    pub fn weak_match(&self, other: &ETag) -> bool {
61        self.weak == other.weak
62    }
63
64    /// Serialize to string
65    pub fn to_string(&self) -> String {
66        let hash_hex: String = self.strong.iter().map(|b| format!("{:02x}", b)).collect();
67        format!("\"{}:{}\"", hash_hex, self.weak)
68    }
69
70    /// Parse from string
71    pub fn from_string(s: &str) -> Option<Self> {
72        let s = s.trim_matches('"');
73        let mut parts = s.split(':');
74
75        let hash_hex = parts.next()?;
76        let version = parts.next()?.parse().ok()?;
77
78        if hash_hex.len() != 32 {
79            return None;
80        }
81
82        let mut strong = [0u8; 16];
83        for (i, chunk) in hash_hex.as_bytes().chunks(2).enumerate() {
84            let hex = std::str::from_utf8(chunk).ok()?;
85            strong[i] = u8::from_str_radix(hex, 16).ok()?;
86        }
87
88        Some(Self {
89            strong,
90            weak: version,
91        })
92    }
93}
94
95/// ETag negotiator for cache validation
96#[derive(Debug, Default)]
97pub struct ETagNegotiator {
98    /// Local ETags by resource ID
99    local: std::collections::HashMap<String, ETag>,
100}
101
102impl ETagNegotiator {
103    /// Create a new negotiator
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    /// Set local ETag for a resource
109    pub fn set_local(&mut self, resource_id: &str, etag: ETag) {
110        self.local.insert(resource_id.to_string(), etag);
111    }
112
113    /// Get local ETag for a resource
114    pub fn get_local(&self, resource_id: &str) -> Option<&ETag> {
115        self.local.get(resource_id)
116    }
117
118    /// Negotiate with remote ETag
119    pub fn negotiate(&self, resource_id: &str, remote: &ETag) -> NegotiationResult {
120        match self.local.get(resource_id) {
121            None => NegotiationResult::Stale,
122            Some(local) => {
123                if local.strong_match(remote) {
124                    NegotiationResult::Fresh
125                } else if local.weak_match(remote) {
126                    // Same version but different content - likely patching possible
127                    NegotiationResult::Patch
128                } else {
129                    NegotiationResult::Stale
130                }
131            }
132        }
133    }
134
135    /// Update local ETag after sync
136    pub fn update(&mut self, resource_id: &str, new_content: &[u8], new_version: u64) {
137        let etag = ETag::from_content(new_content, new_version);
138        self.local.insert(resource_id.to_string(), etag);
139    }
140
141    /// Clear all ETags
142    pub fn clear(&mut self) {
143        self.local.clear();
144    }
145
146    /// Get If-None-Match header value
147    pub fn if_none_match(&self, resource_id: &str) -> Option<String> {
148        self.local.get(resource_id).map(|e| e.to_string())
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_etag_creation() {
158        let content = b"Hello, World!";
159        let etag = ETag::from_content(content, 1);
160
161        assert_ne!(etag.strong(), &[0; 16]);
162        assert_eq!(etag.weak_version(), 1);
163    }
164
165    #[test]
166    fn test_etag_roundtrip() {
167        let content = b"Test content";
168        let original = ETag::from_content(content, 42);
169        let serialized = original.to_string();
170        let parsed = ETag::from_string(&serialized).unwrap();
171
172        assert_eq!(original.strong(), parsed.strong());
173        assert_eq!(original.weak_version(), parsed.weak_version());
174    }
175
176    #[test]
177    fn test_negotiation() {
178        let mut negotiator = ETagNegotiator::new();
179
180        let content = b"Test content";
181        let etag = ETag::from_content(content, 1);
182        negotiator.set_local("resource1", etag.clone());
183
184        // Same content - Fresh
185        assert_eq!(
186            negotiator.negotiate("resource1", &etag),
187            NegotiationResult::Fresh
188        );
189
190        // Unknown resource - Stale
191        assert_eq!(
192            negotiator.negotiate("unknown", &etag),
193            NegotiationResult::Stale
194        );
195
196        // Different content - Stale
197        let other = ETag::from_content(b"Different", 2);
198        assert_eq!(
199            negotiator.negotiate("resource1", &other),
200            NegotiationResult::Stale
201        );
202    }
203}