1use 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 #[error("{0} trailing byte(s) after the commit payload")]
25 Trailing(usize),
26}
27
28#[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
38pub 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 const GOLDEN_V1: &[u8] = include_bytes!("goldens/commit_v1.cbor");
89
90 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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}