1use std::collections::{HashMap, HashSet};
9
10use openmls::{
11 credentials::{BasicCredential, Credential as OpenMlsCredential, errors::BasicCredentialError},
12 extensions::{Extension, Extensions},
13 group::{GroupContext, MlsGroup as OpenMlsGroup, StagedCommit},
14 messages::proposals::{AppDataUpdateOperation, Proposal, ProposalType},
15 prelude::{LeafNodeIndex, Sender},
16 treesync::LeafNode,
17};
18use prost::Message;
19use serde::Serialize;
20use thiserror::Error;
21use xmtp_common::RetryableError;
22use xmtp_mls_common::{
23 app_data::{component_id::ComponentId, component_source::ComponentSourceError},
24 group_metadata::{DmMembers, GroupMetadata, GroupMetadataError},
25 group_mutable_metadata::{GroupMutableMetadata, GroupMutableMetadataError},
26 libxmtp_version::{InvalidVersionFormat, LibXMTPVersion},
27};
28use xmtp_proto::xmtp::{
29 identity::MlsCredential,
30 mls::message_contents::group_updated::{
31 Inbox as InboxProto, MetadataFieldChange as MetadataFieldChangeProto,
32 },
33};
34
35use crate::{
36 group_membership::{GroupMembership, MembershipDiff},
37 group_permissions::GroupMutablePermissionsError,
38};
39
40#[derive(Debug, Error)]
42pub enum CommitRuleError {
43 #[error(
45 "Identity sequence {identity_sequence} does not precede group sequence {envelope_sequence}"
46 )]
47 IdentitySequenceNotBeforeEnvelope {
48 identity_sequence: u64,
50 envelope_sequence: u64,
52 },
53 #[error("Actor could not be found")]
54 ActorCouldNotBeFound,
55 #[error("Inbox validation failed for {0}")]
57 InboxValidationFailed(String),
58 #[error("Insufficient permissions")]
59 InsufficientPermissions,
60 #[error("Invalid version format: {0}")]
61 InvalidVersionFormat(String),
62 #[error("Minimum supported protocol version {0} exceeds current version")]
63 ProtocolVersionTooLow(String),
64 #[error("Actor not a member of the group")]
66 ActorNotMember,
67 #[error("Subject not a member of the group")]
68 SubjectDoesNotExist,
69 #[error("Multiple actors in commit")]
72 MultipleActors,
73 #[error("Missing group membership")]
74 MissingGroupMembership,
75 #[error("Missing mutable metadata")]
76 MissingMutableMetadata,
77 #[error("Unexpected installations added:")]
78 UnexpectedInstallationAdded(Vec<Vec<u8>>),
79 #[error("Sequence ID can only increase")]
80 SequenceIdDecreased,
81 #[error("Unexpected installations removed: {0:?}")]
82 UnexpectedInstallationsRemoved(Vec<Vec<u8>>),
83 #[error(transparent)]
84 GroupMetadata(#[from] GroupMetadataError),
85 #[error(transparent)]
86 MlsCredential(#[from] BasicCredentialError),
87 #[error(transparent)]
88 GroupMutableMetadata(#[from] GroupMutableMetadataError),
89 #[error(transparent)]
90 ProtoDecode(#[from] prost::DecodeError),
91 #[error(transparent)]
92 GroupMutablePermissions(#[from] GroupMutablePermissionsError),
93 #[error("PSKs are not supported")]
94 NoPSKSupport,
95 #[error("Invalid AppDataUpdate proposal list for component {0}")]
98 InvalidAppDataUpdateList(ComponentId),
99 #[error("Unsupported proposal type: {0:?}")]
100 UnsupportedProposalType(ProposalType),
101 #[error("Exceeded max characters for this field. Must be under: {length}")]
102 TooManyCharacters { length: usize },
103 #[error("Proposer could not be determined for inbox change in proposal-enabled group")]
104 ProposerNotFound,
105 #[error("Proposals are not enabled on this group")]
106 ProposalsNotEnabled,
107 #[error("min_version {requested} would downgrade existing floor {current}")]
112 MinVersionDowngrade { requested: String, current: String },
113 #[error("min_version remove is rejected; existing floor is {current}")]
118 MinVersionRemoveOnExistingFloor { current: String },
119 #[error(transparent)]
126 ComponentSource(#[from] ComponentSourceError),
127 #[error(transparent)]
128 Conversion(#[from] xmtp_proto::ConversionError),
129}
130
131impl RetryableError for CommitRuleError {
132 fn is_retryable(&self) -> bool {
133 false
134 }
135}
136
137impl CommitRuleError {
138 pub fn is_safe_rejection(&self) -> bool {
141 match self {
142 Self::IdentitySequenceNotBeforeEnvelope { .. }
143 | Self::ActorCouldNotBeFound
144 | Self::InboxValidationFailed(_)
145 | Self::InsufficientPermissions
146 | Self::InvalidVersionFormat(_)
147 | Self::ActorNotMember
148 | Self::SubjectDoesNotExist
149 | Self::MultipleActors
150 | Self::UnexpectedInstallationAdded(_)
151 | Self::SequenceIdDecreased
152 | Self::UnexpectedInstallationsRemoved(_)
153 | Self::MlsCredential(_)
154 | Self::ProtoDecode(_)
155 | Self::NoPSKSupport
156 | Self::InvalidAppDataUpdateList(_)
157 | Self::TooManyCharacters { .. }
158 | Self::ProposerNotFound
159 | Self::ProposalsNotEnabled
160 | Self::MinVersionDowngrade { .. }
161 | Self::MinVersionRemoveOnExistingFloor { .. }
162 | Self::MissingGroupMembership
163 | Self::MissingMutableMetadata
164 | Self::GroupMetadata(_)
165 | Self::GroupMutableMetadata(_)
166 | Self::GroupMutablePermissions(_)
167 | Self::ComponentSource(_)
168 | Self::Conversion(_) => true,
169 Self::ProtocolVersionTooLow(_) | Self::UnsupportedProposalType(_) => false,
170 }
171 }
172}
173
174impl From<InvalidVersionFormat> for CommitRuleError {
175 fn from(error: InvalidVersionFormat) -> Self {
176 Self::InvalidVersionFormat(error.0)
177 }
178}
179
180#[derive(Clone, PartialEq, Hash, Serialize)]
181pub struct CommitParticipant {
182 pub inbox_id: String,
183 pub installation_id: Vec<u8>,
184 pub is_creator: bool,
185 pub is_admin: bool,
186 pub is_super_admin: bool,
187}
188
189impl std::fmt::Debug for CommitParticipant {
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 let Self {
192 inbox_id,
193 installation_id,
194 is_creator,
195 is_admin,
196 is_super_admin,
197 } = &self;
198 write!(
199 f,
200 "CommitParticipant {{ inbox_id={}, installation_id={}, is_creator={}, is_admin={}, is_super_admin={} }}",
201 inbox_id,
202 hex::encode(installation_id),
203 is_creator,
204 is_admin,
205 is_super_admin,
206 )
207 }
208}
209
210impl CommitParticipant {
211 pub fn build(
212 inbox_id: String,
213 installation_id: Vec<u8>,
214 immutable_metadata: &GroupMetadata,
215 mutable_metadata: &GroupMutableMetadata,
216 ) -> Self {
217 let is_creator = inbox_id == immutable_metadata.creator_inbox_id;
218 let is_admin = mutable_metadata.is_admin(&inbox_id);
219 let is_super_admin = mutable_metadata.is_super_admin(&inbox_id);
220
221 Self {
222 inbox_id,
223 installation_id,
224 is_creator,
225 is_admin,
226 is_super_admin,
227 }
228 }
229
230 pub fn from_leaf_node(
231 leaf_node: &LeafNode,
232 immutable_metadata: &GroupMetadata,
233 mutable_metadata: &GroupMutableMetadata,
234 ) -> Result<Self, CommitRuleError> {
235 let inbox_id = inbox_id_from_credential(leaf_node.credential())?;
236 let installation_id = leaf_node.signature_key().as_slice().to_vec();
237
238 Ok(Self::build(
239 inbox_id,
240 installation_id,
241 immutable_metadata,
242 mutable_metadata,
243 ))
244 }
245
246 fn actor_authority(&self) -> xmtp_mls_common::app_data::validation::ActorAuthority {
249 xmtp_mls_common::app_data::validation::ActorAuthority {
250 is_admin: self.is_admin,
251 is_super_admin: self.is_super_admin,
252 }
253 }
254}
255
256impl From<&CommitParticipant> for xmtp_mls_common::app_data::validation::ActorAuthority {
257 fn from(participant: &CommitParticipant) -> Self {
258 participant.actor_authority()
259 }
260}
261
262pub struct MembershipValidationInfo<'a> {
265 pub actor: &'a CommitParticipant,
266 pub added_inboxes: &'a [Inbox],
267 pub removed_inboxes: &'a [Inbox],
268 pub dm_members: Option<&'a DmMembers<String>>,
269}
270
271#[derive(Debug, Clone, Serialize)]
274pub struct MetadataChanges {
275 pub metadata_field_changes: Vec<MetadataFieldChange>,
276 pub admins_added: Vec<Inbox>,
277 pub admins_removed: Vec<Inbox>,
278 pub super_admins_added: Vec<Inbox>,
279 pub super_admins_removed: Vec<Inbox>,
280}
281
282impl MetadataChanges {
283 pub fn is_empty(&self) -> bool {
284 self.metadata_field_changes.is_empty()
285 && self.admins_added.is_empty()
286 && self.admins_removed.is_empty()
287 && self.super_admins_added.is_empty()
288 && self.super_admins_removed.is_empty()
289 }
290}
291
292#[derive(Debug, Clone, Serialize)]
293pub struct Inbox {
294 pub inbox_id: String,
295 #[allow(dead_code)]
296 pub is_creator: bool,
297 pub is_admin: bool,
298 pub is_super_admin: bool,
299 #[serde(skip_serializing_if = "Option::is_none")]
301 pub proposer: Option<CommitParticipant>,
302}
303
304#[derive(Debug, Clone, Serialize)]
305pub struct MetadataFieldChange {
306 pub field_name: String,
307 #[allow(dead_code)]
308 pub old_value: Option<String>,
309 #[allow(dead_code)]
310 pub new_value: Option<String>,
311}
312
313impl MetadataFieldChange {
314 pub fn new(field_name: String, old_value: Option<String>, new_value: Option<String>) -> Self {
315 Self {
316 field_name,
317 old_value,
318 new_value,
319 }
320 }
321}
322
323pub fn validate_committed_proposals<'a>(
338 proposals: impl IntoIterator<Item = &'a Proposal>,
339 has_state: impl Fn(ComponentId) -> bool,
340) -> Result<(), CommitRuleError> {
341 let mut rejection = None;
342 let mut updated = HashSet::new();
343 let mut removed = HashSet::new();
344 for proposal in proposals {
345 match proposal {
346 Proposal::Add(_) | Proposal::Update(_) | Proposal::Remove(_) => {}
347 Proposal::AppDataUpdate(update) => {
348 let id = ComponentId::from(update.component_id());
349 let valid = match update.operation() {
350 AppDataUpdateOperation::Update(_) => {
351 updated.insert(id);
352 !removed.contains(&id)
353 }
354 AppDataUpdateOperation::Remove => {
355 removed.insert(id) && !updated.contains(&id) && has_state(id)
356 }
357 };
358 if !valid {
359 rejection.get_or_insert(CommitRuleError::InvalidAppDataUpdateList(id));
360 }
361 }
362 Proposal::PreSharedKey(_) => {
363 rejection.get_or_insert(CommitRuleError::NoPSKSupport);
364 }
365 unsupported => {
366 return Err(CommitRuleError::UnsupportedProposalType(
367 unsupported.proposal_type(),
368 ));
369 }
370 }
371 }
372 rejection.map_or(Ok(()), Err)
373}
374
375pub struct ProposalChanges {
376 pub added_installations: HashSet<Vec<u8>>,
377 pub removed_installations: HashSet<Vec<u8>>,
378 pub credentials_to_verify: Vec<CommitParticipant>,
379 pub added_inbox_proposers: HashMap<String, CommitParticipant>,
381 pub removed_inbox_proposers: HashMap<String, CommitParticipant>,
383}
384
385pub fn get_proposal_changes(
391 staged_commit: &StagedCommit,
392 openmls_group: &OpenMlsGroup,
393 immutable_metadata: &GroupMetadata,
394 mutable_metadata: &GroupMutableMetadata,
395) -> Result<ProposalChanges, CommitRuleError> {
396 let mut added_installations: HashSet<Vec<u8>> = HashSet::new();
398 let mut removed_installations: HashSet<Vec<u8>> = HashSet::new();
399 let mut credentials_to_verify: Vec<CommitParticipant> = vec![];
400 let mut added_inbox_proposers: HashMap<String, CommitParticipant> = HashMap::new();
401 let mut removed_inbox_proposers: HashMap<String, CommitParticipant> = HashMap::new();
402
403 for proposal in staged_commit.queued_proposals() {
404 let proposer = match proposal.sender() {
406 Sender::Member(leaf_index) => extract_commit_participant(
407 leaf_index,
408 openmls_group,
409 immutable_metadata,
410 mutable_metadata,
411 )?,
412 _ => return Err(CommitRuleError::ActorNotMember),
413 };
414
415 match proposal.proposal() {
416 Proposal::Update(update_proposal) => {
419 credentials_to_verify.push(CommitParticipant::from_leaf_node(
420 update_proposal.leaf_node(),
421 immutable_metadata,
422 mutable_metadata,
423 )?);
424 }
425 Proposal::Add(add_proposal) => {
427 let leaf_node = add_proposal.key_package().leaf_node();
430 let installation_id = leaf_node.signature_key().as_slice().to_vec();
431 let inbox_id = inbox_id_from_credential(leaf_node.credential())?;
432 added_installations.insert(installation_id);
433 added_inbox_proposers.insert(inbox_id, proposer);
434 }
435 Proposal::Remove(remove_proposal) => {
437 let leaf_node = openmls_group
438 .member_at(remove_proposal.removed())
439 .ok_or(CommitRuleError::SubjectDoesNotExist)?;
440 let installation_id = leaf_node.signature_key.to_vec();
441 let inbox_id = inbox_id_from_credential(&leaf_node.credential)?;
442 removed_installations.insert(installation_id);
443 removed_inbox_proposers.insert(inbox_id, proposer);
444 }
445 _ => continue,
446 }
447 }
448
449 Ok(ProposalChanges {
450 added_installations,
451 removed_installations,
452 credentials_to_verify,
453 added_inbox_proposers,
454 removed_inbox_proposers,
455 })
456}
457
458pub fn get_latest_group_membership(
464 staged_commit: &StagedCommit,
465) -> Result<GroupMembership, CommitRuleError> {
466 extract_group_membership(staged_commit.group_context().extensions())
467}
468
469pub fn read_post_commit_mutable_metadata(
472 group: &OpenMlsGroup,
473 staged_commit: &StagedCommit,
474 registry: &xmtp_mls_common::app_data::component_registry::ComponentRegistry,
475) -> Result<GroupMutableMetadata, CommitRuleError> {
476 use openmls::extensions::{AppDataDictionary, AppDataDictionaryExtension};
477 use xmtp_mls_common::{
478 app_data::component_id::ComponentId,
479 group_mutable_metadata::{METADATA_FIELD_COMPONENT_MAP, merge_dict_into_mutable_metadata},
480 };
481 let mut dictionary = AppDataDictionary::new();
482 for id in METADATA_FIELD_COMPONENT_MAP
483 .iter()
484 .map(|(_, id)| *id)
485 .chain([ComponentId::ADMIN_LIST, ComponentId::SUPER_ADMIN_LIST])
486 {
487 if let Some(bytes) =
488 xmtp_mls_common::app_data::component_source::read_post_commit_component_bytes(
489 id,
490 group,
491 staged_commit,
492 registry,
493 )?
494 {
495 dictionary.insert(id.as_u16(), bytes);
496 }
497 }
498 let extensions = Extensions::from_vec(vec![Extension::AppDataDictionary(
499 AppDataDictionaryExtension::new(dictionary),
500 )])
501 .expect("one dictionary extension has no duplicate types");
502 let mut metadata = GroupMutableMetadata::new(HashMap::new(), Vec::new(), Vec::new());
503 merge_dict_into_mutable_metadata(&mut metadata, &extensions)?;
504 Ok(metadata)
505}
506
507pub fn read_committed_metadata(
508 group: &OpenMlsGroup,
509) -> Result<(GroupMetadata, GroupMutableMetadata), CommitRuleError> {
510 let seed = xmtp_mls_common::app_data::component_source::read_group_metadata_from_dict(group)?
511 .ok_or(GroupMetadataError::MissingExtension)?;
512 let immutable =
513 GroupMetadata::try_from(xmtp_proto::xmtp::mls::message_contents::GroupMetadataV1 {
514 conversation_type: seed.conversation_type,
515 creator_inbox_id: seed.creator_inbox_id,
516 creator_account_address: String::new(),
517 dm_members: seed.dm_members,
518 oneshot_message: seed.oneshot,
519 })?;
520 let mut mutable = GroupMutableMetadata::new(HashMap::new(), Vec::new(), Vec::new());
521 xmtp_mls_common::app_data::component_source::merge_app_data_into_mutable_metadata(
522 &mut mutable,
523 group,
524 )?;
525 Ok((immutable, mutable))
526}
527
528pub fn validate_identity_sequence_order(
532 membership: &GroupMembership,
533 envelope_sequence: u64,
534) -> Result<(), CommitRuleError> {
535 for identity_sequence in membership.members.values() {
536 if *identity_sequence >= envelope_sequence {
537 return Err(CommitRuleError::IdentitySequenceNotBeforeEnvelope {
538 identity_sequence: *identity_sequence,
539 envelope_sequence,
540 });
541 }
542 }
543 Ok(())
544}
545
546pub fn extract_readded_installations(
550 actor: &CommitParticipant,
551 added_installations: &mut HashSet<Vec<u8>>,
552 removed_installations: &mut HashSet<Vec<u8>>,
553 failed_installations: &mut HashSet<Vec<u8>>,
554) -> HashSet<Vec<u8>> {
555 if !actor.is_super_admin {
556 return HashSet::new();
557 }
558 let successfully_readded = added_installations
559 .intersection(removed_installations)
560 .cloned()
561 .collect::<HashSet<Vec<u8>>>();
562 added_installations.retain(|installation_id| !successfully_readded.contains(installation_id));
563 removed_installations.retain(|installation_id| !successfully_readded.contains(installation_id));
564
565 let unsuccessfully_readded = failed_installations
567 .intersection(removed_installations)
568 .cloned()
569 .collect::<HashSet<Vec<u8>>>();
570 failed_installations
571 .retain(|installation_id| !unsuccessfully_readded.contains(installation_id));
572 removed_installations
573 .retain(|installation_id| !unsuccessfully_readded.contains(installation_id));
574
575 successfully_readded
576 .union(&unsuccessfully_readded)
577 .cloned()
578 .collect()
579}
580
581pub fn get_current_group_members(openmls_group: &OpenMlsGroup) -> HashSet<Vec<u8>> {
582 openmls_group
583 .members()
584 .map(|member| member.signature_key)
585 .collect()
586}
587
588pub fn validate_membership_diff(
592 old_membership: &GroupMembership,
593 new_membership: &GroupMembership,
594 diff: &MembershipDiff<'_>,
595) -> Result<(), CommitRuleError> {
596 for inbox_id in diff.updated_inboxes.iter() {
597 let old_sequence_id = old_membership
598 .get(inbox_id)
599 .ok_or(CommitRuleError::SubjectDoesNotExist)?;
600 let new_sequence_id = new_membership
601 .get(inbox_id)
602 .ok_or(CommitRuleError::SubjectDoesNotExist)?;
603
604 if new_sequence_id.lt(old_sequence_id) {
605 return Err(CommitRuleError::SequenceIdDecreased);
606 }
607 }
608
609 Ok(())
610}
611
612pub fn validate_one_app_data_update(
637 component_id: xmtp_mls_common::app_data::component_id::ComponentId,
638 operation: &openmls::messages::proposals::AppDataUpdateOperation,
639 actor: xmtp_mls_common::app_data::validation::ActorAuthority,
640 proposer_inbox_id: &str,
641 registry: &xmtp_mls_common::app_data::component_registry::ComponentRegistry,
642 openmls_group: &OpenMlsGroup,
643 dm_members: Option<&DmMembers<String>>,
644) -> Result<(), CommitRuleError> {
645 use xmtp_mls_common::app_data::{
646 component_id::ComponentId, component_source::read_from_app_data_dict,
647 };
648
649 let old_value = read_from_app_data_dict(component_id, openmls_group);
654 let membership = membership_inbox_ids(
657 read_from_app_data_dict(ComponentId::GROUP_MEMBERSHIP, openmls_group).as_deref(),
658 );
659 if component_id == ComponentId::GROUP_MEMBERSHIP
660 && removes_super_admin(
661 operation,
662 read_from_app_data_dict(ComponentId::SUPER_ADMIN_LIST, openmls_group).as_deref(),
663 )
664 {
665 tracing::warn!(
666 proposer_inbox_id,
667 "AppDataUpdate proposal rejected: cannot remove super admin"
668 );
669 return Err(CommitRuleError::InsufficientPermissions);
670 }
671
672 validate_standalone_app_data_update(
673 component_id,
674 operation,
675 actor,
676 proposer_inbox_id,
677 registry,
678 old_value.as_deref(),
679 dm_members,
680 membership.as_ref(),
681 )
682}
683
684#[allow(clippy::too_many_arguments)]
695pub(crate) fn validate_standalone_app_data_update(
696 component_id: xmtp_mls_common::app_data::component_id::ComponentId,
697 operation: &openmls::messages::proposals::AppDataUpdateOperation,
698 actor: xmtp_mls_common::app_data::validation::ActorAuthority,
699 proposer_inbox_id: &str,
700 registry: &xmtp_mls_common::app_data::component_registry::ComponentRegistry,
701 old_value: Option<&[u8]>,
702 dm_members: Option<&DmMembers<String>>,
703 membership: Option<&std::collections::HashSet<xmtp_mls_common::inbox_id::InboxId>>,
704) -> Result<(), CommitRuleError> {
705 use openmls::messages::proposals::AppDataUpdateOperation;
706 use xmtp_mls_common::app_data::component_source::{
707 ComponentSourceError, apply_app_data_update_payload,
708 };
709
710 validate_one_app_data_update_with_old_value(
711 component_id,
712 operation,
713 actor,
714 proposer_inbox_id,
715 registry,
716 old_value,
717 dm_members,
718 membership,
719 )?;
720 let AppDataUpdateOperation::Update(payload) = operation else {
721 return Ok(());
722 };
723 match apply_app_data_update_payload(component_id, payload.as_slice(), old_value, registry) {
724 Err(ComponentSourceError::FieldBoundExceeded { .. }) => {
725 Err(CommitRuleError::InsufficientPermissions)
726 }
727 _ => Ok(()),
728 }
729}
730
731pub(crate) fn removes_super_admin(
745 operation: &openmls::messages::proposals::AppDataUpdateOperation,
746 super_admins: Option<&[u8]>,
747) -> bool {
748 use openmls::messages::proposals::AppDataUpdateOperation;
749 use tls_codec::{Deserialize as _, VLBytes};
750 use xmtp_mls_common::{
751 app_data::{components::inbox_id_set::SuperAdminListComponent, typed::Component},
752 inbox_id::InboxId,
753 tls_map::{TlsMapDelta, TlsMapMutation},
754 };
755
756 let AppDataUpdateOperation::Update(payload) = operation else {
757 return false;
758 };
759 let (Some(super_admins), Ok(delta)) = (
760 super_admins.and_then(|bytes| SuperAdminListComponent::decode_value(bytes).ok()),
761 TlsMapDelta::<InboxId, VLBytes>::tls_deserialize_exact(payload.as_slice()),
762 ) else {
763 return false;
764 };
765 delta.mutations.iter().any(
766 |mutation| matches!(mutation, TlsMapMutation::Delete { key } if super_admins.contains(key)),
767 )
768}
769
770fn membership_inbox_ids(
774 snapshot: Option<&[u8]>,
775) -> Option<std::collections::HashSet<xmtp_mls_common::inbox_id::InboxId>> {
776 use tls_codec::{Deserialize as _, VLBytes};
777 use xmtp_mls_common::{inbox_id::InboxId, tls_map::TlsMap};
778
779 let map = TlsMap::<InboxId, VLBytes>::tls_deserialize_exact(snapshot?).ok()?;
780 Some(map.keys().copied().collect())
781}
782
783fn enforce_min_version_monotonicity(
796 operation: &openmls::messages::proposals::AppDataUpdateOperation,
797 old_value: Option<&[u8]>,
798) -> Result<(), CommitRuleError> {
799 use openmls::messages::proposals::AppDataUpdateOperation;
800 let Some(old_bytes) = old_value else {
803 return Ok(());
804 };
805 let Ok(old_str) = std::str::from_utf8(old_bytes) else {
809 return Ok(());
810 };
811 let Ok(old_v) = LibXMTPVersion::parse(old_str) else {
812 return Ok(());
813 };
814 match operation {
815 AppDataUpdateOperation::Update(payload) => {
816 let new_bytes = payload.as_slice();
817 let new_str = std::str::from_utf8(new_bytes)
818 .map_err(|_| CommitRuleError::InvalidVersionFormat(format!("{:?}", new_bytes)))?;
819 let new_v = LibXMTPVersion::parse(new_str)?;
820 if new_v < old_v {
821 return Err(CommitRuleError::MinVersionDowngrade {
822 requested: new_str.to_string(),
823 current: old_str.to_string(),
824 });
825 }
826 Ok(())
827 }
828 AppDataUpdateOperation::Remove => Err(CommitRuleError::MinVersionRemoveOnExistingFloor {
829 current: old_str.to_string(),
830 }),
831 }
832}
833
834fn permits_dm_participant_insert(
836 operation: &openmls::messages::proposals::AppDataUpdateOperation,
837 proposer_inbox_id: &str,
838 dm_members: Option<&DmMembers<String>>,
839) -> bool {
840 use openmls::messages::proposals::AppDataUpdateOperation;
841 use tls_codec::{Deserialize as _, VLBytes};
842 use xmtp_mls_common::{
843 inbox_id::InboxId,
844 tls_map::{TlsMapDelta, TlsMapMutation},
845 };
846
847 let Some(dm) = dm_members else { return false };
848 let AppDataUpdateOperation::Update(payload) = operation else {
849 return false;
850 };
851 let Ok(delta) = TlsMapDelta::<InboxId, VLBytes>::tls_deserialize_exact(payload.as_slice())
852 else {
853 return false;
854 };
855 let mut inserted = delta
856 .mutations
857 .iter()
858 .filter_map(|mutation| match mutation {
859 TlsMapMutation::Insert { key, .. } => Some(key.to_hex()),
860 _ => None,
861 });
862 let Some(inbox_id) = inserted.next() else {
863 return false;
864 };
865 inserted.next().is_none()
866 && inbox_id != proposer_inbox_id
867 && (inbox_id == dm.member_one_inbox_id.as_ref()
868 || inbox_id == dm.member_two_inbox_id.as_ref())
869}
870
871#[allow(clippy::too_many_arguments)]
880pub(crate) fn validate_one_app_data_update_with_old_value(
881 component_id: xmtp_mls_common::app_data::component_id::ComponentId,
882 operation: &openmls::messages::proposals::AppDataUpdateOperation,
883 actor: xmtp_mls_common::app_data::validation::ActorAuthority,
884 proposer_inbox_id: &str,
885 registry: &xmtp_mls_common::app_data::component_registry::ComponentRegistry,
886 old_value: Option<&[u8]>,
887 dm_members: Option<&DmMembers<String>>,
888 membership: Option<&std::collections::HashSet<xmtp_mls_common::inbox_id::InboxId>>,
889) -> Result<(), CommitRuleError> {
890 use xmtp_mls_common::app_data::{
891 registry_table::lookup_component,
892 validation::{ComponentChange, validate_component_write},
893 };
894
895 if component_id
901 == xmtp_mls_common::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION
902 {
903 enforce_min_version_monotonicity(operation, old_value).inspect_err(|err| {
904 tracing::warn!(
905 proposer_inbox_id,
906 component_id = %component_id,
907 error = %err,
908 "AppDataUpdate proposal rejected: min_version monotonicity"
909 );
910 })?;
911 }
912
913 let component = lookup_component(component_id);
928 let changes = if let Some(component) = component {
929 component
930 .expand_to_changes(operation, old_value)
931 .map_err(|e| {
932 let wrapped =
933 xmtp_mls_common::app_data::component_source::ComponentSourceError::from(e);
934 tracing::warn!(
935 proposer_inbox_id,
936 component_id = %component_id,
937 error = %wrapped,
938 "AppDataUpdate proposal rejected: failed to expand payload"
939 );
940 CommitRuleError::InsufficientPermissions
941 })?
942 } else {
943 match xmtp_mls_common::app_data::component_source::expand_app_data_update_to_changes(
944 component_id,
945 operation,
946 old_value,
947 registry,
948 ) {
949 Ok(changes) => changes,
950 Err(err) => {
951 tracing::warn!(
952 proposer_inbox_id,
953 component_id = %component_id,
954 error = %err,
955 "AppDataUpdate proposal rejected"
956 );
957 return Err(CommitRuleError::InsufficientPermissions);
958 }
959 }
960 };
961
962 let dm_participant_insert = component_id
965 == xmtp_mls_common::app_data::component_id::ComponentId::GROUP_MEMBERSHIP
966 && permits_dm_participant_insert(operation, proposer_inbox_id, dm_members);
967 let proposer = xmtp_mls_common::inbox_id::InboxId::from_hex(proposer_inbox_id).ok();
968 let is_dm_participant = dm_members.is_some_and(|dm| {
969 [&dm.member_one_inbox_id, &dm.member_two_inbox_id]
970 .iter()
971 .any(|member| member.as_str() == proposer_inbox_id)
972 });
973 for change in &changes {
974 if dm_participant_insert
975 && change.op == xmtp_mls_common::app_data::component_registry::ComponentOp::Insert
976 {
977 continue;
978 }
979 let cc = ComponentChange::builder()
980 .component_id(component_id)
981 .op(change.op)
982 .actor(actor)
983 .maybe_old_value(old_value)
984 .maybe_new_value(change.value.as_deref())
985 .maybe_proposer(proposer)
986 .is_dm_participant(is_dm_participant)
987 .maybe_key(change.key.as_deref())
988 .maybe_membership(membership)
989 .build();
990
991 if let Err(e) = validate_component_write(&cc, registry) {
995 tracing::warn!(
996 proposer_inbox_id,
997 component_id = %component_id,
998 op = %change.op,
999 error = %e,
1000 "AppDataUpdate proposal rejected"
1001 );
1002 return Err(CommitRuleError::InsufficientPermissions);
1003 }
1004 }
1005
1006 if let Some(component) = component {
1016 let post_value = match operation {
1017 openmls::messages::proposals::AppDataUpdateOperation::Update(payload) => component
1018 .apply_update_payload(payload.as_slice(), old_value)
1019 .map_err(|e| {
1020 tracing::warn!(
1021 proposer_inbox_id,
1022 component_id = %component_id,
1023 error = %e,
1024 "AppDataUpdate proposal rejected: failed to compute post-state"
1025 );
1026 CommitRuleError::InsufficientPermissions
1027 })?,
1028 openmls::messages::proposals::AppDataUpdateOperation::Remove => Vec::new(),
1029 };
1030 let invariant_change = ComponentChange::builder()
1031 .component_id(component_id)
1032 .op(match operation {
1033 openmls::messages::proposals::AppDataUpdateOperation::Update(_) => {
1034 xmtp_mls_common::app_data::component_registry::ComponentOp::Update
1035 }
1036 openmls::messages::proposals::AppDataUpdateOperation::Remove => {
1037 xmtp_mls_common::app_data::component_registry::ComponentOp::Delete
1038 }
1039 })
1040 .actor(actor)
1041 .maybe_old_value(old_value)
1042 .maybe_new_value(match operation {
1043 openmls::messages::proposals::AppDataUpdateOperation::Update(_) => {
1044 Some(post_value.as_slice())
1045 }
1046 openmls::messages::proposals::AppDataUpdateOperation::Remove => None,
1047 })
1048 .build();
1049 if let Err(e) = component.validate_invariant(&invariant_change, registry) {
1050 tracing::warn!(
1051 proposer_inbox_id,
1052 component_id = %component_id,
1053 error = %e,
1054 "AppDataUpdate proposal rejected: component invariant violated"
1055 );
1056 return Err(CommitRuleError::InsufficientPermissions);
1057 }
1058 }
1059
1060 Ok(())
1061}
1062
1063pub(crate) fn app_data_update_proposer_leaf(
1071 sender: &Sender,
1072) -> Result<&LeafNodeIndex, CommitRuleError> {
1073 match sender {
1074 Sender::Member(leaf_index) => Ok(leaf_index),
1075 Sender::External(_) | Sender::NewMemberCommit | Sender::NewMemberProposal => {
1076 Err(CommitRuleError::ActorNotMember)
1077 }
1078 }
1079}
1080
1081pub struct AppDataUpdateInCommit<'a> {
1083 pub component_id: xmtp_mls_common::app_data::component_id::ComponentId,
1084 pub operation: &'a openmls::messages::proposals::AppDataUpdateOperation,
1085 pub actor: xmtp_mls_common::app_data::validation::ActorAuthority,
1086 pub proposer_inbox_id: &'a str,
1087}
1088
1089pub fn validate_app_data_update_sequence<'a>(
1096 updates: impl IntoIterator<Item = AppDataUpdateInCommit<'a>>,
1097 committed: impl Fn(xmtp_mls_common::app_data::component_id::ComponentId) -> Option<Vec<u8>>,
1098 registry: &xmtp_mls_common::app_data::component_registry::ComponentRegistry,
1099 dm_members: Option<&DmMembers<String>>,
1100 membership: Option<&HashSet<xmtp_mls_common::inbox_id::InboxId>>,
1101) -> Result<
1102 HashMap<xmtp_mls_common::app_data::component_id::ComponentId, Option<Vec<u8>>>,
1103 CommitRuleError,
1104> {
1105 use openmls::messages::proposals::AppDataUpdateOperation;
1106 use xmtp_mls_common::app_data::component_source::apply_app_data_update_payload;
1107
1108 let mut post_states = HashMap::new();
1109 for update in updates {
1110 let old_value = post_states
1111 .entry(update.component_id)
1112 .or_insert_with(|| committed(update.component_id))
1113 .clone();
1114 validate_one_app_data_update_with_old_value(
1115 update.component_id,
1116 update.operation,
1117 update.actor,
1118 update.proposer_inbox_id,
1119 registry,
1120 old_value.as_deref(),
1121 dm_members,
1122 membership,
1123 )?;
1124 let post_value = match update.operation {
1125 AppDataUpdateOperation::Update(payload) => Some(
1126 apply_app_data_update_payload(
1127 update.component_id,
1128 payload.as_slice(),
1129 old_value.as_deref(),
1130 registry,
1131 )
1132 .map_err(|_| CommitRuleError::InsufficientPermissions)?,
1133 ),
1134 AppDataUpdateOperation::Remove => None,
1135 };
1136 post_states.insert(update.component_id, post_value);
1137 }
1138 Ok(post_states)
1139}
1140
1141pub fn validate_app_data_update_proposals_in_commit(
1164 staged_commit: &StagedCommit,
1165 openmls_group: &OpenMlsGroup,
1166 immutable_metadata: &GroupMetadata,
1167 mutable_metadata: &GroupMutableMetadata,
1168 registry: &xmtp_mls_common::app_data::component_registry::ComponentRegistry,
1169) -> Result<(), CommitRuleError> {
1170 use xmtp_mls_common::app_data::{
1171 component_id::ComponentId,
1172 component_source::{read_from_app_data_dict, read_post_commit_component_bytes},
1173 validation::ActorAuthority,
1174 };
1175
1176 let mut proposals = staged_commit.app_data_update_proposals().peekable();
1189 if proposals.peek().is_none() {
1190 return Ok(());
1191 }
1192
1193 let proposals = proposals
1197 .map(|queued| {
1198 let leaf = *app_data_update_proposer_leaf(queued.sender())?;
1199 Ok((queued, leaf))
1200 })
1201 .collect::<Result<Vec<_>, CommitRuleError>>()?;
1202 let mut participants: HashMap<LeafNodeIndex, CommitParticipant> = HashMap::new();
1203 for (_, leaf) in &proposals {
1204 if let std::collections::hash_map::Entry::Vacant(entry) = participants.entry(*leaf) {
1205 entry.insert(extract_commit_participant(
1206 leaf,
1207 openmls_group,
1208 immutable_metadata,
1209 mutable_metadata,
1210 )?);
1211 }
1212 }
1213 let membership = read_post_commit_component_bytes(
1216 ComponentId::GROUP_MEMBERSHIP,
1217 openmls_group,
1218 staged_commit,
1219 registry,
1220 )
1221 .map_err(|_| CommitRuleError::InsufficientPermissions)?;
1222 let component_post_states = validate_app_data_update_sequence(
1223 proposals.iter().map(|(queued, leaf)| {
1224 let proposal = queued.app_data_update_proposal();
1225 let proposer = &participants[leaf];
1226 AppDataUpdateInCommit {
1227 component_id: ComponentId::from(proposal.component_id()),
1228 operation: proposal.operation(),
1229 actor: ActorAuthority::from(proposer),
1230 proposer_inbox_id: &proposer.inbox_id,
1231 }
1232 }),
1233 |component_id| read_from_app_data_dict(component_id, openmls_group),
1234 registry,
1235 immutable_metadata.dm_members.as_ref(),
1236 membership_inbox_ids(membership.as_deref()).as_ref(),
1237 )?;
1238
1239 if let Some(post_registry) = component_post_states.get(&ComponentId::COMPONENT_REGISTRY) {
1242 let bytes = post_registry
1243 .as_deref()
1244 .ok_or(CommitRuleError::InsufficientPermissions)?;
1245 let registry =
1246 xmtp_mls_common::app_data::component_registry::ComponentRegistry::from_bytes(bytes)
1247 .map_err(|_| CommitRuleError::InsufficientPermissions)?;
1248 xmtp_mls_common::app_data::policy_set::validate_registry_action_policies(®istry)
1249 .map_err(|error| {
1250 tracing::warn!(%error, "invalid registry action policy in commit");
1251 CommitRuleError::InsufficientPermissions
1252 })?;
1253 }
1254
1255 Ok(())
1256}
1257
1258pub fn extract_commit_participant(
1260 leaf_index: &LeafNodeIndex,
1261 group: &OpenMlsGroup,
1262 immutable_metadata: &GroupMetadata,
1263 mutable_metadata: &GroupMutableMetadata,
1264) -> Result<CommitParticipant, CommitRuleError> {
1265 if let Some(leaf_node) = group.member_at(*leaf_index) {
1266 let installation_id = leaf_node.signature_key.to_vec();
1267 let inbox_id = inbox_id_from_credential(&leaf_node.credential)?;
1268 Ok(CommitParticipant::build(
1269 inbox_id,
1270 installation_id,
1271 immutable_metadata,
1272 mutable_metadata,
1273 ))
1274 } else {
1275 Err(CommitRuleError::ActorNotMember)
1277 }
1278}
1279
1280#[tracing::instrument(level = "trace", skip_all)]
1282pub fn extract_group_membership(
1283 extensions: &Extensions<GroupContext>,
1284) -> Result<GroupMembership, CommitRuleError> {
1285 let proto =
1286 xmtp_mls_common::app_data::component_source::read_group_membership_from_dict(extensions)
1287 .map_err(|e| {
1288 CommitRuleError::GroupMutableMetadata(
1289 xmtp_mls_common::group_mutable_metadata::GroupMutableMetadataError::from(e),
1290 )
1291 })?
1292 .ok_or(CommitRuleError::MissingGroupMembership)?;
1293 Ok(GroupMembership {
1294 members: proto.members,
1295 failed_installations: proto.failed_installations,
1296 })
1297}
1298
1299pub fn metadata_changes_between(
1300 immutable_metadata: &GroupMetadata,
1301 old_mutable_metadata: &GroupMutableMetadata,
1302 new_mutable_metadata: &GroupMutableMetadata,
1303) -> MetadataChanges {
1304 let metadata_field_changes =
1305 mutable_metadata_field_changes(old_mutable_metadata, new_mutable_metadata);
1306
1307 MetadataChanges {
1308 metadata_field_changes,
1309 admins_added: get_added_members(
1310 &old_mutable_metadata.admin_list,
1311 &new_mutable_metadata.admin_list,
1312 immutable_metadata,
1313 old_mutable_metadata,
1314 ),
1315 admins_removed: get_removed_members(
1316 &old_mutable_metadata.admin_list,
1317 &new_mutable_metadata.admin_list,
1318 immutable_metadata,
1319 old_mutable_metadata,
1320 ),
1321 super_admins_added: get_added_members(
1322 &old_mutable_metadata.super_admin_list,
1323 &new_mutable_metadata.super_admin_list,
1324 immutable_metadata,
1325 old_mutable_metadata,
1326 ),
1327 super_admins_removed: get_removed_members(
1328 &old_mutable_metadata.super_admin_list,
1329 &new_mutable_metadata.super_admin_list,
1330 immutable_metadata,
1331 old_mutable_metadata,
1332 ),
1333 }
1334}
1335
1336fn get_added_members(
1340 old: &[String],
1341 new: &[String],
1342 immutable_metadata: &GroupMetadata,
1343 mutable_metadata: &GroupMutableMetadata,
1344) -> Vec<Inbox> {
1345 new.iter()
1346 .filter(|new_inbox| !old.contains(new_inbox))
1347 .map(|inbox_id| build_inbox(inbox_id, immutable_metadata, mutable_metadata))
1348 .collect()
1349}
1350
1351fn get_removed_members(
1355 old: &[String],
1356 new: &[String],
1357 immutable_metadata: &GroupMetadata,
1358 mutable_metadata: &GroupMutableMetadata,
1359) -> Vec<Inbox> {
1360 old.iter()
1361 .filter(|old_inbox| !new.contains(old_inbox))
1362 .map(|inbox_id| build_inbox(inbox_id, immutable_metadata, mutable_metadata))
1363 .collect()
1364}
1365
1366fn build_inbox(
1367 inbox_id: &String,
1368 immutable_metadata: &GroupMetadata,
1369 mutable_metadata: &GroupMutableMetadata,
1370) -> Inbox {
1371 Inbox {
1372 inbox_id: inbox_id.to_string(),
1373 is_admin: mutable_metadata.is_admin(inbox_id),
1374 is_super_admin: mutable_metadata.is_super_admin(inbox_id),
1375 is_creator: immutable_metadata.creator_inbox_id.eq(inbox_id),
1376 proposer: None,
1377 }
1378}
1379
1380pub fn build_inbox_with_proposer(
1381 inbox_id: &String,
1382 immutable_metadata: &GroupMetadata,
1383 mutable_metadata: &GroupMutableMetadata,
1384 proposer: CommitParticipant,
1385) -> Inbox {
1386 Inbox {
1387 inbox_id: inbox_id.to_string(),
1388 is_admin: mutable_metadata.is_admin(inbox_id),
1389 is_super_admin: mutable_metadata.is_super_admin(inbox_id),
1390 is_creator: immutable_metadata.creator_inbox_id.eq(inbox_id),
1391 proposer: Some(proposer),
1392 }
1393}
1394
1395fn mutable_metadata_field_changes(
1399 old_metadata: &GroupMutableMetadata,
1400 new_metadata: &GroupMutableMetadata,
1401) -> Vec<MetadataFieldChange> {
1402 let all_keys = old_metadata
1403 .attributes
1404 .keys()
1405 .chain(new_metadata.attributes.keys())
1406 .fold(HashSet::new(), |mut key_set, key| {
1407 key_set.insert(key);
1408 key_set
1409 });
1410
1411 all_keys
1412 .into_iter()
1413 .filter_map(|key| {
1414 let old_val = old_metadata.attributes.get(key);
1415 let new_val = new_metadata.attributes.get(key);
1416 if old_val.ne(&new_val) {
1417 Some(MetadataFieldChange::new(
1418 key.clone(),
1419 old_val.cloned(),
1420 new_val.cloned(),
1421 ))
1422 } else {
1423 None
1424 }
1425 })
1426 .collect()
1427}
1428
1429pub fn inbox_id_from_credential(credential: &OpenMlsCredential) -> Result<String, CommitRuleError> {
1431 let basic_credential = BasicCredential::try_from(credential.clone())?;
1432 let identity_bytes = basic_credential.identity();
1433 let decoded = MlsCredential::decode(identity_bytes)?;
1434
1435 Ok(decoded.inbox_id)
1436}
1437
1438pub fn extract_committer_and_proposers(
1454 staged_commit: &StagedCommit,
1455 committer_leaf_index: LeafNodeIndex,
1456 openmls_group: &OpenMlsGroup,
1457 immutable_metadata: &GroupMetadata,
1458 mutable_metadata: &GroupMutableMetadata,
1459) -> Result<(CommitParticipant, Vec<CommitParticipant>), CommitRuleError> {
1460 let mut proposer_leaf_indices: Vec<&LeafNodeIndex> = Vec::new();
1462 for proposal in staged_commit.queued_proposals() {
1463 match proposal.sender() {
1464 Sender::Member(member_leaf_node_index) => {
1465 if !proposer_leaf_indices.contains(&member_leaf_node_index) {
1467 proposer_leaf_indices.push(member_leaf_node_index);
1468 }
1469 }
1470 _ => return Err(CommitRuleError::ActorNotMember),
1471 }
1472 }
1473
1474 let mut proposers: Vec<CommitParticipant> = Vec::new();
1476 for leaf_index in &proposer_leaf_indices {
1477 let participant = extract_commit_participant(
1478 leaf_index,
1479 openmls_group,
1480 immutable_metadata,
1481 mutable_metadata,
1482 )?;
1483 proposers.push(participant);
1484 }
1485
1486 let committer = extract_commit_participant(
1487 &committer_leaf_index,
1488 openmls_group,
1489 immutable_metadata,
1490 mutable_metadata,
1491 )?;
1492
1493 Ok((committer, proposers))
1494}
1495
1496impl From<&MetadataFieldChange> for MetadataFieldChangeProto {
1497 fn from(change: &MetadataFieldChange) -> Self {
1498 MetadataFieldChangeProto {
1499 field_name: change.field_name.clone(),
1500 old_value: change.old_value.clone(),
1501 new_value: change.new_value.clone(),
1502 }
1503 }
1504}
1505
1506impl From<&Inbox> for InboxProto {
1507 fn from(inbox: &Inbox) -> Self {
1508 InboxProto {
1509 inbox_id: inbox.inbox_id.clone(),
1510 }
1511 }
1512}
1513
1514#[cfg(test)]
1515mod app_data_update_tests;
1516#[cfg(test)]
1517mod committed_proposal_outcomes;
1518#[cfg(test)]
1519mod readded_installations_tests;
1520
1521#[cfg(test)]
1522mod permission_on_receive_tests {
1523 use super::*;
1531 use openmls::messages::proposals::AppDataUpdateOperation;
1532 use xmtp_mls_common::app_data::{
1533 component_id::ComponentId, component_registry::ComponentRegistry,
1534 validation::ActorAuthority,
1535 };
1536
1537 #[xmtp_common::test(unwrap_try = true)]
1538 fn dm_membership_insert_exception_rejects_other_inboxes() {
1539 use tls_codec::{Serialize as _, VLBytes};
1540 use xmtp_mls_common::{inbox_id::InboxId, tls_map::TlsMapDelta};
1541 let creator = InboxId::from_bytes([1; 32]);
1542 let peer = InboxId::from_bytes([2; 32]);
1543 let outsider = InboxId::from_bytes([3; 32]);
1544 let dm = DmMembers {
1545 member_one_inbox_id: creator.to_hex(),
1546 member_two_inbox_id: peer.to_hex(),
1547 };
1548 for (keys, allowed) in [
1549 (vec![peer], true),
1550 (vec![creator], false),
1551 (vec![outsider], false),
1552 (vec![peer, outsider], false),
1553 (vec![peer, peer], false),
1554 ] {
1555 let mut delta = TlsMapDelta::<InboxId, VLBytes>::new();
1556 for key in keys {
1557 delta = delta.insert(key, vec![].into());
1558 }
1559 let operation = AppDataUpdateOperation::Update(delta.tls_serialize_detached()?.into());
1560 assert_eq!(
1561 permits_dm_participant_insert(&operation, &creator.to_hex(), Some(&dm)),
1562 allowed
1563 );
1564 assert!(!permits_dm_participant_insert(
1565 &operation,
1566 &creator.to_hex(),
1567 None
1568 ));
1569 }
1570 }
1571
1572 fn non_admin_actor() -> ActorAuthority {
1573 ActorAuthority {
1574 is_admin: false,
1575 is_super_admin: false,
1576 }
1577 }
1578
1579 fn admin_actor() -> ActorAuthority {
1580 ActorAuthority {
1581 is_admin: true,
1582 is_super_admin: false,
1583 }
1584 }
1585
1586 #[xmtp_common::test(unwrap_try = true)]
1587 fn non_admin_writing_super_admin_only_component_is_rejected() {
1588 let operation = AppDataUpdateOperation::Update(vec![0u8; 16].into());
1589 let registry = ComponentRegistry::new();
1590 let err = validate_one_app_data_update_with_old_value(
1591 ComponentId::COMPONENT_REGISTRY,
1592 &operation,
1593 non_admin_actor(),
1594 "test-inbox",
1595 ®istry,
1596 None,
1597 None,
1598 None,
1599 )
1600 .expect_err("non-admin write to super-admin-only component must be rejected");
1601 assert!(
1602 matches!(err, CommitRuleError::InsufficientPermissions),
1603 "expected InsufficientPermissions, got {err:?}"
1604 );
1605 }
1606
1607 #[xmtp_common::test(unwrap_try = true)]
1608 fn plain_admin_writing_super_admin_only_component_is_rejected() {
1609 let operation = AppDataUpdateOperation::Update(vec![0u8; 16].into());
1610 let registry = ComponentRegistry::new();
1611 let err = validate_one_app_data_update_with_old_value(
1612 ComponentId::COMPONENT_REGISTRY,
1613 &operation,
1614 admin_actor(),
1615 "test-inbox",
1616 ®istry,
1617 None,
1618 None,
1619 None,
1620 )
1621 .expect_err("plain-admin write to super-admin-only component must be rejected");
1622 assert!(
1623 matches!(err, CommitRuleError::InsufficientPermissions),
1624 "expected InsufficientPermissions, got {err:?}"
1625 );
1626 }
1627
1628 #[xmtp_common::test(unwrap_try = true)]
1629 fn non_admin_writing_component_with_no_registry_entry_is_rejected() {
1630 let unknown_id = ComponentId::new(0x80FF);
1634 let operation = AppDataUpdateOperation::Update(vec![0u8; 16].into());
1635 let registry = ComponentRegistry::new();
1636 let err = validate_one_app_data_update_with_old_value(
1637 unknown_id,
1638 &operation,
1639 non_admin_actor(),
1640 "test-inbox",
1641 ®istry,
1642 None,
1643 None,
1644 None,
1645 )
1646 .expect_err("write to unregistered component must be rejected");
1647 assert!(
1648 matches!(err, CommitRuleError::InsufficientPermissions),
1649 "expected InsufficientPermissions, got {err:?}"
1650 );
1651 }
1652}
1653
1654#[cfg(test)]
1655mod min_version_monotonicity_tests {
1656 use super::*;
1657 use openmls::messages::proposals::AppDataUpdateOperation;
1658
1659 fn update_op(s: &str) -> AppDataUpdateOperation {
1660 AppDataUpdateOperation::Update(s.as_bytes().to_vec().into())
1661 }
1662
1663 #[xmtp_common::test(unwrap_try = true)]
1664 fn first_set_with_no_prior_floor_is_allowed() {
1665 enforce_min_version_monotonicity(&update_op("1.11.0-dev"), None)?;
1666 }
1667
1668 #[xmtp_common::test(unwrap_try = true)]
1669 fn equal_version_is_allowed() {
1670 enforce_min_version_monotonicity(&update_op("1.11.0-dev"), Some(b"1.11.0-dev"))?;
1671 }
1672
1673 #[xmtp_common::test(unwrap_try = true)]
1674 fn higher_version_is_allowed() {
1675 enforce_min_version_monotonicity(&update_op("1.11.0"), Some(b"1.11.0-dev"))?;
1676 enforce_min_version_monotonicity(&update_op("1.12.0"), Some(b"1.11.0-dev"))?;
1677 enforce_min_version_monotonicity(&update_op("2.0.0"), Some(b"1.11.0-dev"))?;
1678 }
1679
1680 #[xmtp_common::test(unwrap_try = true)]
1682 fn lower_version_is_rejected() {
1683 let err = enforce_min_version_monotonicity(&update_op("1.10.0"), Some(b"1.11.0-dev"))
1684 .expect_err("downgrade must be rejected");
1685 assert!(
1686 matches!(
1687 err,
1688 CommitRuleError::MinVersionDowngrade { ref requested, ref current }
1689 if requested == "1.10.0" && current == "1.11.0-dev"
1690 ),
1691 "expected MinVersionDowngrade, got {err:?}",
1692 );
1693 }
1694
1695 #[xmtp_common::test(unwrap_try = true)]
1697 fn remove_with_prior_floor_is_rejected() {
1698 let err =
1699 enforce_min_version_monotonicity(&AppDataUpdateOperation::Remove, Some(b"1.11.0-dev"))
1700 .expect_err("remove on a set floor must be rejected");
1701 assert!(
1702 matches!(
1703 err,
1704 CommitRuleError::MinVersionRemoveOnExistingFloor { ref current }
1705 if current == "1.11.0-dev"
1706 ),
1707 "expected MinVersionRemoveOnExistingFloor, got {err:?}",
1708 );
1709 }
1710
1711 #[xmtp_common::test(unwrap_try = true)]
1712 fn remove_with_no_prior_floor_is_allowed() {
1713 enforce_min_version_monotonicity(&AppDataUpdateOperation::Remove, None)?;
1714 }
1715
1716 #[xmtp_common::test(unwrap_try = true)]
1717 fn malformed_prior_skips_check() {
1718 enforce_min_version_monotonicity(&update_op("1.11.0-dev"), Some(b"not-a-version"))?;
1721 enforce_min_version_monotonicity(&update_op("1.11.0-dev"), Some(&[0xff, 0xfe, 0xfd]))?;
1722 }
1723
1724 #[xmtp_common::test(unwrap_try = true)]
1725 fn malformed_new_value_surfaces_parse_error() {
1726 let err =
1727 enforce_min_version_monotonicity(&update_op("not-a-version"), Some(b"1.11.0-dev"))
1728 .expect_err("malformed new value must error");
1729 assert!(
1730 matches!(err, CommitRuleError::InvalidVersionFormat(_)),
1731 "expected InvalidVersionFormat, got {err:?}",
1732 );
1733 }
1734
1735 #[xmtp_common::test(unwrap_try = true)]
1737 fn prerelease_ordering_matches_semver() {
1738 enforce_min_version_monotonicity(&update_op("1.10.0"), Some(b"1.10.0-rc.1"))?;
1742 let err = enforce_min_version_monotonicity(&update_op("1.10.0-rc.1"), Some(b"1.10.0"))
1743 .expect_err("rc → release reverse must be rejected");
1744 assert!(
1745 matches!(err, CommitRuleError::MinVersionDowngrade { .. }),
1746 "expected MinVersionDowngrade, got {err:?}",
1747 );
1748 }
1749
1750 #[xmtp_common::test(unwrap_try = true)]
1751 fn dev_prerelease_is_lower_than_release() {
1752 let dev = LibXMTPVersion::parse("1.11.0-dev")?;
1760 let release = LibXMTPVersion::parse("1.11.0")?;
1761 assert!(
1762 release > dev,
1763 "expected 1.11.0 > 1.11.0-dev per semver §11, got release={release:?} dev={dev:?}",
1764 );
1765 assert!(dev < release, "expected 1.11.0-dev < 1.11.0 per semver §11");
1766
1767 enforce_min_version_monotonicity(&update_op("1.11.0"), Some(b"1.11.0-dev"))?;
1768
1769 let err = enforce_min_version_monotonicity(&update_op("1.11.0-dev"), Some(b"1.11.0"))
1770 .expect_err("release → -dev reverse must be rejected");
1771 assert!(
1772 matches!(
1773 err,
1774 CommitRuleError::MinVersionDowngrade { ref requested, ref current }
1775 if requested == "1.11.0-dev" && current == "1.11.0"
1776 ),
1777 "expected MinVersionDowngrade, got {err:?}",
1778 );
1779 }
1780}