1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value as JsonValue;
6use uuid::Uuid;
7
8use crate::identity::{Keypair, PubKey, Signature};
9use ciborium::value::{CanonicalValue, Value};
10use meerkat_core::comms::PeerLifecycleKind;
11pub use meerkat_core::comms::SenderContentTaint;
12use meerkat_core::types::{ContentBlock, HandlingMode, RenderMetadata};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
16#[serde(rename_all = "lowercase")]
17pub enum Status {
18 Accepted,
19 Completed,
20 Failed,
21}
22
23impl From<Status> for meerkat_core::interaction::ResponseStatus {
24 fn from(s: Status) -> Self {
25 match s {
26 Status::Accepted => meerkat_core::interaction::ResponseStatus::Accepted,
27 Status::Completed => meerkat_core::interaction::ResponseStatus::Completed,
28 Status::Failed => meerkat_core::interaction::ResponseStatus::Failed,
29 }
30 }
31}
32
33impl From<meerkat_core::interaction::ResponseStatus> for Status {
34 fn from(s: meerkat_core::interaction::ResponseStatus) -> Self {
35 match s {
36 meerkat_core::interaction::ResponseStatus::Accepted => Status::Accepted,
37 meerkat_core::interaction::ResponseStatus::Completed => Status::Completed,
38 meerkat_core::interaction::ResponseStatus::Failed => Status::Failed,
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "lowercase")]
46pub enum MessageKind {
47 Message {
49 body: String,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
53 blocks: Option<Vec<ContentBlock>>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
61 content_taint: Option<SenderContentTaint>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 handling_mode: Option<HandlingMode>,
64 },
65 Request {
67 intent: String,
68 params: JsonValue,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 blocks: Option<Vec<ContentBlock>>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 content_taint: Option<SenderContentTaint>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 handling_mode: Option<HandlingMode>,
77 },
78 Lifecycle {
80 kind: PeerLifecycleKind,
81 params: JsonValue,
82 },
83 Response {
85 in_reply_to: Uuid,
86 status: Status,
87 result: JsonValue,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 blocks: Option<Vec<ContentBlock>>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 content_taint: Option<SenderContentTaint>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 handling_mode: Option<HandlingMode>,
96 },
97 Ack { in_reply_to: Uuid },
99}
100
101impl MessageKind {
102 #[must_use]
106 pub fn content_taint(&self) -> Option<SenderContentTaint> {
107 match self {
108 Self::Message { content_taint, .. }
109 | Self::Request { content_taint, .. }
110 | Self::Response { content_taint, .. } => *content_taint,
111 Self::Lifecycle { .. } | Self::Ack { .. } => None,
112 }
113 }
114
115 pub(crate) fn with_content_taint(mut self, taint: Option<SenderContentTaint>) -> Self {
122 match &mut self {
123 Self::Message { content_taint, .. }
124 | Self::Request { content_taint, .. }
125 | Self::Response { content_taint, .. } => *content_taint = taint,
126 Self::Lifecycle { .. } | Self::Ack { .. } => {}
127 }
128 self
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134pub struct Envelope {
135 pub id: Uuid,
137 pub from: PubKey,
139 pub to: PubKey,
141 pub kind: MessageKind,
143 pub sig: Signature,
145}
146
147impl Envelope {
148 pub fn signable_bytes(&self) -> Vec<u8> {
158 fn canonicalize(value: &mut Value) {
159 match value {
160 Value::Array(items) => {
161 for item in items {
162 canonicalize(item);
163 }
164 }
165 Value::Map(entries) => {
166 for (key, val) in entries.iter_mut() {
167 canonicalize(key);
168 canonicalize(val);
169 }
170
171 entries.sort_by(|(k1, _), (k2, _)| match (k1, k2) {
172 (Value::Text(a), Value::Text(b)) => match a.len().cmp(&b.len()) {
173 std::cmp::Ordering::Equal => a.cmp(b),
174 ord => ord,
175 },
176 _ => {
177 CanonicalValue::from(k1.clone()).cmp(&CanonicalValue::from(k2.clone()))
178 }
179 });
180 }
181 Value::Tag(_, inner) => canonicalize(inner),
182 _ => {}
183 }
184 }
185
186 let signable = (&self.id, &self.from, &self.to, &self.kind);
188
189 let mut value = match Value::serialized(&signable) {
190 Ok(value) => value,
191 Err(_) => return Vec::new(),
192 };
193 canonicalize(&mut value);
194
195 let mut buf = Vec::new();
196 if ciborium::into_writer(&value, &mut buf).is_err() {
197 return Vec::new();
198 }
199 buf
200 }
201
202 pub fn sign(&mut self, keypair: &Keypair) {
204 let bytes = self.signable_bytes();
205 if bytes.is_empty() {
206 self.sig = Signature::new([0u8; 64]);
207 return;
208 }
209 self.sig = keypair.sign(&bytes);
210 }
211
212 pub fn verify(&self) -> bool {
214 let bytes = self.signable_bytes();
215 if bytes.is_empty() {
216 return false;
217 }
218 self.from.verify(&bytes, &self.sig)
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224#[serde(tag = "type", rename_all = "snake_case")]
225pub enum InboxItem {
226 External { envelope: Envelope },
228 PlainEvent {
230 body: String,
231 source: meerkat_core::PlainEventSource,
232 #[serde(default)]
233 handling_mode: HandlingMode,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
237 interaction_id: Option<Uuid>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 blocks: Option<Vec<ContentBlock>>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 render_metadata: Option<RenderMetadata>,
244 },
245}
246
247#[cfg(test)]
248#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn test_status_variants() {
254 let _ = Status::Accepted;
256 let _ = Status::Completed;
257 let _ = Status::Failed;
258 }
259
260 #[test]
261 fn test_message_kind_message_fields() {
262 let msg = MessageKind::Message {
263 body: "hello".to_string(),
264 blocks: None,
265 content_taint: None,
266 handling_mode: None,
267 };
268 if let MessageKind::Message {
269 body,
270 blocks,
271 content_taint,
272 handling_mode,
273 } = msg
274 {
275 assert_eq!(body, "hello");
276 assert_eq!(blocks, None);
277 assert_eq!(content_taint, None);
278 assert_eq!(handling_mode, None);
279 } else {
280 panic!("Expected Message variant");
281 }
282 }
283
284 #[test]
285 fn test_message_kind_request_fields() {
286 let req = MessageKind::Request {
287 intent: "review-pr".to_string(),
288 params: serde_json::json!({"pr": 42}),
289 blocks: None,
290 content_taint: None,
291 handling_mode: None,
292 };
293 if let MessageKind::Request {
294 intent,
295 params,
296 blocks,
297 content_taint,
298 handling_mode,
299 } = req
300 {
301 assert_eq!(intent, "review-pr");
302 assert_eq!(params["pr"], 42);
303 assert_eq!(blocks, None);
304 assert_eq!(content_taint, None);
305 assert_eq!(handling_mode, None);
306 } else {
307 panic!("Expected Request variant");
308 }
309 }
310
311 #[test]
312 fn test_message_kind_response_fields() {
313 let id = Uuid::new_v4();
314 let resp = MessageKind::Response {
315 in_reply_to: id,
316 status: Status::Completed,
317 result: serde_json::json!({"approved": true}),
318 blocks: None,
319 content_taint: None,
320 handling_mode: None,
321 };
322 if let MessageKind::Response {
323 in_reply_to,
324 status,
325 result,
326 ..
327 } = resp
328 {
329 assert_eq!(in_reply_to, id);
330 assert_eq!(status, Status::Completed);
331 assert_eq!(result["approved"], true);
332 } else {
333 panic!("Expected Response variant");
334 }
335 }
336
337 #[test]
338 fn test_message_kind_ack_fields() {
339 let id = Uuid::new_v4();
340 let ack = MessageKind::Ack { in_reply_to: id };
341 if let MessageKind::Ack { in_reply_to } = ack {
342 assert_eq!(in_reply_to, id);
343 } else {
344 panic!("Expected Ack variant");
345 }
346 }
347
348 #[test]
349 fn test_envelope_fields() {
350 let envelope = Envelope {
351 id: Uuid::new_v4(),
352 from: PubKey::new([1u8; 32]),
353 to: PubKey::new([2u8; 32]),
354 kind: MessageKind::Message {
355 body: "test".to_string(),
356 blocks: None,
357 content_taint: None,
358 handling_mode: None,
359 },
360 sig: Signature::new([0u8; 64]),
361 };
362 assert!(envelope.id != Uuid::nil());
363 assert_eq!(envelope.from.as_bytes()[0], 1);
364 assert_eq!(envelope.to.as_bytes()[0], 2);
365 }
366
367 #[test]
368 fn test_status_cbor_roundtrip() {
369 for status in [Status::Accepted, Status::Completed, Status::Failed] {
370 let mut buf = Vec::new();
371 ciborium::into_writer(&status, &mut buf).unwrap();
372 let decoded: Status = ciborium::from_reader(&buf[..]).unwrap();
373 assert_eq!(status, decoded);
374 }
375 }
376
377 #[test]
378 fn test_message_kind_cbor_roundtrip() {
379 let kinds = vec![
380 MessageKind::Message {
381 body: "hello".to_string(),
382 blocks: None,
383 content_taint: None,
384 handling_mode: None,
385 },
386 MessageKind::Request {
387 intent: "test".to_string(),
388 params: serde_json::json!({}),
389 blocks: None,
390 content_taint: None,
391 handling_mode: None,
392 },
393 MessageKind::Response {
394 in_reply_to: Uuid::new_v4(),
395 status: Status::Completed,
396 result: serde_json::json!(null),
397 blocks: None,
398 content_taint: None,
399 handling_mode: None,
400 },
401 MessageKind::Message {
402 body: "declared".to_string(),
403 blocks: None,
404 content_taint: Some(SenderContentTaint::Tainted),
405 handling_mode: None,
406 },
407 MessageKind::Response {
408 in_reply_to: Uuid::new_v4(),
409 status: Status::Completed,
410 result: serde_json::json!(null),
411 blocks: None,
412 content_taint: Some(SenderContentTaint::Clean),
413 handling_mode: None,
414 },
415 MessageKind::Ack {
416 in_reply_to: Uuid::new_v4(),
417 },
418 ];
419 for kind in kinds {
420 let mut buf = Vec::new();
421 ciborium::into_writer(&kind, &mut buf).unwrap();
422 let decoded: MessageKind = ciborium::from_reader(&buf[..]).unwrap();
423 assert_eq!(kind, decoded);
424 }
425 }
426
427 #[test]
428 fn test_envelope_cbor_roundtrip() {
429 let envelope = Envelope {
430 id: Uuid::new_v4(),
431 from: PubKey::new([1u8; 32]),
432 to: PubKey::new([2u8; 32]),
433 kind: MessageKind::Message {
434 body: "test".to_string(),
435 blocks: None,
436 content_taint: None,
437 handling_mode: None,
438 },
439 sig: Signature::new([0u8; 64]),
440 };
441 let mut buf = Vec::new();
442 ciborium::into_writer(&envelope, &mut buf).unwrap();
443 let decoded: Envelope = ciborium::from_reader(&buf[..]).unwrap();
444 assert_eq!(envelope, decoded);
445 }
446
447 #[test]
448 fn test_inbox_item_external_fields() {
449 let envelope = Envelope {
450 id: Uuid::new_v4(),
451 from: PubKey::new([1u8; 32]),
452 to: PubKey::new([2u8; 32]),
453 kind: MessageKind::Ack {
454 in_reply_to: Uuid::new_v4(),
455 },
456 sig: Signature::new([0u8; 64]),
457 };
458 let item = InboxItem::External {
459 envelope: envelope.clone(),
460 };
461 if let InboxItem::External { envelope: e } = item {
462 assert_eq!(e.id, envelope.id);
463 } else {
464 panic!("Expected External variant");
465 }
466 }
467
468 #[test]
469 fn test_inbox_item_cbor_roundtrip() {
470 let items = vec![
471 InboxItem::External {
472 envelope: Envelope {
473 id: Uuid::new_v4(),
474 from: PubKey::new([1u8; 32]),
475 to: PubKey::new([2u8; 32]),
476 kind: MessageKind::Ack {
477 in_reply_to: Uuid::new_v4(),
478 },
479 sig: Signature::new([0u8; 64]),
480 },
481 },
482 InboxItem::PlainEvent {
483 body: "event".to_string(),
484 source: meerkat_core::PlainEventSource::Tcp,
485 handling_mode: HandlingMode::Queue,
486 interaction_id: Some(Uuid::new_v4()),
487 blocks: None,
488 render_metadata: None,
489 },
490 ];
491 for item in items {
492 let mut buf = Vec::new();
493 ciborium::into_writer(&item, &mut buf).unwrap();
494 let decoded: InboxItem = ciborium::from_reader(&buf[..]).unwrap();
495 assert_eq!(item, decoded);
496 }
497 }
498
499 #[test]
500 fn test_status_encodes_as_string() {
501 let status = Status::Accepted;
503 let mut buf = Vec::new();
504 ciborium::into_writer(&status, &mut buf).unwrap();
505 let cbor_str = String::from_utf8_lossy(&buf);
508 assert!(
509 cbor_str.contains("accepted"),
510 "Status should encode as string 'accepted', got: {buf:?}"
511 );
512 }
513
514 #[test]
515 fn test_message_kind_tags_are_strings() {
516 let msg = MessageKind::Message {
518 body: "test".to_string(),
519 blocks: None,
520 content_taint: None,
521 handling_mode: None,
522 };
523 let mut buf = Vec::new();
524 ciborium::into_writer(&msg, &mut buf).unwrap();
525 let cbor_str = String::from_utf8_lossy(&buf);
526 assert!(
528 cbor_str.contains("type") && cbor_str.contains("message"),
529 "MessageKind should use string tags, got: {buf:?}"
530 );
531 }
532
533 #[test]
536 fn test_signable_bytes_deterministic() {
537 let envelope = Envelope {
538 id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
539 from: PubKey::new([1u8; 32]),
540 to: PubKey::new([2u8; 32]),
541 kind: MessageKind::Message {
542 body: "test".to_string(),
543 blocks: None,
544 content_taint: None,
545 handling_mode: None,
546 },
547 sig: Signature::new([0u8; 64]),
548 };
549 let bytes1 = envelope.signable_bytes();
550 let bytes2 = envelope.signable_bytes();
551 assert_eq!(bytes1, bytes2, "signable_bytes must be deterministic");
552 }
553
554 #[test]
555 fn test_envelope_sign() {
556 use crate::identity::Keypair;
557
558 let keypair = Keypair::generate();
559 let mut envelope = Envelope {
560 id: Uuid::new_v4(),
561 from: keypair.public_key(),
562 to: PubKey::new([2u8; 32]),
563 kind: MessageKind::Message {
564 body: "test".to_string(),
565 blocks: None,
566 content_taint: None,
567 handling_mode: None,
568 },
569 sig: Signature::new([0u8; 64]),
570 };
571 envelope.sign(&keypair);
572 assert_ne!(envelope.sig, Signature::new([0u8; 64]));
574 }
575
576 #[test]
577 fn test_envelope_verify() {
578 use crate::identity::Keypair;
579
580 let keypair = Keypair::generate();
581 let mut envelope = Envelope {
582 id: Uuid::new_v4(),
583 from: keypair.public_key(),
584 to: PubKey::new([2u8; 32]),
585 kind: MessageKind::Message {
586 body: "test".to_string(),
587 blocks: None,
588 content_taint: None,
589 handling_mode: None,
590 },
591 sig: Signature::new([0u8; 64]),
592 };
593 envelope.sign(&keypair);
594 assert!(envelope.verify(), "signed envelope should verify");
595 }
596
597 #[test]
598 fn test_rct_contracts_envelope_signable_bytes_are_canonical() {
599 let envelope = Envelope {
600 id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
601 from: PubKey::new([1u8; 32]),
602 to: PubKey::new([2u8; 32]),
603 kind: MessageKind::Message {
604 body: "hello".to_string(),
605 blocks: None,
606 content_taint: None,
607 handling_mode: None,
608 },
609 sig: Signature::new([0u8; 64]),
610 };
611
612 let bytes = envelope.signable_bytes();
613
614 let mut expected = Vec::new();
615 expected.extend_from_slice(&[
616 0x84, 0x50, 0x55, 0x0e, 0x84, 0x00, 0xe2, 0x9b, 0x41, 0xd4, 0xa7, 0x16, 0x44, 0x66,
617 0x55, 0x44, 0x00, 0x00, 0x58, 0x20,
618 ]);
619 expected.extend(std::iter::repeat_n(0x01, 32));
620 expected.extend_from_slice(&[0x58, 0x20]);
621 expected.extend(std::iter::repeat_n(0x02, 32));
622 expected.extend_from_slice(&[
623 0xa2, 0x64, b'b', b'o', b'd', b'y', 0x65, b'h', b'e', b'l', b'l', b'o', 0x64, b't',
624 b'y', b'p', b'e', 0x67, b'm', b'e', b's', b's', b'a', b'g', b'e',
625 ]);
626
627 assert_eq!(bytes, expected);
628 }
629
630 #[test]
631 fn test_regression_ack_must_match_sent_message() {
632 use crate::identity::Keypair;
633
634 let sender_keypair = Keypair::generate();
635 let receiver_keypair = Keypair::generate();
636
637 let sent_id = Uuid::new_v4();
638 let sent_envelope = Envelope {
639 id: sent_id,
640 from: sender_keypair.public_key(),
641 to: receiver_keypair.public_key(),
642 kind: MessageKind::Message {
643 body: "hello".to_string(),
644 blocks: None,
645 content_taint: None,
646 handling_mode: None,
647 },
648 sig: Signature::new([0u8; 64]),
649 };
650
651 let mut valid_ack = Envelope {
652 id: Uuid::new_v4(),
653 from: receiver_keypair.public_key(),
654 to: sender_keypair.public_key(),
655 kind: MessageKind::Ack {
656 in_reply_to: sent_id,
657 },
658 sig: Signature::new([0u8; 64]),
659 };
660 valid_ack.sign(&receiver_keypair);
661
662 assert!(valid_ack.verify(), "valid ACK should verify");
663 assert_eq!(
664 valid_ack.from, sent_envelope.to,
665 "ACK from should match sent to"
666 );
667 if let MessageKind::Ack { in_reply_to } = valid_ack.kind {
668 assert_eq!(in_reply_to, sent_id, "ACK in_reply_to should match sent id");
669 }
670 }
671
672 #[test]
673 fn test_regression_ack_wrong_in_reply_to_is_invalid() {
674 use crate::identity::Keypair;
675
676 let sender_keypair = Keypair::generate();
677 let receiver_keypair = Keypair::generate();
678
679 let sent_id = Uuid::new_v4();
680 let wrong_id = Uuid::new_v4();
681
682 let mut wrong_ack = Envelope {
683 id: Uuid::new_v4(),
684 from: receiver_keypair.public_key(),
685 to: sender_keypair.public_key(),
686 kind: MessageKind::Ack {
687 in_reply_to: wrong_id,
688 },
689 sig: Signature::new([0u8; 64]),
690 };
691 wrong_ack.sign(&receiver_keypair);
692
693 assert!(wrong_ack.verify(), "signature should still verify");
694 if let MessageKind::Ack { in_reply_to } = wrong_ack.kind {
695 assert_ne!(
696 in_reply_to, sent_id,
697 "wrong ACK in_reply_to should not match sent id"
698 );
699 }
700 }
701
702 #[test]
703 fn test_regression_ack_from_wrong_peer_is_invalid() {
704 use crate::identity::Keypair;
705
706 let sender_keypair = Keypair::generate();
707 let receiver_keypair = Keypair::generate();
708 let imposter_keypair = Keypair::generate();
709
710 let sent_id = Uuid::new_v4();
711 let sent_to = receiver_keypair.public_key();
712
713 let mut imposter_ack = Envelope {
714 id: Uuid::new_v4(),
715 from: imposter_keypair.public_key(),
716 to: sender_keypair.public_key(),
717 kind: MessageKind::Ack {
718 in_reply_to: sent_id,
719 },
720 sig: Signature::new([0u8; 64]),
721 };
722 imposter_ack.sign(&imposter_keypair);
723
724 assert!(imposter_ack.verify(), "imposter signature should verify");
725 assert_ne!(
726 imposter_ack.from, sent_to,
727 "imposter ACK from should not match sent to"
728 );
729 }
730
731 #[test]
732 fn test_regression_ack_to_wrong_recipient_is_invalid() {
733 use crate::identity::Keypair;
734
735 let sender_keypair = Keypair::generate();
736 let receiver_keypair = Keypair::generate();
737 let wrong_recipient_keypair = Keypair::generate();
738
739 let sent_id = Uuid::new_v4();
740
741 let mut misrouted_ack = Envelope {
742 id: Uuid::new_v4(),
743 from: receiver_keypair.public_key(),
744 to: wrong_recipient_keypair.public_key(),
745 kind: MessageKind::Ack {
746 in_reply_to: sent_id,
747 },
748 sig: Signature::new([0u8; 64]),
749 };
750 misrouted_ack.sign(&receiver_keypair);
751
752 assert!(
753 misrouted_ack.verify(),
754 "misrouted ACK signature should verify"
755 );
756 assert_ne!(
757 misrouted_ack.to,
758 sender_keypair.public_key(),
759 "misrouted ACK 'to' should not match sender's public key"
760 );
761 }
762
763 #[test]
766 fn test_inbox_item_plain_event_serde_roundtrip() {
767 use meerkat_core::PlainEventSource;
768
769 let item = InboxItem::PlainEvent {
770 body: "New email from john@example.com".to_string(),
771 source: PlainEventSource::Tcp,
772 handling_mode: HandlingMode::Queue,
773 interaction_id: None,
774 blocks: None,
775 render_metadata: None,
776 };
777
778 let json = serde_json::to_string(&item).unwrap();
780 let parsed: InboxItem = serde_json::from_str(&json).unwrap();
781 assert_eq!(item, parsed);
782
783 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
785 assert_eq!(value["type"], "plain_event");
786 assert_eq!(value["body"], "New email from john@example.com");
787 assert_eq!(value["source"], "tcp");
788 }
789
790 #[test]
791 fn test_inbox_item_plain_event_cbor_roundtrip() {
792 use meerkat_core::PlainEventSource;
793
794 let items = vec![
795 InboxItem::PlainEvent {
796 body: "hello".to_string(),
797 source: PlainEventSource::Tcp,
798 handling_mode: HandlingMode::Queue,
799 interaction_id: None,
800 blocks: None,
801 render_metadata: None,
802 },
803 InboxItem::PlainEvent {
804 body: r#"{"event":"email"}"#.to_string(),
805 source: PlainEventSource::Stdin,
806 handling_mode: HandlingMode::Queue,
807 interaction_id: None,
808 blocks: None,
809 render_metadata: None,
810 },
811 InboxItem::PlainEvent {
812 body: "webhook payload".to_string(),
813 source: PlainEventSource::Webhook,
814 handling_mode: HandlingMode::Queue,
815 interaction_id: None,
816 blocks: None,
817 render_metadata: None,
818 },
819 InboxItem::PlainEvent {
820 body: "rpc event".to_string(),
821 source: PlainEventSource::Rpc,
822 handling_mode: HandlingMode::Queue,
823 interaction_id: None,
824 blocks: None,
825 render_metadata: None,
826 },
827 ];
828 for item in items {
829 let mut buf = Vec::new();
830 ciborium::into_writer(&item, &mut buf).unwrap();
831 let decoded: InboxItem = ciborium::from_reader(&buf[..]).unwrap();
832 assert_eq!(item, decoded);
833 }
834 }
835
836 #[test]
837 fn test_inbox_item_plain_event_backward_compat_json() {
838 let old_json = r#"{"type":"plain_event","body":"hello","source":"tcp"}"#;
840 let parsed: InboxItem = serde_json::from_str(old_json).unwrap();
841 match parsed {
842 InboxItem::PlainEvent {
843 body,
844 source,
845 handling_mode: _,
846 interaction_id,
847 blocks,
848 render_metadata,
849 } => {
850 assert_eq!(body, "hello");
851 assert_eq!(source, meerkat_core::PlainEventSource::Tcp);
852 assert_eq!(interaction_id, None);
853 assert_eq!(blocks, None);
854 assert_eq!(render_metadata, None);
855 }
856 other => panic!("Expected PlainEvent, got {other:?}"),
857 }
858 }
859
860 #[test]
861 fn test_inbox_item_plain_event_with_interaction_id_json_roundtrip() {
862 let id = Uuid::new_v4();
863 let item = InboxItem::PlainEvent {
864 body: "tracked event".to_string(),
865 source: meerkat_core::PlainEventSource::Rpc,
866 handling_mode: HandlingMode::Queue,
867 interaction_id: Some(id),
868 blocks: None,
869 render_metadata: None,
870 };
871
872 let json = serde_json::to_string(&item).unwrap();
873 let parsed: InboxItem = serde_json::from_str(&json).unwrap();
874 assert_eq!(item, parsed);
875
876 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
878 assert_eq!(value["interaction_id"], id.to_string());
879 }
880
881 #[test]
882 fn test_inbox_item_plain_event_with_interaction_id_cbor_roundtrip() {
883 let id = Uuid::new_v4();
884 let item = InboxItem::PlainEvent {
885 body: "tracked event".to_string(),
886 source: meerkat_core::PlainEventSource::Rpc,
887 handling_mode: HandlingMode::Queue,
888 interaction_id: Some(id),
889 blocks: None,
890 render_metadata: None,
891 };
892
893 let mut buf = Vec::new();
894 ciborium::into_writer(&item, &mut buf).unwrap();
895 let decoded: InboxItem = ciborium::from_reader(&buf[..]).unwrap();
896 assert_eq!(item, decoded);
897 }
898
899 #[test]
900 fn test_inbox_item_plain_event_none_interaction_id_skipped_in_json() {
901 let item = InboxItem::PlainEvent {
902 body: "untracked".to_string(),
903 source: meerkat_core::PlainEventSource::Tcp,
904 handling_mode: HandlingMode::Queue,
905 interaction_id: None,
906 blocks: None,
907 render_metadata: None,
908 };
909
910 let json = serde_json::to_string(&item).unwrap();
911 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
912 assert!(
914 value.get("interaction_id").is_none(),
915 "interaction_id: None should not be serialized"
916 );
917 }
918
919 #[test]
922 fn message_kind_with_blocks_cbor_roundtrip() {
923 let kind = MessageKind::Message {
924 body: "hello".to_string(),
925 blocks: Some(vec![
926 ContentBlock::Text {
927 text: "hello".to_string(),
928 },
929 ContentBlock::Image {
930 media_type: "image/png".to_string(),
931 data: "iVBORw0KGgo=".into(),
932 },
933 ]),
934 content_taint: None,
935 handling_mode: None,
936 };
937 let mut buf = Vec::new();
938 ciborium::into_writer(&kind, &mut buf).unwrap();
939 let decoded: MessageKind = ciborium::from_reader(&buf[..]).unwrap();
940 assert_eq!(kind, decoded);
941 }
942
943 #[test]
944 fn message_kind_without_blocks_backwards_compat() {
945 use crate::identity::Keypair;
946
947 let keypair = Keypair::generate();
950 let kind = MessageKind::Message {
951 body: "test".to_string(),
952 blocks: None,
953 content_taint: None,
954 handling_mode: None,
955 };
956
957 let mut buf = Vec::new();
959 ciborium::into_writer(&kind, &mut buf).unwrap();
960 let decoded: MessageKind = ciborium::from_reader(&buf[..]).unwrap();
961 assert_eq!(kind, decoded);
962
963 let mut envelope = Envelope {
965 id: Uuid::new_v4(),
966 from: keypair.public_key(),
967 to: PubKey::new([2u8; 32]),
968 kind,
969 sig: Signature::new([0u8; 64]),
970 };
971 envelope.sign(&keypair);
972 assert!(
973 envelope.verify(),
974 "blocks=None envelope should still verify"
975 );
976
977 let json = serde_json::to_string(&envelope.kind).unwrap();
979 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
980 assert!(
981 value.get("blocks").is_none(),
982 "blocks: None should not appear in JSON"
983 );
984 assert!(
987 value.get("content_taint").is_none(),
988 "content_taint: None should not appear in JSON"
989 );
990 let cbor_value: ciborium::value::Value = {
991 let mut buf = Vec::new();
992 ciborium::into_writer(&envelope.kind, &mut buf).unwrap();
993 ciborium::from_reader(&buf[..]).unwrap()
994 };
995 let ciborium::value::Value::Map(entries) = cbor_value else {
996 panic!("MessageKind should encode as a CBOR map");
997 };
998 assert!(
999 !entries.iter().any(|(key, _)| matches!(
1000 key,
1001 ciborium::value::Value::Text(text) if text == "content_taint"
1002 )),
1003 "content_taint: None should not appear in CBOR"
1004 );
1005 }
1006
1007 #[test]
1008 fn test_rct_contracts_signable_bytes_with_content_taint_are_canonical() {
1009 let envelope = Envelope {
1014 id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
1015 from: PubKey::new([1u8; 32]),
1016 to: PubKey::new([2u8; 32]),
1017 kind: MessageKind::Message {
1018 body: "hello".to_string(),
1019 blocks: None,
1020 content_taint: Some(SenderContentTaint::Tainted),
1021 handling_mode: Some(HandlingMode::Queue),
1022 },
1023 sig: Signature::new([0u8; 64]),
1024 };
1025
1026 let bytes = envelope.signable_bytes();
1027
1028 let mut expected = Vec::new();
1029 expected.extend_from_slice(&[
1030 0x84, 0x50, 0x55, 0x0e, 0x84, 0x00, 0xe2, 0x9b, 0x41, 0xd4, 0xa7, 0x16, 0x44, 0x66,
1031 0x55, 0x44, 0x00, 0x00, 0x58, 0x20,
1032 ]);
1033 expected.extend(std::iter::repeat_n(0x01, 32));
1034 expected.extend_from_slice(&[0x58, 0x20]);
1035 expected.extend(std::iter::repeat_n(0x02, 32));
1036 expected.push(0xa4);
1040 expected.extend_from_slice(&[0x64, b'b', b'o', b'd', b'y']);
1041 expected.extend_from_slice(&[0x65, b'h', b'e', b'l', b'l', b'o']);
1042 expected.extend_from_slice(&[0x64, b't', b'y', b'p', b'e']);
1043 expected.extend_from_slice(&[0x67, b'm', b'e', b's', b's', b'a', b'g', b'e']);
1044 expected.extend_from_slice(&[
1045 0x6d, b'c', b'o', b'n', b't', b'e', b'n', b't', b'_', b't', b'a', b'i', b'n', b't',
1046 ]);
1047 expected.extend_from_slice(&[0x67, b't', b'a', b'i', b'n', b't', b'e', b'd']);
1048 expected.extend_from_slice(&[
1049 0x6d, b'h', b'a', b'n', b'd', b'l', b'i', b'n', b'g', b'_', b'm', b'o', b'd', b'e',
1050 ]);
1051 expected.extend_from_slice(&[0x65, b'q', b'u', b'e', b'u', b'e']);
1052
1053 assert_eq!(bytes, expected);
1054 }
1055
1056 #[test]
1057 fn envelope_with_content_taint_signs_and_verifies() {
1058 use crate::identity::Keypair;
1059
1060 for taint in [SenderContentTaint::Clean, SenderContentTaint::Tainted] {
1061 let keypair = Keypair::generate();
1062 let mut envelope = Envelope {
1063 id: Uuid::new_v4(),
1064 from: keypair.public_key(),
1065 to: PubKey::new([2u8; 32]),
1066 kind: MessageKind::Request {
1067 intent: "review".to_string(),
1068 params: serde_json::json!({"pr": 7}),
1069 blocks: None,
1070 content_taint: Some(taint),
1071 handling_mode: None,
1072 },
1073 sig: Signature::new([0u8; 64]),
1074 };
1075 envelope.sign(&keypair);
1076 assert!(
1077 envelope.verify(),
1078 "envelope with content_taint {taint} should verify"
1079 );
1080
1081 envelope.kind = envelope.kind.with_content_taint(Some(match taint {
1084 SenderContentTaint::Clean => SenderContentTaint::Tainted,
1085 SenderContentTaint::Tainted => SenderContentTaint::Clean,
1086 }));
1087 assert!(
1088 !envelope.verify(),
1089 "tampered content_taint must fail signature verification"
1090 );
1091 }
1092 }
1093
1094 #[test]
1095 fn content_taint_none_and_clean_are_distinct_wire_states() {
1096 let undeclared = MessageKind::Message {
1100 body: "hello".to_string(),
1101 blocks: None,
1102 content_taint: None,
1103 handling_mode: None,
1104 };
1105 let clean = MessageKind::Message {
1106 body: "hello".to_string(),
1107 blocks: None,
1108 content_taint: Some(SenderContentTaint::Clean),
1109 handling_mode: None,
1110 };
1111 assert_ne!(undeclared, clean);
1112
1113 let undeclared_json = serde_json::to_value(&undeclared).unwrap();
1114 let clean_json = serde_json::to_value(&clean).unwrap();
1115 assert!(undeclared_json.get("content_taint").is_none());
1116 assert_eq!(clean_json["content_taint"], "clean");
1117
1118 let undeclared_roundtrip: MessageKind = serde_json::from_value(undeclared_json).unwrap();
1119 let clean_roundtrip: MessageKind = serde_json::from_value(clean_json).unwrap();
1120 assert_eq!(undeclared_roundtrip.content_taint(), None);
1121 assert_eq!(
1122 clean_roundtrip.content_taint(),
1123 Some(SenderContentTaint::Clean)
1124 );
1125 }
1126}