1mod v2;
4
5use serde::Deserialize;
6
7use super::{CollabOpId, CollaborationOperationEnvelope};
8
9#[derive(Debug, thiserror::Error)]
10pub enum CollaborationCodecError {
11 #[error("collaboration operation encoding failed: {0}")]
12 Encoding(String),
13 #[error("collaboration operation decoding failed: {0}")]
14 Decoding(String),
15 #[error("unsupported collaboration operation version {0}")]
16 UnsupportedVersion(u16),
17 #[error("invalid collaboration operation: {0}")]
18 Invalid(String),
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct DecodedCollaborationOperation {
23 pub operation_id: CollabOpId,
24 pub operation: CollaborationOperationEnvelope,
25}
26
27#[derive(Deserialize)]
28struct VersionProbe {
29 schema_version: u16,
30}
31
32pub(crate) fn encode(
33 operation: &CollaborationOperationEnvelope,
34) -> Result<Vec<u8>, CollaborationCodecError> {
35 operation.validate()?;
36 v2::encode(operation)
37}
38
39pub(crate) fn decode(
40 bytes: &[u8],
41) -> Result<DecodedCollaborationOperation, CollaborationCodecError> {
42 let probe: VersionProbe = rmp_serde::from_slice(bytes)
43 .map_err(|error| CollaborationCodecError::Decoding(error.to_string()))?;
44 if probe.schema_version != super::COLLABORATION_OPERATION_SCHEMA_VERSION {
45 return Err(CollaborationCodecError::UnsupportedVersion(
46 probe.schema_version,
47 ));
48 }
49 let operation = v2::decode(bytes)?;
50 operation.validate()?;
51 operation.canonical_body.store(bytes.to_vec());
54 Ok(DecodedCollaborationOperation {
55 operation_id: CollabOpId::for_bytes(bytes),
56 operation,
57 })
58}
59
60#[cfg(test)]
61mod tests {
62 use serde::Serialize;
63
64 use super::*;
65 use crate::object::{
66 AnnotationKind, Attribution, ChangeId, CollaborationAnchor, CollaborationAnchorStatus,
67 CollaborationIdempotencyKey, CollaborationOperationBodyV1, CollaborationResolution,
68 ContentHash, DiscussionRecordId, DiscussionTurnV1, LegacyDiscussionId,
69 LegacyDiscussionResolutionV1, LegacySourceLocator, Principal, StateAttachmentId, StateId,
70 VisibilityTier,
71 };
72
73 #[derive(Serialize)]
74 struct Unsupported<'a> {
75 schema_version: u16,
76 body: &'a [u8],
77 }
78
79 #[test]
80 fn unsupported_version_is_rejected_before_body_decode() {
81 let bytes = rmp_serde::to_vec_named(&Unsupported {
82 schema_version: 3,
83 body: &[0xc1],
84 })
85 .unwrap();
86 assert!(matches!(
87 decode(&bytes),
88 Err(CollaborationCodecError::UnsupportedVersion(3))
89 ));
90 }
91
92 fn golden_operation(name: &str, body: CollaborationOperationBodyV1) -> (String, Vec<u8>) {
93 let root = matches!(
94 body,
95 CollaborationOperationBodyV1::Open { .. }
96 | CollaborationOperationBodyV1::LegacyImported { .. }
97 );
98 let operation = CollaborationOperationEnvelope::new(
99 "disc-018f47ea-4a54-7c89-b012-3456789abcde"
100 .parse::<DiscussionRecordId>()
101 .unwrap(),
102 if root {
103 Vec::new()
104 } else if matches!(body, CollaborationOperationBodyV1::ResolveConflict { .. }) {
105 vec![
106 CollabOpId::from_bytes([7; 32]),
107 CollabOpId::from_bytes([8; 32]),
108 ]
109 } else {
110 vec![CollabOpId::from_bytes([7; 32])]
111 },
112 CollaborationIdempotencyKey::new("k").unwrap(),
113 Attribution::human(Principal::new("A", "a@b")),
114 0,
115 body,
116 )
117 .unwrap();
118 (name.to_string(), operation.encode().unwrap())
119 }
120
121 fn golden_vectors() -> Vec<(String, Vec<u8>)> {
122 let state = StateId::from_bytes([1; 32]);
123 let change = ChangeId::from_bytes([2; 16]);
124 let turn = || DiscussionTurnV1::new("x").unwrap();
125 let open = |anchor| CollaborationOperationBodyV1::Open {
126 blocking: false,
127 title: "t".to_string(),
128 anchor,
129 visibility: VisibilityTier::default(),
130 turn: turn(),
131 thread_ref: None,
132 };
133 let locator = LegacySourceLocator::new(
134 state,
135 StateAttachmentId::from_hash(ContentHash::from_bytes([3; 32])),
136 ContentHash::from_bytes([4; 32]),
137 );
138 let legacy = |resolution| CollaborationOperationBodyV1::LegacyImported {
139 source: locator.clone(),
140 legacy_discussion_id: LegacyDiscussionId::new("l").unwrap(),
141 aliases: vec![LegacySourceLocator::new(
142 StateId::from_bytes([5; 32]),
143 StateAttachmentId::from_hash(ContentHash::from_bytes([6; 32])),
144 ContentHash::from_bytes([7; 32]),
145 )],
146 title: "t".to_string(),
147 anchor: CollaborationAnchor::Symbol {
148 state_id: state,
149 path: "p".to_string(),
150 symbol: "s".to_string(),
151 },
152 visibility: VisibilityTier::default(),
153 turns: vec![turn()],
154 resolution,
155 };
156 vec![
157 golden_operation("open_repository", open(CollaborationAnchor::Repository)),
158 golden_operation(
159 "open_state",
160 open(CollaborationAnchor::State { state_id: state }),
161 ),
162 golden_operation(
163 "open_change",
164 open(CollaborationAnchor::Change { change_id: change }),
165 ),
166 golden_operation(
167 "open_path",
168 open(CollaborationAnchor::Path {
169 state_id: state,
170 path: "p".to_string(),
171 }),
172 ),
173 golden_operation(
174 "open_symbol",
175 open(CollaborationAnchor::Symbol {
176 state_id: state,
177 path: "p".to_string(),
178 symbol: "s".to_string(),
179 }),
180 ),
181 golden_operation(
182 "append_turn",
183 CollaborationOperationBodyV1::AppendTurn { turn: turn() },
184 ),
185 golden_operation(
186 "rebind_anchor",
187 CollaborationOperationBodyV1::RebindAnchor {
188 anchor: CollaborationAnchor::Symbol {
189 state_id: state,
190 path: "p".to_string(),
191 symbol: "s2".to_string(),
192 },
193 status: CollaborationAnchorStatus::Moved,
194 body_changed_since_open: true,
195 },
196 ),
197 golden_operation(
198 "resolve_state",
199 CollaborationOperationBodyV1::Resolve {
200 resolution: CollaborationResolution::AddressedByState { state_id: state },
201 },
202 ),
203 golden_operation(
204 "resolve_change",
205 CollaborationOperationBodyV1::Resolve {
206 resolution: CollaborationResolution::AddressedByChange { change_id: change },
207 },
208 ),
209 golden_operation(
210 "resolve_dismissed",
211 CollaborationOperationBodyV1::Resolve {
212 resolution: CollaborationResolution::Dismissed {
213 reason: "r".to_string(),
214 },
215 },
216 ),
217 golden_operation(
218 "resolve_annotation",
219 CollaborationOperationBodyV1::Resolve {
220 resolution: CollaborationResolution::Annotation {
221 annotation_id: "a".to_string(),
222 },
223 },
224 ),
225 golden_operation(
226 "resolve_into_annotation",
227 CollaborationOperationBodyV1::Resolve {
228 resolution: CollaborationResolution::IntoAnnotation {
229 annotation_kind: AnnotationKind::Rationale,
230 content: "why".to_string(),
231 tags: vec!["design".to_string()],
232 },
233 },
234 ),
235 golden_operation(
236 "reopen",
237 CollaborationOperationBodyV1::Reopen {
238 reason: "r".to_string(),
239 },
240 ),
241 golden_operation(
242 "resolve_conflict",
243 CollaborationOperationBodyV1::ResolveConflict {
244 competing: vec![
245 CollabOpId::from_bytes([7; 32]),
246 CollabOpId::from_bytes([8; 32]),
247 ],
248 selected: CollabOpId::from_bytes([7; 32]),
249 },
250 ),
251 golden_operation("legacy_open", legacy(LegacyDiscussionResolutionV1::Open)),
252 golden_operation(
253 "legacy_state",
254 legacy(LegacyDiscussionResolutionV1::AddressedByState { state_id: state }),
255 ),
256 golden_operation(
257 "legacy_dismissed",
258 legacy(LegacyDiscussionResolutionV1::Dismissed {
259 reason: "r".to_string(),
260 }),
261 ),
262 golden_operation(
263 "legacy_annotation",
264 legacy(LegacyDiscussionResolutionV1::Annotation {
265 annotation_id: "a".to_string(),
266 }),
267 ),
268 ]
269 }
270
271 #[test]
272 fn v2_full_variant_msgpack_vectors_are_frozen() {
273 let expected = [
274 (
275 "open_repository",
276 "69b3baebacb9d29c3d6c5cac73d6f14b2fe5231e061065249e808e2d92cddefe",
277 ),
278 (
279 "open_state",
280 "785cf6234fdfc4a01ff068a9b67a2c0d60172f59795c39c3c5b4e1037176a964",
281 ),
282 (
283 "open_change",
284 "e520779bc52b05e753a139bddafd25dcc8765182b865cd305691f85129b58c03",
285 ),
286 (
287 "open_path",
288 "1c814db69893d0ee71abe9d5a1d7d17724ff893fdf36bf90e9c40aa4beae9803",
289 ),
290 (
291 "open_symbol",
292 "9f43eb25d8c920d89a31c148fcd177e789e909eb7d3715fc2f899f85fa680197",
293 ),
294 (
295 "append_turn",
296 "777d8164d530fe27545685f23b283765a3b912f96a262dbad836faba498a9279",
297 ),
298 (
299 "rebind_anchor",
300 "e548683d8c7f20c8550627886f196a81b91e6ae52b8cdaadbfdd8944ceda1f8d",
301 ),
302 (
303 "resolve_state",
304 "7063d23abba098f608b13f2b807892bf7a5b61cb7b8487897ba84e95ff519dc9",
305 ),
306 (
307 "resolve_change",
308 "bcaa6bd96243624dcfe25942e92f59104d08291b2daf5dfd4c8d68f6fbd98472",
309 ),
310 (
311 "resolve_dismissed",
312 "4277710d09ae7043616335695b98fc89ce15583fd90437b28587c310492fe8f0",
313 ),
314 (
315 "resolve_annotation",
316 "c4eee97f235a3b534e423f20159ddba3077f9e0079ebc0697fa6c2426b394827",
317 ),
318 (
319 "resolve_into_annotation",
320 "e1470222f139ff1e6e06480996a7c27c2ef9d162eed2405ad3d838304d94d73e",
321 ),
322 (
323 "reopen",
324 "fa59e9c396a77ce07e57cb7b312f76438c3a002a1a4f053d1da13d9d10e03f7d",
325 ),
326 (
327 "resolve_conflict",
328 "d4c76d8ff078907d528613f7811151797d204f01216876623aecd675e9466606",
329 ),
330 (
331 "legacy_open",
332 "56e355d619b3db940a4cce75d525dac493d7e925c3e37cf85e1a789c6454713b",
333 ),
334 (
335 "legacy_state",
336 "44281502243244c40fc8be8b1fa452e3fd4491a455271462cb1236bac5b8136d",
337 ),
338 (
339 "legacy_dismissed",
340 "c9dd8f6850af3fce3797bb2ce853414dee066d29821756cbb8e2c6627ed56688",
341 ),
342 (
343 "legacy_annotation",
344 "8148359eab1c913a5a1b98debc9aa9f2a455cbbbc7b376b43b80b59c0b709331",
345 ),
346 ];
347 let actual = golden_vectors()
348 .into_iter()
349 .map(|(name, bytes)| {
350 let decoded = CollaborationOperationEnvelope::decode(&bytes).unwrap();
351 assert_eq!(decoded.operation_id, CollabOpId::for_bytes(&bytes));
352 assert_eq!(
353 decoded.operation.id().expect("id reuses decoded bytes"),
354 decoded.operation_id
355 );
356 assert_eq!(
357 decoded.operation.id().expect("cached id"),
358 CollabOpId::for_bytes(&decoded.operation.encode().expect("re-encode"))
359 );
360 (name, ContentHash::compute(&bytes).to_hex())
361 })
362 .collect::<Vec<_>>();
363 assert_eq!(actual.len(), expected.len());
364 for ((actual_name, actual_hash), (expected_name, expected_hash)) in
365 actual.iter().zip(expected)
366 {
367 assert_eq!(actual_name, expected_name);
368 assert_eq!(actual_hash, expected_hash);
369 }
370 }
371}