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 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 };
127 let locator = LegacySourceLocator::new(
128 state,
129 StateAttachmentId::from_hash(ContentHash::from_bytes([3; 32])),
130 ContentHash::from_bytes([4; 32]),
131 );
132 let legacy = |resolution| CollaborationOperationBodyV1::LegacyImported {
133 source: locator.clone(),
134 legacy_discussion_id: LegacyDiscussionId::new("l").unwrap(),
135 aliases: vec![LegacySourceLocator::new(
136 StateId::from_bytes([5; 32]),
137 StateAttachmentId::from_hash(ContentHash::from_bytes([6; 32])),
138 ContentHash::from_bytes([7; 32]),
139 )],
140 title: "t".to_string(),
141 anchor: CollaborationAnchor::Symbol {
142 state_id: state,
143 path: "p".to_string(),
144 symbol: "s".to_string(),
145 },
146 visibility: VisibilityTier::default(),
147 turns: vec![turn()],
148 resolution,
149 };
150 vec![
151 golden_operation("open_repository", open(CollaborationAnchor::Repository)),
152 golden_operation(
153 "open_state",
154 open(CollaborationAnchor::State { state_id: state }),
155 ),
156 golden_operation(
157 "open_change",
158 open(CollaborationAnchor::Change { change_id: change }),
159 ),
160 golden_operation(
161 "open_path",
162 open(CollaborationAnchor::Path {
163 state_id: state,
164 path: "p".to_string(),
165 }),
166 ),
167 golden_operation(
168 "open_symbol",
169 open(CollaborationAnchor::Symbol {
170 state_id: state,
171 path: "p".to_string(),
172 symbol: "s".to_string(),
173 }),
174 ),
175 golden_operation(
176 "append_turn",
177 CollaborationOperationBodyV1::AppendTurn { turn: turn() },
178 ),
179 golden_operation(
180 "resolve_state",
181 CollaborationOperationBodyV1::Resolve {
182 resolution: CollaborationResolution::AddressedByState { state_id: state },
183 },
184 ),
185 golden_operation(
186 "resolve_change",
187 CollaborationOperationBodyV1::Resolve {
188 resolution: CollaborationResolution::AddressedByChange { change_id: change },
189 },
190 ),
191 golden_operation(
192 "resolve_dismissed",
193 CollaborationOperationBodyV1::Resolve {
194 resolution: CollaborationResolution::Dismissed {
195 reason: "r".to_string(),
196 },
197 },
198 ),
199 golden_operation(
200 "resolve_annotation",
201 CollaborationOperationBodyV1::Resolve {
202 resolution: CollaborationResolution::Annotation {
203 annotation_id: "a".to_string(),
204 },
205 },
206 ),
207 golden_operation(
208 "reopen",
209 CollaborationOperationBodyV1::Reopen {
210 reason: "r".to_string(),
211 },
212 ),
213 golden_operation(
214 "resolve_conflict",
215 CollaborationOperationBodyV1::ResolveConflict {
216 competing: vec![
217 CollabOpId::from_bytes([7; 32]),
218 CollabOpId::from_bytes([8; 32]),
219 ],
220 selected: CollabOpId::from_bytes([7; 32]),
221 },
222 ),
223 golden_operation("legacy_open", legacy(LegacyDiscussionResolutionV1::Open)),
224 golden_operation(
225 "legacy_state",
226 legacy(LegacyDiscussionResolutionV1::AddressedByState { state_id: state }),
227 ),
228 golden_operation(
229 "legacy_dismissed",
230 legacy(LegacyDiscussionResolutionV1::Dismissed {
231 reason: "r".to_string(),
232 }),
233 ),
234 golden_operation(
235 "legacy_annotation",
236 legacy(LegacyDiscussionResolutionV1::Annotation {
237 annotation_id: "a".to_string(),
238 }),
239 ),
240 ]
241 }
242
243 #[test]
244 fn v1_full_variant_msgpack_vectors_are_frozen() {
245 let expected = [
246 (
247 "open_repository",
248 "e7c3f3e61da7272d9dd32bde3c25c3db0d0aa7259ec17eb3aa0af78d4d7954d4",
249 ),
250 (
251 "open_state",
252 "478b51c28064b6c8085bfd7e81d70cb2824fd0aca889bd0ee7d84b59bf7a9caa",
253 ),
254 (
255 "open_change",
256 "18129f50b4ef581b050b17c6e7e1e1328b1e698249884f7ad02c669ced662672",
257 ),
258 (
259 "open_path",
260 "e3f332e030f3c472d0777ea308a22bcbbeb57b0c620d5395448046674248638c",
261 ),
262 (
263 "open_symbol",
264 "f68b0bd1ffd107ca9ace80ac8520c988fa92d68c681a6d8748e9231af98b9e0c",
265 ),
266 (
267 "append_turn",
268 "1a1fce360b5ab50ab74c7b58790820f46a7f153b79cf6b00b57b31d57797afb5",
269 ),
270 (
271 "resolve_state",
272 "689f69143df9e0513b41b5d6a53c47ec610b1e75ceac41a7a6553690616a0ba2",
273 ),
274 (
275 "resolve_change",
276 "a1158654efb9d351b9ff56d74c59e5b4c1c9705afc9c704621224ba196892701",
277 ),
278 (
279 "resolve_dismissed",
280 "da1a17112cf975035e2ec8ac124648926937600039bba6f117b85ead73f6f510",
281 ),
282 (
283 "resolve_annotation",
284 "5bfbaec8982a10865950109c4357d80099fde41afff07d75df7a31b595636f40",
285 ),
286 (
287 "reopen",
288 "79386d06f7b7ef6f7ae47f9c4461674dc435121f912e1e0fca6f664c1c951f25",
289 ),
290 (
291 "resolve_conflict",
292 "c3e3a161fa47a83e06873135cc8067478a8e4e2a00d6c39fd2b1b35659ebed73",
293 ),
294 (
295 "legacy_open",
296 "4428a385ca518c899d0dc3b34241d06b4dc6a1d327bb50c586c3b74890c90904",
297 ),
298 (
299 "legacy_state",
300 "3053742d3d3cdf30ae2586f5bc615bcf26b44539693141f04974d0babd951745",
301 ),
302 (
303 "legacy_dismissed",
304 "6280ef6b44c8eb27a57bb0ea9d72b220c6223505d56fb2161999dde3be964ed6",
305 ),
306 (
307 "legacy_annotation",
308 "2e184a2ab41d4a7762808f7ec4e917ec474e667c048e9a1bfdaea9677e9b18f0",
309 ),
310 ];
311 let actual = golden_vectors()
312 .into_iter()
313 .map(|(name, bytes)| {
314 let decoded = CollaborationOperationEnvelope::decode(&bytes).unwrap();
315 assert_eq!(decoded.operation_id, CollabOpId::for_bytes(&bytes));
316 (name, ContentHash::compute(&bytes).to_hex())
317 })
318 .collect::<Vec<_>>();
319 assert_eq!(actual.len(), expected.len());
320 for ((actual_name, actual_hash), (expected_name, expected_hash)) in
321 actual.iter().zip(expected)
322 {
323 assert_eq!(actual_name, expected_name);
324 assert_eq!(actual_hash, expected_hash);
325 }
326 }
327}