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