Skip to main content

xmtp_mls_common/app_data/components/
tls_map_components.rs

1//! [`Component`] impls for the two `TlsMap`-shaped components:
2//! [`GroupMembershipComponent`] (`GROUP_MEMBERSHIP`, key: [`InboxId`])
3//! and [`ComponentRegistryComponent`] (`COMPONENT_REGISTRY`, key:
4//! [`ComponentId`]).
5//!
6//! Both store their value as `TlsMap<K, VLBytes>` where the inner
7//! `VLBytes` payload is opaque to this layer:
8//!
9//! - `GROUP_MEMBERSHIP` value bytes are prost-encoded
10//!   [`GroupMembershipEntryV1`](xmtp_proto::xmtp::mls::message_contents::GroupMembershipEntry)
11//!   blobs; downstream consumers decode them after reading.
12//! - `COMPONENT_REGISTRY` value bytes are prost-encoded
13//!   [`ComponentMetadata`](xmtp_proto::xmtp::mls::message_contents::ComponentMetadata)
14//!   blobs.
15//!
16//! `Update` payloads are encoded as
17//! [`TlsMapDelta<K, VLBytes>`](crate::tls_map::TlsMapDelta) — single
18//! mutation per `AppDataUpdate` proposal at the steady-state path
19//! (UpdatePermission inserts/updates one registry entry; group
20//! membership updates one inbox at a time).
21
22use openmls::messages::proposals::AppDataUpdateOperation;
23use tls_codec::{Deserialize, Serialize, VLBytes};
24use xmtp_proto::xmtp::mls::message_contents::ComponentType;
25
26use crate::{
27    app_data::{
28        component_id::ComponentId,
29        component_registry::{ComponentOp, ComponentRegistry, ComponentRegistryError},
30        typed::{Component, ComponentTypedError, ExpandedComponentChange},
31    },
32    inbox_id::InboxId,
33    tls_map::{TlsMap, TlsMapDelta, TlsMapMutation},
34};
35
36/// Apply a `TlsMapDelta<K, VLBytes>` wire payload over the prior dict
37/// bytes (a `TlsMap<K, VLBytes>` snapshot, or `None` if this is the
38/// first write — bootstrap, where prior state is empty).
39///
40/// Returns the new dict bytes: the re-serialized `TlsMap<K, VLBytes>`
41/// snapshot. The dict always holds the raw map as state; the wire
42/// always carries a delta describing the change. This function is
43/// the one boundary that translates between them.
44///
45/// Generic over the key type so both `InboxId`-keyed and
46/// `ComponentId`-keyed maps share the same body. Also reachable from the
47/// type-aware fallback in
48/// [`super::type_dispatch::apply_update_payload_for_type`] (with
49/// `K = VLBytes` for the `TlsMapBytesBytes` shape).
50pub(crate) fn apply_tls_map_delta<K>(
51    payload: &[u8],
52    prior: Option<&[u8]>,
53) -> Result<Vec<u8>, ComponentTypedError>
54where
55    K: tls_codec::Serialize
56        + tls_codec::Deserialize
57        + tls_codec::Size
58        + Ord
59        + Eq
60        + Clone
61        + std::fmt::Debug,
62{
63    let delta = TlsMapDelta::<K, VLBytes>::tls_deserialize_exact(payload)?;
64    let mut map: TlsMap<K, VLBytes> = match prior {
65        Some(bytes) => TlsMap::<K, VLBytes>::tls_deserialize_exact(bytes)?,
66        None => TlsMap::new(),
67    };
68    map.apply_delta(delta)?;
69    Ok(map.tls_serialize_detached()?)
70}
71
72/// Expand a `TlsMap`-shape `AppDataUpdate` proposal into per-mutation
73/// `ExpandedComponentChange` entries.
74///
75/// Convention for `value` field on the emitted changes:
76/// - `Insert(_, v)` and `Update(_, v)` carry the new value bytes
77///   (`v.as_slice().to_vec()`) — this is what change-aware policies
78///   would inspect.
79/// - `Delete(k)` carries the key bytes via `K::tls_serialize_detached`,
80///   matching the `TlsSet` expand convention where the value field
81///   identifies the affected element.
82///
83/// `prior` is currently unused — Map components don't have a
84/// `RemoveByHash` analogue (deletes carry the literal key on the
85/// wire). Kept in the signature for trait-shape uniformity.
86///
87/// **Why no payload validation here.** Beyond the TLS codec check
88/// (`tls_deserialize_exact` rejects truncated / oversized payloads
89/// and structurally-malformed deltas), this expansion does not
90/// validate the per-mutation contents — it just feeds them to the
91/// validator's per-element policy check. Authoritative semantic
92/// validation happens at apply time inside [`TlsMap::apply_delta`]
93/// (e.g. `Update`/`Delete` of an absent key returns `KeyNotFound`,
94/// duplicate `Insert` returns the appropriate map error). Validating
95/// here too would either duplicate that work or, worse, drift out of
96/// sync with the apply-time rules and reject things the apply step
97/// would happily accept. Keep it single-source: codec here, semantics
98/// at apply.
99pub(crate) fn expand_tls_map_changes<K>(
100    op: &AppDataUpdateOperation,
101    _prior: Option<&[u8]>,
102) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError>
103where
104    K: tls_codec::Serialize + tls_codec::Deserialize + tls_codec::Size,
105{
106    match op {
107        AppDataUpdateOperation::Remove => Ok(vec![ExpandedComponentChange {
108            op: ComponentOp::Delete,
109            value: None,
110        }]),
111        AppDataUpdateOperation::Update(payload) => {
112            let delta = TlsMapDelta::<K, VLBytes>::tls_deserialize_exact(payload.as_slice())?;
113            expand_decoded_tls_map_delta(delta)
114        }
115    }
116}
117
118/// Expansion body shared by [`expand_tls_map_changes`] and the
119/// registry-specific [`ComponentRegistryComponent::expand_to_changes`]
120/// override (which validates the decoded delta first and must not
121/// decode the payload twice).
122fn expand_decoded_tls_map_delta<K>(
123    delta: TlsMapDelta<K, VLBytes>,
124) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError>
125where
126    K: tls_codec::Serialize + tls_codec::Size,
127{
128    let mut out = Vec::with_capacity(delta.mutations.len());
129    for mutation in delta.mutations {
130        match mutation {
131            TlsMapMutation::Insert { value, .. } => out.push(ExpandedComponentChange {
132                op: ComponentOp::Insert,
133                value: Some(value.as_slice().to_vec()),
134            }),
135            TlsMapMutation::Update { value, .. } => out.push(ExpandedComponentChange {
136                op: ComponentOp::Update,
137                value: Some(value.as_slice().to_vec()),
138            }),
139            TlsMapMutation::Delete { key } => {
140                let key_bytes = key.tls_serialize_detached()?;
141                out.push(ExpandedComponentChange {
142                    op: ComponentOp::Delete,
143                    value: Some(key_bytes),
144                });
145            }
146        }
147    }
148    Ok(out)
149}
150
151// ============================================================================
152// GROUP_MEMBERSHIP — TlsMap<InboxId, VLBytes>
153// ============================================================================
154
155/// `Component` impl for the `GROUP_MEMBERSHIP` component.
156///
157/// The decoded value is a `TlsMap<InboxId, VLBytes>` where each value
158/// is the prost-encoded
159/// [`GroupMembershipEntryV1`](xmtp_proto::xmtp::mls::message_contents::GroupMembershipEntry)
160/// for that member. This impl handles only the wire codec; entry
161/// content is decoded by the caller.
162pub struct GroupMembershipComponent;
163
164impl Component for GroupMembershipComponent {
165    const ID: ComponentId = ComponentId::GROUP_MEMBERSHIP;
166    const COMPONENT_TYPE: ComponentType = ComponentType::TlsMapInboxIdBytes;
167    type Value = TlsMap<InboxId, VLBytes>;
168    // The mutation type is the full wire-level delta. Group
169    // membership updates need to be atomic — every installation
170    // change for an inbox (additions, removals, sequence-id bumps)
171    // must travel as one proposal so receivers apply them together.
172    type Mutation = TlsMapDelta<InboxId, VLBytes>;
173
174    fn decode_value(bytes: &[u8]) -> Result<Self::Value, ComponentTypedError> {
175        TlsMap::<InboxId, VLBytes>::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_tls_map_delta::<InboxId>(payload, prior)
191    }
192
193    fn expand_to_changes(
194        op: &AppDataUpdateOperation,
195        prior: Option<&[u8]>,
196    ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
197        expand_tls_map_changes::<InboxId>(op, prior)
198    }
199}
200
201// ============================================================================
202// COMPONENT_REGISTRY — TlsMap<ComponentId, VLBytes>
203// ============================================================================
204
205/// `Component` impl for the `COMPONENT_REGISTRY` component.
206///
207/// The decoded value is a `TlsMap<ComponentId, VLBytes>` where each
208/// value is the prost-encoded
209/// [`ComponentMetadata`](xmtp_proto::xmtp::mls::message_contents::ComponentMetadata)
210/// describing one registered component.
211///
212/// Unlike the other map component, this impl validates every mutation
213/// in an incoming delta against the registry's write invariants (see
214/// [`validate_registry_delta`]) before applying or expanding it. The
215/// registry is load-bearing for *all* app-data validation — every
216/// policy lookup and every type-aware dispatch decodes it — so a
217/// single invalid entry accepted here would poison every subsequent
218/// [`ComponentRegistry::from_bytes`] load for every member of the
219/// group.
220pub struct ComponentRegistryComponent;
221
222/// Validate every mutation in a `COMPONENT_REGISTRY` delta against the
223/// registry's write invariants, mirroring what
224/// [`ComponentRegistry::set`] / [`ComponentRegistry::remove`] enforce
225/// for local writes:
226///
227/// - Insert/Update: the entry must pass
228///   [`ComponentRegistry::validate_entry`] (id in the component space,
229///   not reserved, not hardcoded, metadata decodable and structurally
230///   complete), and Updates must not target an immutable-range id
231///   (write-once).
232/// - Delete: the id must be modifiable at all — deletes of
233///   out-of-space / reserved / hardcoded / immutable ids are rejected.
234///   The tolerant [`ComponentRegistry::from_bytes`] means a poisoned
235///   dict CAN carry entries under such ids (preserved-but-invisible),
236///   so this arm is a deliberate policy choice, not dead code:
237///   non-modifiable poisoned entries are intentionally NOT
238///   wire-repairable via Delete. Deny-by-default already keeps them
239///   harmless, and keeping the non-modifiable ranges closed to every
240///   wire write beats opening a repair path nobody should need.
241///   (Poisoned entries under modifiable ids remain repairable — their
242///   Delete passes this check and removes the raw entry at apply.)
243///
244/// Shared by [`ComponentRegistryComponent::apply_update_payload`] and
245/// [`ComponentRegistryComponent::expand_to_changes`], so both call
246/// sites judge a delta's mutations identically. Scope caveat: this
247/// helper only sees `Update` payloads (the `TlsMapDelta`). The
248/// whole-registry `AppDataUpdateOperation::Remove` rejection in
249/// `expand_to_changes` is enforced only during commit validation
250/// (`ValidatedCommit::from_staged_commit` in `xmtp_mls`) — the apply
251/// pipeline (`accumulate_app_data_updates` in
252/// `xmtp_mls::groups::app_data`) maps `Remove` straight to a dict
253/// removal without consulting this component impl, and does not
254/// re-check because both current commit-processing paths validate
255/// before applying. The Remove ban is a validator-side guarantee, not
256/// an apply-time one.
257fn validate_registry_delta(
258    delta: &TlsMapDelta<ComponentId, VLBytes>,
259) -> Result<(), ComponentTypedError> {
260    for mutation in &delta.mutations {
261        match mutation {
262            TlsMapMutation::Insert { key, value } => {
263                ComponentRegistry::validate_entry(*key, value.as_slice())?;
264            }
265            TlsMapMutation::Update { key, value } => {
266                ComponentRegistry::validate_entry(*key, value.as_slice())?;
267                if key.is_immutable() {
268                    return Err(ComponentRegistryError::ImmutableComponent(*key).into());
269                }
270            }
271            TlsMapMutation::Delete { key } => {
272                if !key.is_in_component_space() {
273                    return Err(ComponentRegistryError::InvalidComponentId(*key).into());
274                }
275                if key.is_reserved() {
276                    return Err(ComponentRegistryError::ReservedRange(*key).into());
277                }
278                if key.is_hardcoded() {
279                    return Err(ComponentRegistryError::HardcodedComponent(*key).into());
280                }
281                if key.is_immutable() {
282                    return Err(ComponentRegistryError::ImmutableComponent(*key).into());
283                }
284            }
285        }
286    }
287    Ok(())
288}
289
290impl Component for ComponentRegistryComponent {
291    const ID: ComponentId = ComponentId::COMPONENT_REGISTRY;
292    const COMPONENT_TYPE: ComponentType = ComponentType::TlsMapBytesBytes;
293    type Value = TlsMap<ComponentId, VLBytes>;
294    // The mutation type is the full wire-level delta so a single
295    // proposal can register/update/remove multiple component
296    // entries atomically (e.g. bulk-registering custom components).
297    type Mutation = TlsMapDelta<ComponentId, VLBytes>;
298
299    fn decode_value(bytes: &[u8]) -> Result<Self::Value, ComponentTypedError> {
300        TlsMap::<ComponentId, VLBytes>::tls_deserialize_exact(bytes).map_err(Into::into)
301    }
302
303    fn encode_value(value: &Self::Value) -> Result<Vec<u8>, ComponentTypedError> {
304        value.tls_serialize_detached().map_err(Into::into)
305    }
306
307    fn encode_mutation(mutation: &Self::Mutation) -> Result<Vec<u8>, ComponentTypedError> {
308        mutation.tls_serialize_detached().map_err(Into::into)
309    }
310
311    fn apply_update_payload(
312        payload: &[u8],
313        prior: Option<&[u8]>,
314    ) -> Result<Vec<u8>, ComponentTypedError> {
315        let delta = TlsMapDelta::<ComponentId, VLBytes>::tls_deserialize_exact(payload)?;
316        validate_registry_delta(&delta)?;
317        let mut map = match prior {
318            Some(bytes) => TlsMap::<ComponentId, VLBytes>::tls_deserialize_exact(bytes)?,
319            None => TlsMap::new(),
320        };
321        map.apply_delta(delta)?;
322        Ok(map.tls_serialize_detached()?)
323    }
324
325    fn expand_to_changes(
326        op: &AppDataUpdateOperation,
327        _prior: Option<&[u8]>,
328    ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
329        match op {
330            // Removing the entire registry component is never legal —
331            // the registry is what makes every other app-data write
332            // validatable (deny-by-default keys off its entries), and
333            // its absence is also the "unmigrated group" marker. A
334            // commit deleting it would strand the group in a
335            // validated-but-unreadable state. `HardcodedComponent`
336            // is the same verdict `ComponentRegistry::remove` gives
337            // for this id.
338            AppDataUpdateOperation::Remove => Err(ComponentRegistryError::HardcodedComponent(
339                ComponentId::COMPONENT_REGISTRY,
340            )
341            .into()),
342            AppDataUpdateOperation::Update(payload) => {
343                let delta =
344                    TlsMapDelta::<ComponentId, VLBytes>::tls_deserialize_exact(payload.as_slice())?;
345                validate_registry_delta(&delta)?;
346                expand_decoded_tls_map_delta(delta)
347            }
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::app_data::component_permissions::component_permissions;
356    use prost::Message;
357    use xmtp_proto::xmtp::mls::message_contents::{
358        MetadataPolicy as MetadataPolicyProto,
359        metadata_policy::{Kind as MetadataPolicyKind, MetadataBasePolicy},
360    };
361
362    fn fixture_inbox_id(seed: u8) -> InboxId {
363        let mut bytes = [0u8; 32];
364        bytes[0] = seed;
365        InboxId::from_bytes(bytes)
366    }
367
368    /// Encoded, structurally valid `ComponentMetadata` bytes — registry
369    /// deltas are entry-validated on apply/expand, so tests must carry
370    /// real metadata, not placeholder strings.
371    fn valid_meta_bytes() -> Vec<u8> {
372        let allow = MetadataPolicyProto {
373            kind: Some(MetadataPolicyKind::Base(MetadataBasePolicy::Allow as i32)),
374        };
375        crate::app_data::component_registry::new_component_metadata(
376            component_permissions()
377                .insert(allow.clone())
378                .update(allow.clone())
379                .delete(allow)
380                .call(),
381            ComponentType::Bytes,
382        )
383        .encode_to_vec()
384    }
385
386    #[xmtp_common::test(unwrap_try = true)]
387    fn group_membership_round_trip_value() {
388        let mut map: TlsMap<InboxId, VLBytes> = TlsMap::new();
389        map.insert(fixture_inbox_id(1), VLBytes::new(b"member1-blob".to_vec()))
390            .unwrap();
391        let bytes = GroupMembershipComponent::encode_value(&map).unwrap();
392        let decoded = GroupMembershipComponent::decode_value(&bytes).unwrap();
393        assert_eq!(decoded.len(), 1);
394        assert_eq!(
395            decoded.get(&fixture_inbox_id(1)).unwrap().as_slice(),
396            b"member1-blob"
397        );
398    }
399
400    #[xmtp_common::test(unwrap_try = true)]
401    fn group_membership_apply_insert_against_empty() {
402        let delta = TlsMapDelta::<InboxId, VLBytes>::new()
403            .insert(fixture_inbox_id(2), VLBytes::new(b"v".to_vec()));
404        let payload = GroupMembershipComponent::encode_mutation(&delta).unwrap();
405        let new_bytes = GroupMembershipComponent::apply_update_payload(&payload, None).unwrap();
406        let new = GroupMembershipComponent::decode_value(&new_bytes).unwrap();
407        assert_eq!(new.len(), 1);
408        assert_eq!(new.get(&fixture_inbox_id(2)).unwrap().as_slice(), b"v");
409    }
410
411    #[xmtp_common::test(unwrap_try = true)]
412    fn group_membership_apply_update_against_existing() {
413        let mut prior_map: TlsMap<InboxId, VLBytes> = TlsMap::new();
414        prior_map
415            .insert(fixture_inbox_id(3), VLBytes::new(b"old".to_vec()))
416            .unwrap();
417        let prior_bytes = GroupMembershipComponent::encode_value(&prior_map).unwrap();
418
419        let delta = TlsMapDelta::<InboxId, VLBytes>::new()
420            .update(fixture_inbox_id(3), VLBytes::new(b"new".to_vec()));
421        let payload = GroupMembershipComponent::encode_mutation(&delta).unwrap();
422        let new_bytes =
423            GroupMembershipComponent::apply_update_payload(&payload, Some(&prior_bytes)).unwrap();
424        let new = GroupMembershipComponent::decode_value(&new_bytes).unwrap();
425        assert_eq!(new.get(&fixture_inbox_id(3)).unwrap().as_slice(), b"new");
426    }
427
428    #[xmtp_common::test(unwrap_try = true)]
429    fn group_membership_apply_batched_delta_atomically() {
430        // The motivating case for delta-as-mutation: all
431        // installation changes for an inbox in one proposal —
432        // sequence-id bump on member 1, new member 2, removed
433        // member 3 land atomically.
434        let mut prior_map: TlsMap<InboxId, VLBytes> = TlsMap::new();
435        prior_map
436            .insert(fixture_inbox_id(1), VLBytes::new(b"seq=5".to_vec()))
437            .unwrap();
438        prior_map
439            .insert(fixture_inbox_id(3), VLBytes::new(b"to-remove".to_vec()))
440            .unwrap();
441        let prior_bytes = GroupMembershipComponent::encode_value(&prior_map).unwrap();
442
443        let delta = TlsMapDelta::<InboxId, VLBytes>::new()
444            .update(fixture_inbox_id(1), VLBytes::new(b"seq=7".to_vec()))
445            .insert(fixture_inbox_id(2), VLBytes::new(b"seq=1".to_vec()))
446            .delete(fixture_inbox_id(3));
447        let payload = GroupMembershipComponent::encode_mutation(&delta).unwrap();
448        let new_bytes =
449            GroupMembershipComponent::apply_update_payload(&payload, Some(&prior_bytes)).unwrap();
450        let new = GroupMembershipComponent::decode_value(&new_bytes).unwrap();
451
452        assert_eq!(new.len(), 2);
453        assert_eq!(new.get(&fixture_inbox_id(1)).unwrap().as_slice(), b"seq=7");
454        assert_eq!(new.get(&fixture_inbox_id(2)).unwrap().as_slice(), b"seq=1");
455        assert!(new.get(&fixture_inbox_id(3)).is_none());
456    }
457
458    #[xmtp_common::test(unwrap_try = true)]
459    fn group_membership_expand_insert_carries_value_bytes() {
460        let delta = TlsMapDelta::<InboxId, VLBytes>::new()
461            .insert(fixture_inbox_id(4), VLBytes::new(b"new-member".to_vec()));
462        let payload = GroupMembershipComponent::encode_mutation(&delta).unwrap();
463        let op = AppDataUpdateOperation::Update(payload.into());
464        let changes = GroupMembershipComponent::expand_to_changes(&op, None).unwrap();
465        assert_eq!(changes.len(), 1);
466        assert_eq!(changes[0].op, ComponentOp::Insert);
467        assert_eq!(changes[0].value.as_deref(), Some(&b"new-member"[..]));
468    }
469
470    #[xmtp_common::test(unwrap_try = true)]
471    fn group_membership_expand_delete_carries_key_bytes() {
472        let delta = TlsMapDelta::<InboxId, VLBytes>::new().delete(fixture_inbox_id(5));
473        let payload = GroupMembershipComponent::encode_mutation(&delta).unwrap();
474        let op = AppDataUpdateOperation::Update(payload.into());
475        let changes = GroupMembershipComponent::expand_to_changes(&op, None).unwrap();
476        assert_eq!(changes.len(), 1);
477        assert_eq!(changes[0].op, ComponentOp::Delete);
478        // The value field carries the TLS-encoded key for context.
479        assert!(changes[0].value.is_some());
480    }
481
482    #[xmtp_common::test(unwrap_try = true)]
483    fn group_membership_expand_batched_yields_one_per_mutation() {
484        let delta = TlsMapDelta::<InboxId, VLBytes>::new()
485            .update(fixture_inbox_id(1), VLBytes::new(b"v1".to_vec()))
486            .insert(fixture_inbox_id(2), VLBytes::new(b"v2".to_vec()))
487            .delete(fixture_inbox_id(3));
488        let payload = GroupMembershipComponent::encode_mutation(&delta).unwrap();
489        let op = AppDataUpdateOperation::Update(payload.into());
490        let changes = GroupMembershipComponent::expand_to_changes(&op, None).unwrap();
491        assert_eq!(changes.len(), 3);
492        assert_eq!(changes[0].op, ComponentOp::Update);
493        assert_eq!(changes[1].op, ComponentOp::Insert);
494        assert_eq!(changes[2].op, ComponentOp::Delete);
495    }
496
497    #[xmtp_common::test(unwrap_try = true)]
498    fn component_registry_round_trip() {
499        let mut map: TlsMap<ComponentId, VLBytes> = TlsMap::new();
500        map.insert(
501            ComponentId::ADMIN_LIST,
502            VLBytes::new(b"meta-bytes".to_vec()),
503        )
504        .unwrap();
505        let bytes = ComponentRegistryComponent::encode_value(&map).unwrap();
506        let decoded = ComponentRegistryComponent::decode_value(&bytes).unwrap();
507        assert_eq!(decoded.len(), 1);
508        assert_eq!(
509            decoded.get(&ComponentId::ADMIN_LIST).unwrap().as_slice(),
510            b"meta-bytes"
511        );
512    }
513
514    #[xmtp_common::test(unwrap_try = true)]
515    fn component_registry_apply_update_replaces_metadata() {
516        let old_meta = valid_meta_bytes();
517        let mut new_meta = valid_meta_bytes();
518        // Same structural validity, different type tag so the
519        // replacement is observable.
520        new_meta = {
521            let mut decoded = xmtp_proto::xmtp::mls::message_contents::ComponentMetadata::decode(
522                new_meta.as_slice(),
523            )
524            .unwrap();
525            decoded.component_type = ComponentType::String as i32;
526            decoded.encode_to_vec()
527        };
528
529        let mut prior_map: TlsMap<ComponentId, VLBytes> = TlsMap::new();
530        prior_map
531            .insert(ComponentId::GROUP_NAME, VLBytes::new(old_meta))
532            .unwrap();
533        let prior_bytes = ComponentRegistryComponent::encode_value(&prior_map).unwrap();
534
535        let delta = TlsMapDelta::<ComponentId, VLBytes>::new()
536            .update(ComponentId::GROUP_NAME, VLBytes::new(new_meta.clone()));
537        let payload = ComponentRegistryComponent::encode_mutation(&delta).unwrap();
538        let new_bytes =
539            ComponentRegistryComponent::apply_update_payload(&payload, Some(&prior_bytes)).unwrap();
540        let new = ComponentRegistryComponent::decode_value(&new_bytes).unwrap();
541        assert_eq!(
542            new.get(&ComponentId::GROUP_NAME).unwrap().as_slice(),
543            new_meta.as_slice()
544        );
545    }
546
547    #[xmtp_common::test(unwrap_try = true)]
548    fn component_registry_apply_batched_delta_atomically() {
549        // Bulk-register two custom components in one proposal.
550        let delta = TlsMapDelta::<ComponentId, VLBytes>::new()
551            .insert(ComponentId::new(0xC100), VLBytes::new(valid_meta_bytes()))
552            .insert(ComponentId::new(0xC101), VLBytes::new(valid_meta_bytes()));
553        let payload = ComponentRegistryComponent::encode_mutation(&delta).unwrap();
554        let new_bytes = ComponentRegistryComponent::apply_update_payload(&payload, None).unwrap();
555        let new = ComponentRegistryComponent::decode_value(&new_bytes).unwrap();
556        assert_eq!(new.len(), 2);
557        assert!(new.get(&ComponentId::new(0xC100)).is_some());
558        assert!(new.get(&ComponentId::new(0xC101)).is_some());
559    }
560
561    // ------------------------------------------------------------------
562    // Registry delta entry-validation (apply + expand must agree)
563    // ------------------------------------------------------------------
564
565    /// Apply and expand must return the same verdict for the same
566    /// delta — they share `validate_registry_delta`, and this pins it.
567    fn assert_registry_delta_rejected_everywhere(delta: TlsMapDelta<ComponentId, VLBytes>) {
568        let payload = ComponentRegistryComponent::encode_mutation(&delta).unwrap();
569        let apply_err = ComponentRegistryComponent::apply_update_payload(&payload, None);
570        assert!(
571            matches!(apply_err, Err(ComponentTypedError::RegistryMutation(_))),
572            "apply must reject, got {apply_err:?}"
573        );
574        let op = AppDataUpdateOperation::Update(payload.into());
575        let expand_err = ComponentRegistryComponent::expand_to_changes(&op, None);
576        assert!(
577            matches!(expand_err, Err(ComponentTypedError::RegistryMutation(_))),
578            "expand must reject, got {expand_err:?}"
579        );
580    }
581
582    #[xmtp_common::test(unwrap_try = true)]
583    fn registry_delta_rejects_reserved_range_insert() {
584        // THE poison case: a reserved-range entry accepted into the
585        // dict would fail every subsequent registry load. It must be
586        // rejected at the wire, on both the apply and validate paths.
587        assert_registry_delta_rejected_everywhere(
588            TlsMapDelta::<ComponentId, VLBytes>::new()
589                .insert(ComponentId::new(0xFF00), VLBytes::new(valid_meta_bytes())),
590        );
591    }
592
593    #[xmtp_common::test(unwrap_try = true)]
594    fn registry_delta_rejects_hardcoded_insert() {
595        assert_registry_delta_rejected_everywhere(
596            TlsMapDelta::<ComponentId, VLBytes>::new().insert(
597                ComponentId::SUPER_ADMIN_LIST,
598                VLBytes::new(valid_meta_bytes()),
599            ),
600        );
601    }
602
603    #[xmtp_common::test(unwrap_try = true)]
604    fn registry_delta_rejects_out_of_space_insert() {
605        assert_registry_delta_rejected_everywhere(
606            TlsMapDelta::<ComponentId, VLBytes>::new()
607                .insert(ComponentId::new(0x0001), VLBytes::new(valid_meta_bytes())),
608        );
609    }
610
611    #[xmtp_common::test(unwrap_try = true)]
612    fn registry_delta_rejects_undecodable_metadata_insert() {
613        assert_registry_delta_rejected_everywhere(
614            TlsMapDelta::<ComponentId, VLBytes>::new().insert(
615                ComponentId::new(0xC100),
616                VLBytes::new(vec![0xFF, 0xFF, 0xFF, 0xFF]),
617            ),
618        );
619    }
620
621    #[xmtp_common::test(unwrap_try = true)]
622    fn registry_delta_rejects_immutable_update_and_delete() {
623        // Write-once: immutable-range registry entries can be inserted
624        // but never overwritten or deleted, mirroring
625        // `ComponentRegistry::{set, remove}`.
626        assert_registry_delta_rejected_everywhere(
627            TlsMapDelta::<ComponentId, VLBytes>::new()
628                .update(ComponentId::new(0xBE00), VLBytes::new(valid_meta_bytes())),
629        );
630        assert_registry_delta_rejected_everywhere(
631            TlsMapDelta::<ComponentId, VLBytes>::new().delete(ComponentId::new(0xBE00)),
632        );
633    }
634
635    #[xmtp_common::test(unwrap_try = true)]
636    fn registry_delta_rejects_hardcoded_delete() {
637        assert_registry_delta_rejected_everywhere(
638            TlsMapDelta::<ComponentId, VLBytes>::new().delete(ComponentId::SUPER_ADMIN_LIST),
639        );
640    }
641
642    #[xmtp_common::test(unwrap_try = true)]
643    fn registry_component_rejects_whole_component_remove() {
644        // Deleting the entire COMPONENT_REGISTRY dict slot would strand
645        // the group (the registry's presence is the migration marker
646        // and the policy source). Never legal.
647        let err =
648            ComponentRegistryComponent::expand_to_changes(&AppDataUpdateOperation::Remove, None);
649        assert!(
650            matches!(err, Err(ComponentTypedError::RegistryMutation(_))),
651            "Remove of the registry component must be rejected, got {err:?}"
652        );
653    }
654
655    #[xmtp_common::test(unwrap_try = true)]
656    fn registry_delta_valid_mutations_expand_and_apply() {
657        // The happy path still works end-to-end after entry validation:
658        // register a custom component, then observe it in both the
659        // expanded changes and the applied map.
660        let delta = TlsMapDelta::<ComponentId, VLBytes>::new()
661            .insert(ComponentId::new(0xC200), VLBytes::new(valid_meta_bytes()));
662        let payload = ComponentRegistryComponent::encode_mutation(&delta).unwrap();
663
664        let op = AppDataUpdateOperation::Update(payload.clone().into());
665        let changes = ComponentRegistryComponent::expand_to_changes(&op, None).unwrap();
666        assert_eq!(changes.len(), 1);
667        assert_eq!(changes[0].op, ComponentOp::Insert);
668
669        let new_bytes = ComponentRegistryComponent::apply_update_payload(&payload, None).unwrap();
670        let new = ComponentRegistryComponent::decode_value(&new_bytes).unwrap();
671        assert!(new.get(&ComponentId::new(0xC200)).is_some());
672    }
673
674    #[xmtp_common::test(unwrap_try = true)]
675    fn apply_rejects_non_delta_payload() {
676        // The wire is always a `TlsMapDelta`, never a raw `TlsMap`.
677        // A caller that mistakenly emitted a snapshot as the payload
678        // must surface as a decode failure, not silently overwrite
679        // the dict.
680        let mut map: TlsMap<InboxId, VLBytes> = TlsMap::new();
681        map.insert(fixture_inbox_id(1), VLBytes::new(b"v1".to_vec()))
682            .unwrap();
683        let raw_map_bytes = map.tls_serialize_detached().unwrap();
684        let err = GroupMembershipComponent::apply_update_payload(&raw_map_bytes, None).unwrap_err();
685        assert!(
686            matches!(err, ComponentTypedError::TlsCodec(_)),
687            "expected TlsCodec decode error for non-delta payload, got {err:?}"
688        );
689    }
690
691    #[xmtp_common::test(unwrap_try = true)]
692    fn component_types_are_correct() {
693        assert_eq!(
694            GroupMembershipComponent::COMPONENT_TYPE,
695            ComponentType::TlsMapInboxIdBytes
696        );
697        assert_eq!(
698            ComponentRegistryComponent::COMPONENT_TYPE,
699            ComponentType::TlsMapBytesBytes
700        );
701    }
702}