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, 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            value: None,
70        }]),
71        AppDataUpdateOperation::Update(payload) => {
72            let delta = TlsSetDelta::<InboxId>::tls_deserialize_exact(payload.as_slice())?;
73
74            // Build the hash → InboxId index ONCE up front for the
75            // whole delta — never inside the mutation loop. The
76            // mutation loop below stays a tight O(m) scan that does
77            // O(1) hash-index lookups; building the index inside the
78            // loop would degrade the resolve step from O(n+m) to
79            // O(n*m) and bite us once admin/super-admin lists grow.
80            //
81            // Conditional on `needs_index`: if the delta has zero
82            // `RemoveByHash` mutations, the prior set is never decoded
83            // and no index is allocated — common case (pure
84            // Insert/Remove deltas) pays nothing for the index path.
85            //
86            // Mirrors `TlsSet::apply_delta`'s "build once per call"
87            // discipline (see `tls_set.rs`'s `apply_delta` docs for
88            // the same O(n+m) argument).
89            let needs_index = delta
90                .mutations
91                .iter()
92                .any(|m| matches!(m, TlsSetMutation::RemoveByHash(_)));
93            let hash_index: Option<HashMap<TlsKeyHash, InboxId>> = if needs_index {
94                match prior {
95                    Some(bytes) => {
96                        let prior_set = TlsSet::<InboxId>::tls_deserialize_exact(bytes)?;
97                        let mut idx = HashMap::with_capacity(prior_set.len());
98                        for key in prior_set.iter() {
99                            // A hash clash between two distinct keys
100                            // is cryptographically infeasible with
101                            // SHA-256, but defense-in-depth: reject
102                            // any silently-lossy index. Matters if
103                            // SHA-256 is ever weakened or if a future
104                            // `Serialize` impl produces the same
105                            // bytes for distinct logical values — a
106                            // collision-tolerant index would silently
107                            // drop one of the entries and let
108                            // `RemoveByHash` resolve to the wrong
109                            // member. Matches `TlsSet::apply_delta`'s
110                            // `DuplicateHash` check at apply time.
111                            if idx.insert(TlsKeyHash::of(key)?, *key).is_some() {
112                                return Err(ComponentTypedError::TlsSetApply(
113                                    TlsSetError::DuplicateHash,
114                                ));
115                            }
116                        }
117                        Some(idx)
118                    }
119                    // No prior bytes → empty set → every RemoveByHash
120                    // trivially misses. Skip the allocation; each
121                    // lookup returns None.
122                    None => None,
123                }
124            } else {
125                None
126            };
127
128            let mut out = Vec::with_capacity(delta.mutations.len());
129            for mutation in delta.mutations {
130                match mutation {
131                    TlsSetMutation::Insert(key) => out.push(ExpandedComponentChange {
132                        op: ComponentOp::Insert,
133                        value: Some(key.into_bytes().to_vec()),
134                    }),
135                    TlsSetMutation::Remove(key) => out.push(ExpandedComponentChange {
136                        op: ComponentOp::Delete,
137                        value: Some(key.into_bytes().to_vec()),
138                    }),
139                    TlsSetMutation::RemoveByHash(target) => {
140                        let resolved = hash_index
141                            .as_ref()
142                            .and_then(|idx| idx.get(&target))
143                            .map(|id| id.as_bytes().to_vec());
144                        out.push(ExpandedComponentChange {
145                            op: ComponentOp::Delete,
146                            value: resolved,
147                        });
148                    }
149                }
150            }
151            Ok(out)
152        }
153    }
154}
155
156macro_rules! inbox_id_set_component {
157    ($struct_name:ident, $id:expr) => {
158        pub struct $struct_name;
159
160        impl Component for $struct_name {
161            const ID: ComponentId = $id;
162            const COMPONENT_TYPE: ComponentType = ComponentType::TlsSetInboxId;
163            type Value = TlsSet<InboxId>;
164            // The mutation type is the full wire-level delta so a
165            // single proposal can carry multiple Insert/Remove
166            // mutations atomically. Single-mutation callers build a
167            // one-element delta via `TlsSetDelta::new().insert(x)`.
168            type Mutation = TlsSetDelta<InboxId>;
169
170            fn decode_value(bytes: &[u8]) -> Result<Self::Value, ComponentTypedError> {
171                TlsSet::<InboxId>::tls_deserialize_exact(bytes).map_err(Into::into)
172            }
173
174            fn encode_value(value: &Self::Value) -> Result<Vec<u8>, ComponentTypedError> {
175                value.tls_serialize_detached().map_err(Into::into)
176            }
177
178            fn encode_mutation(mutation: &Self::Mutation) -> Result<Vec<u8>, ComponentTypedError> {
179                mutation.tls_serialize_detached().map_err(Into::into)
180            }
181
182            fn apply_update_payload(
183                payload: &[u8],
184                prior: Option<&[u8]>,
185            ) -> Result<Vec<u8>, ComponentTypedError> {
186                apply_inbox_id_set_delta(payload, prior)
187            }
188
189            fn expand_to_changes(
190                op: &AppDataUpdateOperation,
191                prior: Option<&[u8]>,
192            ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
193                expand_inbox_id_set_changes(op, prior)
194            }
195        }
196    };
197}
198
199inbox_id_set_component!(AdminListComponent, ComponentId::ADMIN_LIST);
200inbox_id_set_component!(SuperAdminListComponent, ComponentId::SUPER_ADMIN_LIST);
201inbox_id_set_component!(DmMembersComponent, ComponentId::DM_MEMBERS);
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn fixture_inbox_id(seed: u8) -> InboxId {
208        let mut bytes = [0u8; 32];
209        bytes[0] = seed;
210        InboxId::from_bytes(bytes)
211    }
212
213    #[xmtp_common::test(unwrap_try = true)]
214    fn round_trip_admin_list_value() {
215        let mut set = TlsSet::<InboxId>::new();
216        set.insert(fixture_inbox_id(1)).unwrap();
217        set.insert(fixture_inbox_id(2)).unwrap();
218        let bytes = AdminListComponent::encode_value(&set).unwrap();
219        let decoded = AdminListComponent::decode_value(&bytes).unwrap();
220        assert_eq!(decoded.len(), 2);
221        assert!(decoded.contains(&fixture_inbox_id(1)));
222        assert!(decoded.contains(&fixture_inbox_id(2)));
223    }
224
225    #[xmtp_common::test(unwrap_try = true)]
226    fn encode_mutation_serializes_full_delta() {
227        let delta = TlsSetDelta::<InboxId>::new().insert(fixture_inbox_id(7));
228        let bytes = AdminListComponent::encode_mutation(&delta).unwrap();
229        let round_trip = TlsSetDelta::<InboxId>::tls_deserialize_exact(&bytes).unwrap();
230        assert_eq!(round_trip.mutations.len(), 1);
231        match &round_trip.mutations[0] {
232            TlsSetMutation::Insert(id) => assert_eq!(*id, fixture_inbox_id(7)),
233            other => panic!("unexpected mutation: {other:?}"),
234        }
235    }
236
237    #[xmtp_common::test(unwrap_try = true)]
238    fn encode_mutation_supports_batched_delta() {
239        // The motivating case for delta-as-mutation: multiple
240        // changes in one proposal. e.g. promote two new admins and
241        // demote a third atomically.
242        let delta = TlsSetDelta::<InboxId>::new()
243            .insert(fixture_inbox_id(1))
244            .insert(fixture_inbox_id(2))
245            .remove(fixture_inbox_id(3));
246        let bytes = AdminListComponent::encode_mutation(&delta).unwrap();
247        let round_trip = TlsSetDelta::<InboxId>::tls_deserialize_exact(&bytes).unwrap();
248        assert_eq!(round_trip.mutations.len(), 3);
249    }
250
251    #[xmtp_common::test(unwrap_try = true)]
252    fn apply_insert_against_empty_prior() {
253        let delta = TlsSetDelta::<InboxId>::new().insert(fixture_inbox_id(3));
254        let payload = AdminListComponent::encode_mutation(&delta).unwrap();
255        let new_bytes = AdminListComponent::apply_update_payload(&payload, None).unwrap();
256        let new = AdminListComponent::decode_value(&new_bytes).unwrap();
257        assert_eq!(new.len(), 1);
258        assert!(new.contains(&fixture_inbox_id(3)));
259    }
260
261    #[xmtp_common::test(unwrap_try = true)]
262    fn apply_remove_against_existing_prior() {
263        // Build a prior with two members.
264        let mut prior_set = TlsSet::<InboxId>::new();
265        prior_set.insert(fixture_inbox_id(1)).unwrap();
266        prior_set.insert(fixture_inbox_id(2)).unwrap();
267        let prior_bytes = SuperAdminListComponent::encode_value(&prior_set).unwrap();
268
269        // Send a Remove(inbox_id_1) delta.
270        let delta = TlsSetDelta::<InboxId>::new().remove(fixture_inbox_id(1));
271        let payload = SuperAdminListComponent::encode_mutation(&delta).unwrap();
272
273        let new_bytes =
274            SuperAdminListComponent::apply_update_payload(&payload, Some(&prior_bytes)).unwrap();
275        let new = SuperAdminListComponent::decode_value(&new_bytes).unwrap();
276        assert_eq!(new.len(), 1);
277        assert!(new.contains(&fixture_inbox_id(2)));
278    }
279
280    #[xmtp_common::test(unwrap_try = true)]
281    fn apply_batched_delta_atomically() {
282        // Apply Insert(1) + Insert(2) + Remove(3) in one shot.
283        let mut prior_set = TlsSet::<InboxId>::new();
284        prior_set.insert(fixture_inbox_id(3)).unwrap();
285        let prior_bytes = AdminListComponent::encode_value(&prior_set).unwrap();
286
287        let delta = TlsSetDelta::<InboxId>::new()
288            .insert(fixture_inbox_id(1))
289            .insert(fixture_inbox_id(2))
290            .remove(fixture_inbox_id(3));
291        let payload = AdminListComponent::encode_mutation(&delta).unwrap();
292
293        let new_bytes =
294            AdminListComponent::apply_update_payload(&payload, Some(&prior_bytes)).unwrap();
295        let new = AdminListComponent::decode_value(&new_bytes).unwrap();
296        assert_eq!(new.len(), 2);
297        assert!(new.contains(&fixture_inbox_id(1)));
298        assert!(new.contains(&fixture_inbox_id(2)));
299        assert!(!new.contains(&fixture_inbox_id(3)));
300    }
301
302    #[xmtp_common::test(unwrap_try = true)]
303    fn expand_insert_yields_single_change() {
304        let delta = TlsSetDelta::<InboxId>::new().insert(fixture_inbox_id(5));
305        let payload = AdminListComponent::encode_mutation(&delta).unwrap();
306        let op = AppDataUpdateOperation::Update(payload.into());
307        let changes = AdminListComponent::expand_to_changes(&op, None).unwrap();
308        assert_eq!(changes.len(), 1);
309        assert_eq!(changes[0].op, ComponentOp::Insert);
310        assert_eq!(
311            changes[0].value.as_deref(),
312            Some(&fixture_inbox_id(5).as_bytes()[..])
313        );
314    }
315
316    #[xmtp_common::test(unwrap_try = true)]
317    fn expand_batched_delta_yields_one_change_per_mutation() {
318        // Pinned: per-element validation iterates per-mutation, so a
319        // batched delta produces one ExpandedComponentChange per
320        // entry.
321        let delta = TlsSetDelta::<InboxId>::new()
322            .insert(fixture_inbox_id(10))
323            .insert(fixture_inbox_id(11))
324            .remove(fixture_inbox_id(12));
325        let payload = AdminListComponent::encode_mutation(&delta).unwrap();
326        let op = AppDataUpdateOperation::Update(payload.into());
327        let changes = AdminListComponent::expand_to_changes(&op, None).unwrap();
328        assert_eq!(changes.len(), 3);
329        assert_eq!(changes[0].op, ComponentOp::Insert);
330        assert_eq!(changes[1].op, ComponentOp::Insert);
331        assert_eq!(changes[2].op, ComponentOp::Delete);
332    }
333
334    #[xmtp_common::test(unwrap_try = true)]
335    fn expand_remove_by_hash_resolves_against_prior() {
336        let target = fixture_inbox_id(9);
337        let mut prior_set = TlsSet::<InboxId>::new();
338        prior_set.insert(target).unwrap();
339        let prior_bytes = AdminListComponent::encode_value(&prior_set).unwrap();
340
341        let target_hash = TlsKeyHash::of(&target).unwrap();
342        let delta = TlsSetDelta::<InboxId>::new().remove_by_hash(target_hash);
343        let payload = AdminListComponent::encode_mutation(&delta).unwrap();
344        let op = AppDataUpdateOperation::Update(payload.into());
345
346        let changes = AdminListComponent::expand_to_changes(&op, Some(&prior_bytes)).unwrap();
347        assert_eq!(changes.len(), 1);
348        assert_eq!(changes[0].op, ComponentOp::Delete);
349        assert_eq!(changes[0].value.as_deref(), Some(&target.as_bytes()[..]));
350    }
351
352    #[xmtp_common::test(unwrap_try = true)]
353    fn expand_remove_by_hash_with_no_prior_yields_unresolved() {
354        let target_hash = TlsKeyHash::of(&fixture_inbox_id(9)).unwrap();
355        let delta = TlsSetDelta::<InboxId>::new().remove_by_hash(target_hash);
356        let payload = AdminListComponent::encode_mutation(&delta).unwrap();
357        let op = AppDataUpdateOperation::Update(payload.into());
358
359        let changes = AdminListComponent::expand_to_changes(&op, None).unwrap();
360        assert_eq!(changes.len(), 1);
361        assert_eq!(changes[0].op, ComponentOp::Delete);
362        assert!(changes[0].value.is_none());
363    }
364
365    #[xmtp_common::test(unwrap_try = true)]
366    fn apply_rejects_non_delta_payload() {
367        // The wire is always a `TlsSetDelta<InboxId>`, never a raw
368        // `TlsSet`. A bootstrap caller that mistakenly emitted a full
369        // set as the payload must surface as a decode failure, not
370        // silently overwrite the dict.
371        let mut set = TlsSet::<InboxId>::new();
372        set.insert(fixture_inbox_id(1)).unwrap();
373        let raw_set_bytes = set.tls_serialize_detached().unwrap();
374        let err = AdminListComponent::apply_update_payload(&raw_set_bytes, None).unwrap_err();
375        assert!(
376            matches!(err, ComponentTypedError::TlsCodec(_)),
377            "expected TlsCodec decode error for non-delta payload, got {err:?}"
378        );
379    }
380
381    #[xmtp_common::test(unwrap_try = true)]
382    fn dm_members_uses_same_codec() {
383        // Identical to admin_list — sanity check that the macro
384        // expansion works for the immutable-component variant too.
385        let mut set = TlsSet::<InboxId>::new();
386        set.insert(fixture_inbox_id(42)).unwrap();
387        let bytes = DmMembersComponent::encode_value(&set).unwrap();
388        let decoded = DmMembersComponent::decode_value(&bytes).unwrap();
389        assert!(decoded.contains(&fixture_inbox_id(42)));
390        assert_eq!(
391            DmMembersComponent::COMPONENT_TYPE,
392            ComponentType::TlsSetInboxId
393        );
394    }
395}