Skip to main content

objects/object/collaboration/codec/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2
3mod 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}