1use crate::mode::util::validate_commitment_payload_for_session;
2use crate::mode::{Mode, ModeResponse};
3use macp_core::error::MacpError;
4use macp_core::session::Session;
5use macp_pb::pb::Envelope;
6use prost::Message;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
30#[non_exhaustive]
31pub struct MultiRoundState {
32 pub round: u64,
33 pub participants: Vec<String>,
34 pub contributions: BTreeMap<String, String>,
35 #[serde(default)]
36 pub convergence_type: String,
37 #[serde(default)]
38 pub converged: bool,
39}
40
41#[derive(Debug, Clone, Deserialize)]
43struct ContributeJson {
44 value: String,
45}
46
47#[doc(hidden)]
75pub fn parse_contribute_value(payload: &[u8], semantics_rev: u32) -> Result<String, MacpError> {
76 if payload.is_empty() {
80 return Err(MacpError::InvalidPayload);
81 }
82 let proto_decoded =
83 <macp_pb::multi_round_pb::ContributePayload as Message>::decode(payload).ok();
84 if let Ok(text) = std::str::from_utf8(payload) {
85 if let Ok(c) = serde_json::from_str::<ContributeJson>(text) {
86 let trust_proto = semantics_rev >= 3
87 && proto_decoded
88 .as_ref()
89 .is_some_and(|m| m.encode_to_vec() == payload);
90 if !trust_proto {
91 return Ok(c.value);
92 }
93 tracing::debug!(
94 value_len = payload.len(),
95 "Contribute payload is both valid JSON and canonical proto; preferring proto (issue #192 tie-break)"
96 );
97 }
98 }
99 proto_decoded
100 .map(|c| c.value)
101 .ok_or(MacpError::InvalidPayload)
102}
103
104#[derive(Debug, Serialize)]
106struct ResolutionPayload {
107 converged_value: String,
108 round: u64,
109 #[serde(rename = "final")]
110 final_values: BTreeMap<String, String>,
111}
112
113pub struct MultiRoundMode;
114
115impl MultiRoundMode {
116 fn encode_state(state: &MultiRoundState) -> Vec<u8> {
117 crate::mode::util::encode_mode_state(state)
118 }
119
120 fn decode_state(data: &[u8]) -> Result<MultiRoundState, MacpError> {
121 crate::mode::util::decode_mode_state(data)
122 }
123
124 fn check_convergence(state: &MultiRoundState) -> bool {
125 let all_contributed = state
126 .participants
127 .iter()
128 .all(|p| state.contributions.contains_key(p));
129
130 if !all_contributed {
131 return false;
132 }
133
134 let values: Vec<&String> = state.contributions.values().collect();
135 values.windows(2).all(|w| w[0] == w[1])
136 }
137}
138
139impl Mode for MultiRoundMode {
140 fn on_session_start(
141 &self,
142 session: &Session,
143 _env: &Envelope,
144 ) -> Result<ModeResponse, MacpError> {
145 let participants = session.participants.clone();
146
147 if participants.is_empty() {
148 return Err(MacpError::InvalidPayload);
149 }
150
151 let state = MultiRoundState {
152 round: 0,
153 participants,
154 contributions: BTreeMap::new(),
155 convergence_type: "all_equal".into(),
156 converged: false,
157 };
158
159 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
160 }
161
162 fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
163 match env.message_type.as_str() {
164 "Contribute" => self.handle_contribute(session, env),
165 "Commitment" => self.handle_commitment(session, env),
166 _ => Err(MacpError::InvalidPayload),
167 }
168 }
169
170 fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
171 if env.message_type == "Commitment" {
172 if env.sender != session.initiator_sender {
174 return Err(MacpError::Forbidden);
175 }
176 return Ok(());
177 }
178 if !session.participants.is_empty() && !session.participants.contains(&env.sender) {
180 return Err(MacpError::Forbidden);
181 }
182 Ok(())
183 }
184}
185
186impl MultiRoundMode {
187 fn handle_contribute(
188 &self,
189 session: &Session,
190 env: &Envelope,
191 ) -> Result<ModeResponse, MacpError> {
192 let mut state = Self::decode_state(&session.mode_state)?;
193
194 if state.converged {
195 return Err(MacpError::InvalidPayload);
196 }
197
198 let value = parse_contribute_value(&env.payload, session.semantics_rev)?;
199
200 let previous = state.contributions.get(&env.sender);
201 let value_changed = previous.is_none_or(|prev| *prev != value);
202
203 if value_changed {
204 state.round += 1;
205 state.contributions.insert(env.sender.clone(), value);
206 }
207
208 if Self::check_convergence(&state) {
209 state.converged = true;
210 }
211
212 Ok(ModeResponse::PersistState(Self::encode_state(&state)))
213 }
214
215 fn handle_commitment(
216 &self,
217 session: &Session,
218 env: &Envelope,
219 ) -> Result<ModeResponse, MacpError> {
220 let state = Self::decode_state(&session.mode_state)?;
221
222 if !state.converged {
223 return Err(MacpError::InvalidPayload);
224 }
225
226 validate_commitment_payload_for_session(session, &env.payload)?;
227
228 let converged_value = state
229 .contributions
230 .values()
231 .next()
232 .cloned()
233 .unwrap_or_default();
234 let resolution = ResolutionPayload {
235 converged_value,
236 round: state.round,
237 final_values: state.contributions.clone(),
238 };
239 let resolution_bytes =
240 serde_json::to_vec(&resolution).expect("ResolutionPayload is always serializable");
241
242 Ok(ModeResponse::PersistAndResolve {
243 state: Self::encode_state(&state),
244 resolution: resolution_bytes,
245 })
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 use macp_core::session::CURRENT_SEMANTICS_REV;
254 use macp_pb::pb::CommitmentPayload;
255
256 fn base_session() -> Session {
257 Session::builder("s1", "ext.multi_round.v1", "coordinator")
258 .ttl_ms(60_000)
259 .mode_version("1.0.0")
260 .configuration_version("cfg-1")
261 .build()
262 }
263
264 fn session_start_env() -> Envelope {
265 Envelope {
266 macp_version: "1.0".into(),
267 mode: "ext.multi_round.v1".into(),
268 message_type: "SessionStart".into(),
269 message_id: "m0".into(),
270 session_id: "s1".into(),
271 sender: "coordinator".into(),
272 timestamp_unix_ms: 1_700_000_000_000,
273 payload: vec![],
274 }
275 }
276
277 fn contribute_env_with_payload(sender: &str, payload: Vec<u8>) -> Envelope {
278 Envelope {
279 macp_version: "1.0".into(),
280 mode: "ext.multi_round.v1".into(),
281 message_type: "Contribute".into(),
282 message_id: format!("m_{}", sender),
283 session_id: "s1".into(),
284 sender: sender.into(),
285 timestamp_unix_ms: 1_700_000_000_000,
286 payload,
287 }
288 }
289
290 fn contribute_env(sender: &str, value: &str) -> Envelope {
292 let payload = macp_pb::multi_round_pb::ContributePayload {
293 value: value.into(),
294 }
295 .encode_to_vec();
296 contribute_env_with_payload(sender, payload)
297 }
298
299 fn contribute_env_json(sender: &str, value: &str) -> Envelope {
301 let payload = serde_json::json!({"value": value}).to_string();
302 contribute_env_with_payload(sender, payload.into_bytes())
303 }
304
305 fn commitment_env(sender: &str) -> Envelope {
306 let payload = CommitmentPayload {
307 commitment_id: "c1".into(),
308 action: "multi_round.converged".into(),
309 authority_scope: "test".into(),
310 reason: "converged".into(),
311 mode_version: "1.0.0".into(),
312 policy_version: String::new(),
313 configuration_version: "cfg-1".into(),
314 outcome_positive: true,
315 supersedes: None,
316 }
317 .encode_to_vec();
318 Envelope {
319 macp_version: "1.0".into(),
320 mode: "ext.multi_round.v1".into(),
321 message_type: "Commitment".into(),
322 message_id: "m_commit".into(),
323 session_id: "s1".into(),
324 sender: sender.into(),
325 timestamp_unix_ms: 1_700_000_000_000,
326 payload,
327 }
328 }
329
330 fn session_with_state(state: &MultiRoundState) -> Session {
331 let mut s = base_session();
332 s.mode_state = MultiRoundMode::encode_state(state);
333 s.participants = state.participants.clone();
334 s
335 }
336
337 #[test]
338 fn session_start_parses_valid_config() {
339 let mode = MultiRoundMode;
340 let mut session = base_session();
341 session.participants = vec!["alice".into(), "bob".into()];
342 let env = session_start_env();
343
344 let result = mode.on_session_start(&session, &env).unwrap();
345 match result {
346 ModeResponse::PersistState(data) => {
347 let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
348 assert_eq!(state.round, 0);
349 assert_eq!(state.participants, vec!["alice", "bob"]);
350 assert!(state.contributions.is_empty());
351 assert!(!state.converged);
352 }
353 _ => panic!("Expected PersistState"),
354 }
355 }
356
357 #[test]
358 fn session_start_rejects_empty_participants() {
359 let mode = MultiRoundMode;
360 let session = base_session();
361 let env = session_start_env();
362
363 let err = mode.on_session_start(&session, &env).unwrap_err();
364 assert_eq!(err.to_string(), "InvalidPayload");
365 }
366
367 #[test]
368 fn contribute_first_value_increments_round() {
369 let mode = MultiRoundMode;
370 let state = MultiRoundState {
371 round: 0,
372 participants: vec!["alice".into(), "bob".into()],
373 contributions: BTreeMap::new(),
374 convergence_type: "all_equal".into(),
375 converged: false,
376 };
377 let session = session_with_state(&state);
378 let env = contribute_env("alice", "option_a");
379
380 let result = mode.on_message(&session, &env).unwrap();
381 match result {
382 ModeResponse::PersistState(data) => {
383 let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
384 assert_eq!(new_state.round, 1);
385 assert_eq!(new_state.contributions.get("alice").unwrap(), "option_a");
386 assert!(!new_state.converged);
387 }
388 _ => panic!("Expected PersistState"),
389 }
390 }
391
392 #[test]
393 fn resubmit_same_value_does_not_increment_round() {
394 let mode = MultiRoundMode;
395 let mut contributions = BTreeMap::new();
396 contributions.insert("alice".to_string(), "option_a".to_string());
397 let state = MultiRoundState {
398 round: 1,
399 participants: vec!["alice".into(), "bob".into()],
400 contributions,
401 convergence_type: "all_equal".into(),
402 converged: false,
403 };
404 let session = session_with_state(&state);
405 let env = contribute_env("alice", "option_a");
406
407 let result = mode.on_message(&session, &env).unwrap();
408 match result {
409 ModeResponse::PersistState(data) => {
410 let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
411 assert_eq!(new_state.round, 1);
412 }
413 _ => panic!("Expected PersistState"),
414 }
415 }
416
417 #[test]
418 fn revise_value_increments_round() {
419 let mode = MultiRoundMode;
420 let mut contributions = BTreeMap::new();
421 contributions.insert("alice".to_string(), "option_a".to_string());
422 let state = MultiRoundState {
423 round: 1,
424 participants: vec!["alice".into(), "bob".into()],
425 contributions,
426 convergence_type: "all_equal".into(),
427 converged: false,
428 };
429 let session = session_with_state(&state);
430 let env = contribute_env("alice", "option_b");
431
432 let result = mode.on_message(&session, &env).unwrap();
433 match result {
434 ModeResponse::PersistState(data) => {
435 let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
436 assert_eq!(new_state.round, 2);
437 assert_eq!(new_state.contributions.get("alice").unwrap(), "option_b");
438 }
439 _ => panic!("Expected PersistState"),
440 }
441 }
442
443 #[test]
444 fn convergence_sets_converged_flag() {
445 let mode = MultiRoundMode;
446 let mut contributions = BTreeMap::new();
447 contributions.insert("alice".to_string(), "option_a".to_string());
448 let state = MultiRoundState {
449 round: 1,
450 participants: vec!["alice".into(), "bob".into()],
451 contributions,
452 convergence_type: "all_equal".into(),
453 converged: false,
454 };
455 let session = session_with_state(&state);
456 let env = contribute_env("bob", "option_a");
457
458 let result = mode.on_message(&session, &env).unwrap();
459 match result {
460 ModeResponse::PersistState(data) => {
461 let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
462 assert_eq!(new_state.round, 2);
463 assert!(new_state.converged);
464 }
465 _ => panic!("Expected PersistState (convergence tracked, not auto-resolved)"),
466 }
467 }
468
469 #[test]
470 fn commitment_after_convergence_resolves() {
471 let mode = MultiRoundMode;
472 let mut contributions = BTreeMap::new();
473 contributions.insert("alice".to_string(), "option_a".to_string());
474 contributions.insert("bob".to_string(), "option_a".to_string());
475 let state = MultiRoundState {
476 round: 2,
477 participants: vec!["alice".into(), "bob".into()],
478 contributions,
479 convergence_type: "all_equal".into(),
480 converged: true,
481 };
482 let session = session_with_state(&state);
483 let env = commitment_env("coordinator");
484
485 let result = mode.on_message(&session, &env).unwrap();
486 match result {
487 ModeResponse::PersistAndResolve { resolution, .. } => {
488 let res: serde_json::Value = serde_json::from_slice(&resolution).unwrap();
489 assert_eq!(res["converged_value"], "option_a");
490 assert_eq!(res["round"], 2);
491 }
492 _ => panic!("Expected PersistAndResolve"),
493 }
494 }
495
496 #[test]
497 fn commitment_before_convergence_rejected() {
498 let mode = MultiRoundMode;
499 let state = MultiRoundState {
500 round: 0,
501 participants: vec!["alice".into(), "bob".into()],
502 contributions: BTreeMap::new(),
503 convergence_type: "all_equal".into(),
504 converged: false,
505 };
506 let session = session_with_state(&state);
507 let env = commitment_env("coordinator");
508
509 let err = mode.on_message(&session, &env).unwrap_err();
510 assert_eq!(err.to_string(), "InvalidPayload");
511 }
512
513 #[test]
514 fn contribute_after_convergence_rejected() {
515 let mode = MultiRoundMode;
516 let mut contributions = BTreeMap::new();
517 contributions.insert("alice".to_string(), "option_a".to_string());
518 contributions.insert("bob".to_string(), "option_a".to_string());
519 let state = MultiRoundState {
520 round: 2,
521 participants: vec!["alice".into(), "bob".into()],
522 contributions,
523 convergence_type: "all_equal".into(),
524 converged: true,
525 };
526 let session = session_with_state(&state);
527 let env = contribute_env("alice", "option_b");
528
529 let err = mode.on_message(&session, &env).unwrap_err();
530 assert_eq!(err.to_string(), "InvalidPayload");
531 }
532
533 #[test]
534 fn non_initiator_commitment_rejected() {
535 let mode = MultiRoundMode;
536 let mut contributions = BTreeMap::new();
537 contributions.insert("alice".to_string(), "option_a".to_string());
538 contributions.insert("bob".to_string(), "option_a".to_string());
539 let state = MultiRoundState {
540 round: 2,
541 participants: vec!["alice".into(), "bob".into()],
542 contributions,
543 convergence_type: "all_equal".into(),
544 converged: true,
545 };
546 let session = session_with_state(&state);
547 let env = commitment_env("alice"); let err = mode.authorize_sender(&session, &env).unwrap_err();
550 assert_eq!(err.to_string(), "Forbidden");
551 }
552
553 #[test]
554 fn no_convergence_when_values_differ() {
555 let mode = MultiRoundMode;
556 let mut contributions = BTreeMap::new();
557 contributions.insert("alice".to_string(), "option_a".to_string());
558 let state = MultiRoundState {
559 round: 1,
560 participants: vec!["alice".into(), "bob".into()],
561 contributions,
562 convergence_type: "all_equal".into(),
563 converged: false,
564 };
565 let session = session_with_state(&state);
566 let env = contribute_env("bob", "option_b");
567
568 let result = mode.on_message(&session, &env).unwrap();
569 match result {
570 ModeResponse::PersistState(data) => {
571 let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
572 assert!(!new_state.converged);
573 }
574 _ => panic!("Expected PersistState"),
575 }
576 }
577
578 #[test]
579 fn no_convergence_when_not_all_contributed() {
580 let mode = MultiRoundMode;
581 let state = MultiRoundState {
582 round: 0,
583 participants: vec!["alice".into(), "bob".into(), "carol".into()],
584 contributions: BTreeMap::new(),
585 convergence_type: "all_equal".into(),
586 converged: false,
587 };
588 let session = session_with_state(&state);
589 let env = contribute_env("alice", "option_a");
590
591 let result = mode.on_message(&session, &env).unwrap();
592 assert!(matches!(result, ModeResponse::PersistState(_)));
593 }
594
595 #[test]
596 fn non_contribute_message_rejected() {
597 let mode = MultiRoundMode;
598 let state = MultiRoundState {
599 round: 0,
600 participants: vec!["alice".into()],
601 contributions: BTreeMap::new(),
602 convergence_type: "all_equal".into(),
603 converged: false,
604 };
605 let session = session_with_state(&state);
606 let env = Envelope {
607 macp_version: "1.0".into(),
608 mode: "ext.multi_round.v1".into(),
609 message_type: "Message".into(),
610 message_id: "m1".into(),
611 session_id: "s1".into(),
612 sender: "alice".into(),
613 timestamp_unix_ms: 1_700_000_000_000,
614 payload: b"hello".to_vec(),
615 };
616
617 let err = mode.on_message(&session, &env).unwrap_err();
618 assert_eq!(err.error_code(), "INVALID_ENVELOPE");
619 }
620
621 #[test]
622 fn contribute_invalid_payload_returns_error() {
623 let mode = MultiRoundMode;
624 let state = MultiRoundState {
625 round: 0,
626 participants: vec!["alice".into()],
627 contributions: BTreeMap::new(),
628 convergence_type: "all_equal".into(),
629 converged: false,
630 };
631 let session = session_with_state(&state);
632 let env = Envelope {
633 macp_version: "1.0".into(),
634 mode: "ext.multi_round.v1".into(),
635 message_type: "Contribute".into(),
636 message_id: "m1".into(),
637 session_id: "s1".into(),
638 sender: "alice".into(),
639 timestamp_unix_ms: 1_700_000_000_000,
640 payload: b"not json".to_vec(),
641 };
642
643 let err = mode.on_message(&session, &env).unwrap_err();
644 assert_eq!(err.to_string(), "InvalidPayload");
645 }
646
647 #[test]
651 fn contribute_json_fallback_still_accepted() {
652 let mode = MultiRoundMode;
653 let state = MultiRoundState {
654 round: 0,
655 participants: vec!["alice".into(), "bob".into()],
656 contributions: BTreeMap::new(),
657 convergence_type: "all_equal".into(),
658 converged: false,
659 };
660 let session = session_with_state(&state);
661
662 let result = mode
663 .on_message(&session, &contribute_env_json("alice", "option_a"))
664 .unwrap();
665 match result {
666 ModeResponse::PersistState(data) => {
667 let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
668 assert_eq!(state.contributions["alice"], "option_a");
669 assert_eq!(state.round, 1);
670 }
671 _ => panic!("Expected PersistState"),
672 }
673 }
674
675 #[test]
678 fn proto_and_json_contributions_are_equivalent() {
679 let mode = MultiRoundMode;
680 let state = MultiRoundState {
681 round: 0,
682 participants: vec!["alice".into(), "bob".into()],
683 contributions: BTreeMap::new(),
684 convergence_type: "all_equal".into(),
685 converged: false,
686 };
687 let session = session_with_state(&state);
688
689 let after_json = match mode
690 .on_message(&session, &contribute_env_json("alice", "option_a"))
691 .unwrap()
692 {
693 ModeResponse::PersistState(data) => data,
694 _ => panic!("Expected PersistState"),
695 };
696 let session = {
697 let state: MultiRoundState = serde_json::from_slice(&after_json).unwrap();
698 session_with_state(&state)
699 };
700
701 match mode
703 .on_message(&session, &contribute_env("alice", "option_a"))
704 .unwrap()
705 {
706 ModeResponse::PersistState(data) => {
707 let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
708 assert_eq!(state.round, 1, "unchanged value must not advance the round");
709 assert_eq!(state.contributions["alice"], "option_a");
710 }
711 _ => panic!("Expected PersistState"),
712 }
713 }
714
715 #[test]
718 fn contribute_empty_payload_rejected() {
719 let mode = MultiRoundMode;
720 let state = MultiRoundState {
721 round: 0,
722 participants: vec!["alice".into()],
723 contributions: BTreeMap::new(),
724 convergence_type: "all_equal".into(),
725 converged: false,
726 };
727 let session = session_with_state(&state);
728 let env = contribute_env_with_payload("alice", vec![]);
729
730 let err = mode.on_message(&session, &env).unwrap_err();
731 assert_eq!(err.to_string(), "InvalidPayload");
732 }
733
734 #[test]
735 fn encode_decode_round_trip() {
736 let mut contributions = BTreeMap::new();
737 contributions.insert("alice".into(), "value_a".into());
738 let original = MultiRoundState {
739 round: 5,
740 participants: vec!["alice".into(), "bob".into()],
741 contributions,
742 convergence_type: "all_equal".into(),
743 converged: true,
744 };
745
746 let encoded = MultiRoundMode::encode_state(&original);
747 let decoded = MultiRoundMode::decode_state(&encoded).unwrap();
748
749 assert_eq!(decoded.round, original.round);
750 assert_eq!(decoded.participants, original.participants);
751 assert_eq!(decoded.contributions, original.contributions);
752 assert_eq!(decoded.converged, original.converged);
753 }
754
755 #[test]
756 fn decode_invalid_state_returns_error() {
757 let err = MultiRoundMode::decode_state(b"garbage").unwrap_err();
758 assert_eq!(err.to_string(), "InvalidModeState");
759 }
760
761 #[test]
762 fn three_participant_convergence() {
763 let mode = MultiRoundMode;
764
765 let mut contributions = BTreeMap::new();
766 contributions.insert("alice".to_string(), "option_a".to_string());
767 contributions.insert("bob".to_string(), "option_a".to_string());
768 let state = MultiRoundState {
769 round: 2,
770 participants: vec!["alice".into(), "bob".into(), "carol".into()],
771 contributions,
772 convergence_type: "all_equal".into(),
773 converged: false,
774 };
775 let session = session_with_state(&state);
776 let env = contribute_env("carol", "option_a");
777
778 let result = mode.on_message(&session, &env).unwrap();
779 match result {
780 ModeResponse::PersistState(data) => {
781 let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
782 assert!(new_state.converged);
783 }
784 _ => panic!("Expected PersistState with converged=true"),
785 }
786 }
787
788 #[test]
789 fn unknown_message_type_rejected() {
790 let mode = MultiRoundMode;
791 let state = MultiRoundState {
792 round: 0,
793 participants: vec!["alice".into(), "bob".into()],
794 contributions: BTreeMap::new(),
795 convergence_type: "all_equal".into(),
796 converged: false,
797 };
798 let session = session_with_state(&state);
799 let env = Envelope {
800 macp_version: "1.0".into(),
801 mode: "ext.multi_round.v1".into(),
802 message_type: "UnknownType".into(),
803 message_id: "msg-unknown".into(),
804 session_id: "s1".into(),
805 sender: "alice".into(),
806 timestamp_unix_ms: 0,
807 payload: vec![],
808 };
809 let err = mode.on_message(&session, &env).unwrap_err();
810 assert_eq!(err.error_code(), "INVALID_ENVELOPE");
811 }
812
813 fn length_123_no_leading_brace_value() -> String {
821 format!(r#""value":"{}"}}"#, "a".repeat(112))
822 }
823
824 #[test]
825 fn canonical_proto_length_13_preserved_wrong_at_rev2() {
826 let payload = macp_pb::multi_round_pb::ContributePayload {
827 value: r#"{"value":"x"}"#.into(),
828 }
829 .encode_to_vec();
830 assert_eq!(payload.len(), 15);
831
832 let decoded = parse_contribute_value(&payload, 2).unwrap();
833 assert_eq!(decoded, "x");
834 }
835
836 #[test]
837 fn canonical_proto_length_13_corrected_at_rev3() {
838 let payload = macp_pb::multi_round_pb::ContributePayload {
839 value: r#"{"value":"x"}"#.into(),
840 }
841 .encode_to_vec();
842
843 let decoded = parse_contribute_value(&payload, 3).unwrap();
844 assert_eq!(decoded, r#"{"value":"x"}"#);
845 }
846
847 #[test]
848 fn canonical_proto_length_32_preserved_wrong_at_rev2() {
849 let filler = "a".repeat(20);
850 let value = format!(r#"{{"value":"{}"}}"#, filler);
851 assert_eq!(value.len(), 32);
852 let payload = macp_pb::multi_round_pb::ContributePayload {
853 value: value.clone(),
854 }
855 .encode_to_vec();
856
857 let decoded = parse_contribute_value(&payload, 2).unwrap();
858 assert_eq!(decoded, filler);
859 assert_ne!(decoded, value);
860 }
861
862 #[test]
863 fn canonical_proto_length_32_corrected_at_rev3() {
864 let filler = "a".repeat(20);
865 let value = format!(r#"{{"value":"{}"}}"#, filler);
866 let payload = macp_pb::multi_round_pb::ContributePayload {
867 value: value.clone(),
868 }
869 .encode_to_vec();
870
871 let decoded = parse_contribute_value(&payload, 3).unwrap();
872 assert_eq!(decoded, value);
873 }
874
875 #[test]
876 fn canonical_proto_length_123_preserved_wrong_at_rev2() {
877 let value = length_123_no_leading_brace_value();
878 assert_eq!(value.len(), 123);
879 let payload = macp_pb::multi_round_pb::ContributePayload {
880 value: value.clone(),
881 }
882 .encode_to_vec();
883
884 let decoded = parse_contribute_value(&payload, 2).unwrap();
885 assert_eq!(decoded, "a".repeat(112));
886 assert_ne!(decoded, value);
887 }
888
889 #[test]
890 fn canonical_proto_length_123_corrected_at_rev3() {
891 let value = length_123_no_leading_brace_value();
892 let payload = macp_pb::multi_round_pb::ContributePayload {
893 value: value.clone(),
894 }
895 .encode_to_vec();
896
897 let decoded = parse_contribute_value(&payload, 3).unwrap();
898 assert_eq!(decoded, value);
899 }
900
901 #[test]
914 fn reverse_direction_residual_is_a_documented_trade_off_at_rev3() {
915 let content = "a".repeat(112);
916 let legacy_json_reading = format!(r#"{{"value":"{}"}}"#, content);
917 assert_eq!(legacy_json_reading.len(), 124);
918
919 let mut payload = vec![0x0Au8];
920 payload.extend_from_slice(legacy_json_reading.as_bytes());
921 assert_eq!(payload.len(), 125);
922
923 let decoded = parse_contribute_value(&payload, 3).expect("must decode");
924 assert_eq!(
925 decoded,
926 length_123_no_leading_brace_value(),
927 "proto reading must win -- the identical bytes as canonical_proto_length_123_*, \
928 read from the opposite direction"
929 );
930 assert_ne!(
931 decoded, content,
932 "the legacy-JSON reading (112 a's) is a real, accepted, documented residual, \
933 not what this returns"
934 );
935 }
936
937 #[test]
948 fn whitespace_prefixed_legacy_json_with_unknown_proto_field_shape_still_decodes_as_json() {
949 let mut payload = vec![0x0Au8, 0x0Du8];
953 payload.extend_from_slice(br#"{"value":"x"}"#);
954 payload.extend_from_slice(&[0x20u8, 0x0Au8]);
955 assert_eq!(payload.len(), 17);
956
957 let proto_decoded =
960 <macp_pb::multi_round_pb::ContributePayload as Message>::decode(payload.as_slice())
961 .expect("prost must silently skip the unrecognized field 4 and decode field 1");
962 assert_eq!(proto_decoded.value, "{\"value\":\"x\"}");
963 assert_ne!(
964 proto_decoded.encode_to_vec(),
965 payload,
966 "re-encoding must drop the unknown field 4, proving this payload is not canonical"
967 );
968
969 let decoded = parse_contribute_value(&payload, 3).expect("must decode");
970 assert_eq!(
971 decoded, "x",
972 "non-canonical proto must not defeat the tie-break; JSON is correctly preferred"
973 );
974 }
975
976 #[test]
984 fn canonical_proto_exhaustively_round_trips_at_rev3() {
985 fn cycle_fill(pattern: &[u8], len: usize) -> String {
986 (0..len)
987 .map(|i| pattern[i % pattern.len()] as char)
988 .collect()
989 }
990
991 fn leading_brace(pattern: &[u8], len: usize) -> String {
996 const SKELETON: usize = 12;
997 if len < SKELETON {
998 return cycle_fill(pattern, len);
999 }
1000 format!(r#"{{"value":"{}"}}"#, cycle_fill(pattern, len - SKELETON))
1001 }
1002
1003 fn no_leading_brace(pattern: &[u8], len: usize) -> String {
1007 const SKELETON: usize = 11;
1008 if len < SKELETON {
1009 return cycle_fill(pattern, len);
1010 }
1011 format!(r#""value":"{}"}}"#, cycle_fill(pattern, len - SKELETON))
1012 }
1013
1014 type ContributeValueShape = (&'static str, fn(usize) -> String);
1015
1016 let shapes: [ContributeValueShape; 5] = [
1017 ("plain_ascii", |len| leading_brace(b"a", len)),
1018 ("all_digit", |len| leading_brace(b"7", len)),
1019 ("quoted_json_string_shaped", |len| {
1020 leading_brace(b"1a:", len)
1021 }),
1022 ("object_shaped", |len| leading_brace(b"{}", len)),
1023 ("no_leading_brace", |len| no_leading_brace(b"a", len)),
1024 ];
1025
1026 for (shape_name, make_value) in shapes {
1027 let expected_rev2_mismatches: &[usize] = if shape_name == "no_leading_brace" {
1028 &[123]
1029 } else {
1030 &[13, 32]
1031 };
1032
1033 for len in 1..=300usize {
1034 let value = make_value(len);
1035 assert_eq!(
1036 value.len(),
1037 len,
1038 "shape {shape_name} generator produced the wrong length"
1039 );
1040 let payload = macp_pb::multi_round_pb::ContributePayload {
1041 value: value.clone(),
1042 }
1043 .encode_to_vec();
1044
1045 let rev3 = parse_contribute_value(&payload, 3).unwrap_or_else(|e| {
1046 panic!("shape {shape_name} len {len}: rev3 decode failed: {e:?}")
1047 });
1048 assert_eq!(
1049 rev3, value,
1050 "shape {shape_name} len {len}: rev3 must always recover the true value"
1051 );
1052
1053 let rev2 = parse_contribute_value(&payload, 2).unwrap_or_else(|e| {
1054 panic!("shape {shape_name} len {len}: rev2 decode failed: {e:?}")
1055 });
1056 if expected_rev2_mismatches.contains(&len) {
1057 assert_ne!(
1058 rev2, value,
1059 "shape {shape_name} len {len}: expected a known rev2 collision, \
1060 but it decoded correctly"
1061 );
1062 } else {
1063 assert_eq!(
1064 rev2, value,
1065 "shape {shape_name} len {len}: unexpected rev2 mismatch outside \
1066 the known collision set"
1067 );
1068 }
1069 }
1070 }
1071 }
1072
1073 #[test]
1079 fn handle_contribute_end_to_end_applies_the_rev3_fix() {
1080 let mode = MultiRoundMode;
1081 let state = MultiRoundState {
1082 round: 0,
1083 participants: vec!["alice".into()],
1084 contributions: BTreeMap::new(),
1085 convergence_type: "all_equal".into(),
1086 converged: false,
1087 };
1088 let session = session_with_state(&state);
1089 assert_eq!(
1090 session.semantics_rev, CURRENT_SEMANTICS_REV,
1091 "a freshly-built session must bind the current revision"
1092 );
1093
1094 let payload = macp_pb::multi_round_pb::ContributePayload {
1095 value: r#"{"value":"x"}"#.into(),
1096 }
1097 .encode_to_vec();
1098 let env = contribute_env_with_payload("alice", payload);
1099
1100 let result = mode.on_message(&session, &env).unwrap();
1101 match result {
1102 ModeResponse::PersistState(data) => {
1103 let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
1104 assert_eq!(state.contributions["alice"], r#"{"value":"x"}"#);
1105 }
1106 _ => panic!("Expected PersistState"),
1107 }
1108 }
1109
1110 #[test]
1116 fn handle_contribute_end_to_end_preserves_the_collision_at_rev2() {
1117 let mode = MultiRoundMode;
1118 let state = MultiRoundState {
1119 round: 0,
1120 participants: vec!["alice".into()],
1121 contributions: BTreeMap::new(),
1122 convergence_type: "all_equal".into(),
1123 converged: false,
1124 };
1125 let mut session = session_with_state(&state);
1126 session.semantics_rev = 2;
1127
1128 let payload = macp_pb::multi_round_pb::ContributePayload {
1129 value: r#"{"value":"x"}"#.into(),
1130 }
1131 .encode_to_vec();
1132 let env = contribute_env_with_payload("alice", payload);
1133
1134 let result = mode.on_message(&session, &env).unwrap();
1135 match result {
1136 ModeResponse::PersistState(data) => {
1137 let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
1138 assert_eq!(state.contributions["alice"], "x");
1139 }
1140 _ => panic!("Expected PersistState"),
1141 }
1142 }
1143}