Skip to main content

blockworx_doc/
encode.rs

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