Skip to main content

xmtp_mls_common/app_data/components/
inbox_id_set.rs

1//! [`Component`] impls for the `TlsSet<InboxId>`-shaped components:
2//! [`AdminListComponent`] (`ADMIN_LIST`),
3//! [`SuperAdminListComponent`] (`SUPER_ADMIN_LIST`), and
4//! [`DmMembersComponent`] (`DM_MEMBERS`, immutable).
5//!
6//! All three share an identical wire format and trait shape. They
7//! differ only in their `ComponentId` and the dispatch layer's
8//! immutability gate (`DM_MEMBERS` is in the immutable range, so the
9//! dispatch layer rejects `Update` writes before they reach this impl
10//! — but the impl is still valid as a `read` path).
11
12use openmls::messages::proposals::AppDataUpdateOperation;
13use std::collections::HashMap;
14use tls_codec::{Deserialize, Serialize};
15use xmtp_proto::xmtp::mls::message_contents::ComponentType;
16
17use crate::{
18    app_data::{
19        component_id::ComponentId,
20        component_registry::ComponentOp,
21        typed::{Component, ComponentInvariantError, ComponentTypedError, ExpandedComponentChange},
22    },
23    inbox_id::InboxId,
24    tls_set::{TlsKeyHash, TlsSet, TlsSetDelta, TlsSetError, TlsSetMutation},
25};
26
27/// Apply a `TlsSetDelta<InboxId>` wire payload over the prior dict
28/// bytes (a `TlsSet<InboxId>` snapshot, or `None` if this is the first
29/// write — bootstrap, where prior state is empty).
30///
31/// Returns the new dict bytes: the re-serialized `TlsSet<InboxId>`
32/// snapshot. The dict always holds the raw set as state; the wire
33/// always carries a delta describing the change. This function is
34/// the one boundary that translates between them.
35///
36/// Shared between the three inbox-id-set impls, and reachable from the
37/// type-aware fallback in
38/// [`super::type_dispatch::apply_update_payload_for_type`] for any
39/// future `TlsSetInboxId` component an old client does not yet have a
40/// per-id `Component` impl for.
41pub(crate) fn apply_inbox_id_set_delta(
42    payload: &[u8],
43    prior: Option<&[u8]>,
44) -> Result<Vec<u8>, ComponentTypedError> {
45    let delta = TlsSetDelta::<InboxId>::tls_deserialize_exact(payload)?;
46    let mut set: TlsSet<InboxId> = match prior {
47        Some(bytes) => TlsSet::<InboxId>::tls_deserialize_exact(bytes)?,
48        None => TlsSet::new(),
49    };
50    set.apply_delta(delta)?;
51    Ok(set.tls_serialize_detached()?)
52}
53
54/// Expand an `AppDataUpdate` proposal for an inbox-id-set component
55/// into the per-element `ExpandedComponentChange` entries the validator
56/// iterates over.
57///
58/// Mirrors the existing `expand_app_data_update_to_changes`
59/// implementation in `xmtp_mls::groups::app_data::component_source`
60/// (which #7 will retire). `RemoveByHash` mutations resolve back to
61/// the underlying `InboxId` via a hash index built from the prior set.
62pub(crate) fn expand_inbox_id_set_changes(
63    op: &AppDataUpdateOperation,
64    prior: Option<&[u8]>,
65) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
66    match op {
67        AppDataUpdateOperation::Remove => Ok(vec![ExpandedComponentChange {
68            op: ComponentOp::Delete,
69            key: None,
70            value: None,
71        }]),
72        AppDataUpdateOperation::Update(payload) => {
73            let delta = TlsSetDelta::<InboxId>::tls_deserialize_exact(payload.as_slice())?;
74
75            // Build the hash → InboxId index ONCE up front for the
76            // whole delta — never inside the mutation loop. The
77            // mutation loop below stays a tight O(m) scan that does
78            // O(1) hash-index lookups; building the index inside the
79            // loop would degrade the resolve step from O(n+m) to
80            // O(n*m) and bite us once admin/super-admin lists grow.
81            //
82            // Conditional on `needs_index`: if the delta has zero
83            // `RemoveByHash` mutations, the prior set is never decoded
84            // and no index is allocated — common case (pure
85            // Insert/Remove deltas) pays nothing for the index path.
86            //
87            // Mirrors `TlsSet::apply_delta`'s "build once per call"
88            // discipline (see `tls_set.rs`'s `apply_delta` docs for
89            // the same O(n+m) argument).
90            let needs_index = delta
91                .mutations
92                .iter()
93                .any(|m| matches!(m, TlsSetMutation::RemoveByHash(_)));
94            let hash_index: Option<HashMap<TlsKeyHash, InboxId>> = if needs_index {
95                match prior {
96                    Some(bytes) => {
97                        let prior_set = TlsSet::<InboxId>::tls_deserialize_exact(bytes)?;
98                        let mut idx = HashMap::with_capacity(prior_set.len());
99                        for key in prior_set.iter() {
100                            // A hash clash between two distinct keys
101                            // is cryptographically infeasible with
102                            // SHA-256, but defense-in-depth: reject
103                            // any silently-lossy index. Matters if
104                            // SHA-256 is ever weakened or if a future
105                            // `Serialize` impl produces the same
106                            // bytes for distinct logical values — a
107                            // collision-tolerant index would silently
108                            // drop one of the entries and let
109                            // `RemoveByHash` resolve to the wrong
110                            // member. Matches `TlsSet::apply_delta`'s
111                            // `DuplicateHash` check at apply time.
112                            if idx.insert(TlsKeyHash::of(key)?, *key).is_some() {
113                                return Err(ComponentTypedError::TlsSetApply(
114                                    TlsSetError::DuplicateHash,
115                                ));
116                            }
117                        }
118                        Some(idx)
119                    }
120                    // No prior bytes → empty set → every RemoveByHash
121                    // trivially misses. Skip the allocation; each
122                    // lookup returns None.
123                    None => None,
124                }
125            } else {
126                None
127            };
128
129            let mut out = Vec::with_capacity(delta.mutations.len());
130            for mutation in delta.mutations {
131                match mutation {
132                    TlsSetMutation::Insert(key) => out.push(ExpandedComponentChange {
133                        op: ComponentOp::Insert,
134                        key: None,
135                        value: Some(key.into_bytes().to_vec()),
136                    }),
137                    TlsSetMutation::Remove(key) => out.push(ExpandedComponentChange {
138                        op: ComponentOp::Delete,
139                        key: None,
140                        value: Some(key.into_bytes().to_vec()),
141                    }),
142                    TlsSetMutation::RemoveByHash(target) => {
143                        let resolved = hash_index
144                            .as_ref()
145                            .and_then(|idx| idx.get(&target))
146                            .map(|id| id.as_bytes().to_vec());
147                        out.push(ExpandedComponentChange {
148                            op: ComponentOp::Delete,
149                            key: None,
150                            value: resolved,
151                        });
152                    }
153                }
154            }
155            Ok(out)
156        }
157    }
158}
159
160macro_rules! inbox_id_set_component {
161    ($struct_name:ident, $id:expr) => {
162        pub struct $struct_name;
163
164        impl Component for $struct_name {
165            const ID: ComponentId = $id;
166            const COMPONENT_TYPE: ComponentType = ComponentType::TlsSetInboxId;
167            type Value = TlsSet<InboxId>;
168            // The mutation type is the full wire-level delta so a
169            // single proposal can carry multiple Insert/Remove
170            // mutations atomically. Single-mutation callers build a
171            // one-element delta via `TlsSetDelta::new().insert(x)`.
172            type Mutation = TlsSetDelta<InboxId>;
173
174            fn decode_value(bytes: &[u8]) -> Result<Self::Value, ComponentTypedError> {
175                TlsSet::<InboxId>::tls_deserialize_exact(bytes).map_err(Into::into)
176            }
177
178            fn encode_value(value: &Self::Value) -> Result<Vec<u8>, ComponentTypedError> {
179                value.tls_serialize_detached().map_err(Into::into)
180            }
181
182            fn encode_mutation(mutation: &Self::Mutation) -> Result<Vec<u8>, ComponentTypedError> {
183                mutation.tls_serialize_detached().map_err(Into::into)
184            }
185
186            fn apply_update_payload(
187                payload: &[u8],
188                prior: Option<&[u8]>,
189            ) -> Result<Vec<u8>, ComponentTypedError> {
190                apply_inbox_id_set_delta(payload, prior)
191            }
192
193            fn expand_to_changes(
194                op: &AppDataUpdateOperation,
195                prior: Option<&[u8]>,
196            ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
197                expand_inbox_id_set_changes(op, prior)
198            }
199        }
200    };
201}
202
203inbox_id_set_component!(AdminListComponent, ComponentId::ADMIN_LIST);
204
205pub struct SuperAdminListComponent;
206
207impl Component for SuperAdminListComponent {
208    const ID: ComponentId = ComponentId::SUPER_ADMIN_LIST;
209    const COMPONENT_TYPE: ComponentType = ComponentType::TlsSetInboxId;
210    type Value = TlsSet<InboxId>;
211    type Mutation = TlsSetDelta<InboxId>;
212
213    fn decode_value(bytes: &[u8]) -> Result<Self::Value, ComponentTypedError> {
214        TlsSet::<InboxId>::tls_deserialize_exact(bytes).map_err(Into::into)
215    }
216
217    fn encode_value(value: &Self::Value) -> Result<Vec<u8>, ComponentTypedError> {
218        value.tls_serialize_detached().map_err(Into::into)
219    }
220
221    fn encode_mutation(mutation: &Self::Mutation) -> Result<Vec<u8>, ComponentTypedError> {
222        mutation.tls_serialize_detached().map_err(Into::into)
223    }
224
225    fn apply_update_payload(
226        payload: &[u8],
227        prior: Option<&[u8]>,
228    ) -> Result<Vec<u8>, ComponentTypedError> {
229        apply_inbox_id_set_delta(payload, prior)
230    }
231
232    fn expand_to_changes(
233        op: &AppDataUpdateOperation,
234        prior: Option<&[u8]>,
235    ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
236        expand_inbox_id_set_changes(op, prior)
237    }
238
239    fn validate_invariant(
240        change: &crate::app_data::validation::ComponentChange<'_>,
241        _registry: &crate::app_data::component_registry::ComponentRegistry,
242    ) -> Result<(), ComponentInvariantError> {
243        // Registry policies can only inspect the actor. They cannot express
244        // this transition predicate over the resulting set. An empty set is
245        // valid at creation for a DM, but an existing non-empty set must not
246        // transition to empty or the group would have no super-admin.
247        let prior = match change.old_value {
248            Some(bytes) => TlsSet::<InboxId>::tls_deserialize_exact(bytes).map_err(|_| {
249                ComponentInvariantError::Violation {
250                    component_id: Self::ID,
251                    reason: "pre-state is not a valid inbox-id set".into(),
252                }
253            })?,
254            None => return Ok(()),
255        };
256        if prior.is_empty() {
257            return Ok(());
258        }
259        let post_is_empty = match change.new_value {
260            Some(bytes) => TlsSet::<InboxId>::tls_deserialize_exact(bytes)
261                .map_err(|_| ComponentInvariantError::Violation {
262                    component_id: Self::ID,
263                    reason: "post-state is not a valid inbox-id set".into(),
264                })?
265                .is_empty(),
266            None => true,
267        };
268        if post_is_empty {
269            return Err(ComponentInvariantError::Violation {
270                component_id: Self::ID,
271                reason: "a non-empty super-admin list cannot transition to empty".into(),
272            });
273        }
274        Ok(())
275    }
276}
277
278inbox_id_set_component!(DmMembersComponent, ComponentId::DM_MEMBERS);
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::app_data::{component_registry::ComponentRegistry, validation::ActorAuthority};
284
285    fn fixture_inbox_id(seed: u8) -> InboxId {
286        let mut bytes = [0u8; 32];
287        bytes[0] = seed;
288        InboxId::from_bytes(bytes)
289    }
290
291    #[xmtp_common::test(unwrap_try = true)]
292    fn round_trip_admin_list_value() {
293        let mut set = TlsSet::<InboxId>::new();
294        set.insert(fixture_inbox_id(1)).unwrap();
295        set.insert(fixture_inbox_id(2)).unwrap();
296        let bytes = AdminListComponent::encode_value(&set).unwrap();
297        let decoded = AdminListComponent::decode_value(&bytes).unwrap();
298        assert_eq!(decoded.len(), 2);
299        assert!(decoded.contains(&fixture_inbox_id(1)));
300        assert!(decoded.contains(&fixture_inbox_id(2)));
301    }
302
303    #[xmtp_common::test(unwrap_try = true)]
304    fn encode_mutation_serializes_full_delta() {
305        let delta = TlsSetDelta::<InboxId>::new().insert(fixture_inbox_id(7));
306        let bytes = AdminListComponent::encode_mutation(&delta).unwrap();
307        let round_trip = TlsSetDelta::<InboxId>::tls_deserialize_exact(&bytes).unwrap();
308        assert_eq!(round_trip.mutations.len(), 1);
309        match &round_trip.mutations[0] {
310            TlsSetMutation::Insert(id) => assert_eq!(*id, fixture_inbox_id(7)),
311            other => panic!("unexpected mutation: {other:?}"),
312        }
313    }
314
315    #[xmtp_common::test(unwrap_try = true)]
316    fn encode_mutation_supports_batched_delta() {
317        // The motivating case for delta-as-mutation: multiple
318        // changes in one proposal. e.g. promote two new admins and
319        // demote a third atomically.
320        let delta = TlsSetDelta::<InboxId>::new()
321            .insert(fixture_inbox_id(1))
322            .insert(fixture_inbox_id(2))
323            .remove(fixture_inbox_id(3));
324        let bytes = AdminListComponent::encode_mutation(&delta).unwrap();
325        let round_trip = TlsSetDelta::<InboxId>::tls_deserialize_exact(&bytes).unwrap();
326        assert_eq!(round_trip.mutations.len(), 3);
327    }
328
329    #[xmtp_common::test(unwrap_try = true)]
330    fn apply_insert_against_empty_prior() {
331        let delta = TlsSetDelta::<InboxId>::new().insert(fixture_inbox_id(3));
332        let payload = AdminListComponent::encode_mutation(&delta).unwrap();
333        let new_bytes = AdminListComponent::apply_update_payload(&payload, None).unwrap();
334        let new = AdminListComponent::decode_value(&new_bytes).unwrap();
335        assert_eq!(new.len(), 1);
336        assert!(new.contains(&fixture_inbox_id(3)));
337    }
338
339    #[xmtp_common::test(unwrap_try = true)]
340    fn apply_remove_against_existing_prior() {
341        // Build a prior with two members.
342        let mut prior_set = TlsSet::<InboxId>::new();
343        prior_set.insert(fixture_inbox_id(1)).unwrap();
344        prior_set.insert(fixture_inbox_id(2)).unwrap();
345        let prior_bytes = SuperAdminListComponent::encode_value(&prior_set).unwrap();
346
347        // Send a Remove(inbox_id_1) delta.
348        let delta = TlsSetDelta::<InboxId>::new().remove(fixture_inbox_id(1));
349        let payload = SuperAdminListComponent::encode_mutation(&delta).unwrap();
350
351        let new_bytes =
352            SuperAdminListComponent::apply_update_payload(&payload, Some(&prior_bytes)).unwrap();
353        let new = SuperAdminListComponent::decode_value(&new_bytes).unwrap();
354        assert_eq!(new.len(), 1);
355        assert!(new.contains(&fixture_inbox_id(2)));
356    }
357
358    #[xmtp_common::test(unwrap_try = true)]
359    fn super_admin_list_rejects_transition_from_non_empty_to_empty() {
360        let id = fixture_inbox_id(1);
361        let mut prior = TlsSet::<InboxId>::new();
362        prior.insert(id)?;
363        let prior = SuperAdminListComponent::encode_value(&prior)?;
364        let post = SuperAdminListComponent::encode_value(&TlsSet::new())?;
365        let change = crate::app_data::validation::ComponentChange::builder()
366            .component_id(ComponentId::SUPER_ADMIN_LIST)
367            .op(ComponentOp::Update)
368            .actor(ActorAuthority {
369                is_admin: true,
370                is_super_admin: true,
371            })
372            .old_value(&prior)
373            .new_value(&post)
374            .build();
375
376        let err = SuperAdminListComponent::validate_invariant(&change, &ComponentRegistry::new())
377            .unwrap_err();
378        assert!(matches!(err, ComponentInvariantError::Violation { .. }));
379    }
380
381    #[xmtp_common::test(unwrap_try = true)]
382    fn super_admin_list_allows_empty_creation_state() {
383        let empty = SuperAdminListComponent::encode_value(&TlsSet::<InboxId>::new())?;
384        let change = crate::app_data::validation::ComponentChange::builder()
385            .component_id(ComponentId::SUPER_ADMIN_LIST)
386            .op(ComponentOp::Insert)
387            .actor(ActorAuthority {
388                is_admin: true,
389                is_super_admin: true,
390            })
391            .new_value(&empty)
392            .build();
393
394        SuperAdminListComponent::validate_invariant(&change, &ComponentRegistry::new())?;
395    }
396
397    #[xmtp_common::test(unwrap_try = true)]
398    fn apply_batched_delta_atomically() {
399        // Apply Insert(1) + Insert(2) + Remove(3) in one shot.
400        let mut prior_set = TlsSet::<InboxId>::new();
401        prior_set.insert(fixture_inbox_id(3)).unwrap();
402        let prior_bytes = AdminListComponent::encode_value(&prior_set).unwrap();
403
404        let delta = TlsSetDelta::<InboxId>::new()
405            .insert(fixture_inbox_id(1))
406            .insert(fixture_inbox_id(2))
407            .remove(fixture_inbox_id(3));
408        let payload = AdminListComponent::encode_mutation(&delta).unwrap();
409
410        let new_bytes =
411            AdminListComponent::apply_update_payload(&payload, Some(&prior_bytes)).unwrap();
412        let new = AdminListComponent::decode_value(&new_bytes).unwrap();
413        assert_eq!(new.len(), 2);
414        assert!(new.contains(&fixture_inbox_id(1)));
415        assert!(new.contains(&fixture_inbox_id(2)));
416        assert!(!new.contains(&fixture_inbox_id(3)));
417    }
418
419    #[xmtp_common::test(unwrap_try = true)]
420    fn expand_insert_yields_single_change() {
421        let delta = TlsSetDelta::<InboxId>::new().insert(fixture_inbox_id(5));
422        let payload = AdminListComponent::encode_mutation(&delta).unwrap();
423        let op = AppDataUpdateOperation::Update(payload.into());
424        let changes = AdminListComponent::expand_to_changes(&op, None).unwrap();
425        assert_eq!(changes.len(), 1);
426        assert_eq!(changes[0].op, ComponentOp::Insert);
427        assert_eq!(
428            changes[0].value.as_deref(),
429            Some(&fixture_inbox_id(5).as_bytes()[..])
430        );
431    }
432
433    #[xmtp_common::test(unwrap_try = true)]
434    fn expand_batched_delta_yields_one_change_per_mutation() {
435        // Pinned: per-element validation iterates per-mutation, so a
436        // batched delta produces one ExpandedComponentChange per
437        // entry.
438        let delta = TlsSetDelta::<InboxId>::new()
439            .insert(fixture_inbox_id(10))
440            .insert(fixture_inbox_id(11))
441            .remove(fixture_inbox_id(12));
442        let payload = AdminListComponent::encode_mutation(&delta).unwrap();
443        let op = AppDataUpdateOperation::Update(payload.into());
444        let changes = AdminListComponent::expand_to_changes(&op, None).unwrap();
445        assert_eq!(changes.len(), 3);
446        assert_eq!(changes[0].op, ComponentOp::Insert);
447        assert_eq!(changes[1].op, ComponentOp::Insert);
448        assert_eq!(changes[2].op, ComponentOp::Delete);
449    }
450
451    #[xmtp_common::test(unwrap_try = true)]
452    fn expand_remove_by_hash_resolves_against_prior() {
453        let target = fixture_inbox_id(9);
454        let mut prior_set = TlsSet::<InboxId>::new();
455        prior_set.insert(target).unwrap();
456        let prior_bytes = AdminListComponent::encode_value(&prior_set).unwrap();
457
458        let target_hash = TlsKeyHash::of(&target).unwrap();
459        let delta = TlsSetDelta::<InboxId>::new().remove_by_hash(target_hash);
460        let payload = AdminListComponent::encode_mutation(&delta).unwrap();
461        let op = AppDataUpdateOperation::Update(payload.into());
462
463        let changes = AdminListComponent::expand_to_changes(&op, Some(&prior_bytes)).unwrap();
464        assert_eq!(changes.len(), 1);
465        assert_eq!(changes[0].op, ComponentOp::Delete);
466        assert_eq!(changes[0].value.as_deref(), Some(&target.as_bytes()[..]));
467    }
468
469    #[xmtp_common::test(unwrap_try = true)]
470    fn expand_remove_by_hash_with_no_prior_yields_unresolved() {
471        let target_hash = TlsKeyHash::of(&fixture_inbox_id(9)).unwrap();
472        let delta = TlsSetDelta::<InboxId>::new().remove_by_hash(target_hash);
473        let payload = AdminListComponent::encode_mutation(&delta).unwrap();
474        let op = AppDataUpdateOperation::Update(payload.into());
475
476        let changes = AdminListComponent::expand_to_changes(&op, None).unwrap();
477        assert_eq!(changes.len(), 1);
478        assert_eq!(changes[0].op, ComponentOp::Delete);
479        assert!(changes[0].value.is_none());
480    }
481
482    #[xmtp_common::test(unwrap_try = true)]
483    fn apply_rejects_non_delta_payload() {
484        // The wire is always a `TlsSetDelta<InboxId>`, never a raw
485        // `TlsSet`. A bootstrap caller that mistakenly emitted a full
486        // set as the payload must surface as a decode failure, not
487        // silently overwrite the dict.
488        let mut set = TlsSet::<InboxId>::new();
489        set.insert(fixture_inbox_id(1)).unwrap();
490        let raw_set_bytes = set.tls_serialize_detached().unwrap();
491        let err = AdminListComponent::apply_update_payload(&raw_set_bytes, None).unwrap_err();
492        assert!(
493            matches!(err, ComponentTypedError::TlsCodec(_)),
494            "expected TlsCodec decode error for non-delta payload, got {err:?}"
495        );
496    }
497
498    #[xmtp_common::test(unwrap_try = true)]
499    fn dm_members_uses_same_codec() {
500        // Identical to admin_list — sanity check that the macro
501        // expansion works for the immutable-component variant too.
502        let mut set = TlsSet::<InboxId>::new();
503        set.insert(fixture_inbox_id(42)).unwrap();
504        let bytes = DmMembersComponent::encode_value(&set).unwrap();
505        let decoded = DmMembersComponent::decode_value(&bytes).unwrap();
506        assert!(decoded.contains(&fixture_inbox_id(42)));
507        assert_eq!(
508            DmMembersComponent::COMPONENT_TYPE,
509            ComponentType::TlsSetInboxId
510        );
511    }
512}