1use std::collections::HashSet;
15use std::mem::size_of;
16
17use crate::bounds::{
18 checked_add, checked_capacity, checked_mul, checked_range, decoded_len, encode_input_len,
19 encode_u32_len, u32_to_usize,
20};
21use crate::error::CodecError;
22
23#[derive(Debug, Clone)]
25pub struct CrdtOp {
26 pub lamport: u64,
28 pub actor_id: u64,
30 pub content: Vec<u8>,
32}
33
34pub fn encode(ops: &[CrdtOp]) -> Result<Vec<u8>, CodecError> {
48 if ops.is_empty() {
49 return Ok(0u32.to_le_bytes().to_vec());
50 }
51
52 let count = encode_input_len(ops.len(), "CRDT operation count")?;
53
54 let mut actor_dict: Vec<u64> = Vec::new();
56 let mut actor_map: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
57 for op in ops {
58 actor_map.entry(op.actor_id).or_insert_with(|| {
59 let idx = actor_dict.len();
60 actor_dict.push(op.actor_id);
61 idx
62 });
63 }
64
65 if actor_dict.len() > u16::MAX as usize {
66 return Err(CodecError::ResourceLimit {
67 resource: "CRDT actor dictionary entries".into(),
68 requested: actor_dict.len(),
69 limit: u16::MAX as usize,
70 });
71 }
72
73 let lamports: Vec<i64> = ops.iter().map(|op| op.lamport as i64).collect();
75 let lamport_block = crate::delta::encode(&lamports)?;
76
77 let use_u8 = actor_dict.len() <= 256;
79 let actor_indices: Vec<u8> = if use_u8 {
80 ops.iter().map(|op| actor_map[&op.actor_id] as u8).collect()
81 } else {
82 ops.iter()
83 .flat_map(|op| (actor_map[&op.actor_id] as u16).to_le_bytes())
84 .collect()
85 };
86
87 let content_refs: Vec<&[u8]> = ops.iter().map(|op| op.content.as_slice()).collect();
89 let content_block = crate::fsst::encode(&content_refs)?;
90
91 let actor_bytes = checked_mul(actor_dict.len(), 8, "CRDT actor dictionary")?;
93 let capacity = checked_add(
94 checked_add(
95 checked_add(6, actor_bytes, "CRDT output")?,
96 checked_add(4, lamport_block.len(), "CRDT output")?,
97 "CRDT output",
98 )?,
99 checked_add(
100 checked_add(5, actor_indices.len(), "CRDT output")?,
101 checked_add(4, content_block.len(), "CRDT output")?,
102 "CRDT output",
103 )?,
104 "CRDT output",
105 )?;
106 decoded_len(capacity, "CRDT output")?;
107 let mut out = Vec::with_capacity(capacity);
108 out.extend_from_slice(&count.to_le_bytes());
109 out.extend_from_slice(
110 &u16::try_from(actor_dict.len())
111 .map_err(|_| CodecError::ResourceLimit {
112 resource: "CRDT actor dictionary entries".into(),
113 requested: actor_dict.len(),
114 limit: u16::MAX as usize,
115 })?
116 .to_le_bytes(),
117 );
118 for &actor in &actor_dict {
119 out.extend_from_slice(&actor.to_le_bytes());
120 }
121 out.extend_from_slice(
122 &encode_u32_len(lamport_block.len(), "CRDT Lamport block")?.to_le_bytes(),
123 );
124 out.extend_from_slice(&lamport_block);
125 out.push(if use_u8 { 1 } else { 2 }); out.extend_from_slice(
127 &encode_u32_len(actor_indices.len(), "CRDT actor-index block")?.to_le_bytes(),
128 );
129 out.extend_from_slice(&actor_indices);
130 out.extend_from_slice(
131 &encode_u32_len(content_block.len(), "CRDT content block")?.to_le_bytes(),
132 );
133 out.extend_from_slice(&content_block);
134
135 Ok(out)
136}
137
138pub fn decode(data: &[u8]) -> Result<Vec<CrdtOp>, CodecError> {
140 if data.len() < 4 {
141 return Err(CodecError::Truncated {
142 expected: 4,
143 actual: data.len(),
144 });
145 }
146
147 let count = u32_to_usize(
148 u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
149 "CRDT operation count",
150 )?;
151 let per_operation_overhead = checked_add(
152 size_of::<CrdtOp>(),
153 checked_add(
154 size_of::<i64>(),
155 size_of::<usize>(),
156 "CRDT temporary operation overhead",
157 )?,
158 "CRDT decoded operation overhead",
159 )?;
160 decoded_len(
161 checked_mul(count, per_operation_overhead, "CRDT decoded operations")?,
162 "CRDT",
163 )?;
164 if count == 0 {
165 if data.len() != 4 {
166 return Err(CodecError::Corrupt {
167 detail: "non-canonical empty CRDT frame".into(),
168 });
169 }
170 return Ok(Vec::new());
171 }
172
173 let mut pos = 4;
174 let actor_count = usize::from(u16::from_le_bytes(
175 checked_range(data, pos, 2, "CRDT actor count")?
176 .try_into()
177 .map_err(|_| CodecError::Corrupt {
178 detail: "invalid CRDT actor count".into(),
179 })?,
180 ));
181 if actor_count == 0 || actor_count > count {
182 return Err(CodecError::Corrupt {
183 detail: "CRDT actor count is invalid".into(),
184 });
185 }
186 pos = checked_add(pos, 2, "CRDT actor cursor")?;
187 let actor_bytes = checked_mul(actor_count, 8, "CRDT actor dictionary")?;
188 let actor_data = checked_range(data, pos, actor_bytes, "CRDT actor dictionary")?;
189 let actor_dict: Vec<u64> = actor_data
192 .as_chunks::<8>()
193 .0
194 .iter()
195 .map(|c| u64::from_le_bytes(*c))
196 .collect();
197 let unique_actors: HashSet<u64> = actor_dict.iter().copied().collect();
198 if unique_actors.len() != actor_dict.len() {
199 return Err(CodecError::Corrupt {
200 detail: "duplicate CRDT actor dictionary entry".into(),
201 });
202 }
203 pos = checked_add(pos, actor_bytes, "CRDT actor cursor")?;
204 let lamport_size = u32_to_usize(
205 u32::from_le_bytes(
206 checked_range(data, pos, 4, "CRDT Lamport size")?
207 .try_into()
208 .map_err(|_| CodecError::Corrupt {
209 detail: "invalid CRDT Lamport size".into(),
210 })?,
211 ),
212 "CRDT Lamport block",
213 )?;
214 pos = checked_add(pos, 4, "CRDT Lamport cursor")?;
215 let lamports = crate::delta::decode(checked_range(
216 data,
217 pos,
218 lamport_size,
219 "CRDT Lamport block",
220 )?)?;
221 if lamports.len() != count {
222 return Err(CodecError::Corrupt {
223 detail: "CRDT Lamport count mismatch".into(),
224 });
225 }
226 pos = checked_add(pos, lamport_size, "CRDT Lamport cursor")?;
227 let index_width = checked_range(data, pos, 1, "CRDT actor-index width")?[0];
228 let expected_width = if actor_count <= 256 { 1 } else { 2 };
229 if index_width != expected_width {
230 return Err(CodecError::Corrupt {
231 detail: "CRDT actor-index width is non-canonical".into(),
232 });
233 }
234 pos = checked_add(pos, 1, "CRDT actor-index cursor")?;
235 let index_size = u32_to_usize(
236 u32::from_le_bytes(
237 checked_range(data, pos, 4, "CRDT actor-index size")?
238 .try_into()
239 .map_err(|_| CodecError::Corrupt {
240 detail: "invalid CRDT actor-index size".into(),
241 })?,
242 ),
243 "CRDT actor-index block",
244 )?;
245 pos = checked_add(pos, 4, "CRDT actor-index cursor")?;
246 let expected_index_size = checked_mul(count, usize::from(index_width), "CRDT actor indices")?;
247 if index_size != expected_index_size {
248 return Err(CodecError::Corrupt {
249 detail: "CRDT actor-index count mismatch".into(),
250 });
251 }
252 let index_data = checked_range(data, pos, index_size, "CRDT actor-index block")?;
253 let actor_indices: Vec<usize> = if index_width == 1 {
254 index_data.iter().map(|&index| usize::from(index)).collect()
255 } else {
256 index_data
257 .as_chunks::<2>()
258 .0
259 .iter()
260 .map(|chunk| usize::from(u16::from_le_bytes(*chunk)))
261 .collect()
262 };
263 if actor_indices.iter().any(|&index| index >= actor_count) {
264 return Err(CodecError::Corrupt {
265 detail: "CRDT actor index out of range".into(),
266 });
267 }
268 let actor_capacity = checked_capacity(
269 actor_count,
270 size_of::<bool>(),
271 "CRDT actor first-seen allocation",
272 )?;
273 let mut first_seen = vec![false; actor_capacity];
274 let mut next_actor = 0usize;
275 for &index in &actor_indices {
276 if !first_seen[index] {
277 if index != next_actor {
278 return Err(CodecError::Corrupt {
279 detail: "CRDT actor dictionary is not in first-use order".into(),
280 });
281 }
282 first_seen[index] = true;
283 next_actor += 1;
284 }
285 }
286 if next_actor != actor_count {
287 return Err(CodecError::Corrupt {
288 detail: "CRDT actor dictionary contains unused entries".into(),
289 });
290 }
291 pos = checked_add(pos, index_size, "CRDT actor-index cursor")?;
292 let content_size = u32_to_usize(
293 u32::from_le_bytes(
294 checked_range(data, pos, 4, "CRDT content size")?
295 .try_into()
296 .map_err(|_| CodecError::Corrupt {
297 detail: "invalid CRDT content size".into(),
298 })?,
299 ),
300 "CRDT content block",
301 )?;
302 pos = checked_add(pos, 4, "CRDT content cursor")?;
303 let contents = crate::fsst::decode(checked_range(
304 data,
305 pos,
306 content_size,
307 "CRDT content block",
308 )?)?;
309 if contents.len() != count || checked_add(pos, content_size, "CRDT frame end")? != data.len() {
310 return Err(CodecError::Corrupt {
311 detail: "CRDT content count or frame length mismatch".into(),
312 });
313 }
314
315 let operation_capacity = checked_capacity(count, size_of::<CrdtOp>(), "CRDT operations")?;
317 let mut ops = Vec::with_capacity(operation_capacity);
318 for i in 0..count {
319 ops.push(CrdtOp {
320 lamport: lamports[i] as u64,
321 actor_id: actor_dict[actor_indices[i]],
322 content: contents[i].clone(),
323 });
324 }
325
326 Ok(ops)
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn empty_roundtrip() {
335 let encoded = encode(&[]).unwrap();
336 let decoded = decode(&encoded).unwrap();
337 assert!(decoded.is_empty());
338 }
339
340 #[test]
341 fn basic_roundtrip() {
342 let ops = vec![
343 CrdtOp {
344 lamport: 1,
345 actor_id: 100,
346 content: b"insert 'hello'".to_vec(),
347 },
348 CrdtOp {
349 lamport: 2,
350 actor_id: 100,
351 content: b"insert ' world'".to_vec(),
352 },
353 CrdtOp {
354 lamport: 3,
355 actor_id: 200,
356 content: b"delete [0..5]".to_vec(),
357 },
358 ];
359 let encoded = encode(&ops).unwrap();
360 let decoded = decode(&encoded).unwrap();
361
362 assert_eq!(decoded.len(), 3);
363 assert_eq!(decoded[0].lamport, 1);
364 assert_eq!(decoded[0].actor_id, 100);
365 assert_eq!(decoded[0].content, b"insert 'hello'");
366 assert_eq!(decoded[2].actor_id, 200);
367 }
368
369 #[test]
370 fn compression_with_many_ops() {
371 let mut ops = Vec::new();
372 for i in 0..1000 {
373 ops.push(CrdtOp {
374 lamport: i,
375 actor_id: i % 5, content: format!("op-{i}: set key_{} = value_{}", i % 50, i).into_bytes(),
377 });
378 }
379 let encoded = encode(&ops).unwrap();
380 let decoded = decode(&encoded).unwrap();
381
382 assert_eq!(decoded.len(), 1000);
383 for (orig, dec) in ops.iter().zip(decoded.iter()) {
384 assert_eq!(orig.lamport, dec.lamport);
385 assert_eq!(orig.actor_id, dec.actor_id);
386 assert_eq!(orig.content, dec.content);
387 }
388
389 let raw_size: usize = ops.iter().map(|op| 16 + op.content.len()).sum();
391 let ratio = raw_size as f64 / encoded.len() as f64;
392 assert!(
393 ratio > 1.2,
394 "CRDT ops should compress >1.2x, got {ratio:.2}x"
395 );
396 }
397
398 #[test]
399 fn rejects_count_mismatch_invalid_indices_and_trailing_bytes() {
400 let ops = vec![CrdtOp {
401 lamport: 1,
402 actor_id: 7,
403 content: b"x".to_vec(),
404 }];
405 let mut trailing = encode(&ops).unwrap();
406 trailing.push(0);
407 assert!(matches!(decode(&trailing), Err(CodecError::Corrupt { .. })));
408
409 let mut malformed = encode(&ops).unwrap();
410 let actor_count = usize::from(u16::from_le_bytes([malformed[4], malformed[5]]));
411 let mut cursor = 6 + actor_count * 8;
412 let lamport_size =
413 u32::from_le_bytes(malformed[cursor..cursor + 4].try_into().unwrap()) as usize;
414 cursor += 4 + lamport_size + 1 + 4;
415 malformed[cursor] = 1;
416 assert!(matches!(
417 decode(&malformed),
418 Err(CodecError::Corrupt { .. })
419 ));
420 }
421
422 #[test]
423 fn wide_actor_indices_roundtrip() {
424 let ops: Vec<CrdtOp> = (0..300)
425 .map(|i| CrdtOp {
426 lamport: i,
427 actor_id: i,
428 content: vec![b'x'],
429 })
430 .collect();
431 let encoded = encode(&ops).unwrap();
432 let decoded = decode(&encoded).unwrap();
433 assert_eq!(decoded.len(), ops.len());
434 for (expected, actual) in ops.iter().zip(&decoded) {
435 assert_eq!(actual.actor_id, expected.actor_id);
436 }
437 }
438
439 #[test]
440 fn actor_dictionary_dedup() {
441 let ops: Vec<CrdtOp> = (0..100)
442 .map(|i| CrdtOp {
443 lamport: i,
444 actor_id: 42, content: b"x".to_vec(),
446 })
447 .collect();
448 let encoded = encode(&ops).unwrap();
449 let decoded = decode(&encoded).unwrap();
450
451 for op in &decoded {
452 assert_eq!(op.actor_id, 42);
453 }
454 }
455}