1use std::error::Error as StdError;
18use std::sync::Arc;
19
20use openmls_traits::signatures::Signer;
21use openmls_traits::{OpenMlsProvider, storage::StorageProvider};
22
23use hashgraph_like_consensus::protos::consensus::v1::Proposal;
24use prost::Message;
25use tracing::{error, info, warn};
26
27use crate::{
28 ConsensusPlugin, ConversationEvent, PeerScoringPlugin, ProcessResult, ProposalKind,
29 ScoreSnapshot, StewardList, StewardListConfig, StewardListPlugin,
30 conversation::{ConversationQueues, member_set},
31 freeze::{buffer_commit_candidate, compute_commit_hash},
32 mls_crypto::{DecryptResult, MlsService},
33 process_result::NoopReason,
34 protos::de_mls::messages::v1::{
35 AppMessage, ConversationSync, ConversationUpdateRequest, EventMembershipChange,
36 TimingConfig, TypeMembershipChange, app_message, conversation_update_request,
37 },
38};
39
40use crate::{
41 Conversation, ConversationError, ConversationState, consensus::bridge::forward_incoming_vote,
42};
43
44fn authorize_fast_path_proposal(proposal: &Proposal, mls_sender: &[u8]) -> bool {
52 if proposal.expected_voters_count != 1 {
53 return true;
54 }
55 if proposal.proposal_owner != mls_sender {
56 return false;
57 }
58 let Ok(request) = ConversationUpdateRequest::decode(proposal.payload.as_slice()) else {
59 return false;
60 };
61 matches!(
62 request.payload,
63 Some(conversation_update_request::Payload::RemoveMember(ref r)) if r.member_id == mls_sender
64 )
65}
66
67pub fn decode_inbound_payload<Pr, M: MlsService>(
70 provider: &Pr,
71 conversation: &mut ConversationQueues,
72 mls: &mut M,
73 payload: &[u8],
74) -> Result<ProcessResult, ConversationError>
75where
76 Pr: OpenMlsProvider,
77 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
78{
79 if let Ok(app_message) = AppMessage::decode(payload) {
83 match app_message.payload {
84 Some(app_message::Payload::CommitCandidate(candidate)) => {
85 return buffer_commit_candidate(conversation, mls, candidate);
86 }
87 Some(app_message::Payload::MemberWelcome(welcome)) => {
88 if welcome.welcome_bytes.is_empty() {
89 return Ok(ProcessResult::Noop(NoopReason::EmptyWelcomePayload));
90 }
91 if !conversation
92 .record_welcome_broadcast(compute_commit_hash(&welcome.welcome_bytes))
93 {
94 return Ok(ProcessResult::Noop(NoopReason::DuplicateWelcomeBroadcast));
95 }
96 return Ok(ProcessResult::WelcomeBroadcastReceived(Box::new(welcome)));
97 }
98 _ => {}
99 }
100 }
101
102 let res = mls.decrypt_application_only(provider, payload)?;
106
107 match res {
108 DecryptResult::Application(app_bytes, sender) => {
109 let mut app_msg = AppMessage::decode(app_bytes.as_ref())?;
110 if let Some(app_message::Payload::ConversationMessage(cm)) = &mut app_msg.payload {
113 cm.sender_credential = sender.clone();
114 }
115 if let Some(app_message::Payload::Proposal(proposal)) = &app_msg.payload
116 && !authorize_fast_path_proposal(proposal, &sender)
117 {
118 warn!(
119 conversation = conversation.name(),
120 proposal_id = proposal.proposal_id,
121 sender = ?sender,
122 owner = ?proposal.proposal_owner,
123 "fast-path proposal rejected: sender is not the self-removal target"
124 );
125 return Ok(ProcessResult::Noop(NoopReason::FastPathRejected));
126 }
127 if let Some(app_message::Payload::BanRequest(ban)) = &app_msg.payload
130 && !mls.is_member(&ban.user_to_ban)
131 {
132 info!(
133 conversation = conversation.name(),
134 target = ?ban.user_to_ban,
135 "ban request skipped: target not a member"
136 );
137 return Ok(ProcessResult::Noop(NoopReason::BanTargetNotMember));
138 }
139 app_msg.try_into()
140 }
141 DecryptResult::Removed(_) => Ok(ProcessResult::LeaveConversation),
142 DecryptResult::Ignored => {
143 tracing::debug!(
144 conversation = conversation.name(),
145 "app message ignored (wrong epoch/conversation)"
146 );
147 Ok(ProcessResult::Noop(NoopReason::DecryptIgnored))
148 }
149 _ => {
150 warn!(
151 conversation = conversation.name(),
152 "unexpected MLS message type on app subtopic"
153 );
154 Ok(ProcessResult::Noop(NoopReason::UnexpectedMlsType))
155 }
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum DispatchOutcome {
162 Done,
164 Dropped,
166 LeaveRequested,
170}
171
172impl<C, Sc, St> Conversation<C, Sc, St>
173where
174 C: ConsensusPlugin,
175 Sc: PeerScoringPlugin,
176 St: StewardListPlugin,
177{
178 pub fn process_inbound<Pr>(
184 &mut self,
185 provider: &Pr,
186 sender: &[u8],
187 payload: &[u8],
188 signer: &impl Signer,
189 ) -> Result<DispatchOutcome, ConversationError>
190 where
191 Pr: OpenMlsProvider,
192 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
193 {
194 if sender == self.app_id.as_ref() {
195 return Ok(DispatchOutcome::Dropped);
196 }
197 let result = self.decode_inbound(provider, payload)?;
198 self.dispatch_inbound_result(provider, result, signer)
199 }
200
201 pub fn apply_welcome_sync<Pr>(
202 &mut self,
203 provider: &Pr,
204 sync_bytes: &[u8],
205 signer: &impl Signer,
206 ) -> Result<(), ConversationError>
207 where
208 Pr: OpenMlsProvider,
209 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
210 {
211 if sync_bytes.is_empty() {
212 return Ok(());
213 }
214 let result = self.decode_inbound(provider, sync_bytes)?;
215 self.dispatch_inbound_result(provider, result, signer)?;
216 Ok(())
217 }
218
219 pub(crate) fn dispatch_inbound_result<Pr>(
220 &mut self,
221 provider: &Pr,
222 result: ProcessResult,
223 signer: &impl Signer,
224 ) -> Result<DispatchOutcome, ConversationError>
225 where
226 Pr: OpenMlsProvider,
227 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
228 {
229 match result {
230 ProcessResult::AppMessage(msg) => {
231 self.emit_event(ConversationEvent::AppMessage(*msg));
232 Ok(DispatchOutcome::Done)
233 }
234 ProcessResult::Proposal(proposal) => {
235 self.on_incoming_proposal(*proposal)?;
236 Ok(DispatchOutcome::Done)
237 }
238 ProcessResult::Vote(vote) => {
239 let outcome_applied = self.queues.is_consensus_outcome_applied(vote.proposal_id);
240 forward_incoming_vote::<C>(
241 &self.conversation_id,
242 *vote,
243 &self.services.consensus,
244 outcome_applied,
245 )?;
246 Ok(DispatchOutcome::Done)
247 }
248 ProcessResult::MembershipChangeReceived(request) => {
249 self.handle_incoming_update_request(provider, *request, signer)?;
250 Ok(DispatchOutcome::Done)
251 }
252 ProcessResult::ConversationUpdated => {
253 self.on_conversation_updated(provider, signer)?;
254 Ok(DispatchOutcome::Done)
255 }
256 ProcessResult::LeaveConversation => {
257 self.prepare_self_leave(provider)?;
258 Ok(DispatchOutcome::LeaveRequested)
259 }
260 ProcessResult::CommitCandidateReceived {
261 steward_id: steward,
262 } => {
263 self.on_commit_candidate_received(provider, &steward, signer)?;
264 Ok(DispatchOutcome::Done)
265 }
266 ProcessResult::ConversationSyncReceived(sync) => {
267 self.on_conversation_sync(*sync)?;
268 Ok(DispatchOutcome::Done)
269 }
270 ProcessResult::WelcomeBroadcastReceived(welcome) => {
271 self.emit_event(ConversationEvent::WelcomeReady {
272 welcome: *welcome,
273 minted_locally: false,
274 });
275 Ok(DispatchOutcome::Done)
276 }
277 ProcessResult::Noop(reason) => {
278 tracing::debug!(
279 conversation = %self.conversation_id,
280 ?reason,
281 "inbound dispatched as noop"
282 );
283 Ok(DispatchOutcome::Done)
284 }
285 }
286 }
287
288 fn on_incoming_proposal(&mut self, proposal: Proposal) -> Result<(), ConversationError> {
300 let decoded = match ConversationUpdateRequest::decode(proposal.payload.as_slice()) {
301 Ok(req) => Some(req),
302 Err(e) => {
303 tracing::debug!(
304 proposal_id = proposal.proposal_id,
305 error = %e,
306 "incoming proposal payload failed to decode; treated as opaque commit"
307 );
308 None
309 }
310 };
311 if let Some(req) = decoded.as_ref() {
312 let current_epoch = self.mls().current_epoch()?;
313 match &req.payload {
314 Some(conversation_update_request::Payload::EmergencyCriteria(_)) => {
315 self.queues.insert_emergency(proposal.proposal_id);
316 }
317 Some(conversation_update_request::Payload::MemberInvite(_))
318 | Some(conversation_update_request::Payload::RemoveMember(_)) => {
319 self.queues
320 .insert_pending_update(req.clone(), current_epoch);
321 }
322 _ => {}
323 }
324 }
325 let proposal_id = proposal.proposal_id;
326 let expected_voters = proposal.expected_voters_count;
327 let kind = decoded
328 .as_ref()
329 .map(ProposalKind::of)
330 .unwrap_or(ProposalKind::Commit);
331
332 let scope = C::Scope::from(self.conversation_id.clone());
333 self.services
334 .consensus
335 .process_incoming_proposal(&scope, proposal)?;
336 if expected_voters > 1 {
340 if let Some(request) = decoded {
344 self.emit_event(ConversationEvent::VoteRequested {
345 proposal_id,
346 request,
347 });
348 }
349 let delay = self.config.voting_delay_for(kind);
350 let vote = self.config.liveness_criteria_yes;
351 self.register_auto_vote(proposal_id, delay, vote);
352 }
353 Ok(())
354 }
355
356 pub(crate) fn on_joined<Pr>(
362 &mut self,
363 provider: &Pr,
364 signer: &impl Signer,
365 ) -> Result<(), ConversationError>
366 where
367 Pr: OpenMlsProvider,
368 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
369 {
370 let msg: AppMessage = EventMembershipChange {
371 conversation_id: self.conversation_id.clone(),
372 member: self.self_member_id.to_vec(),
373 change_type: TypeMembershipChange::Add as i32,
374 }
375 .into();
376 let conversation_id = self.conversation_id.clone();
377 let mls = self.mls_mut();
378 let members = mls.members().unwrap_or_default();
379 let payload = mls.build_message(provider, signer, &msg)?;
380 self.broadcast(payload);
381 self.sync_scoring_members(&members);
382 info!(conversation = %conversation_id, "joined conversation");
383 Ok(())
384 }
385
386 fn on_conversation_updated<Pr>(
391 &mut self,
392 provider: &Pr,
393 signer: &impl Signer,
394 ) -> Result<(), ConversationError>
395 where
396 Pr: OpenMlsProvider,
397 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
398 {
399 let mls_members = self.mls().members().unwrap_or_default();
400 self.sync_scoring_members(&mls_members);
401 self.prune_pending_updates_after_commit()?;
402
403 self.services.steward_list.reset_retry();
407 let state = self.current_state();
408 let working_event = if matches!(
409 state,
410 ConversationState::Working
411 | ConversationState::Freezing
412 | ConversationState::Selection
413 | ConversationState::Reelection
414 ) {
415 Some(self.start_working())
416 } else {
417 None
418 };
419
420 self.steward_list_housekeeping(provider, signer)?;
421 self.process_buffered_updates(provider, signer)?;
422 self.maybe_close_recovery_window(provider, signer);
423
424 if let Some(event) = working_event {
425 self.emit_event(ConversationEvent::PhaseChange(event));
426 }
427 Ok(())
428 }
429
430 fn maybe_close_recovery_window<Pr>(&mut self, provider: &Pr, signer: &impl Signer)
433 where
434 Pr: OpenMlsProvider,
435 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
436 {
437 if !self.is_in_recovery_mode() {
438 return;
439 }
440 if let Err(e) = self.initiate_steward_election(provider, true, signer) {
441 info!(
442 conversation = %self.conversation_id,
443 error = %e,
444 "post-recovery election deferred"
445 );
446 }
447 }
448
449 fn prepare_self_leave<Pr>(&mut self, provider: &Pr) -> Result<(), ConversationError>
453 where
454 Pr: OpenMlsProvider,
455 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
456 {
457 self.emit_event(ConversationEvent::Leaving);
458 self.cancel_all_auto_votes();
459 if let Err(e) = self.mls_mut().delete(provider) {
464 error!(error = %e, "self-leave: MLS storage delete failed; leaving anyway");
465 }
466 Ok(())
467 }
468
469 fn on_commit_candidate_received<Pr>(
472 &mut self,
473 provider: &Pr,
474 steward: &[u8],
475 signer: &impl Signer,
476 ) -> Result<(), ConversationError>
477 where
478 Pr: OpenMlsProvider,
479 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
480 {
481 tracing::debug!(
482 conversation = %self.conversation_id,
483 steward = ?steward,
484 "candidate received from peer steward"
485 );
486 let state = self.current_state();
487 if state != ConversationState::Working && state != ConversationState::Reelection {
488 return Ok(());
489 }
490
491 let Some(event) = self.start_freezing() else {
492 return Ok(());
493 };
494 let epoch = self.mls().current_epoch()?;
495 self.queues.start_freeze_round(epoch);
496
497 let self_member_id = Arc::clone(&self.self_member_id);
498 let outbound = if self.services.steward_list.is_steward(&self_member_id) {
499 match self.create_commit_candidate(provider, signer, &self_member_id) {
500 Ok(payload) => payload,
501 Err(e) => {
502 error!(
503 conversation = %self.conversation_id,
504 error = %e,
505 "own commit candidate build failed"
506 );
507 None
508 }
509 }
510 } else {
511 None
512 };
513
514 self.emit_event(ConversationEvent::PhaseChange(event));
515 if let Some(payload) = outbound {
516 self.broadcast(payload);
517 }
518 Ok(())
519 }
520
521 fn on_conversation_sync(&mut self, sync: ConversationSync) -> Result<(), ConversationError> {
526 if self.services.steward_list.current_list().is_some() {
527 return Ok(());
528 }
529 let conversation_id = self.conversation_id.clone();
530 let (members, current_epoch) = {
531 let mls = self.mls();
532 (mls.members()?, mls.current_epoch()?)
533 };
534 let local_default_peer_score = self.services.scoring.default_score();
535 if !validate_conversation_sync(
536 &conversation_id,
537 &sync,
538 current_epoch,
539 &members,
540 local_default_peer_score,
541 )? {
542 return Ok(());
543 }
544
545 let sn = sync.steward_members.len();
546 self.apply_conversation_sync_to_entry(&sync)?;
547
548 info!(
549 conversation = %conversation_id,
550 election_epoch = sync.election_epoch,
551 stewards = sn,
552 scores = sync.peer_scores.len(),
553 timing = sync.timing.is_some(),
554 "conversation sync applied"
555 );
556 Ok(())
557 }
558
559 fn apply_conversation_sync_to_entry(
560 &mut self,
561 sync: &ConversationSync,
562 ) -> Result<(), ConversationError> {
563 let mut protocol_config =
564 StewardListConfig::new(sync.sn_min as usize, sync.sn_max as usize)?;
565 protocol_config.allow_subset_candidates = sync.allow_subset_candidates;
566
567 let sn = sync.steward_members.len();
568 self.services.steward_list.set_config(protocol_config);
569 self.services.steward_list.install_list(
570 sync.election_epoch,
571 &sync.steward_members,
572 sn,
573 sync.retry_round,
574 )?;
575 self.services
576 .steward_list
577 .set_max_retries(sync.max_reelection_attempts);
578 self.services
579 .scoring
580 .set_threshold(sync.threshold_peer_score);
581 let snapshot = ScoreSnapshot {
582 diverged: sync
583 .peer_scores
584 .iter()
585 .map(|ps| (ps.member_id.clone(), ps.score))
586 .collect(),
587 };
588 let _ = self.services.scoring.apply_snapshot(&snapshot);
594 self.config.liveness_criteria_yes = sync.liveness_criteria_yes;
595 self.config.pending_update_max_epochs = sync.pending_update_max_epochs;
596 if let Some(timing) = &sync.timing {
597 self.config.apply_timing(timing);
598 }
599 Ok(())
600 }
601}
602
603fn validate_conversation_sync(
615 conversation_id: &str,
616 sync: &ConversationSync,
617 current_epoch: u64,
618 members: &[Vec<u8>],
619 local_default_peer_score: i64,
620) -> Result<bool, ConversationError> {
621 if sync.election_epoch > current_epoch {
622 info!(
623 conversation = conversation_id,
624 election_epoch = sync.election_epoch,
625 current_epoch,
626 "conversation sync rejected: election_epoch > current_epoch"
627 );
628 return Ok(false);
629 }
630
631 let members_set = member_set(members);
632 let any_present = sync
633 .steward_members
634 .iter()
635 .any(|s| members_set.contains(s.as_slice()));
636 let ordering_valid = StewardList::validate(
637 &sync.steward_members,
638 sync.election_epoch,
639 conversation_id.as_bytes(),
640 &sync.steward_members,
641 &StewardListConfig::new(sync.sn_min as usize, sync.sn_max as usize)?,
642 sync.retry_round,
643 )?;
644 if !(any_present && ordering_valid) {
645 info!(
646 conversation = conversation_id,
647 any_present,
648 ordering = ordering_valid,
649 "conversation sync rejected: invalid"
650 );
651 return Ok(false);
652 }
653
654 if let Some(timing) = &sync.timing
655 && let Some(zero_field) = first_zero_timing_field(timing)
656 {
657 info!(
658 conversation = conversation_id,
659 field = zero_field,
660 "conversation sync rejected: zero-valued timing field"
661 );
662 return Ok(false);
663 }
664
665 if local_default_peer_score <= sync.threshold_peer_score {
666 info!(
667 conversation = conversation_id,
668 local_default_peer_score,
669 threshold_peer_score = sync.threshold_peer_score,
670 "conversation sync rejected: default_peer_score at or below threshold would mark new members removable on add"
671 );
672 return Ok(false);
673 }
674 Ok(true)
675}
676
677fn first_zero_timing_field(timing: &TimingConfig) -> Option<&'static str> {
682 if timing.commit_inactivity_duration_ms == 0 {
683 Some("commit_inactivity_duration_ms")
684 } else if timing.freeze_duration_ms == 0 {
685 Some("freeze_duration_ms")
686 } else if timing.proposal_expiration_ms == 0 {
687 Some("proposal_expiration_ms")
688 } else if timing.consensus_timeout_ms == 0 {
689 Some("consensus_timeout_ms")
690 } else if timing.recovery_inactivity_duration_ms == 0 {
691 Some("recovery_inactivity_duration_ms")
692 } else {
693 None
694 }
695}
696
697#[cfg(test)]
698mod decode_inbound_payload_tests {
699 use super::*;
700 use crate::conversation::self_leave_proposal_id;
701
702 fn member(id: u8) -> Vec<u8> {
703 vec![id; 20]
704 }
705
706 fn remove_payload(member_id: &[u8]) -> Vec<u8> {
707 ConversationUpdateRequest::remove_member(member_id.to_vec()).encode_to_vec()
708 }
709
710 fn proposal_for_self_remove(sender: &[u8], expected_voters: u32) -> Proposal {
711 Proposal {
712 name: "test".into(),
713 payload: remove_payload(sender),
714 proposal_id: self_leave_proposal_id(sender),
715 proposal_owner: sender.to_vec(),
716 votes: Vec::new(),
717 expected_voters_count: expected_voters,
718 round: 1,
719 timestamp: 0,
720 expiration_timestamp: u64::MAX,
721 liveness_criteria_yes: true,
722 }
723 }
724
725 #[test]
726 fn fast_path_allows_self_removal_matching_sender() {
727 let sender = member(1);
728 let proposal = proposal_for_self_remove(&sender, 1);
729 assert!(authorize_fast_path_proposal(&proposal, &sender));
730 }
731
732 #[test]
733 fn fast_path_rejects_target_other_than_sender() {
734 let sender = member(1);
735 let victim = member(2);
736 let mut proposal = proposal_for_self_remove(&victim, 1);
737 proposal.proposal_owner = sender.clone();
738 assert!(!authorize_fast_path_proposal(&proposal, &sender));
739 }
740
741 #[test]
742 fn fast_path_rejects_owner_mismatch() {
743 let sender = member(1);
744 let imposter = member(3);
745 let mut proposal = proposal_for_self_remove(&sender, 1);
746 proposal.proposal_owner = imposter;
747 assert!(!authorize_fast_path_proposal(&proposal, &sender));
748 }
749
750 #[test]
751 fn fast_path_rejects_non_remove_payload() {
752 let sender = member(1);
753 let mut proposal = proposal_for_self_remove(&sender, 1);
754 proposal.payload = vec![0xff; 8]; assert!(!authorize_fast_path_proposal(&proposal, &sender));
756 }
757
758 #[test]
759 fn expected_voters_gt_one_bypasses_authz() {
760 let sender = member(1);
761 let victim = member(2);
762 let mut proposal = proposal_for_self_remove(&victim, 5);
763 proposal.proposal_owner = sender.clone();
764 assert!(authorize_fast_path_proposal(&proposal, &sender));
767 }
768}
769
770#[cfg(test)]
771mod conversation_sync_tests {
772 use super::*;
773 use crate::protos::de_mls::messages::v1::TimingConfig;
774
775 fn nonzero_timing() -> TimingConfig {
776 TimingConfig {
777 commit_inactivity_duration_ms: 60_000,
778 freeze_duration_ms: 30_000,
779 proposal_expiration_ms: 3_600_000,
780 consensus_timeout_ms: 30_000,
781 recovery_inactivity_duration_ms: 5_000,
782 }
783 }
784
785 #[test]
786 fn nonzero_timing_passes() {
787 assert!(first_zero_timing_field(&nonzero_timing()).is_none());
788 }
789
790 fn valid_sync_with(threshold: i64) -> ConversationSync {
791 ConversationSync {
792 steward_members: vec![b"alice".to_vec()],
793 election_epoch: 0,
794 sn_min: 1,
795 sn_max: 5,
796 allow_subset_candidates: false,
797 peer_scores: vec![],
798 timing: Some(nonzero_timing()),
799 retry_round: 0,
800 max_reelection_attempts: 1,
801 liveness_criteria_yes: true,
802 threshold_peer_score: threshold,
803 pending_update_max_epochs: 3,
804 }
805 }
806
807 #[test]
810 fn validate_accepts_default_above_threshold() {
811 let sync = valid_sync_with(0);
812 assert!(validate_conversation_sync("g", &sync, 0, &[b"alice".to_vec()], 100).unwrap());
813 }
814
815 #[test]
819 fn validate_rejects_default_equal_to_threshold() {
820 let sync = valid_sync_with(50);
821 assert!(!validate_conversation_sync("g", &sync, 0, &[b"alice".to_vec()], 50).unwrap());
822 }
823
824 #[test]
827 fn validate_rejects_default_below_threshold() {
828 let sync = valid_sync_with(100);
829 assert!(!validate_conversation_sync("g", &sync, 0, &[b"alice".to_vec()], 50).unwrap());
830 }
831
832 #[test]
833 fn each_zero_field_is_detected() {
834 let cases = [
835 (
836 "commit_inactivity_duration_ms",
837 TimingConfig {
838 commit_inactivity_duration_ms: 0,
839 ..nonzero_timing()
840 },
841 ),
842 (
843 "freeze_duration_ms",
844 TimingConfig {
845 freeze_duration_ms: 0,
846 ..nonzero_timing()
847 },
848 ),
849 (
850 "proposal_expiration_ms",
851 TimingConfig {
852 proposal_expiration_ms: 0,
853 ..nonzero_timing()
854 },
855 ),
856 (
857 "consensus_timeout_ms",
858 TimingConfig {
859 consensus_timeout_ms: 0,
860 ..nonzero_timing()
861 },
862 ),
863 (
864 "recovery_inactivity_duration_ms",
865 TimingConfig {
866 recovery_inactivity_duration_ms: 0,
867 ..nonzero_timing()
868 },
869 ),
870 ];
871 for (name, timing) in cases {
872 assert_eq!(
873 first_zero_timing_field(&timing),
874 Some(name),
875 "expected field {name} to be detected as zero"
876 );
877 }
878 }
879}