driven/streaming/
etag_negotiator.rs1use crate::binary::checksum::compute_blake3;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum NegotiationResult {
10 Fresh,
12 Stale,
14 Patch,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub struct ETag {
21 strong: [u8; 16],
23 weak: u64,
25}
26
27impl ETag {
28 pub fn from_content(content: &[u8], version: u64) -> Self {
30 Self {
31 strong: compute_blake3(content),
32 weak: version,
33 }
34 }
35
36 pub fn weak(version: u64) -> Self {
38 Self {
39 strong: [0; 16],
40 weak: version,
41 }
42 }
43
44 pub fn strong(&self) -> &[u8; 16] {
46 &self.strong
47 }
48
49 pub fn weak_version(&self) -> u64 {
51 self.weak
52 }
53
54 pub fn strong_match(&self, other: &ETag) -> bool {
56 self.strong != [0; 16] && self.strong == other.strong
57 }
58
59 pub fn weak_match(&self, other: &ETag) -> bool {
61 self.weak == other.weak
62 }
63
64 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 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#[derive(Debug, Default)]
97pub struct ETagNegotiator {
98 local: std::collections::HashMap<String, ETag>,
100}
101
102impl ETagNegotiator {
103 pub fn new() -> Self {
105 Self::default()
106 }
107
108 pub fn set_local(&mut self, resource_id: &str, etag: ETag) {
110 self.local.insert(resource_id.to_string(), etag);
111 }
112
113 pub fn get_local(&self, resource_id: &str) -> Option<&ETag> {
115 self.local.get(resource_id)
116 }
117
118 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 NegotiationResult::Patch
128 } else {
129 NegotiationResult::Stale
130 }
131 }
132 }
133 }
134
135 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 pub fn clear(&mut self) {
143 self.local.clear();
144 }
145
146 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 assert_eq!(
186 negotiator.negotiate("resource1", &etag),
187 NegotiationResult::Fresh
188 );
189
190 assert_eq!(
192 negotiator.negotiate("unknown", &etag),
193 NegotiationResult::Stale
194 );
195
196 let other = ETag::from_content(b"Different", 2);
198 assert_eq!(
199 negotiator.negotiate("resource1", &other),
200 NegotiationResult::Stale
201 );
202 }
203}