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