Skip to main content

heddle_object_model/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        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}