Skip to main content

heddle_object_model/object/collaboration/codec/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2
3mod v2;
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    v2::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 != super::COLLABORATION_OPERATION_SCHEMA_VERSION {
45        return Err(CollaborationCodecError::UnsupportedVersion(
46            probe.schema_version,
47        ));
48    }
49    let operation = v2::decode(bytes)?;
50    operation.validate()?;
51    // `operation_id` is the hash of these exact bytes. Admitted records have
52    // already proven them canonical, so `id` reuses the body instead of encoding.
53    operation.canonical_body.store(bytes.to_vec());
54    Ok(DecodedCollaborationOperation {
55        operation_id: CollabOpId::for_bytes(bytes),
56        operation,
57    })
58}
59
60#[cfg(test)]
61mod tests {
62    use serde::Serialize;
63
64    use super::*;
65    use crate::object::{
66        AnnotationKind, Attribution, ChangeId, CollaborationAnchor, CollaborationAnchorStatus,
67        CollaborationIdempotencyKey, CollaborationOperationBodyV1, CollaborationResolution,
68        ContentHash, DiscussionRecordId, DiscussionTurnV1, LegacyDiscussionId,
69        LegacyDiscussionResolutionV1, LegacySourceLocator, Principal, StateAttachmentId, StateId,
70        VisibilityTier,
71    };
72
73    #[derive(Serialize)]
74    struct Unsupported<'a> {
75        schema_version: u16,
76        body: &'a [u8],
77    }
78
79    #[test]
80    fn unsupported_version_is_rejected_before_body_decode() {
81        let bytes = rmp_serde::to_vec_named(&Unsupported {
82            schema_version: 3,
83            body: &[0xc1],
84        })
85        .unwrap();
86        assert!(matches!(
87            decode(&bytes),
88            Err(CollaborationCodecError::UnsupportedVersion(3))
89        ));
90    }
91
92    fn golden_operation(name: &str, body: CollaborationOperationBodyV1) -> (String, Vec<u8>) {
93        let root = matches!(
94            body,
95            CollaborationOperationBodyV1::Open { .. }
96                | CollaborationOperationBodyV1::LegacyImported { .. }
97        );
98        let operation = CollaborationOperationEnvelope::new(
99            "disc-018f47ea-4a54-7c89-b012-3456789abcde"
100                .parse::<DiscussionRecordId>()
101                .unwrap(),
102            if root {
103                Vec::new()
104            } else if matches!(body, CollaborationOperationBodyV1::ResolveConflict { .. }) {
105                vec![
106                    CollabOpId::from_bytes([7; 32]),
107                    CollabOpId::from_bytes([8; 32]),
108                ]
109            } else {
110                vec![CollabOpId::from_bytes([7; 32])]
111            },
112            CollaborationIdempotencyKey::new("k").unwrap(),
113            Attribution::human(Principal::new("A", "a@b")),
114            0,
115            body,
116        )
117        .unwrap();
118        (name.to_string(), operation.encode().unwrap())
119    }
120
121    fn golden_vectors() -> Vec<(String, Vec<u8>)> {
122        let state = StateId::from_bytes([1; 32]);
123        let change = ChangeId::from_bytes([2; 16]);
124        let turn = || DiscussionTurnV1::new("x").unwrap();
125        let open = |anchor| CollaborationOperationBodyV1::Open {
126            blocking: false,
127            title: "t".to_string(),
128            anchor,
129            visibility: VisibilityTier::default(),
130            turn: turn(),
131            thread_ref: None,
132        };
133        let locator = LegacySourceLocator::new(
134            state,
135            StateAttachmentId::from_hash(ContentHash::from_bytes([3; 32])),
136            ContentHash::from_bytes([4; 32]),
137        );
138        let legacy = |resolution| CollaborationOperationBodyV1::LegacyImported {
139            source: locator.clone(),
140            legacy_discussion_id: LegacyDiscussionId::new("l").unwrap(),
141            aliases: vec![LegacySourceLocator::new(
142                StateId::from_bytes([5; 32]),
143                StateAttachmentId::from_hash(ContentHash::from_bytes([6; 32])),
144                ContentHash::from_bytes([7; 32]),
145            )],
146            title: "t".to_string(),
147            anchor: CollaborationAnchor::Symbol {
148                state_id: state,
149                path: "p".to_string(),
150                symbol: "s".to_string(),
151            },
152            visibility: VisibilityTier::default(),
153            turns: vec![turn()],
154            resolution,
155        };
156        vec![
157            golden_operation("open_repository", open(CollaborationAnchor::Repository)),
158            golden_operation(
159                "open_state",
160                open(CollaborationAnchor::State { state_id: state }),
161            ),
162            golden_operation(
163                "open_change",
164                open(CollaborationAnchor::Change { change_id: change }),
165            ),
166            golden_operation(
167                "open_path",
168                open(CollaborationAnchor::Path {
169                    state_id: state,
170                    path: "p".to_string(),
171                }),
172            ),
173            golden_operation(
174                "open_symbol",
175                open(CollaborationAnchor::Symbol {
176                    state_id: state,
177                    path: "p".to_string(),
178                    symbol: "s".to_string(),
179                }),
180            ),
181            golden_operation(
182                "append_turn",
183                CollaborationOperationBodyV1::AppendTurn { turn: turn() },
184            ),
185            golden_operation(
186                "rebind_anchor",
187                CollaborationOperationBodyV1::RebindAnchor {
188                    anchor: CollaborationAnchor::Symbol {
189                        state_id: state,
190                        path: "p".to_string(),
191                        symbol: "s2".to_string(),
192                    },
193                    status: CollaborationAnchorStatus::Moved,
194                    body_changed_since_open: true,
195                },
196            ),
197            golden_operation(
198                "resolve_state",
199                CollaborationOperationBodyV1::Resolve {
200                    resolution: CollaborationResolution::AddressedByState { state_id: state },
201                },
202            ),
203            golden_operation(
204                "resolve_change",
205                CollaborationOperationBodyV1::Resolve {
206                    resolution: CollaborationResolution::AddressedByChange { change_id: change },
207                },
208            ),
209            golden_operation(
210                "resolve_dismissed",
211                CollaborationOperationBodyV1::Resolve {
212                    resolution: CollaborationResolution::Dismissed {
213                        reason: "r".to_string(),
214                    },
215                },
216            ),
217            golden_operation(
218                "resolve_annotation",
219                CollaborationOperationBodyV1::Resolve {
220                    resolution: CollaborationResolution::Annotation {
221                        annotation_id: "a".to_string(),
222                    },
223                },
224            ),
225            golden_operation(
226                "resolve_into_annotation",
227                CollaborationOperationBodyV1::Resolve {
228                    resolution: CollaborationResolution::IntoAnnotation {
229                        annotation_kind: AnnotationKind::Rationale,
230                        content: "why".to_string(),
231                        tags: vec!["design".to_string()],
232                    },
233                },
234            ),
235            golden_operation(
236                "reopen",
237                CollaborationOperationBodyV1::Reopen {
238                    reason: "r".to_string(),
239                },
240            ),
241            golden_operation(
242                "resolve_conflict",
243                CollaborationOperationBodyV1::ResolveConflict {
244                    competing: vec![
245                        CollabOpId::from_bytes([7; 32]),
246                        CollabOpId::from_bytes([8; 32]),
247                    ],
248                    selected: CollabOpId::from_bytes([7; 32]),
249                },
250            ),
251            golden_operation("legacy_open", legacy(LegacyDiscussionResolutionV1::Open)),
252            golden_operation(
253                "legacy_state",
254                legacy(LegacyDiscussionResolutionV1::AddressedByState { state_id: state }),
255            ),
256            golden_operation(
257                "legacy_dismissed",
258                legacy(LegacyDiscussionResolutionV1::Dismissed {
259                    reason: "r".to_string(),
260                }),
261            ),
262            golden_operation(
263                "legacy_annotation",
264                legacy(LegacyDiscussionResolutionV1::Annotation {
265                    annotation_id: "a".to_string(),
266                }),
267            ),
268        ]
269    }
270
271    #[test]
272    fn v2_full_variant_msgpack_vectors_are_frozen() {
273        let expected = [
274            (
275                "open_repository",
276                "69b3baebacb9d29c3d6c5cac73d6f14b2fe5231e061065249e808e2d92cddefe",
277            ),
278            (
279                "open_state",
280                "785cf6234fdfc4a01ff068a9b67a2c0d60172f59795c39c3c5b4e1037176a964",
281            ),
282            (
283                "open_change",
284                "e520779bc52b05e753a139bddafd25dcc8765182b865cd305691f85129b58c03",
285            ),
286            (
287                "open_path",
288                "1c814db69893d0ee71abe9d5a1d7d17724ff893fdf36bf90e9c40aa4beae9803",
289            ),
290            (
291                "open_symbol",
292                "9f43eb25d8c920d89a31c148fcd177e789e909eb7d3715fc2f899f85fa680197",
293            ),
294            (
295                "append_turn",
296                "777d8164d530fe27545685f23b283765a3b912f96a262dbad836faba498a9279",
297            ),
298            (
299                "rebind_anchor",
300                "e548683d8c7f20c8550627886f196a81b91e6ae52b8cdaadbfdd8944ceda1f8d",
301            ),
302            (
303                "resolve_state",
304                "7063d23abba098f608b13f2b807892bf7a5b61cb7b8487897ba84e95ff519dc9",
305            ),
306            (
307                "resolve_change",
308                "bcaa6bd96243624dcfe25942e92f59104d08291b2daf5dfd4c8d68f6fbd98472",
309            ),
310            (
311                "resolve_dismissed",
312                "4277710d09ae7043616335695b98fc89ce15583fd90437b28587c310492fe8f0",
313            ),
314            (
315                "resolve_annotation",
316                "c4eee97f235a3b534e423f20159ddba3077f9e0079ebc0697fa6c2426b394827",
317            ),
318            (
319                "resolve_into_annotation",
320                "e1470222f139ff1e6e06480996a7c27c2ef9d162eed2405ad3d838304d94d73e",
321            ),
322            (
323                "reopen",
324                "fa59e9c396a77ce07e57cb7b312f76438c3a002a1a4f053d1da13d9d10e03f7d",
325            ),
326            (
327                "resolve_conflict",
328                "d4c76d8ff078907d528613f7811151797d204f01216876623aecd675e9466606",
329            ),
330            (
331                "legacy_open",
332                "56e355d619b3db940a4cce75d525dac493d7e925c3e37cf85e1a789c6454713b",
333            ),
334            (
335                "legacy_state",
336                "44281502243244c40fc8be8b1fa452e3fd4491a455271462cb1236bac5b8136d",
337            ),
338            (
339                "legacy_dismissed",
340                "c9dd8f6850af3fce3797bb2ce853414dee066d29821756cbb8e2c6627ed56688",
341            ),
342            (
343                "legacy_annotation",
344                "8148359eab1c913a5a1b98debc9aa9f2a455cbbbc7b376b43b80b59c0b709331",
345            ),
346        ];
347        let actual = golden_vectors()
348            .into_iter()
349            .map(|(name, bytes)| {
350                let decoded = CollaborationOperationEnvelope::decode(&bytes).unwrap();
351                assert_eq!(decoded.operation_id, CollabOpId::for_bytes(&bytes));
352                assert_eq!(
353                    decoded.operation.id().expect("id reuses decoded bytes"),
354                    decoded.operation_id
355                );
356                assert_eq!(
357                    decoded.operation.id().expect("cached id"),
358                    CollabOpId::for_bytes(&decoded.operation.encode().expect("re-encode"))
359                );
360                (name, ContentHash::compute(&bytes).to_hex())
361            })
362            .collect::<Vec<_>>();
363        assert_eq!(actual.len(), expected.len());
364        for ((actual_name, actual_hash), (expected_name, expected_hash)) in
365            actual.iter().zip(expected)
366        {
367            assert_eq!(actual_name, expected_name);
368            assert_eq!(actual_hash, expected_hash);
369        }
370    }
371}