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