Skip to main content

blockworx/doc_ng/
encode.rs

1//! The wire and storage form: CBOR over the versioned [`CommitEnvelope`],
2//! one codec for both.
3//!
4//! CBOR is self-describing and tagged by name, so field order is free and
5//! an added variant disturbs no stored payload. What it does not give is
6//! any guarantee that today's names survive a refactor: renaming a Rust
7//! variant renames its wire tag, compiles clean, and orphans every stored
8//! commit. Only the goldens catch that, which is why they exist.
9//!
10//! Bytes are not identities here (nothing is hashed), so byte-exact
11//! *encode* stability is not load-bearing and nothing asserts it.
12
13use crate::doc_ng::commit::CommitEnvelope;
14
15#[derive(Debug, thiserror::Error)]
16pub enum DecodeError {
17    #[error("malformed commit payload: {0}")]
18    Malformed(#[from] ciborium::de::Error<std::io::Error>),
19    /// ciborium stops at the end of the first value, so a payload with
20    /// anything appended decodes happily and two different byte strings
21    /// mean one commit. Refused here instead: at a trust boundary the
22    /// bytes are the message, and leftovers mean the framing is wrong.
23    #[error("{0} trailing byte(s) after the commit payload")]
24    Trailing(usize),
25}
26
27/// Serialization is infallible: the envelope is plain data with derived
28/// impls, and a `Vec` sink cannot fail.
29#[expect(clippy::expect_used, clippy::missing_panics_doc)]
30#[must_use]
31pub fn to_bytes(envelope: &CommitEnvelope) -> Vec<u8> {
32    let mut bytes = Vec::new();
33    ciborium::into_writer(envelope, &mut bytes).expect("a commit serializes infallibly");
34    bytes
35}
36
37/// # Errors
38/// Anything the decoder refuses: an unknown variant or envelope version, a
39/// truncated payload, or geometry outside the document extent.
40pub fn from_bytes(bytes: &[u8]) -> Result<CommitEnvelope, DecodeError> {
41    let mut unread = bytes;
42    let envelope = ciborium::from_reader(&mut unread)?;
43    if unread.is_empty() {
44        Ok(envelope)
45    } else {
46        Err(DecodeError::Trailing(unread.len()))
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use crate::doc_ng::fixtures::{
54        block_id, comment_id, image_id, pin_id, route_id, route_label_id, text_id,
55    };
56    use crate::doc_ng::{
57        block_model::{
58            BlockInit, BlockUpdate, CommentInit, CommentUpdate, Icon, ImageInit, ImageUpdate,
59            LabelInit, LabelUpdate, PinInit, PinUpdate, RouteInit, RouteLabelInit,
60            RouteLabelUpdate, RouteUpdate, TextInit, TextUpdate,
61        },
62        commit::Commit,
63        document::TitleBlockUpdate,
64        geometry::{
65            FracVal, GRID_LIMIT, GridPoint, GridRect, GridSize, ScreenPoint, ScreenRect,
66            ScreenSize, Waypoint,
67        },
68        hash::{AssetHash, AssetKind, Hasher},
69        opcode::{Crud, OpCodes},
70        values::{LabelSide, PinDir, Role},
71    };
72
73    /// Generated once and never again: regenerating against current code
74    /// would assert only that the code agrees with itself.
75    const GOLDEN_V1: &[u8] = include_bytes!("goldens/commit_v1.cbor");
76
77    /// How many ops `GOLDEN_V1` covers — a prefix of [`every_op`].
78    const GOLDEN_V1_OPS: usize = 65;
79
80    fn rect(x: i32, y: i32) -> GridRect {
81        GridRect {
82            top_left: GridPoint { x, y },
83            size: GridSize { w: 4, h: 6 },
84        }
85    }
86
87    fn screen_rect(x: f32) -> ScreenRect {
88        ScreenRect {
89            top_left: ScreenPoint {
90                x: FracVal::from(x),
91                y: FracVal::from(2.5),
92            },
93            size: ScreenSize {
94                w: FracVal::from(8.25),
95                h: FracVal::from(9.5),
96            },
97        }
98    }
99
100    fn asset(bytes: &[u8]) -> AssetHash {
101        let mut hasher = Hasher::<AssetKind>::new();
102        hasher.update(bytes);
103        hasher.finalize()
104    }
105
106    fn label_init(name: &str) -> LabelInit {
107        LabelInit {
108            name: name.into(),
109            side: LabelSide::Bottom,
110            offset: FracVal::from(1.5),
111            hidden: true,
112        }
113    }
114
115    /// Every `LabelUpdate` variant, so a namespace's vocabulary is covered
116    /// wherever it is reached from.
117    fn every_label_update() -> Vec<LabelUpdate> {
118        vec![
119            LabelUpdate::Name("renamed".into()),
120            LabelUpdate::Side(LabelSide::Center),
121            LabelUpdate::Offset(FracVal::from(0.75)),
122            LabelUpdate::Hidden(false),
123        ]
124    }
125
126    /// One op per variant the vocabulary can express, with deliberately
127    /// distinct values so a golden proves *values*, not just shapes.
128    ///
129    /// **Append only.** The goldens pin a prefix of this list, so
130    /// inserting in the middle re-points pinned bytes at the wrong
131    /// expected value.
132    fn every_op() -> Vec<OpCodes> {
133        let mut ops = vec![OpCodes::Document(TitleBlockUpdate::Name("drawing".into()))];
134
135        ops.push(OpCodes::Block(
136            block_id(1),
137            Crud::Create(BlockInit {
138                parent: block_id(9),
139                rect: rect(2, 3),
140                locked: true,
141                title: label_init("title"),
142                type_label: label_init("type"),
143                icon: Icon {
144                    asset: asset(b"icon"),
145                    rect: screen_rect(1.0),
146                },
147            }),
148        ));
149        for update in [
150            BlockUpdate::Parent(block_id(8)),
151            BlockUpdate::Rect(rect(11, 12)),
152            BlockUpdate::Locked(false),
153            BlockUpdate::Icon(Icon {
154                asset: asset(b"other"),
155                rect: screen_rect(3.0),
156            }),
157        ] {
158            ops.push(OpCodes::Block(block_id(1), Crud::Update(update)));
159        }
160        for label in every_label_update() {
161            ops.push(OpCodes::Block(
162                block_id(1),
163                Crud::Update(BlockUpdate::Title(label)),
164            ));
165        }
166        for label in every_label_update() {
167            ops.push(OpCodes::Block(
168                block_id(1),
169                Crud::Update(BlockUpdate::TypeLabel(label)),
170            ));
171        }
172        ops.push(OpCodes::Block(block_id(1), Crud::Delete));
173        ops.push(OpCodes::Block(block_id(1), Crud::Restore));
174
175        ops.push(OpCodes::Pin(
176            pin_id(2),
177            Crud::Create(PinInit {
178                owner: block_id(1),
179                name: "clk".into(),
180                type_name: "clock".into(),
181                tag: "t0".into(),
182                tag_hidden: true,
183                rect: rect(4, 5),
184                dir: PinDir::Output,
185                pin_accent: Role::Accent3,
186                port_accent: Role::Accent4,
187                port_pin_accent: Role::Accent5,
188                flip_lr: true,
189            }),
190        ));
191        for update in [
192            PinUpdate::Owner(block_id(7)),
193            PinUpdate::Name("rst".into()),
194            PinUpdate::TypeName("reset".into()),
195            PinUpdate::Tag("t1".into()),
196            PinUpdate::TagHidden(false),
197            PinUpdate::Rect(rect(13, 14)),
198            PinUpdate::Dir(PinDir::InOut),
199            PinUpdate::PinAccent(Role::Accent1),
200            PinUpdate::PortAccent(Role::Accent2),
201            PinUpdate::PortPinAccent(Role::Accent6),
202            PinUpdate::FlipLR(false),
203        ] {
204            ops.push(OpCodes::Pin(pin_id(2), Crud::Update(update)));
205        }
206        ops.push(OpCodes::Pin(pin_id(2), Crud::Delete));
207        ops.push(OpCodes::Pin(pin_id(2), Crud::Restore));
208
209        ops.push(OpCodes::Route(
210            route_id(3),
211            Crud::Create(RouteInit {
212                owner: block_id(1),
213                name: "net7".into(),
214                from: pin_id(2),
215                to: pin_id(4),
216                role: Role::Accent2,
217                waypoints: vec![Waypoint {
218                    pos: GridPoint { x: 6, y: 7 },
219                    locked: true,
220                }],
221            }),
222        ));
223        for update in [
224            RouteUpdate::Owner(block_id(6)),
225            RouteUpdate::Name("net8".into()),
226            RouteUpdate::Role(Role::Accent1),
227            RouteUpdate::Waypoints(vec![Waypoint {
228                pos: GridPoint { x: 15, y: 16 },
229                locked: false,
230            }]),
231        ] {
232            ops.push(OpCodes::Route(route_id(3), Crud::Update(update)));
233        }
234        ops.push(OpCodes::Route(route_id(3), Crud::Delete));
235        ops.push(OpCodes::Route(route_id(3), Crud::Restore));
236
237        ops.push(OpCodes::RouteLabel(
238            route_label_id(4),
239            Crud::Create(RouteLabelInit {
240                owner: route_id(3),
241                pos: FracVal::from(0.25),
242            }),
243        ));
244        for update in [
245            RouteLabelUpdate::Owner(route_id(5)),
246            RouteLabelUpdate::Pos(FracVal::from(0.5)),
247        ] {
248            ops.push(OpCodes::RouteLabel(route_label_id(4), Crud::Update(update)));
249        }
250        ops.push(OpCodes::RouteLabel(route_label_id(4), Crud::Delete));
251        ops.push(OpCodes::RouteLabel(route_label_id(4), Crud::Restore));
252
253        ops.push(OpCodes::Text(
254            text_id(5),
255            Crud::Create(TextInit {
256                owner: block_id(1),
257                text: "note".into(),
258                pos: GridPoint { x: 8, y: 9 },
259                role: Role::Accent1,
260            }),
261        ));
262        for update in [
263            TextUpdate::Owner(block_id(5)),
264            TextUpdate::Text("edited".into()),
265            TextUpdate::Pos(GridPoint { x: 17, y: 18 }),
266            TextUpdate::Role(Role::Accent4),
267        ] {
268            ops.push(OpCodes::Text(text_id(5), Crud::Update(update)));
269        }
270        ops.push(OpCodes::Text(text_id(5), Crud::Delete));
271        ops.push(OpCodes::Text(text_id(5), Crud::Restore));
272
273        ops.push(OpCodes::Comment(
274            comment_id(6),
275            Crud::Create(CommentInit {
276                owner: block_id(1),
277                rect: rect(10, 11),
278                role: Role::Accent6,
279                title: label_init("comment"),
280            }),
281        ));
282        for update in [
283            CommentUpdate::Owner(block_id(4)),
284            CommentUpdate::Rect(rect(19, 20)),
285            CommentUpdate::Role(Role::Accent5),
286        ] {
287            ops.push(OpCodes::Comment(comment_id(6), Crud::Update(update)));
288        }
289        for label in every_label_update() {
290            ops.push(OpCodes::Comment(
291                comment_id(6),
292                Crud::Update(CommentUpdate::Title(label)),
293            ));
294        }
295        ops.push(OpCodes::Comment(comment_id(6), Crud::Delete));
296        ops.push(OpCodes::Comment(comment_id(6), Crud::Restore));
297
298        ops.push(OpCodes::Image(
299            image_id(7),
300            Crud::Create(ImageInit {
301                owner: block_id(1),
302                asset: asset(b"png"),
303                rect: screen_rect(5.0),
304            }),
305        ));
306        for update in [
307            ImageUpdate::Owner(block_id(3)),
308            ImageUpdate::Asset(asset(b"jpg")),
309            ImageUpdate::Rect(screen_rect(7.0)),
310        ] {
311            ops.push(OpCodes::Image(image_id(7), Crud::Update(update)));
312        }
313        ops.push(OpCodes::Image(image_id(7), Crud::Delete));
314        ops.push(OpCodes::Image(image_id(7), Crud::Restore));
315
316        ops
317    }
318
319    /// The mirror guard: a new variant anywhere in the vocabulary fails to
320    /// compile here, which is the reminder that [`every_op`] needs a case
321    /// and a golden must be cut to cover it.
322    fn tag(op: &OpCodes) -> String {
323        fn label(update: &LabelUpdate) -> &'static str {
324            match update {
325                LabelUpdate::Name(_) => "Name",
326                LabelUpdate::Side(_) => "Side",
327                LabelUpdate::Offset(_) => "Offset",
328                LabelUpdate::Hidden(_) => "Hidden",
329            }
330        }
331        fn lifecycle<I, U>(crud: &Crud<I, U>, update: impl FnOnce(&U) -> String) -> String {
332            match crud {
333                Crud::Create(_) => "Create".into(),
334                Crud::Restore => "Restore".into(),
335                Crud::Delete => "Delete".into(),
336                Crud::Update(inner) => format!("Update.{}", update(inner)),
337            }
338        }
339        match op {
340            OpCodes::Document(update) => match update {
341                TitleBlockUpdate::Name(_) => "Document.Name".into(),
342            },
343            OpCodes::Block(_, crud) => format!(
344                "Block.{}",
345                lifecycle(crud, |update| match update {
346                    BlockUpdate::Parent(_) => "Parent".into(),
347                    BlockUpdate::Rect(_) => "Rect".into(),
348                    BlockUpdate::Locked(_) => "Locked".into(),
349                    BlockUpdate::Icon(_) => "Icon".into(),
350                    BlockUpdate::Title(inner) => format!("Title.{}", label(inner)),
351                    BlockUpdate::TypeLabel(inner) => format!("TypeLabel.{}", label(inner)),
352                })
353            ),
354            OpCodes::Pin(_, crud) => format!(
355                "Pin.{}",
356                lifecycle(crud, |update| match update {
357                    PinUpdate::Owner(_) => "Owner",
358                    PinUpdate::Name(_) => "Name",
359                    PinUpdate::TypeName(_) => "TypeName",
360                    PinUpdate::Tag(_) => "Tag",
361                    PinUpdate::TagHidden(_) => "TagHidden",
362                    PinUpdate::Rect(_) => "Rect",
363                    PinUpdate::Dir(_) => "Dir",
364                    PinUpdate::PinAccent(_) => "PinAccent",
365                    PinUpdate::PortAccent(_) => "PortAccent",
366                    PinUpdate::PortPinAccent(_) => "PortPinAccent",
367                    PinUpdate::FlipLR(_) => "FlipLR",
368                }
369                .into())
370            ),
371            OpCodes::Route(_, crud) => format!(
372                "Route.{}",
373                lifecycle(crud, |update| match update {
374                    RouteUpdate::Owner(_) => "Owner",
375                    RouteUpdate::Name(_) => "Name",
376                    RouteUpdate::Role(_) => "Role",
377                    RouteUpdate::Waypoints(_) => "Waypoints",
378                }
379                .into())
380            ),
381            OpCodes::RouteLabel(_, crud) => format!(
382                "RouteLabel.{}",
383                lifecycle(crud, |update| match update {
384                    RouteLabelUpdate::Owner(_) => "Owner",
385                    RouteLabelUpdate::Pos(_) => "Pos",
386                }
387                .into())
388            ),
389            OpCodes::Text(_, crud) => format!(
390                "Text.{}",
391                lifecycle(crud, |update| match update {
392                    TextUpdate::Owner(_) => "Owner",
393                    TextUpdate::Text(_) => "Text",
394                    TextUpdate::Pos(_) => "Pos",
395                    TextUpdate::Role(_) => "Role",
396                }
397                .into())
398            ),
399            OpCodes::Comment(_, crud) => format!(
400                "Comment.{}",
401                lifecycle(crud, |update| match update {
402                    CommentUpdate::Owner(_) => "Owner".into(),
403                    CommentUpdate::Rect(_) => "Rect".into(),
404                    CommentUpdate::Role(_) => "Role".into(),
405                    CommentUpdate::Title(inner) => format!("Title.{}", label(inner)),
406                })
407            ),
408            OpCodes::Image(_, crud) => format!(
409                "Image.{}",
410                lifecycle(crud, |update| match update {
411                    ImageUpdate::Owner(_) => "Owner",
412                    ImageUpdate::Asset(_) => "Asset",
413                    ImageUpdate::Rect(_) => "Rect",
414                }
415                .into())
416            ),
417        }
418    }
419
420    fn golden_envelope() -> CommitEnvelope {
421        CommitEnvelope::CommitV1(Commit::new("golden".into(), every_op()))
422    }
423
424    fn ops_of(envelope: &CommitEnvelope) -> &[OpCodes] {
425        let CommitEnvelope::CommitV1(commit) = envelope;
426        commit.ops()
427    }
428
429    /// Bytes pinned by an earlier build must still decode, and to the same
430    /// values. A rename or a changed representation fails here and nowhere
431    /// else: a round trip renames both sides at once and agrees with
432    /// itself.
433    #[test]
434    fn the_golden_decodes_to_its_pinned_values() {
435        let decoded = from_bytes(GOLDEN_V1).expect("pinned bytes must decode in every build");
436        let expected = every_op();
437
438        assert_eq!(ops_of(&decoded).len(), GOLDEN_V1_OPS);
439        assert_eq!(ops_of(&decoded), &expected[..GOLDEN_V1_OPS]);
440    }
441
442    /// The forcing function. When this fails you have added a variant:
443    /// append it to `every_op`, bump nothing here, and cut a *new* golden
444    /// covering it — `GOLDEN_V1` keeps proving what it always proved.
445    #[test]
446    fn every_variant_is_covered_by_a_golden() {
447        let ops = every_op();
448        assert_eq!(
449            ops.len(),
450            GOLDEN_V1_OPS,
451            "a variant was added to the vocabulary but no golden covers it",
452        );
453
454        let mut tags: Vec<String> = ops.iter().map(tag).collect();
455        tags.sort();
456        tags.dedup();
457        assert_eq!(tags.len(), ops.len(), "the fixture repeats a variant");
458    }
459
460    #[test]
461    fn an_envelope_round_trips() {
462        let envelope = golden_envelope();
463        let decoded = from_bytes(&to_bytes(&envelope)).expect("its own output decodes");
464        assert_eq!(decoded, envelope);
465    }
466
467    /// Refused, never skipped: silently dropping an unreadable op would
468    /// fold a different document than its author wrote.
469    #[test]
470    fn an_unknown_update_variant_is_refused() {
471        let payload = ciborium::Value::Map(vec![(
472            ciborium::Value::Text("CommitV1".into()),
473            ciborium::Value::Map(vec![
474                (
475                    ciborium::Value::Text("label".into()),
476                    ciborium::Value::Text("from a newer build".into()),
477                ),
478                (
479                    ciborium::Value::Text("ops".into()),
480                    ciborium::Value::Array(vec![ciborium::Value::Map(vec![(
481                        ciborium::Value::Text("Document".into()),
482                        ciborium::Value::Map(vec![(
483                            ciborium::Value::Text("Subtitle".into()),
484                            ciborium::Value::Text("a register this build lacks".into()),
485                        )]),
486                    )])]),
487                ),
488            ]),
489        )]);
490
491        let mut bytes = Vec::new();
492        ciborium::into_writer(&payload, &mut bytes).expect("the probe serializes");
493        assert!(from_bytes(&bytes).is_err());
494    }
495
496    /// A future envelope version fails to decode rather than being
497    /// reinterpreted as the version this build understands.
498    #[test]
499    fn a_future_envelope_version_is_refused() {
500        let payload = ciborium::Value::Map(vec![(
501            ciborium::Value::Text("CommitV2".into()),
502            ciborium::Value::Map(vec![(
503                ciborium::Value::Text("label".into()),
504                ciborium::Value::Text("from a newer build".into()),
505            )]),
506        )]);
507
508        let mut bytes = Vec::new();
509        ciborium::into_writer(&payload, &mut bytes).expect("the probe serializes");
510        assert!(from_bytes(&bytes).is_err());
511    }
512
513    #[test]
514    fn a_truncated_payload_is_refused() {
515        let bytes = to_bytes(&golden_envelope());
516        for cut in [1, bytes.len() / 2, bytes.len() - 1] {
517            assert!(
518                from_bytes(&bytes[..cut]).is_err(),
519                "a payload cut at {cut} must not decode",
520            );
521        }
522    }
523
524    /// Trailing bytes are refused too: a decoder that stops at the end of
525    /// the first value would accept a payload with anything appended.
526    #[test]
527    fn trailing_bytes_are_refused() {
528        let mut bytes = to_bytes(&golden_envelope());
529        bytes.push(0xff);
530        assert!(from_bytes(&bytes).is_err());
531    }
532
533    /// The trust boundary: bounded-by-the-extent is true of editor output
534    /// and false of whatever a decoder was handed.
535    #[test]
536    fn geometry_outside_the_document_extent_is_refused() {
537        for coordinate in [i32::MAX, i32::MIN, GRID_LIMIT + 1, -GRID_LIMIT - 1] {
538            let payload = ciborium::Value::Map(vec![
539                (
540                    ciborium::Value::Text("x".into()),
541                    ciborium::Value::Integer(coordinate.into()),
542                ),
543                (
544                    ciborium::Value::Text("y".into()),
545                    ciborium::Value::Integer(0.into()),
546                ),
547            ]);
548            let mut bytes = Vec::new();
549            ciborium::into_writer(&payload, &mut bytes).expect("the probe serializes");
550            assert!(
551                ciborium::from_reader::<GridPoint, _>(&bytes[..]).is_err(),
552                "{coordinate} must be refused",
553            );
554        }
555
556        let payload = ciborium::Value::Map(vec![
557            (
558                ciborium::Value::Text("w".into()),
559                ciborium::Value::Integer(u32::MAX.into()),
560            ),
561            (
562                ciborium::Value::Text("h".into()),
563                ciborium::Value::Integer(1.into()),
564            ),
565        ]);
566        let mut bytes = Vec::new();
567        ciborium::into_writer(&payload, &mut bytes).expect("the probe serializes");
568        assert!(ciborium::from_reader::<GridSize, _>(&bytes[..]).is_err());
569
570        let mut bytes = Vec::new();
571        ciborium::into_writer(&i64::MAX, &mut bytes).expect("the probe serializes");
572        assert!(ciborium::from_reader::<FracVal, _>(&bytes[..]).is_err());
573    }
574
575    /// The check must not be so tight it refuses what the editor authors.
576    #[test]
577    fn geometry_inside_the_document_extent_decodes() {
578        for point in [
579            GridPoint { x: 0, y: 0 },
580            GridPoint {
581                x: GRID_LIMIT,
582                y: -GRID_LIMIT,
583            },
584        ] {
585            let mut bytes = Vec::new();
586            ciborium::into_writer(&point, &mut bytes).expect("the probe serializes");
587            assert_eq!(
588                ciborium::from_reader::<GridPoint, _>(&bytes[..]).expect("in-extent decodes"),
589                point,
590            );
591        }
592    }
593
594    /// Run with `cargo test -- --ignored regenerate_goldens` **only** when
595    /// cutting a new golden. Never to make a failing golden pass: that
596    /// asserts the code agrees with itself and throws away the record of
597    /// what the bytes used to mean.
598    #[test]
599    #[ignore = "writes a golden fixture; run deliberately"]
600    fn regenerate_goldens() {
601        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
602            .join("src/doc_ng/goldens/commit_v1.cbor");
603        std::fs::create_dir_all(path.parent().expect("the goldens directory"))
604            .expect("the goldens directory is writable");
605        std::fs::write(&path, to_bytes(&golden_envelope())).expect("the golden is writable");
606    }
607}