Skip to main content

xmtp_mls_validation/
commit.rs

1//! Pure commit validation rules.
2//!
3//! These functions read an OpenMLS group, a staged commit, and the group's
4//! AppData dictionary. They do not read the client database or fetch
5//! identities. `xmtp_mls` runs them inside `ValidatedCommit` and adds the
6//! identity and installation checks that need stored state.
7
8use 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/// A commit breaks a validation rule, or its group state cannot be read.
41#[derive(Debug, Error)]
42pub enum CommitRuleError {
43    /// Identity updates must precede the group envelope. Not retryable.
44    #[error(
45        "Identity sequence {identity_sequence} does not precede group sequence {envelope_sequence}"
46    )]
47    IdentitySequenceNotBeforeEnvelope {
48        /// Identity update sequence `N` named by the proposed membership.
49        identity_sequence: u64,
50        /// Authenticated group envelope sequence `S`; valid references have `N < S`.
51        envelope_sequence: u64,
52    },
53    #[error("Actor could not be found")]
54    ActorCouldNotBeFound,
55    // Subject of the proposal has an invalid credential
56    #[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    // TODO: We will need to relax this once we support external joins
65    #[error("Actor not a member of the group")]
66    ActorNotMember,
67    #[error("Subject not a member of the group")]
68    SubjectDoesNotExist,
69    // Current behaviour is to error out if a Commit includes proposals from multiple actors
70    // TODO: We should relax this once we support self remove
71    #[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    /// The commit's `AppDataUpdate` proposals conflict for this component
96    /// under draft-ietf-mls-extensions-08 §4.7.
97    #[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    /// Sender published an `AppDataUpdate(Update)` against
108    /// `MIN_SUPPORTED_PROTOCOL_VERSION` whose new value is below the
109    /// existing floor. Monotonic-only: a downgrade silently unpauses
110    /// peers between the new and old floors, defeating XIP §3's gate.
111    #[error("min_version {requested} would downgrade existing floor {current}")]
112    MinVersionDowngrade { requested: String, current: String },
113    /// Sender published an `AppDataUpdate(Remove)` against
114    /// `MIN_SUPPORTED_PROTOCOL_VERSION` on a group that already has a
115    /// floor set. Explicit unsetting is just a downgrade in disguise,
116    /// rejected for the same XIP §3 reason.
117    #[error("min_version remove is rejected; existing floor is {current}")]
118    MinVersionRemoveOnExistingFloor { current: String },
119    /// A well-known component value in the AppData dictionary failed
120    /// to decode while validating an AppDataUpdate proposal — most
121    /// commonly a malformed `COMPONENT_REGISTRY`. Treated as a
122    /// terminal wire-format violation so the offending commit is
123    /// rejected rather than silently downgraded to "empty registry"
124    /// (which would let a permissive validator state slip in).
125    #[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    /// Only authenticated, deterministic invalid input can advance the prefix.
139    /// Unsupported versions and proposal types leave the head pending.
140    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    /// Project this participant into the admin/super-admin view that the
247    /// component-permission validator consumes.
248    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
262/// Membership authorization has no metadata or permission diff fields.
263/// Each component update is checked against its authenticated proposer.
264pub 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/// Actual metadata changes, used only for transcripts and status updates.
272/// Component policies authorize these changes before this summary is built.
273#[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    /// The proposer who requested this inbox change (if from a proposal)
300    #[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
323/// Validate the complete proposal list of a commit before any proposal is
324/// interpreted.
325///
326/// Only `Add`, `Update`, `Remove`, and `AppDataUpdate` are allowed. Any other
327/// kind except `PreSharedKey` holds the commit
328/// ([`CommitRuleError::UnsupportedProposalType`]); a hold outranks every
329/// terminal rejection in the same list. `PreSharedKey` is a terminal
330/// rejection.
331///
332/// The `AppDataUpdate` proposals must form a valid list under
333/// draft-ietf-mls-extensions-08 §4.7 whether or not a
334/// `GroupContextExtensions` proposal is present: per component, no Update
335/// together with a Remove, at most one Remove, and a Remove only for a
336/// component that `has_state` in the pre-commit dictionary.
337pub 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    /// Maps inbox_id to the proposer who proposed adding it
380    pub added_inbox_proposers: HashMap<String, CommitParticipant>,
381    /// Maps inbox_id to the proposer who proposed removing it
382    pub removed_inbox_proposers: HashMap<String, CommitParticipant>,
383}
384
385/**
386 * Extracts the installations added and removed via proposals in the commit.
387 * Also returns a list of credentials from existing members that need verification (caused by update proposals)
388 * Tracks which proposer created each proposal for permission validation.
389 */
390pub 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    // The actual installations added and removed via proposals in the commit
397    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        // Extract the proposer for this proposal
405        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            // For update proposals, we need to validate that the credential and installation key
417            // are valid for the inbox_id in the current group membership state
418            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            // For Add Proposals, all we need to do is validate that the installation_id is in the expected diff
426            Proposal::Add(add_proposal) => {
427                // We don't need to validate the credential here, since we've already validated it as part of
428                // building the expected installation diff
429                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            // For Remove Proposals, all we need to do is validate that the installation_id is in the expected diff
436            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
458/**
459 * Extracts the latest `GroupMembership` from the staged commit.
460 *
461 * Returns an error if the extension is not found.
462 */
463pub fn get_latest_group_membership(
464    staged_commit: &StagedCommit,
465) -> Result<GroupMembership, CommitRuleError> {
466    extract_group_membership(staged_commit.group_context().extensions())
467}
468
469/// Read committed metadata once through the active extension representation.
470/// Decode the post-commit metadata for messages, callbacks, and admin side effects.
471pub 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
528/// Require each identity sequence `N` to precede group envelope sequence `S`.
529/// `N >= S` is invalid on every receiver, independent of cache or replica state.
530// implements: GMOD-007
531pub 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
546/// Superadmins are permitted to readd installations, e.g. for fork recovery
547/// We can take these readded installations out of the list of installations to validate
548// implements: GMOD-015
549pub 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    // We only want to intersect with *remaining* removed installations here, to avoid double counting
566    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
588/// Validate that the new group membership is a valid state transition from the old group membership.
589/// Enforces Rule 1 from above
590// implements: GMOD-008
591pub 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
612/// Validate a single `AppDataUpdate` (component_id + operation) against
613/// `registry` on behalf of `actor`.
614///
615/// Shared core for both validator entry points:
616/// `validate_proposal` in `xmtp_mls` (standalone proposal-by-reference messages) and
617/// [`validate_app_data_update_proposals_in_commit`] (proposals inside
618/// commits, inline or referenced). Both paths must enforce identical
619/// permission checks; lifting the loop here keeps them in lockstep so a
620/// future change can't drift the two implementations apart.
621///
622/// Reads the pre-commit stored bytes for `component_id` from the
623/// group's AppData dictionary and threads them into the expansion step
624/// so `RemoveByHash` mutations can be resolved back to the concrete
625/// inbox id being removed. If the component has no prior entry (first
626/// write), `read_from_app_data_dict` returns `None`, which
627/// [`expand_app_data_update_to_changes`] treats as an empty prior set
628/// — `Insert` / `Remove` deltas expand normally, and any `RemoveByHash`
629/// surfaces `value: None` (the CRDT apply step later rejects with
630/// `KeyNotFound`). This matches the `Bytes` component case where a
631/// first-time `Update` has no prior value to diff against.
632///
633/// Returns `Err(InsufficientPermissions)` on the first failure (expand,
634/// per-element check, or field bound) so the caller can reject the wider
635/// message wholesale.
636pub 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    // Pull the pre-commit stored bytes for this component so the expansion
650    // step can resolve `RemoveByHash` mutations back to the concrete
651    // inbox id being removed. `None` is a legal first-write state — see
652    // the fn docstring above for how the expansion handles it.
653    let old_value = read_from_app_data_dict(component_id, openmls_group);
654    // A standalone proposal has no commit, so the self-owned policy reads
655    // the committed membership.
656    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/// Pure core of [`validate_one_app_data_update`]: the policy checks, then
685/// the field bounds, so an oversized proposal is never stored.
686///
687/// Element bounds are judged on the payload alone (see
688/// [`apply_app_data_update_payload`](xmtp_mls_common::app_data::component_source::apply_app_data_update_payload));
689/// the snapshot bound on the committed
690/// state. Other apply failures are left to commit validation,
691/// because a commit may order other proposals ahead of this one and change
692/// the state it applies to.
693// implements: META-068
694#[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
731/// Whether a `GROUP_MEMBERSHIP` operation drops a member of the committed
732/// `SUPER_ADMIN_LIST` snapshot.
733///
734/// Super admins stay members. MLS Remove validation enforces this for
735/// leaves; this check enforces it for the membership entry. Without it, a
736/// receiver rejects an admin's Remove proposal but stores the paired
737/// membership proposal, which then fails every later commit of pending
738/// proposals.
739///
740/// A whole-component `Remove` returns `false`: commits omit it.
741/// A malformed delta or super-admin list also returns `false`; expansion
742/// and the commit-time checks reject those.
743// implements: GMOD-019
744pub(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
770/// The inbox ids of a `GROUP_MEMBERSHIP` snapshot. `None` when the
771/// snapshot is absent or malformed, which fails the self-owned policy's
772/// non-member test closed.
773fn 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
783/// Receive-side enforcement of `MIN_SUPPORTED_PROTOCOL_VERSION`
784/// monotonicity. A proposal that lowers the floor (or removes it
785/// while one was set) is rejected before it can reach the dict.
786/// `Update(new)` with `new >= old` (or `old` absent / unparseable)
787/// passes. `Remove` with an existing floor fails — explicit unsetting
788/// of the floor is just a downgrade in disguise.
789///
790/// This is the source-of-truth check; the send-side guard in
791/// `update_group_min_version` is a friendlier UX layer over the same
792/// invariant. An attacker patching out the send-side gate still hits
793/// this one on every receiver.
794// implements: GMOD-026
795fn 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    // First-set on a group with no prior floor is always allowed —
801    // there's nothing to downgrade against.
802    let Some(old_bytes) = old_value else {
803        return Ok(());
804    };
805    // If the prior bytes don't parse as semver, we can't compare.
806    // Treat as "no prior floor" and accept — refusing every future
807    // update on a malformed prior would brick the group.
808    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
834/// Permit exactly one insertion for the other authenticated DM participant.
835fn 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/// Pure core of [`validate_one_app_data_update`] with `old_value`
872/// passed explicitly so unit tests can exercise the
873/// expand → per-change policy loop without a real MLS group.
874///
875/// `membership` is the set of inbox ids the self-owned non-member test reads:
876/// the post-commit membership inside a commit, the committed one for a
877/// standalone proposal.
878// implements: PERM-011, PERM-014, PERM-026, PERM-027, PERM-028
879#[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    // Source-of-truth monotonicity for `MIN_SUPPORTED_PROTOCOL_VERSION`.
896    // Runs ahead of the per-element policy loop so a downgrade fails
897    // fast with a structured error rather than passing the policy
898    // check and silently relaxing the pause gate. See
899    // `enforce_min_version_monotonicity` for the rule shape.
900    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    // Two dispatch shapes:
914    //
915    // - **Known component**: expand via the per-id `Component` impl
916    //   (decodes Set/Map deltas into per-element changes) and run both
917    //   layers — registry policy AND per-component invariant.
918    //
919    // - **Unknown component** (no per-id impl on this client; the
920    //   sender shipped a newer release): look the component's
921    //   registered [`ComponentType`] up in the registry and run the
922    //   type-aware expansion. Same per-element change list a typed
923    //   client would produce, fed through the same policy loop. The
924    //   per-component invariant hook is skipped — there's no per-id
925    //   trait method to call — but registry-policy enforcement still
926    //   gates the write, so deny-by-default applies.
927    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    // A DM may add its other participant even though its add-member policy
963    // denies general additions. Use the same exception as MLS Add validation.
964    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        // Layer 1: registry-based policy. Applies to both known and
992        // unknown components — every component requires a registry
993        // entry (deny by default).
994        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    // Layer 2: component-local invariants. Run once per proposal with
1007    // the complete pre- and post-operation values. Collection policies
1008    // above still run per mutation, but an invariant such as “a non-empty
1009    // super-admin list cannot become empty” is a transition property and
1010    // cannot be determined from an individual delta mutation.
1011    //
1012    // Only known components have this hook. Unknown ids use the
1013    // type-aware compatibility path and therefore cannot add invariants
1014    // beyond their registry policy.
1015    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
1063/// Resolve the proposer leaf index for a proposal sender, rejecting
1064/// senders that can't legally propose `AppDataUpdate`.
1065///
1066/// External senders and new-member proposals can't carry
1067/// `AppDataUpdate` by design — only an existing leaf can propose one.
1068/// Pulled out so the rejection reason is a single code path that can
1069/// be unit-tested without constructing a `StagedCommit`.
1070pub(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
1081/// One `AppDataUpdate` proposal in a commit, with its sender's authority.
1082pub 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
1089/// Validate a commit's `AppDataUpdate` proposals in order and return each
1090/// touched component's state after the last of them (`None` once removed).
1091///
1092/// Each proposal is judged against its component's state after the
1093/// preceding proposals, starting from `committed`, so neither a transition
1094/// invariant nor an immutable first write can be split across proposals.
1095pub 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
1141/// Validate every `AppDataUpdate` proposal carried by `staged_commit`
1142/// against the group's component registry.
1143///
1144/// `staged_commit.app_data_update_proposals()` iterates both inline
1145/// proposals and references that resolve into the group's proposal
1146/// store, so this covers both shapes. `validate_proposal()` covers the
1147/// standalone-proposal-by-reference path (proposals that arrive as
1148/// their own message), but commits never flow through
1149/// `validate_proposal` — they go through `from_staged_commit`. Without
1150/// this helper, `AppDataUpdate` proposals committed alongside a commit
1151/// would bypass `validate_component_write` entirely, since
1152/// `extract_metadata_changes` only inspects the legacy mutable-metadata
1153/// extension.
1154///
1155/// Delegates the per-proposal permission check to
1156/// [`validate_one_app_data_update`] so the core logic stays shared with
1157/// the standalone-proposal path in `validate_proposal`.
1158///
1159/// # Registry semantics
1160///
1161/// `registry` is the committed registry shared with the other component checks.
1162/// Registry changes and writes that depend on them must use separate commits.
1163pub 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    // Peek first: the common case is zero AppDataUpdate proposals, in
1177    // which case we skip the registry load and the per-proposer work
1178    // entirely. This runs on every commit's validation path.
1179    //
1180    // Safety of the early-exit against unresolvable references: OpenMLS
1181    // rejects commits that reference proposals it can't resolve against
1182    // the group's proposal store *before* `from_staged_commit` is called
1183    // (see `process_message`'s reference-resolution pass). So
1184    // `staged_commit.app_data_update_proposals()` iterates only
1185    // inline-or-successfully-resolved proposals — an attacker can't
1186    // smuggle in a dangling reference that would `peek()` as `None` and
1187    // bypass the loop.
1188    let mut proposals = staged_commit.app_data_update_proposals().peekable();
1189    if proposals.peek().is_none() {
1190        return Ok(());
1191    }
1192
1193    // A single commit's bootstrap can carry multiple AppDataUpdate proposals
1194    // from the same leaf; cache extracted `CommitParticipant`s so we don't
1195    // re-walk the admin lists and re-parse the credential for every one.
1196    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    // The self-owned policy tests a deleted key against the membership
1214    // after the commit.
1215    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    // Validate the final registry after all deltas. Required action entries
1240    // must remain present, and every child in each action tree must be valid.
1241    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(&registry)
1249            .map_err(|error| {
1250                tracing::warn!(%error, "invalid registry action policy in commit");
1251                CommitRuleError::InsufficientPermissions
1252            })?;
1253    }
1254
1255    Ok(())
1256}
1257
1258/// Extracts the [`CommitParticipant`] from the [`LeafNodeIndex`]
1259pub 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        // TODO: Handle external joins/commits
1276        Err(CommitRuleError::ActorNotMember)
1277    }
1278}
1279
1280/// Get [`GroupMembership`] from the AppData dictionary.
1281#[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
1336/**
1337 * Gets the list of inboxes present in the new group membership that are not present in the old group membership.
1338 */
1339fn 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
1351/**
1352 * Gets the list of inboxes present in the old group membership that are not present in the new group membership.
1353 */
1354fn 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
1395/**
1396 * Extracts the changes to the mutable metadata in the commit.
1397 */
1398fn 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
1429/// Extracts the inbox ID from a credential.
1430pub 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
1438/// Takes a [`StagedCommit`] and extracts the committer and all unique proposers.
1439///
1440/// `committer_leaf_index` is the verified sender of the commit message — for
1441/// received commits, that's `ProcessedMessage::sender()` after OpenMLS has
1442/// validated the framing signature against the leaf at that index; for our
1443/// own commits being applied from an intent, that's `mls_group.own_leaf_index()`.
1444/// Either way the cryptographic signature is the source of truth, so we
1445/// don't need a path update to identify the committer.
1446///
1447/// Returns (committer, proposers) where:
1448/// - `committer` is the actor who created the commit
1449/// - `proposers` is a list of all unique members who created proposals
1450///
1451/// Note: The committer may differ from the proposers — this is valid when one
1452/// member commits proposals created by other members.
1453pub 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    // Collect all unique proposers from the proposals
1461    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                // Only add if not already in the list
1466                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    // Convert all proposer leaf indices to CommitParticipants
1475    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    //! Pins the receive-side permission check on `AppDataUpdate`
1524    //! proposals. Every proposal that reaches
1525    //! [`validate_one_app_data_update_with_old_value`] runs through
1526    //! `validate_component_write` (registry policy + hardcoded
1527    //! super-admin gating). Without this guarantee an attacker who
1528    //! patched out the send-side permission check could still poison
1529    //! the dictionary as long as their proposal landed in a commit.
1530    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            &registry,
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            &registry,
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        // Unknown component in well-known range with no registry
1631        // entry → deny by default at the registry-policy layer
1632        // (validate_component_write Layer 3), regardless of actor role.
1633        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            &registry,
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    // verifies: GMOD-026
1681    #[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    // verifies: GMOD-026
1696    #[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        // Lenient on unparseable prior bytes — refusing every future
1719        // update on a malformed floor would brick the group.
1720        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    // verifies: GMOD-027
1736    #[xmtp_common::test(unwrap_try = true)]
1737    fn prerelease_ordering_matches_semver() {
1738        // semver §11: pre-release sorts BEFORE the release. Bumping
1739        // from a pre-release to the corresponding release is allowed;
1740        // going the other way is a downgrade.
1741        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        // The default `PROPOSALS_MIN_PROTOCOL_VERSION` is the
1753        // `-dev` pre-release of the workspace version, so by
1754        // semver §11 the release of the same x.y.z must sort
1755        // above it. Lock both directions:
1756        //   - LibXMTPVersion comparator agrees,
1757        //   - bumping the floor from `-dev` to the release is allowed,
1758        //   - the reverse is a downgrade.
1759        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}