1mod v1;
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 v1::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 != 1 {
45 return Err(CollaborationCodecError::UnsupportedVersion(
46 probe.schema_version,
47 ));
48 }
49 let operation = v1::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: 2,
80 body: &[0xc1],
81 })
82 .unwrap();
83 assert!(matches!(
84 decode(&bytes),
85 Err(CollaborationCodecError::UnsupportedVersion(2))
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 title: "t".to_string(),
124 anchor,
125 visibility: VisibilityTier::default(),
126 turn: turn(),
127 thread_ref: None,
128 };
129 let locator = LegacySourceLocator::new(
130 state,
131 StateAttachmentId::from_hash(ContentHash::from_bytes([3; 32])),
132 ContentHash::from_bytes([4; 32]),
133 );
134 let legacy = |resolution| CollaborationOperationBodyV1::LegacyImported {
135 source: locator.clone(),
136 legacy_discussion_id: LegacyDiscussionId::new("l").unwrap(),
137 aliases: vec![LegacySourceLocator::new(
138 StateId::from_bytes([5; 32]),
139 StateAttachmentId::from_hash(ContentHash::from_bytes([6; 32])),
140 ContentHash::from_bytes([7; 32]),
141 )],
142 title: "t".to_string(),
143 anchor: CollaborationAnchor::Symbol {
144 state_id: state,
145 path: "p".to_string(),
146 symbol: "s".to_string(),
147 },
148 visibility: VisibilityTier::default(),
149 turns: vec![turn()],
150 resolution,
151 };
152 vec![
153 golden_operation("open_repository", open(CollaborationAnchor::Repository)),
154 golden_operation(
155 "open_state",
156 open(CollaborationAnchor::State { state_id: state }),
157 ),
158 golden_operation(
159 "open_change",
160 open(CollaborationAnchor::Change { change_id: change }),
161 ),
162 golden_operation(
163 "open_path",
164 open(CollaborationAnchor::Path {
165 state_id: state,
166 path: "p".to_string(),
167 }),
168 ),
169 golden_operation(
170 "open_symbol",
171 open(CollaborationAnchor::Symbol {
172 state_id: state,
173 path: "p".to_string(),
174 symbol: "s".to_string(),
175 }),
176 ),
177 golden_operation(
178 "append_turn",
179 CollaborationOperationBodyV1::AppendTurn { turn: turn() },
180 ),
181 golden_operation(
182 "rebind_anchor",
183 CollaborationOperationBodyV1::RebindAnchor {
184 anchor: CollaborationAnchor::Symbol {
185 state_id: state,
186 path: "p".to_string(),
187 symbol: "s2".to_string(),
188 },
189 status: CollaborationAnchorStatus::Moved,
190 body_changed_since_open: true,
191 },
192 ),
193 golden_operation(
194 "resolve_state",
195 CollaborationOperationBodyV1::Resolve {
196 resolution: CollaborationResolution::AddressedByState { state_id: state },
197 },
198 ),
199 golden_operation(
200 "resolve_change",
201 CollaborationOperationBodyV1::Resolve {
202 resolution: CollaborationResolution::AddressedByChange { change_id: change },
203 },
204 ),
205 golden_operation(
206 "resolve_dismissed",
207 CollaborationOperationBodyV1::Resolve {
208 resolution: CollaborationResolution::Dismissed {
209 reason: "r".to_string(),
210 },
211 },
212 ),
213 golden_operation(
214 "resolve_annotation",
215 CollaborationOperationBodyV1::Resolve {
216 resolution: CollaborationResolution::Annotation {
217 annotation_id: "a".to_string(),
218 },
219 },
220 ),
221 golden_operation(
222 "resolve_into_annotation",
223 CollaborationOperationBodyV1::Resolve {
224 resolution: CollaborationResolution::IntoAnnotation {
225 annotation_kind: AnnotationKind::Rationale,
226 content: "why".to_string(),
227 tags: vec!["design".to_string()],
228 },
229 },
230 ),
231 golden_operation(
232 "reopen",
233 CollaborationOperationBodyV1::Reopen {
234 reason: "r".to_string(),
235 },
236 ),
237 golden_operation(
238 "resolve_conflict",
239 CollaborationOperationBodyV1::ResolveConflict {
240 competing: vec![
241 CollabOpId::from_bytes([7; 32]),
242 CollabOpId::from_bytes([8; 32]),
243 ],
244 selected: CollabOpId::from_bytes([7; 32]),
245 },
246 ),
247 golden_operation("legacy_open", legacy(LegacyDiscussionResolutionV1::Open)),
248 golden_operation(
249 "legacy_state",
250 legacy(LegacyDiscussionResolutionV1::AddressedByState { state_id: state }),
251 ),
252 golden_operation(
253 "legacy_dismissed",
254 legacy(LegacyDiscussionResolutionV1::Dismissed {
255 reason: "r".to_string(),
256 }),
257 ),
258 golden_operation(
259 "legacy_annotation",
260 legacy(LegacyDiscussionResolutionV1::Annotation {
261 annotation_id: "a".to_string(),
262 }),
263 ),
264 ]
265 }
266
267 #[test]
268 fn v1_full_variant_msgpack_vectors_are_frozen() {
269 let expected = [
270 (
271 "open_repository",
272 "6c4929bfebf65a906406b48957440c591eb8c4f0f7306aea37aca01016c7c256",
273 ),
274 (
275 "open_state",
276 "3733767e55beab34c5add4fa6ad514846320151cab84b8c077ee31456db14e94",
277 ),
278 (
279 "open_change",
280 "0504854868da192b823626573c041b53394217db9a1adb3eb290e913e2471c29",
281 ),
282 (
283 "open_path",
284 "39e55ce36dbc40d6bcad094825366eb962fe5e6170bf7a8a53b0a972679cc4ae",
285 ),
286 (
287 "open_symbol",
288 "a3ec2abb9288b42ab57cb2b1095ebb4b87fcf51bce6e7d57ec60838908fb81e4",
289 ),
290 (
291 "append_turn",
292 "b542d7f781fed9266dd557a8a422af1f3fce98b4fd834c0e88cc867787e32d1f",
293 ),
294 (
295 "rebind_anchor",
296 "105cff08ce66523d98a47a8f384aa4f2a3e91eada886e11c8c7a45a6d4a7aca6",
297 ),
298 (
299 "resolve_state",
300 "7646fc4ed8e8975805491f7760c652514191bd07b7b15eabdcf6b5c8f068439f",
301 ),
302 (
303 "resolve_change",
304 "e5021f4da168fef2bb26297c6fe554cc545a3a3b7dd5a056a23d0de2bdda38b0",
305 ),
306 (
307 "resolve_dismissed",
308 "e81909c0875c57ac3920109d2291aec767595952f883b4ca4390e0af61bce9f3",
309 ),
310 (
311 "resolve_annotation",
312 "9ca40f41cc72bbf6208a22f9dcbfeaa3773d1669366864b704e2251fd012bb39",
313 ),
314 (
315 "resolve_into_annotation",
316 "b3319a3a18c77c52dbf748dba14fdd06542555b7cd89b228b2938ef88888beb7",
317 ),
318 (
319 "reopen",
320 "2b997fdd5a1011255a4b85aa4f3cca3f2d0f97b2ce35d7cdcbb76a5349eeffd0",
321 ),
322 (
323 "resolve_conflict",
324 "01e63b8dbb33f11e1fa8b630e045f73030ff99db66a628fe13e84d5f9e9007b2",
325 ),
326 (
327 "legacy_open",
328 "ada646a3ab1feb54cb0b70a682079a8af0a603930f73ae0716cb861107fc4af3",
329 ),
330 (
331 "legacy_state",
332 "83a69502c2aa2d32606c2d1d5b568ddc4c555933960921e4a4f8b2e375a70e66",
333 ),
334 (
335 "legacy_dismissed",
336 "69cb8998b89d44d77e9851d2eaa619dd550ecf78e6212b07b18c3c1fd5d47989",
337 ),
338 (
339 "legacy_annotation",
340 "368a60c692b5e50bfb542f5654eccc3c62a4f65442e7404c48734386da141d32",
341 ),
342 ];
343 let actual = golden_vectors()
344 .into_iter()
345 .map(|(name, bytes)| {
346 let decoded = CollaborationOperationEnvelope::decode(&bytes).unwrap();
347 assert_eq!(decoded.operation_id, CollabOpId::for_bytes(&bytes));
348 (name, ContentHash::compute(&bytes).to_hex())
349 })
350 .collect::<Vec<_>>();
351 assert_eq!(actual.len(), expected.len());
352 for ((actual_name, actual_hash), (expected_name, expected_hash)) in
353 actual.iter().zip(expected)
354 {
355 assert_eq!(actual_name, expected_name);
356 assert_eq!(actual_hash, expected_hash);
357 }
358 }
359}